diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApi.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApi.cs new file mode 100644 index 000000000..bc527dde3 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Channels; + +namespace ErsatzTV.Application.Channels; + +public record GetChannelByIdForApi(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs new file mode 100644 index 000000000..95b04ab2f --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs @@ -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> +{ + public Task> Handle(GetChannelByIdForApi request, CancellationToken cancellationToken) => + channelRepository.GetChannel(request.Id) + .MapT(ProjectToResponseModel); +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelId.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelId.cs new file mode 100644 index 000000000..a7f6c6aaa --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelId.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Playouts; + +public record GetPlayoutIdByChannelId(int ChannelId) : IRequest>; diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelIdHandler.cs new file mode 100644 index 000000000..ee44c1e66 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutIdByChannelIdHandler.cs @@ -0,0 +1,18 @@ +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class GetPlayoutIdByChannelIdHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> 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()); + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 6d7d9c6a7..79ce5d889 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -11,6 +11,7 @@ public class ChannelRepository(IDbContextFactory 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) diff --git a/ErsatzTV.Tests/Application/Channels/GetChannelByIdForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetChannelByIdForApiHandlerTests.cs new file mode 100644 index 000000000..3b49f5646 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetChannelByIdForApiHandlerTests.cs @@ -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(); + repository.GetChannel(7) + .Returns(Option.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 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(); + repository.GetChannel(Arg.Any()).Returns(Option.None); + var handler = new GetChannelByIdForApiHandler(repository); + + Option result = + await handler.Handle(new GetChannelByIdForApi(99), CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + } +} diff --git a/ErsatzTV.Tests/Application/Playouts/GetPlayoutIdByChannelIdHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/GetPlayoutIdByChannelIdHandlerTests.cs new file mode 100644 index 000000000..228d0f83e --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/GetPlayoutIdByChannelIdHandlerTests.cs @@ -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 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 result = + await CreateHandler().Handle(new GetPlayoutIdByChannelId(channelId + 1000), CancellationToken.None); + + result.IsNone.ShouldBeTrue(); + } +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index f1f12b89f..570ac80a6 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -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(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); - ChannelViewModel vm = MakeVm(5); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Option.Some(vm)); + ChannelResponseModel model = MakeResponseModel(5); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(model)); IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None); var created = result.ShouldBeOfType(); 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(), Arg.Any()) .Returns(Right(new CreateChannelResult(5))); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Option.Some(MakeVm(5))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.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(), Arg.Any()) - .Returns(Right(vm)); + .Returns(Right(MakeVm(7))); + ChannelResponseModel model = MakeResponseModel(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(model)); IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(vm); + result.ShouldBeOfType().Value.ShouldBe(model); await _mediator.Received(1).Send( Arg.Is(c => c.ChannelId == 7), Arg.Any()); + await _mediator.Received(1).Send( + Arg.Is(q => q.Id == 7), + Arg.Any()); } [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(), Arg.Any()) - .Returns(Option.Some(vm)); + ChannelResponseModel model = MakeResponseModel(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(model)); IActionResult result = await _controller.GetById(4, CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(vm); + result.ShouldBeOfType().Value.ShouldBe(model); } [Test] public async Task GetById_Should_Return_404_For_None() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Option.None); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.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(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(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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(); var problemDetails = notFound.Value.ShouldBeOfType(); @@ -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(), Arg.Any()) + .Returns(Option.Some(9)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9, PlayoutScheduleKind.Classic))); + + IActionResult result = await _controller.ResetPlayout(42, mode: null, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(202); + await _mediator.Received(1).Send( + Arg.Is(q => q.ChannelId == 42), + Arg.Any()); + } + [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(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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().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(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.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(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); @@ -431,10 +460,10 @@ public class ChannelControllerTests [Test] public async Task ResetPlayout_Should_Honor_Explicit_Mode() { - _mediator.Send(Arg.Any(), Arg.Any()) + _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(9)); - IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None); + IActionResult result = await _controller.ResetPlayout(5, PlayoutBuildMode.Continue, CancellationToken.None); result.ShouldBeOfType().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, diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index d2357e9e7..cfc6362e3 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -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 GetById(int id, CancellationToken cancellationToken) { - Option result = await mediator.Send(new GetChannelById(id), cancellationToken); + Option 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 Create( @@ -92,10 +92,10 @@ public class ChannelController( Left: error => Task.FromResult(error.ToErrorResult()), Right: async created => { - Option channel = - await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken); + Option 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 Update( @@ -140,7 +140,18 @@ public class ChannelController( { Either 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 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 ResetPlayout( - string channelNumber, + int id, [FromQuery] PlayoutBuildMode? mode, CancellationToken cancellationToken) { Option 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. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index c5d52b106..a727628d1 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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' }) ); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index c814eb19d..e3302c1d8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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) ); }; diff --git a/web/src/api/playouts.test.ts b/web/src/api/playouts.test.ts index 164b8c0fb..da608608f 100644 --- a/web/src/api/playouts.test.ts +++ b/web/src/api/playouts.test.ts @@ -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()); diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts index 5d5e08aab..16dd50102 100644 --- a/web/src/api/playouts.ts +++ b/web/src/api/playouts.ts @@ -123,8 +123,9 @@ export function deletePlayout(playoutId: number): Promise { // 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 { - return request(`/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 { + return request(`/api/channels/${channelId}/playout/reset`, { method: 'POST' }); } export function erasePlayoutItems(playoutId: number): Promise {