feat(api): #288/#197 wrap ChannelViewModel + re-key playout/reset to {id}

GetById/Create/Update return ChannelResponseModel via new GetChannelByIdForApi
read-side query; POST /api/channels/{id:int}/playout/reset (new
GetPlayoutIdByChannelId; by-number kept for HlsSessionWorker broadcast).

Refs #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 02:08:12 +02:00
co-authored by Claude Opus 4.8
parent 9e2d160884
commit 5f89bfc1a0
13 changed files with 273 additions and 53 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelByIdForApi(int Id) : IRequest<Option<ChannelResponseModel>>;
@@ -0,0 +1,13 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByIdForApi, Option<ChannelResponseModel>>
{
public Task<Option<ChannelResponseModel>> Handle(GetChannelByIdForApi request, CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(ProjectToResponseModel);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutIdByChannelId(int ChannelId) : IRequest<Option<int>>;
@@ -0,0 +1,18 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutIdByChannelIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlayoutIdByChannelId, Option<int>>
{
public async Task<Option<int>> Handle(GetPlayoutIdByChannelId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Filter(p => p.Channel.Id == request.ChannelId)
.Map(p => p.Id)
.ToListAsync(cancellationToken)
.Map(list => list.HeadOrNone());
}
}
@@ -11,6 +11,7 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Channels
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.OrderBy(c => c.Id)
@@ -0,0 +1,64 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelByIdForApiHandlerTests
{
[Test]
public async Task Should_Project_ResponseModel_When_Found()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetChannel(7)
.Returns(Option<Channel>.Some(new Channel(Guid.NewGuid())
{
Id = 7,
Number = "7.1",
SortNumber = 7.1,
Name = "Retro Cartoons",
Group = "Kids",
Categories = "animation",
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
PreferredAudioLanguageCode = "eng",
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
IsEnabled = false,
ShowInEpg = false
}));
var handler = new GetChannelByIdForApiHandler(repository);
Option<ChannelResponseModel> result =
await handler.Handle(new GetChannelByIdForApi(7), CancellationToken.None);
result.IsSome.ShouldBeTrue();
ChannelResponseModel channel = result.IfNone(() => throw new InvalidOperationException());
channel.Id.ShouldBe(7);
channel.Number.ShouldBe("7.1");
channel.SortNumber.ShouldBe(7.1);
channel.Name.ShouldBe("Retro Cartoons");
channel.FFmpegProfile.ShouldBe("HLS 720p");
channel.Language.ShouldBe("eng");
channel.StreamingMode.ShouldBe("HLS Segmenter");
channel.IsEnabled.ShouldBeFalse();
channel.ShowInEpg.ShouldBeFalse();
}
[Test]
public async Task Should_Return_None_When_Missing()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetChannel(Arg.Any<int>()).Returns(Option<Channel>.None);
var handler = new GetChannelByIdForApiHandler(repository);
Option<ChannelResponseModel> result =
await handler.Handle(new GetChannelByIdForApi(99), CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,59 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class GetPlayoutIdByChannelIdHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private GetPlayoutIdByChannelIdHandler CreateHandler() => new(_db.Factory);
private async Task<(int ChannelId, int PlayoutId)> SeedChannelWithPlayout(string number)
{
await using TvContext context = _db.CreateContext();
var channel = new Channel(Guid.NewGuid()) { Number = number, Name = $"Channel {number}" };
context.Channels.Add(channel);
await context.SaveChangesAsync();
var playout = new Playout { ChannelId = channel.Id, ScheduleKind = PlayoutScheduleKind.Classic };
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return (channel.Id, playout.Id);
}
[Test]
public async Task Handle_Should_Return_Playout_Id_For_Channel_Id()
{
(int channelId, int playoutId) = await SeedChannelWithPlayout("5.1");
Option<int> result = await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId), CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfNone(-1).ShouldBe(playoutId);
}
[Test]
public async Task Handle_Should_Return_None_When_Channel_Has_No_Playout()
{
(int channelId, _) = await SeedChannelWithPlayout("5.1");
Option<int> result =
await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId + 1000), CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
}
@@ -97,20 +97,20 @@ public class ChannelControllerTests
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
public async Task Create_Should_Return_201_With_Location_And_ResponseModel_Body()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
ChannelViewModel vm = MakeVm(5);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
ChannelResponseModel model = MakeResponseModel(5);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelResponseModel>.Some(model));
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(vm);
created.Value.ShouldBe(model);
}
[Test]
@@ -118,8 +118,8 @@ public class ChannelControllerTests
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelResponseModel>.Some(MakeResponseModel(5)));
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
@@ -188,18 +188,23 @@ public class ChannelControllerTests
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
public async Task Update_Should_Return_200_With_ResponseModel_And_Map_Route_Id()
{
ChannelViewModel vm = MakeVm(7);
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelViewModel>(vm));
.Returns(Right<BaseError, ChannelViewModel>(MakeVm(7)));
ChannelResponseModel model = MakeResponseModel(7);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelResponseModel>.Some(model));
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
await _mediator.Received(1).Send(
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
Arg.Any<CancellationToken>());
await _mediator.Received(1).Send(
Arg.Is<GetChannelByIdForApi>(q => q.Id == 7),
Arg.Any<CancellationToken>());
}
[Test]
@@ -354,22 +359,22 @@ public class ChannelControllerTests
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
public async Task GetById_Should_Return_200_With_ResponseModel_For_Some()
{
ChannelViewModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
ChannelResponseModel model = MakeResponseModel(4);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelResponseModel>.Some(model));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.None);
_mediator.Send(Arg.Any<GetChannelByIdForApi>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelResponseModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
@@ -379,13 +384,21 @@ public class ChannelControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public void ResetPlayout_Route_Is_Keyed_By_Int_Id()
{
MethodInfo reset = typeof(ChannelController).GetMethod(nameof(ChannelController.ResetPlayout))!;
reset.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/channels/{id:int}/playout/reset");
}
[Test]
public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(404, mode: null, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
@@ -393,6 +406,22 @@ public class ChannelControllerTests
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public async Task ResetPlayout_Should_Resolve_Playout_By_Channel_Id()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, PlayoutScheduleKind.Classic)));
IActionResult result = await _controller.ResetPlayout(42, mode: null, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
await _mediator.Received(1).Send(
Arg.Is<GetPlayoutIdByChannelId>(q => q.ChannelId == 42),
Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
@@ -400,12 +429,12 @@ public class ChannelControllerTests
PlayoutScheduleKind scheduleKind,
PlayoutBuildMode expectedMode)
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
@@ -417,11 +446,11 @@ public class ChannelControllerTests
[Test]
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(5, mode: null, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
@@ -431,10 +460,10 @@ public class ChannelControllerTests
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
_mediator.Send(Arg.Any<GetPlayoutIdByChannelId>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
IActionResult result = await _controller.ResetPlayout(5, PlayoutBuildMode.Continue, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
@@ -457,6 +486,20 @@ public class ChannelControllerTests
null,
0);
private static ChannelResponseModel MakeResponseModel(int id) =>
new(
id,
"5",
5.0,
"Test",
"ErsatzTV",
string.Empty,
"HLS 720p",
"eng",
"MPEG-TS",
true,
false);
private static ChannelViewModel MakeVm(int id) =>
new(
id,
+22 -11
View File
@@ -68,11 +68,11 @@ public class ChannelController(
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ChannelResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
Option<ChannelResponseModel> result = await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
return result.ToGetResult();
}
@@ -80,7 +80,7 @@ public class ChannelController(
[Tags("Channels")]
[EndpointSummary("Create a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ChannelResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
@@ -92,10 +92,10 @@ public class ChannelController(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ChannelViewModel> channel =
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
Option<ChannelResponseModel> channel =
await mediator.Send(new GetChannelByIdForApi(created.ChannelId), cancellationToken);
return channel.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
Some: model => (IActionResult)new CreatedResult($"/api/channels/{model.Id}", model),
None: () => ApiResults.NotFoundProblem());
});
}
@@ -130,7 +130,7 @@ public class ChannelController(
[Tags("Channels")]
[EndpointSummary("Update a channel")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ChannelResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
@@ -140,7 +140,18 @@ public class ChannelController(
{
Either<BaseError, ChannelViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
// Re-project through the read-side query so the response carries the same
// ChannelResponseModel shape (incl. SortNumber and FFmpegProfile name) as GET.
Option<ChannelResponseModel> channel =
await mediator.Send(new GetChannelByIdForApi(id), cancellationToken);
return channel.Match(
Some: model => (IActionResult)new OkObjectResult(model),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/channels/{id:int}")]
@@ -203,7 +214,7 @@ public class ChannelController(
return result.ToDeletedResult();
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[HttpPost("/api/channels/{id:int}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
[EndpointDescription(
@@ -215,12 +226,12 @@ public class ChannelController(
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
string channelNumber,
int id,
[FromQuery] PlayoutBuildMode? mode,
CancellationToken cancellationToken)
{
Option<int> maybePlayoutId =
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
await mediator.Send(new GetPlayoutIdByChannelId(id), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
+3 -1
View File
@@ -998,6 +998,7 @@ describe('ChicoryTV SPA scaffold', () => {
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
channelStates: [{ channelId: 7, channelNumber: '5.1', onAir: true, nowPlaying: null }],
playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], totalCount: 1 }
});
@@ -1009,8 +1010,9 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
// Keyed on the resolved channel id (7), not the playout id (20) or the channel number.
expect(window.fetch).toHaveBeenCalledWith(
'/api/channels/5.1/playout/reset',
'/api/channels/7/playout/reset',
expect.objectContaining({ method: 'POST' })
);
});
+10 -1
View File
@@ -1829,8 +1829,17 @@ function PlayoutsScreen() {
if (!selectedSummary) {
return;
}
// The reset endpoint keys on the immutable channel id. The playout summary only carries the
// channel number, so resolve the id from channel state (which covers every channel).
const channelId = channelStates.find(
(state) => state.channelNumber === selectedSummary.channelNumber
)?.channelId;
if (channelId == null) {
setMutationError('Unable to resolve the channel for this playout.');
return;
}
runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
resetChannelPlayout(selectedSummary.channelNumber)
resetChannelPlayout(channelId)
);
};
+3 -12
View File
@@ -115,25 +115,16 @@ describe('playouts api client', () => {
expect(init).toMatchObject({ method: 'DELETE' });
});
it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => {
it('resetChannelPlayout POSTs to the id-keyed channel playout reset route without a mode', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('12.3');
await resetChannelPlayout(20);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/12.3/playout/reset');
expect(url).toBe('/api/channels/20/playout/reset');
expect(init).toMatchObject({ method: 'POST' });
});
it('resetChannelPlayout url-encodes the channel number', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('a b');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/a%20b/playout/reset');
});
it('erasePlayoutItems POSTs to the erase-items route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
+3 -2
View File
@@ -123,8 +123,9 @@ export function deletePlayout(playoutId: number): Promise<void> {
// Resets the given channel's playout. The server picks the correct default mode per schedule kind
// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent.
export function resetChannelPlayout(channelNumber: string): Promise<void> {
return request<void>(`/api/channels/${encodeURIComponent(channelNumber)}/playout/reset`, { method: 'POST' });
// Keyed on the immutable channel id (not the user-mutable number), matching the /api/channels/{id} contract.
export function resetChannelPlayout(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}/playout/reset`, { method: 'POST' });
}
export function erasePlayoutItems(playoutId: number): Promise<void> {