From f1b3879d51821d8ad298e65ce0db3e62c9499331 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:05:51 +0200 Subject: [PATCH 1/7] feat(api): playout erase/scheduling-context + collection custom-order (#210, #211) Backend slice for the ChicoryTV playouts and collections screens. PlayoutController: - POST /api/playouts/{id}/erase-items (204; 404 pre-check; 422 unless Block/Sequential/Scripted) -> ErasePlayoutItems - POST /api/playouts/{id}/erase-items-and-history (204; 404; 422 unless Classic/Block/Sequential/Scripted) -> ErasePlayoutHistory - GET /api/playouts/items/{id}/scheduling-context (200/404) decodes a playout item's stored context by row id via a new GetPlayoutItemSchedulingContext query that reuses ProcessSchedulingContext - PlayoutItemResponseModel gains HasSchedulingContext (no raw JSON in list) - PlayoutListItemResponseModel gains PlayoutMode (ChannelNumber already present) CollectionController: - PUT /api/collections/{id}/custom-order (204; 404 pre-check; 422) with UpdateCollectionCustomOrderRequest deriving CustomIndex from array order - GetCollectionItemsHandler orders by CustomIndex (nulls last) then title/id when the collection's UseCustomPlaybackOrder is set Tests: controller route + behavior tests, OpenAPI ProblemDetails TestCases, GetCollectionItems custom-order handler test. Regenerated v1.json, v1.d.ts, endpoint-index.md. Co-Authored-By: Claude Fable 5 --- .../Queries/GetCollectionItemsHandler.cs | 54 ++- .../GetPlayoutItemSchedulingContext.cs | 3 + .../GetPlayoutItemSchedulingContextHandler.cs | 33 ++ .../Api/Playouts/PlayoutItemResponseModel.cs | 3 +- ...ayoutItemSchedulingContextResponseModel.cs | 4 + .../Playouts/PlayoutListItemResponseModel.cs | 3 +- .../GetCollectionItemsHandlerTests.cs | 138 ++++++++ .../Controllers/CollectionControllerTests.cs | 60 ++++ .../OpenApiErrorResponseContractTests.cs | 7 + .../Controllers/PlayoutControllerTests.cs | 153 ++++++++ .../Controllers/Api/CollectionController.cs | 27 ++ ErsatzTV/Controllers/Api/PlayoutController.cs | 87 ++++- .../UpdateCollectionCustomOrderRequest.cs | 13 + ErsatzTV/wwwroot/openapi/v1.json | 326 +++++++++++++++++- docs/endpoint-index.md | 6 +- web/src/api/generated/v1.d.ts | 8 + 16 files changed, 904 insertions(+), 21 deletions(-) create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContext.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContextHandler.cs create mode 100644 ErsatzTV.Core/Api/Playouts/PlayoutItemSchedulingContextResponseModel.cs create mode 100644 ErsatzTV.Tests/Application/MediaCollections/GetCollectionItemsHandlerTests.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateCollectionCustomOrderRequest.cs diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs index 2ad162178..72dc1191b 100644 --- a/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Queries/GetCollectionItemsHandler.cs @@ -16,32 +16,58 @@ public class GetCollectionItemsHandler(IDbContextFactory dbContextFac { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - bool exists = await dbContext.Collections + // A null flag here means the collection row does not exist (the projection yields no row), + // which lets a single query serve both the existence check and the custom-order flag. + bool? useCustomPlaybackOrder = await dbContext.Collections .AsNoTracking() - .AnyAsync(c => c.Id == request.Id, cancellationToken); - if (!exists) + .Where(c => c.Id == request.Id) + .Select(c => (bool?)c.UseCustomPlaybackOrder) + .SingleOrDefaultAsync(cancellationToken); + if (useCustomPlaybackOrder is null) { return new NotFoundError($"Collection {request.Id} does not exist."); } - // The collection graph is bounded, so load every member id and hydrate them in one shared - // pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported - // because MediaItem ids are globally unique across kinds. - List mediaItemIds = await dbContext.CollectionItems + // The collection graph is bounded, so load every member (with its CustomIndex) and hydrate + // them in one shared pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media + // kinds are supported because MediaItem ids are globally unique across kinds. + var collectionItems = await dbContext.CollectionItems .AsNoTracking() .Where(ci => ci.CollectionId == request.Id) - .Select(ci => ci.MediaItemId) + .Select(ci => new { ci.MediaItemId, ci.CustomIndex }) .ToListAsync(cancellationToken); + List mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList(); + List all = await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken); - // Stable title ordering mirrors the library-browse handler (which orders its rows by name), - // giving the SPA a deterministic, browsable list independent of collection insertion order. - List ordered = all - .OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase) - .ThenBy(i => i.Id) - .ToList(); + List ordered; + if (useCustomPlaybackOrder.Value) + { + // Custom order: sort by CustomIndex (items without one sort last), then title/id as a + // stable tiebreak. + var customIndexByMediaItemId = collectionItems + .GroupBy(ci => ci.MediaItemId) + .ToDictionary(g => g.Key, g => g.Select(ci => ci.CustomIndex).FirstOrDefault()); + + ordered = all + .OrderBy(i => customIndexByMediaItemId.TryGetValue(i.Id, out int? customIndex) && customIndex.HasValue + ? customIndex.Value + : int.MaxValue) + .ThenBy(i => i.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Id) + .ToList(); + } + else + { + // Stable title ordering mirrors the library-browse handler (which orders its rows by name), + // giving the SPA a deterministic, browsable list independent of collection insertion order. + ordered = all + .OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(i => i.Id) + .ToList(); + } int pageNum = Math.Max(0, request.PageNum); int pageSize = Math.Clamp(request.PageSize, 1, 100); diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContext.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContext.cs new file mode 100644 index 000000000..72a8f114b --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContext.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Playouts; + +public record GetPlayoutItemSchedulingContext(int PlayoutItemId) : IRequest>; diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContextHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContextHandler.cs new file mode 100644 index 000000000..d075cd53b --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemSchedulingContextHandler.cs @@ -0,0 +1,33 @@ +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class GetPlayoutItemSchedulingContextHandler( + IDbContextFactory dbContextFactory, + IMediator mediator) + : IRequestHandler> +{ + public async Task> Handle( + GetPlayoutItemSchedulingContext request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + string serializedContext = await dbContext.PlayoutItems + .AsNoTracking() + .Where(pi => pi.Id == request.PlayoutItemId) + .Select(pi => pi.SchedulingContext) + .SingleOrDefaultAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(serializedContext)) + { + return Option.None; + } + + // Decode/enrich exactly the way the troubleshooting decode path does, reusing the + // single ProcessSchedulingContext handler so any future format change stays in one place. + return await mediator.Send(new ProcessSchedulingContext(serializedContext), cancellationToken); + } +} diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs index ab9c7104e..6b080221a 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs @@ -7,4 +7,5 @@ public record PlayoutItemResponseModel( DateTimeOffset Start, DateTimeOffset Finish, string Duration, - FillerKind? FillerKind); + FillerKind? FillerKind, + bool HasSchedulingContext); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemSchedulingContextResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemSchedulingContextResponseModel.cs new file mode 100644 index 000000000..a6d97f4b8 --- /dev/null +++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemSchedulingContextResponseModel.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Playouts; + +public record PlayoutItemSchedulingContextResponseModel(string Context); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs index b7caa66ce..33aa15d5b 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs @@ -10,4 +10,5 @@ public record PlayoutListItemResponseModel( PlayoutScheduleKind ScheduleKind, string ScheduleName, TimeSpan? DailyRebuildTime, - PlayoutBuildStatusResponseModel? BuildStatus); + PlayoutBuildStatusResponseModel? BuildStatus, + ChannelPlayoutMode PlayoutMode); diff --git a/ErsatzTV.Tests/Application/MediaCollections/GetCollectionItemsHandlerTests.cs b/ErsatzTV.Tests/Application/MediaCollections/GetCollectionItemsHandlerTests.cs new file mode 100644 index 000000000..99ecf15bf --- /dev/null +++ b/ErsatzTV.Tests/Application/MediaCollections/GetCollectionItemsHandlerTests.cs @@ -0,0 +1,138 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.MediaCollections; + +[TestFixture] +public class GetCollectionItemsHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Return_NotFound_For_Missing_Collection() + { + var handler = new GetCollectionItemsHandler(_db.Factory); + + Either result = + await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + } + + [Test] + public async Task Handle_Should_Order_By_Title_When_Custom_Order_Disabled() + { + await SeedCollectionGraph(useCustomPlaybackOrder: false); + var handler = new GetCollectionItemsHandler(_db.Factory); + + Either result = + await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None); + + PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single(); + page.TotalCount.ShouldBe(3); + page.Page.Select(i => i.Title).ShouldBe(["Alpha", "Beta", "Zeta"]); + } + + [Test] + public async Task Handle_Should_Order_By_CustomIndex_With_Nulls_Last_When_Custom_Order_Enabled() + { + await SeedCollectionGraph(useCustomPlaybackOrder: true); + var handler = new GetCollectionItemsHandler(_db.Factory); + + Either result = + await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None); + + PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single(); + + // Zeta has CustomIndex 0, Alpha has CustomIndex 1, Beta has no CustomIndex (sorts last). + page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]); + } + + private async Task SeedCollectionGraph(bool useCustomPlaybackOrder) + { + await using TvContext context = _db.CreateContext(); + + var library = new LocalLibrary + { + Id = 1, + Name = "Library", + MediaKind = LibraryMediaKind.Movies, + Paths = [] + }; + var path = new LibraryPath + { + Id = 1, + Path = "/media", + Library = library, + LibraryFolders = [], + MediaItems = [] + }; + library.Paths.Add(path); + + Movie alpha = MakeMovie(101, path, "Alpha"); + Movie beta = MakeMovie(102, path, "Beta"); + Movie zeta = MakeMovie(103, path, "Zeta"); + + var collection = new Collection + { + Id = 10, + Name = "Manual", + UseCustomPlaybackOrder = useCustomPlaybackOrder, + MediaItems = [], + CollectionItems = + [ + new CollectionItem { MediaItemId = 103, CustomIndex = 0 }, + new CollectionItem { MediaItemId = 101, CustomIndex = 1 }, + new CollectionItem { MediaItemId = 102, CustomIndex = null } + ], + MultiCollections = [], + MultiCollectionItems = [] + }; + + context.LocalLibraries.Add(library); + context.Movies.AddRange(alpha, beta, zeta); + context.Collections.Add(collection); + await context.SaveChangesAsync(); + } + + private static Movie MakeMovie(int id, LibraryPath path, string title) => + new() + { + Id = id, + LibraryPath = path, + Collections = [], + CollectionItems = [], + TraktListItems = [], + MovieMetadata = + [ + new MovieMetadata + { + Title = title, + SortTitle = title, + Artwork = [], + Genres = [], + Tags = [], + Studios = [], + Actors = [], + Guids = [], + Subtitles = [], + Directors = [], + Writers = [] + } + ], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }] + }; +} diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs index 8db793dc0..fff04b2be 100644 --- a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs @@ -45,6 +45,66 @@ public class CollectionControllerTests nameof(CollectionController.RemoveItem), "DELETE", "/api/collections/{id:int}/items/{mediaItemId:int}"); + ShouldHaveActionRoute( + nameof(CollectionController.UpdateCustomOrder), + "PUT", + "/api/collections/{id:int}/custom-order"); + } + + [Test] + public async Task UpdateCustomOrder_Should_Return_404_When_Collection_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.UpdateCustomOrder( + 99, + new UpdateCollectionCustomOrderRequest([10, 20]), + CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(404); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task UpdateCustomOrder_Should_Return_204_And_Assign_Index_From_Order() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeVm(7, "Movies"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.UpdateCustomOrder( + 7, + new UpdateCollectionCustomOrderRequest([30, 10, 20]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => + c.CollectionId == 7 && + c.MediaItemCustomOrders.Count == 3 && + c.MediaItemCustomOrders[0].MediaItemId == 30 && c.MediaItemCustomOrders[0].CustomIndex == 0 && + c.MediaItemCustomOrders[1].MediaItemId == 10 && c.MediaItemCustomOrders[1].CustomIndex == 1 && + c.MediaItemCustomOrders[2].MediaItemId == 20 && c.MediaItemCustomOrders[2].CustomIndex == 2), + Arg.Any()); + } + + [Test] + public async Task UpdateCustomOrder_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeVm(7, "Movies"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.UpdateCustomOrder( + 7, + new UpdateCollectionCustomOrderRequest([10]), + CancellationToken.None); + + result.ShouldBeOfType(); } [Test] diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index 6f73776e7..a24049ea0 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -191,6 +191,13 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/playouts/{id}", "delete", "404")] [TestCase("/api/playouts/{id}", "delete", "422")] [TestCase("/api/playouts/{id}/items", "get", "404")] + [TestCase("/api/playouts/{id}/erase-items", "post", "404")] + [TestCase("/api/playouts/{id}/erase-items", "post", "422")] + [TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")] + [TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")] + [TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")] + [TestCase("/api/collections/{id}/custom-order", "put", "404")] + [TestCase("/api/collections/{id}/custom-order", "put", "422")] [TestCase("/api/artwork/uploads", "post", "422")] [TestCase("/api/ffmpeg/profiles/{id}", "get", "404")] [TestCase("/api/ffmpeg/profiles", "post", "404")] diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index daaf70580..9a813f168 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -57,6 +57,130 @@ public class PlayoutControllerTests ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/playouts/{id:int}/blocks"); ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/playouts/{id:int}/blocks/{blockId:int}/history"); ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/playouts/history/{id:int}"); + ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/playouts/{id:int}/erase-items"); + ShouldHaveActionRoute( + nameof(PlayoutController.EraseItemsAndHistory), + "POST", + "/api/playouts/{id:int}/erase-items-and-history"); + ShouldHaveActionRoute( + nameof(PlayoutController.GetItemSchedulingContext), + "GET", + "/api/playouts/items/{id:int}/scheduling-context"); + } + + // ----- Erase items / history ----- + + [Test] + public async Task EraseItems_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.EraseItems(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.Classic)] + [TestCase(PlayoutScheduleKind.ExternalJson)] + public async Task EraseItems_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.EraseItems(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.Block)] + [TestCase(PlayoutScheduleKind.Sequential)] + [TestCase(PlayoutScheduleKind.Scripted)] + public async Task EraseItems_Should_Return_204_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.EraseItems(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9), + Arg.Any()); + } + + [Test] + public async Task EraseItemsAndHistory_Should_Return_404_When_Playout_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.EraseItemsAndHistory(404, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task EraseItemsAndHistory_Should_Return_422_For_Unsupported_Kind() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.ExternalJson })); + + IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [TestCase(PlayoutScheduleKind.Classic)] + [TestCase(PlayoutScheduleKind.Block)] + [TestCase(PlayoutScheduleKind.Sequential)] + [TestCase(PlayoutScheduleKind.Scripted)] + public async Task EraseItemsAndHistory_Should_Return_204_And_Send_Command_For_Supported_Kind( + PlayoutScheduleKind kind) + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9) with { ScheduleKind = kind })); + + IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.PlayoutId == 9), + Arg.Any()); + } + + // ----- Playout item scheduling context ----- + + [Test] + public async Task GetItemSchedulingContext_Should_Return_200_With_Decoded_Context() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some("{ \"decoded\": true }")); + + IActionResult result = await _controller.GetItemSchedulingContext(42, CancellationToken.None); + + var context = result.ShouldBeOfType().Value + .ShouldBeOfType(); + context.Context.ShouldBe("{ \"decoded\": true }"); + await _mediator.Received(1).Send( + Arg.Is(q => q.PlayoutItemId == 42), + Arg.Any()); + } + + [Test] + public async Task GetItemSchedulingContext_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetItemSchedulingContext(404, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(404); } [Test] @@ -364,6 +488,7 @@ public class PlayoutControllerTests item.ChannelName.ShouldBe("Channel"); item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic); item.ScheduleName.ShouldBe("Schedule"); + item.PlayoutMode.ShouldBe(ChannelPlayoutMode.Continuous); item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4)); item.BuildStatus.ShouldNotBeNull(); item.BuildStatus.Success.ShouldBeFalse(); @@ -426,6 +551,34 @@ public class PlayoutControllerTests Arg.Any()); } + [Test] + public async Task GetItems_Should_Flag_HasSchedulingContext_Without_Exposing_Raw_Json() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakePlayout(9))); + + var withContext = new PlayoutItemViewModel( + "Movie", + new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), + "1:00:00", + "{ \"ScheduleId\": 1 }", + Option.None); + var withoutContext = withContext with { SchedulingContext = " " }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutItemsViewModel(2, [withContext, withoutContext])); + + IActionResult actionResult = await _controller.GetItems(9, showFiller: false, 0, 100, CancellationToken.None); + + var result = actionResult.ShouldBeOfType().Value + .ShouldBeOfType(); + result.Page[0].HasSchedulingContext.ShouldBeTrue(); + result.Page[1].HasSchedulingContext.ShouldBeFalse(); + + // The raw JSON blob must never appear on the list DTO. + typeof(PlayoutItemResponseModel).GetProperty("SchedulingContext").ShouldBeNull(); + } + [Test] public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails() { diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs index 983774b94..67e9b77d1 100644 --- a/ErsatzTV/Controllers/Api/CollectionController.cs +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -96,6 +96,33 @@ public class CollectionController(IMediator mediator) : ControllerBase }); } + [HttpPut("/api/collections/{id:int}/custom-order")] + [Tags("Collections")] + [EndpointSummary("Set a collection's custom playback order")] + [EndpointDescription( + "Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position " + + "(the request body has no index field); ids that are not members of the collection are ignored. Set " + + "UseCustomPlaybackOrder on the collection for this order to take effect.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task UpdateCustomOrder( + int id, + [Required] [FromBody] UpdateCollectionCustomOrderRequest request, + CancellationToken cancellationToken) + { + Option maybeCollection = + await mediator.Send(new GetCollectionById(id), cancellationToken); + if (maybeCollection.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return result.ToDeletedResult(); + } + [HttpDelete("/api/collections/{id:int}")] [Tags("Collections")] [EndpointSummary("Delete a collection")] diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index 636986c75..eefe8b647 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -521,6 +521,87 @@ public class PlayoutController(IMediator mediator) : ControllerBase return Accepted(); } + [HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")] + [Tags("Playouts")] + [EndpointSummary("Erase a playout's items")] + [EndpointDescription( + "Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, " + + "preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds " + + "return 422.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task EraseItems(int id, CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not (PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential + or PlayoutScheduleKind.Scripted)) + { + return BaseError.New("[EraseItems] is only valid for Block, Sequential, or Scripted playouts") + .ToErrorResult(); + } + } + + await mediator.Send(new ErasePlayoutItems(id), cancellationToken); + return NoContent(); + } + + [HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")] + [Tags("Playouts")] + [EndpointSummary("Erase a playout's items and history")] + [EndpointDescription( + "Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or " + + "Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task EraseItemsAndHistory(int id, CancellationToken cancellationToken) + { + Option maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); + if (maybePlayout.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + foreach (PlayoutNameViewModel playout in maybePlayout) + { + if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block + or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted)) + { + return BaseError.New( + "[EraseItemsAndHistory] is only valid for Classic, Block, Sequential, or Scripted playouts") + .ToErrorResult(); + } + } + + await mediator.Send(new ErasePlayoutHistory(id), cancellationToken); + return NoContent(); + } + + [HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")] + [Tags("Playouts")] + [EndpointSummary("Decode a playout item's scheduling context")] + [EndpointDescription( + "Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched " + + "JSON. Returns 404 when the item is missing or has no scheduling context.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlayoutItemSchedulingContextResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetItemSchedulingContext(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetPlayoutItemSchedulingContext(id), cancellationToken); + return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult(); + } + [HttpDelete("/api/playouts/{id:int}")] [Tags("Playouts")] [EndpointSummary("Delete a playout")] @@ -649,7 +730,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase vm.ScheduleKind, vm.ScheduleName, vm.DbDailyRebuildTime, - ToBuildStatus(vm.BuildStatus)); + ToBuildStatus(vm.BuildStatus), + vm.PlayoutMode); private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) => buildStatus is null @@ -665,5 +747,6 @@ public class PlayoutController(IMediator mediator) : ControllerBase vm.Start, vm.Finish, vm.Duration, - vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null)); + vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null), + !string.IsNullOrWhiteSpace(vm.SchedulingContext)); } diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateCollectionCustomOrderRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateCollectionCustomOrderRequest.cs new file mode 100644 index 000000000..faaa63714 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateCollectionCustomOrderRequest.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateCollectionCustomOrderRequest(List MediaItemIds) +{ + public UpdateCollectionCustomOrder ToCommand(int collectionId) => + new( + collectionId, + (MediaItemIds ?? []) + .Select((mediaItemId, index) => new MediaItemCustomOrder(mediaItemId, index)) + .ToList()); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 6696380b7..ab6821a91 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2793,6 +2793,96 @@ } } }, + "/api/collections/{id}/custom-order": { + "put": { + "tags": [ + "Collections" + ], + "summary": "Set a collection's custom playback order", + "description": "Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position (the request body has no index field); ids that are not members of the collection are ignored. Set UseCustomPlaybackOrder on the collection for this order to take effect.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/collections/{id}/items/{mediaItemId}": { "delete": { "tags": [ @@ -8132,6 +8222,201 @@ } } }, + "/api/playouts/{id}/erase-items": { + "post": { + "tags": [ + "Playouts" + ], + "summary": "Erase a playout's items", + "description": "Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds return 422.", + "operationId": "ErasePlayoutItems", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playouts/{id}/erase-items-and-history": { + "post": { + "tags": [ + "Playouts" + ], + "summary": "Erase a playout's items and history", + "description": "Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.", + "operationId": "ErasePlayoutItemsAndHistory", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/playouts/items/{id}/scheduling-context": { + "get": { + "tags": [ + "Playouts" + ], + "summary": "Decode a playout item's scheduling context", + "description": "Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched JSON. Returns 404 when the item is missing or has no scheduling context.", + "operationId": "GetPlayoutItemSchedulingContext", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/rerun-collections": { "get": { "tags": [ @@ -19053,7 +19338,8 @@ "start", "finish", "duration", - "fillerKind" + "fillerKind", + "hasSchedulingContext" ], "type": "object", "properties": { @@ -19086,6 +19372,20 @@ "$ref": "#/components/schemas/FillerKind" } ] + }, + "hasSchedulingContext": { + "type": "boolean" + } + } + }, + "PlayoutItemSchedulingContextResponseModel": { + "required": [ + "context" + ], + "type": "object", + "properties": { + "context": { + "type": "string" } } }, @@ -19097,7 +19397,8 @@ "scheduleKind", "scheduleName", "dailyRebuildTime", - "buildStatus" + "buildStatus", + "playoutMode" ], "type": "object", "properties": { @@ -19133,6 +19434,9 @@ "$ref": "#/components/schemas/PlayoutBuildStatusResponseModel" } ] + }, + "playoutMode": { + "$ref": "#/components/schemas/ChannelPlayoutMode" } } }, @@ -21394,6 +21698,24 @@ } } }, + "UpdateCollectionCustomOrderRequest": { + "required": [ + "mediaItemIds" + ], + "type": "object", + "properties": { + "mediaItemIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer", + "format": "int32" + } + } + } + }, "UpdateCollectionRequest": { "required": [ "name", diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 266f18203..4a9d994fe 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -125 endpoints, 198 operations. +129 endpoints, 202 operations. ## Artists @@ -75,6 +75,7 @@ | DELETE | `/api/collections/{id}` | | Delete a collection | | GET | `/api/collections/{id}` | GetCollectionById | Get a collection by id | | PUT | `/api/collections/{id}` | | Update a collection | +| PUT | `/api/collections/{id}/custom-order` | | Set a collection's custom playback order | | GET | `/api/collections/{id}/items` | GetCollectionItems | Get the items in a manual collection | | POST | `/api/collections/{id}/items` | | Add items to a collection | | DELETE | `/api/collections/{id}/items/{mediaItemId}` | | Remove an item from a collection | @@ -221,6 +222,7 @@ | GET | `/api/playouts` | GetPlayouts | List playouts | | POST | `/api/playouts` | | Create a playout | | GET | `/api/playouts/history/{id}` | GetPlayoutHistoryDetails | Decode a playout history row | +| GET | `/api/playouts/items/{id}/scheduling-context` | GetPlayoutItemSchedulingContext | Decode a playout item's scheduling context | | POST | `/api/playouts/reset-all` | ResetAllPlayouts | Reset all playouts | | GET | `/api/playouts/warnings/count` | GetPlayoutWarningsCount | Count playouts with a failed build | | DELETE | `/api/playouts/{id}` | | Delete a playout | @@ -231,6 +233,8 @@ | GET | `/api/playouts/{id}/blocks` | GetPlayoutBlocks | Get the blocks scheduled by a block playout | | GET | `/api/playouts/{id}/blocks/{blockId}/history` | GetPlayoutBlockHistory | Get a block's playout history | | PUT | `/api/playouts/{id}/deco` | | Set (or clear) a playout's default deco | +| POST | `/api/playouts/{id}/erase-items` | ErasePlayoutItems | Erase a playout's items | +| POST | `/api/playouts/{id}/erase-items-and-history` | ErasePlayoutItemsAndHistory | Erase a playout's items and history | | GET | `/api/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout | | GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates | | PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 9896c2f9f..8e02dfa7d 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -1049,6 +1049,10 @@ export interface components { "finish": string; "duration": null | string; "fillerKind": null | components["schemas"]["FillerKind"]; + "hasSchedulingContext": boolean; + }; + "PlayoutItemSchedulingContextResponseModel": { + "context": string; }; "PlayoutListItemResponseModel": { "id": number; @@ -1058,6 +1062,7 @@ export interface components { "scheduleName": string; "dailyRebuildTime": null | string; "buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"]; + "playoutMode": components["schemas"]["ChannelPlayoutMode"]; }; "PlayoutMode": "Flood" | "One" | "Multiple" | "Duration"; "PlayoutResponseModel": { @@ -1482,6 +1487,9 @@ export interface components { "shuffleScheduleItems": boolean; "randomStartPoint": boolean; "fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"]; + }; + "UpdateCollectionCustomOrderRequest": { + "mediaItemIds": null | Array; }; "UpdateCollectionRequest": { "name": null | string; From e66b926206cb3ba2020a39b8d9e82589aaef5815 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:10:45 +0200 Subject: [PATCH 2/7] feat(api): expose PlayoutItem row id on the playout-items list DTO (#210) The SPA needs the item's row id to call GET /api/playouts/items/{id}/scheduling-context. Plumbed through PlayoutItemViewModel -> PlayoutItemResponseModel as a nullable Id (null for synthesized UNSCHEDULED gap rows, which are PlayoutGaps, not PlayoutItems). Additive for existing consumers (Playouts.razor reads the VM by property). Regenerated v1.json + v1.d.ts. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/Playouts/Mapper.cs | 1 + .../Playouts/PlayoutItemViewModel.cs | 1 + .../Queries/GetFuturePlayoutItemsByIdHandler.cs | 2 ++ .../Api/Playouts/PlayoutItemResponseModel.cs | 2 ++ .../Controllers/PlayoutControllerTests.cs | 5 +++++ ErsatzTV/Controllers/Api/PlayoutController.cs | 1 + ErsatzTV/wwwroot/openapi/v1.json | 16 +++++++++------- web/src/api/generated/v1.d.ts | 5 +++-- 8 files changed, 24 insertions(+), 9 deletions(-) diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 18d10475e..da82809b5 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -23,6 +23,7 @@ internal static class Mapper internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => new( + playoutItem.Id, GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle), playoutItem.StartOffset, playoutItem.FinishOffset, diff --git a/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs index dbc2e4e12..641cb6060 100644 --- a/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs +++ b/ErsatzTV.Application/Playouts/PlayoutItemViewModel.cs @@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Application.Playouts; public record PlayoutItemViewModel( + int? Id, string Title, DateTimeOffset Start, DateTimeOffset Finish, diff --git a/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs index 84fe286fc..af80f53ae 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs @@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory dbCon var gap = playoutGaps.Single(g => g.Id == c.Id); TimeSpan gapDuration = gap.Finish - gap.Start; + // gaps are synthesized rows, not PlayoutItems, so they carry no row id return new PlayoutItemViewModel( + null, "UNSCHEDULED", gap.StartOffset, gap.FinishOffset, diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs index 6b080221a..24ae22fb1 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutItemResponseModel.cs @@ -1,8 +1,10 @@ +#nullable enable using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Core.Api.Playouts; public record PlayoutItemResponseModel( + int? Id, string Title, DateTimeOffset Start, DateTimeOffset Finish, diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index 9a813f168..cd285aad6 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -519,6 +519,7 @@ public class PlayoutControllerTests .Returns(Option.Some(MakePlayout(9))); var item = new PlayoutItemViewModel( + 77, "Movie", new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), @@ -526,6 +527,7 @@ public class PlayoutControllerTests string.Empty, Some(FillerKind.MidRoll)); var gap = new PlayoutItemViewModel( + null, "UNSCHEDULED", new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero), @@ -539,9 +541,11 @@ public class PlayoutControllerTests var result = actionResult.ShouldBeOfType().Value.ShouldBeOfType(); result.TotalCount.ShouldBe(2); + result.Page[0].Id.ShouldBe(77); result.Page[0].Title.ShouldBe("Movie"); result.Page[0].Duration.ShouldBe("1:00:00"); result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll); + result.Page[1].Id.ShouldBeNull(); result.Page[1].Title.ShouldBe("UNSCHEDULED"); result.Page[1].FillerKind.ShouldBeNull(); @@ -558,6 +562,7 @@ public class PlayoutControllerTests .Returns(Option.Some(MakePlayout(9))); var withContext = new PlayoutItemViewModel( + 42, "Movie", new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero), diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index eefe8b647..d7da83203 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -743,6 +743,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) => new( + vm.Id, vm.Title, vm.Start, vm.Finish, diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index ab6821a91..cb5b2da7b 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -19334,6 +19334,7 @@ }, "PlayoutItemResponseModel": { "required": [ + "id", "title", "start", "finish", @@ -19343,11 +19344,15 @@ ], "type": "object", "properties": { - "title": { + "id": { "type": [ "null", - "string" - ] + "integer" + ], + "format": "int32" + }, + "title": { + "type": "string" }, "start": { "type": "string", @@ -19358,10 +19363,7 @@ "format": "date-time" }, "duration": { - "type": [ - "null", - "string" - ] + "type": "string" }, "fillerKind": { "oneOf": [ diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 8e02dfa7d..81d187617 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -1044,10 +1044,11 @@ export interface components { "details": string; }; "PlayoutItemResponseModel": { - "title": null | string; + "id": null | number; + "title": string; "start": string; "finish": string; - "duration": null | string; + "duration": string; "fillerKind": null | components["schemas"]["FillerKind"]; "hasSchedulingContext": boolean; }; From 91e23bcd576ca33ecf4eb600de98f9eb6a63db8d Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:30:28 +0200 Subject: [PATCH 3/7] feat(spa): wire playout delete/reset/erase + scheduling context + templates preview calendar (#210) Adds the SPA slice for the newly-merged playout endpoints: - api/playouts: deletePlayout, resetChannelPlayout, erasePlayoutItems, erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext (+ types). - PlayoutsScreen: enable per-playout Reset (channel playout reset), Delete (danger, confirm), and erase-by-kind buttons (Block = items + items&history; Classic/Sequential/Scripted = items&history; none for ExternalJson); replaces the stale "no per-playout reset endpoint" block. Disable Alternate schedules for OnDemand Classic playouts (Blazor parity). - Per-item scheduling-context dialog (info button when hasSchedulingContext && id != null), rendering the decoded JSON in a scrollable
.
- Playout-templates editor: "Preview calendar" toggle with a Monday-first month
  grid; winning template per day computed client-side via a colocated
  appliesToDate/firstMatchingIndex helper that ports
  AlternateScheduleSelector.GetScheduleForDate / PlayoutTemplateEditViewModel
  .AppliesToDate exactly (inclusive range, year-wrap reverse, start-day rollover,
  end-day clamp, explicit-year override).
- Button gains a title prop (parity with IconButton) for the disabled-button hint.

Tests: api client URL/method assertions, PlayoutsScreen action flows
(delete/reset/erase per kind, OnDemand gating, scheduling-context dialog +
error), calendar-helper unit tests, and a preview-calendar render test.

Co-Authored-By: Claude Fable 5 
---
 web/src/App.test.tsx                          | 224 ++++++++++++++++++
 web/src/App.tsx                               | 210 +++++++++++++++-
 web/src/api/playouts.test.ts                  |  68 ++++++
 web/src/api/playouts.ts                       |  24 ++
 web/src/components/forms.tsx                  |   5 +-
 .../screens/PlayoutScheduleEditors.test.tsx   |  20 ++
 web/src/screens/PlayoutScheduleEditors.tsx    |  97 +++++++-
 .../screens/playoutTemplateCalendar.test.ts   | 118 +++++++++
 web/src/screens/playoutTemplateCalendar.ts    | 122 ++++++++++
 9 files changed, 873 insertions(+), 15 deletions(-)
 create mode 100644 web/src/screens/playoutTemplateCalendar.test.ts
 create mode 100644 web/src/screens/playoutTemplateCalendar.ts

diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 47aa024da..936f145ba 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1897,6 +1897,201 @@ describe('ChicoryTV SPA scaffold', () => {
     expect(await screen.findByText('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
   });
 
+  it('deletes the selected playout with confirmation and refetches', async () => {
+    mockDashboardApi({
+      confirm: true,
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    const playoutFetchesBeforeDelete = fetchCount('/api/playouts');
+
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'DELETE' }));
+    });
+    expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeDelete + 1);
+  });
+
+  it('resets the selected channel playout with confirmation', async () => {
+    mockDashboardApi({
+      confirm: true,
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
+
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/channels/5.1/playout/reset',
+        expect.objectContaining({ method: 'POST' })
+      );
+    });
+  });
+
+  it('shows both erase buttons for a Block playout and posts to the right routes', async () => {
+    mockDashboardApi({
+      confirm: true,
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
+      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Erase items' }));
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/playouts/20/erase-items',
+        expect.objectContaining({ method: 'POST' })
+      );
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: 'Erase items and history' }));
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/playouts/20/erase-items-and-history',
+        expect.objectContaining({ method: 'POST' })
+      );
+    });
+  });
+
+  it('shows only erase-items-and-history for a Classic playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
+  });
+
+  it('shows no erase buttons for an ExternalJson playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'ExternalJson' }),
+      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'ExternalJson' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.queryByRole('button', { name: 'Erase items and history' })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
+  });
+
+  it('disables Alternate schedules for an on-demand Classic playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeDisabled();
+  });
+
+  it('enables Alternate schedules for a continuous Classic playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeEnabled();
+  });
+
+  it('opens the scheduling-context dialog for an item that has one', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
+      playoutDetails: playout({ id: 20 }),
+      playoutSchedulingContext: 'SCHEDULING_CONTEXT_BODY',
+      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Scheduling context' }));
+
+    expect(await screen.findByText('SCHEDULING_CONTEXT_BODY')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/playouts/items/99/scheduling-context',
+        expect.any(Object)
+      );
+    });
+  });
+
+  it('shows an error in the scheduling-context dialog when the fetch fails', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
+      playoutDetails: playout({ id: 20 }),
+      playoutSchedulingContextFailure: { detail: 'No scheduling context available', status: 404 },
+      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: 'Scheduling context' }));
+
+    expect(await screen.findByText('No scheduling context available')).toBeInTheDocument();
+  });
+
+  it('does not show a scheduling-context button for items without one', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem({ hasSchedulingContext: false, id: 99 })],
+      playoutDetails: playout({ id: 20 }),
+      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.queryByRole('button', { name: 'Scheduling context' })).not.toBeInTheDocument();
+  });
+
   it('filters the playout selector rail by channel name', async () => {
     mockDashboardApi({
       playoutItems: [playoutItem()],
@@ -3576,6 +3771,8 @@ function mockDashboardApi({
   playoutItemsFailure = null,
   playoutItemsFailuresBeforeSuccess = 0,
   playoutItemsTotalCount = null,
+  playoutSchedulingContext = '{\n  "sample": true\n}',
+  playoutSchedulingContextFailure = null,
   playoutWarningsCount = 0,
   playouts = { page: [], totalCount: 0 },
   prompt = null,
@@ -3641,6 +3838,8 @@ function mockDashboardApi({
   playoutItemsFailure?: unknown;
   playoutItemsFailuresBeforeSuccess?: number;
   playoutItemsTotalCount?: number | null;
+  playoutSchedulingContext?: string;
+  playoutSchedulingContextFailure?: { detail?: string; status?: number; title?: string } | null;
   playoutWarningsCount?: number;
   playouts?: unknown;
   prompt?: string | null;
@@ -3903,6 +4102,23 @@ function mockDashboardApi({
       return Promise.resolve(jsonResponse(playoutTemplateItems));
     }
 
+    if (path.match(/^\/api\/playouts\/\d+\/erase-items(-and-history)?$/)) {
+      if (path in mutationFailures) {
+        const failure = mutationFailures[path] as { status?: number };
+        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
+      }
+      return Promise.resolve(new Response(null, { status: 204 }));
+    }
+
+    if (path.match(/^\/api\/playouts\/items\/\d+\/scheduling-context$/)) {
+      if (playoutSchedulingContextFailure) {
+        return Promise.resolve(
+          jsonResponse(playoutSchedulingContextFailure, playoutSchedulingContextFailure.status ?? 404)
+        );
+      }
+      return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
+    }
+
     if (path === '/api/templates') {
       return Promise.resolve(jsonResponse(templates));
     }
@@ -3948,6 +4164,14 @@ function mockDashboardApi({
         ));
       }
 
+      if (method === 'DELETE') {
+        if (path in mutationFailures) {
+          const failure = mutationFailures[path] as { status?: number };
+          return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
+        }
+        return Promise.resolve(new Response(null, { status: 204 }));
+      }
+
       return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
     }
 
diff --git a/web/src/App.tsx b/web/src/App.tsx
index d6e75683c..d79019e78 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -27,6 +27,7 @@ import {
   Crosshair,
   Film,
   FileImage,
+  Eraser,
   Folder,
   FolderInput,
   FolderTree,
@@ -126,6 +127,11 @@ import {
   bulkMoveChannelsToGroup,
   bulkRenumberChannels,
   createPlayout,
+  deletePlayout,
+  erasePlayoutItems,
+  erasePlayoutItemsAndHistory,
+  getPlayoutItemSchedulingContext,
+  resetChannelPlayout,
   deleteChannel,
   getDecos,
   messageFromError,
@@ -3035,6 +3041,7 @@ function PlayoutsScreen() {
   const [editOpen, setEditOpen] = useState(false);
   const [editBusy, setEditBusy] = useState(false);
   const [editError, setEditError] = useState(null);
+  const [contextItemId, setContextItemId] = useState(null);
 
   if (query.status === 'loading') {
     return ;
@@ -3073,6 +3080,61 @@ function PlayoutsScreen() {
       });
   };
 
+  const runMutation = (confirmMessage: string, action: () => Promise) => {
+    if (mutatingRef.current || !window.confirm(confirmMessage)) {
+      return;
+    }
+
+    setMutationError(null);
+    setMutatingState(true);
+    action()
+      .then(() => {
+        query.refresh();
+      })
+      .catch((error: unknown) => {
+        setMutationError(messageFromError(error));
+      })
+      .finally(() => {
+        setMutatingState(false);
+      });
+  };
+
+  const resetSelectedChannel = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
+      resetChannelPlayout(selectedSummary.channelNumber)
+    );
+  };
+
+  const deleteSelectedPlayout = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Delete the playout for ${selectedSummary.channelName}? This cannot be undone.`, () =>
+      deletePlayout(selectedSummary.id)
+    );
+  };
+
+  const eraseSelectedItems = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Erase all built items for ${selectedSummary.channelName}?`, () =>
+      erasePlayoutItems(selectedSummary.id)
+    );
+  };
+
+  const eraseSelectedItemsAndHistory = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Erase all built items and history for ${selectedSummary.channelName}?`, () =>
+      erasePlayoutItemsAndHistory(selectedSummary.id)
+    );
+  };
+
   const submitAddPlayout = (request: CreatePlayoutRequest) => {
     setAddBusy(true);
     setAddError(null);
@@ -3254,9 +3316,15 @@ function PlayoutsScreen() {
               {selectedSummary.scheduleKind === 'Classic' && (
                 
)} -
- - - Per-playout reset is deferred — the API has no per-playout reset endpoint yet. +
+ + {selectedSummary.scheduleKind === 'Block' && ( + + )} + {(selectedSummary.scheduleKind === 'Classic' || + selectedSummary.scheduleKind === 'Block' || + selectedSummary.scheduleKind === 'Sequential' || + selectedSummary.scheduleKind === 'Scripted') && ( + + )} +
@@ -3309,15 +3417,29 @@ function PlayoutsScreen() { {items.length === 0 ? (
{itemsLoading ? : 'No upcoming items'}
) : ( - items.map((item, index) => ( -
- {formatDateTime(item.start)} - {isFillerItem(item) ?
- )) + items.map((item, index) => { + const itemId = item.id; + + return ( +
+ {formatDateTime(item.start)} + {isFillerItem(item) ?
+ ); + }) )} @@ -3344,10 +3466,72 @@ function PlayoutsScreen() { playout={playout} /> )} + setContextItemId(null)} + /> ); } +function PlayoutSchedulingContextDialog({ itemId, onClose }: { itemId: number | null; onClose: () => void }) { + const [state, setState] = useState< + | { status: 'loading' } + | { status: 'success'; context: string } + | { status: 'error'; error: string } + >({ status: 'loading' }); + const activeRef = useRef(true); + + useEffect(() => { + activeRef.current = true; + + if (itemId != null) { + getPlayoutItemSchedulingContext(itemId) + .then((result) => { + if (activeRef.current) { + setState({ context: result.context, status: 'success' }); + } + }) + .catch((error: unknown) => { + if (activeRef.current) { + setState({ error: messageFromError(error), status: 'error' }); + } + }); + } + + return () => { + activeRef.current = false; + }; + }, [itemId]); + + return ( + + {state.status === 'loading' && } + {state.status === 'error' && ( +
+
+ )} + {state.status === 'success' && ( +
+          {state.context}
+        
+ )} +
+ ); +} + function PlayoutDefaultDecoField({ playout, onSaved }: { playout: PlayoutDetail; onSaved: () => void }) { const [decos, setDecos] = useState(null); const [saving, setSaving] = useState(false); diff --git a/web/src/api/playouts.test.ts b/web/src/api/playouts.test.ts index e42ff9ad5..164b8c0fb 100644 --- a/web/src/api/playouts.test.ts +++ b/web/src/api/playouts.test.ts @@ -1,10 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPlayout, + deletePlayout, + erasePlayoutItems, + erasePlayoutItemsAndHistory, getAlternateSchedules, + getPlayoutItemSchedulingContext, getPlayoutTemplates, replaceAlternateSchedules, replacePlayoutTemplates, + resetChannelPlayout, updatePlayoutDefaultDeco, updatePlayoutDetails, type CreatePlayoutRequest @@ -17,6 +22,10 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +function noContentResponse(): Response { + return new Response(null, { status: 204 }); +} + describe('playouts api client', () => { beforeEach(() => { window.localStorage.clear(); @@ -96,6 +105,65 @@ describe('playouts api client', () => { expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 }); }); + it('deletePlayout DELETEs the id route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse()); + + await expect(deletePlayout(4)).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/4'); + expect(init).toMatchObject({ method: 'DELETE' }); + }); + + it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({})); + + await resetChannelPlayout('12.3'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/channels/12.3/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()); + + await erasePlayoutItems(6); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/6/erase-items'); + expect(init).toMatchObject({ method: 'POST' }); + }); + + it('erasePlayoutItemsAndHistory POSTs to the erase-items-and-history route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse()); + + await erasePlayoutItemsAndHistory(6); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/6/erase-items-and-history'); + expect(init).toMatchObject({ method: 'POST' }); + }); + + it('getPlayoutItemSchedulingContext GETs the item scheduling-context route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ context: '{ "x": 1 }' })); + + await expect(getPlayoutItemSchedulingContext(42)).resolves.toMatchObject({ context: '{ "x": 1 }' }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/items/42/scheduling-context'); + expect(init?.method ?? 'GET').toBe('GET'); + }); + it('getAlternateSchedules GETs the alternate-schedules route', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([])); diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts index 0d93f6390..839281c3d 100644 --- a/web/src/api/playouts.ts +++ b/web/src/api/playouts.ts @@ -9,6 +9,8 @@ export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel']; export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel']; export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel']; export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind']; +export type ChannelPlayoutMode = components['schemas']['ChannelPlayoutMode']; +export type PlayoutItemSchedulingContext = components['schemas']['PlayoutItemSchedulingContextResponseModel']; export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest']; export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest']; @@ -110,6 +112,28 @@ export function resetAllPlayouts(): Promise { return request('/api/playouts/reset-all', { method: 'POST' }); } +export function deletePlayout(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}`, { method: 'DELETE' }); +} + +// 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' }); +} + +export function erasePlayoutItems(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}/erase-items`, { method: 'POST' }); +} + +export function erasePlayoutItemsAndHistory(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' }); +} + +export function getPlayoutItemSchedulingContext(itemId: number): Promise { + return request(`/api/playouts/items/${itemId}/scheduling-context`); +} + export function createPlayout(body: CreatePlayoutRequest): Promise { return request('/api/playouts', { body, diff --git a/web/src/components/forms.tsx b/web/src/components/forms.tsx index 367126dc1..831836409 100644 --- a/web/src/components/forms.tsx +++ b/web/src/components/forms.tsx @@ -20,6 +20,7 @@ export interface ButtonProps { type?: 'button' | 'submit' | 'reset'; onClick?: (e: MouseEvent) => void; style?: CSSProperties; + title?: string; } export function Button({ @@ -33,13 +34,15 @@ export function Button({ fullWidth = false, type = 'button', onClick, - style + style, + title }: ButtonProps) { return ( + + + {showPreview && } + {selected && (
@@ -905,6 +920,86 @@ export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number ); } +const PREVIEW_WEEKDAY_HEADERS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function PreviewCalendar({ + items, + templateNameOf +}: { + items: TemplateDraftItem[]; + templateNameOf: (id: number) => string; +}) { + const now = new Date(); + const [cursor, setCursor] = useState({ month: now.getMonth() + 1, year: now.getFullYear() }); + + const prioritized = items.filter((item) => item.templateId > 0); + const monthLength = daysInMonth(cursor.year, cursor.month); + // Monday-first grid: JS getDay() is 0=Sunday..6=Saturday; shift so Monday=0. + const leadingBlanks = (new Date(cursor.year, cursor.month - 1, 1).getDay() + 6) % 7; + + const cells: Array<{ day: number; winner: string | null } | null> = []; + for (let blank = 0; blank < leadingBlanks; blank += 1) { + cells.push(null); + } + for (let day = 1; day <= monthLength; day += 1) { + const matchIndex = firstMatchingIndex(prioritized, new Date(cursor.year, cursor.month - 1, day)); + cells.push({ + day, + winner: matchIndex >= 0 ? templateNameOf(prioritized[matchIndex].templateId) : null + }); + } + + const goPrevious = () => + setCursor((value) => (value.month === 1 ? { month: 12, year: value.year - 1 } : { month: value.month - 1, year: value.year })); + const goNext = () => + setCursor((value) => (value.month === 12 ? { month: 1, year: value.year + 1 } : { month: value.month + 1, year: value.year })); + + return ( + +
+ + + + {monthName(cursor.month, 'long')} {cursor.year} + + + +
+
+ {PREVIEW_WEEKDAY_HEADERS.map((label) => ( +
+ {label} +
+ ))} + {cells.map((cell, index) => + cell === null ? ( +
+ ) : ( +
+
{cell.day}
+ {cell.winner && ( +
+ {cell.winner} +
+ )} +
+ ) + )} +
+ + ); +} + function templateToDraft(template: PlayoutTemplate): TemplateDraftItem { return { key: nextKey(), diff --git a/web/src/screens/playoutTemplateCalendar.test.ts b/web/src/screens/playoutTemplateCalendar.test.ts new file mode 100644 index 000000000..a4083a3ed --- /dev/null +++ b/web/src/screens/playoutTemplateCalendar.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { appliesToDate, daysInMonth, firstMatchingIndex, type RecurrenceLimits } from './playoutTemplateCalendar'; +import type { DayOfWeek } from '../api'; + +const ALL_DAYS: DayOfWeek[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const ALL_DOM = Array.from({ length: 31 }, (_, index) => index + 1); +const ALL_MONTHS = Array.from({ length: 12 }, (_, index) => index + 1); + +function rec(overrides: Partial = {}): RecurrenceLimits { + return { + daysOfWeek: [...ALL_DAYS], + daysOfMonth: [...ALL_DOM], + monthsOfYear: [...ALL_MONTHS], + limitToDateRange: false, + startMonth: 1, + startDay: 1, + startYear: null, + endMonth: 12, + endDay: 31, + endYear: null, + ...overrides + }; +} + +describe('daysInMonth', () => { + it('returns the length of each month, respecting leap years', () => { + expect(daysInMonth(2026, 1)).toBe(31); + expect(daysInMonth(2026, 2)).toBe(28); + expect(daysInMonth(2024, 2)).toBe(29); // leap year + expect(daysInMonth(2026, 4)).toBe(30); + }); +}); + +describe('appliesToDate', () => { + it('applies to any date with defaults and no date-range limit', () => { + // 2026-01-06 is a Tuesday. + expect(appliesToDate(rec(), new Date(2026, 0, 6))).toBe(true); + }); + + it('filters by day of the week', () => { + const mondayOnly = rec({ daysOfWeek: ['Monday'] }); + expect(appliesToDate(mondayOnly, new Date(2026, 0, 5))).toBe(true); // Monday + expect(appliesToDate(mondayOnly, new Date(2026, 0, 6))).toBe(false); // Tuesday + }); + + it('filters by day of the month', () => { + const firstOnly = rec({ daysOfMonth: [1] }); + expect(appliesToDate(firstOnly, new Date(2026, 5, 1))).toBe(true); + expect(appliesToDate(firstOnly, new Date(2026, 5, 2))).toBe(false); + }); + + it('filters by month of the year', () => { + const juneOnly = rec({ monthsOfYear: [6] }); + expect(appliesToDate(juneOnly, new Date(2026, 5, 15))).toBe(true); + expect(appliesToDate(juneOnly, new Date(2026, 6, 15))).toBe(false); + }); + + it('limits to an inclusive date range on both ends', () => { + const march = rec({ limitToDateRange: true, startMonth: 3, startDay: 1, endMonth: 3, endDay: 31 }); + expect(appliesToDate(march, new Date(2026, 2, 1))).toBe(true); // Mar 1 (start boundary) + expect(appliesToDate(march, new Date(2026, 2, 31))).toBe(true); // Mar 31 (end boundary) + expect(appliesToDate(march, new Date(2026, 1, 28))).toBe(false); // Feb 28 + expect(appliesToDate(march, new Date(2026, 3, 1))).toBe(false); // Apr 1 + }); + + it('wraps the year boundary when the range is reversed', () => { + const winter = rec({ limitToDateRange: true, startMonth: 11, startDay: 1, endMonth: 2, endDay: 1 }); + expect(appliesToDate(winter, new Date(2026, 11, 15))).toBe(true); // Dec 15 + expect(appliesToDate(winter, new Date(2026, 0, 10))).toBe(true); // Jan 10 + expect(appliesToDate(winter, new Date(2026, 10, 1))).toBe(true); // Nov 1 (boundary, inclusive) + expect(appliesToDate(winter, new Date(2026, 1, 1))).toBe(true); // Feb 1 (boundary, inclusive) + expect(appliesToDate(winter, new Date(2026, 5, 1))).toBe(false); // Jun 1 (excluded middle) + }); + + it('uses explicit years and disables wrap-around when both years are set', () => { + // start month/day (Nov 1) is after end month/day (Feb 1), which would normally reverse — but with + // explicit years reverse is forced off, so this is a straight 2025-11-01 .. 2026-02-01 window. + const withYears = rec({ + limitToDateRange: true, + startMonth: 11, + startDay: 1, + startYear: 2025, + endMonth: 2, + endDay: 1, + endYear: 2026 + }); + expect(appliesToDate(withYears, new Date(2025, 11, 15))).toBe(true); // Dec 15 2025 (in window) + expect(appliesToDate(withYears, new Date(2026, 5, 1))).toBe(false); // Jun 1 2026 (after end) + expect(appliesToDate(withYears, new Date(2025, 5, 1))).toBe(false); // Jun 1 2025 (before start) + }); + + it('rolls a too-large start day forward to the 1st of the next month', () => { + // April has 30 days; startDay 31 rolls the window start forward to May 1. + const draft = rec({ limitToDateRange: true, startMonth: 4, startDay: 31, endMonth: 12, endDay: 31 }); + expect(appliesToDate(draft, new Date(2026, 3, 30))).toBe(false); // Apr 30 (before rolled start) + expect(appliesToDate(draft, new Date(2026, 4, 1))).toBe(true); // May 1 (rolled start) + }); + + it('clamps a too-large end day to the last day of the month (incl. leap years)', () => { + const nonLeap = rec({ limitToDateRange: true, startMonth: 1, startDay: 1, endMonth: 2, endDay: 31 }); + expect(appliesToDate(nonLeap, new Date(2026, 1, 28))).toBe(true); // Feb 28 2026 (clamped end) + expect(appliesToDate(nonLeap, new Date(2026, 2, 1))).toBe(false); // Mar 1 2026 + expect(appliesToDate(nonLeap, new Date(2024, 1, 29))).toBe(true); // Feb 29 2024 (leap year) + }); +}); + +describe('firstMatchingIndex', () => { + it('returns the first matching template in priority order', () => { + const items = [rec({ daysOfWeek: ['Monday'] }), rec()]; + expect(firstMatchingIndex(items, new Date(2026, 0, 5))).toBe(0); // Monday → first row wins + expect(firstMatchingIndex(items, new Date(2026, 0, 6))).toBe(1); // Tuesday → falls through to row 1 + }); + + it('returns -1 when no template applies', () => { + const items = [rec({ daysOfWeek: ['Monday'] }), rec({ daysOfWeek: ['Wednesday'] })]; + expect(firstMatchingIndex(items, new Date(2026, 0, 6))).toBe(-1); // Tuesday matches neither + }); +}); diff --git a/web/src/screens/playoutTemplateCalendar.ts b/web/src/screens/playoutTemplateCalendar.ts new file mode 100644 index 000000000..c87c3ab09 --- /dev/null +++ b/web/src/screens/playoutTemplateCalendar.ts @@ -0,0 +1,122 @@ +import type { DayOfWeek } from '../api'; + +// The recurrence limits shared by playout templates and alternate schedules — the subset of fields the +// preview-calendar matcher reads. +export interface RecurrenceLimits { + daysOfWeek: DayOfWeek[]; + daysOfMonth: number[]; + monthsOfYear: number[]; + limitToDateRange: boolean; + startMonth: number; + startDay: number; + startYear: number | null; + endMonth: number; + endDay: number; + endYear: number | null; +} + +// Sunday-first, indexed to match JS Date.getDay() (0 = Sunday). Mirrors System.DayOfWeek used by the +// C# AppliesToDate check. +const WEEKDAY_NAMES: DayOfWeek[] = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday' +]; + +export function daysInMonth(year: number, month: number): number { + // month is 1-based; day 0 of month+1 is the last day of `month`. + return new Date(year, month, 0).getDate(); +} + +// A date compressed to an ordered, comparable integer (yyyymmdd), mirroring C#'s `.Date` comparisons. +function dateKey(year: number, month: number, day: number): number { + return year * 10000 + month * 100 + day; +} + +// Exact TypeScript port of ErsatzTV's C# scheduling logic used by the Blazor "Preview Calendar": +// ErsatzTV/ViewModels/PlayoutTemplateEditViewModel.AppliesToDate → applied to a single template via +// ErsatzTV.Core/Scheduling/AlternateScheduleSelector.GetScheduleForDate. +// - When limitToDateRange is set, the [start, end] window is inclusive on both ends. +// - reverse = (startMonth*100+startDay) > (endMonth*100+endDay) wraps the year boundary, UNLESS +// both startYear and endYear are set (then reverse is forced off and explicit years are used). +// - A start day past the month length rolls over to the 1st of the next month; an end day past the +// month length clamps to the last day of that month (matching the C# try/catch fallbacks). +// - After the range gate, the date's weekday, day-of-month and month must all be in the sets. +export function appliesToDate(rec: RecurrenceLimits, date: Date): boolean { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + if (rec.limitToDateRange) { + let reverse = rec.startMonth * 100 + rec.startDay > rec.endMonth * 100 + rec.endDay; + let startYear = year; + let endYear = year; + + if (rec.startYear != null && rec.endYear != null) { + startYear = rec.startYear; + endYear = rec.endYear; + reverse = false; + } + + // start = new DateTime(startYear, startMonth, startDay); on an out-of-range day roll to the 1st + // of the next month. + let startY = startYear; + let startM = rec.startMonth; + let startD = rec.startDay; + if (rec.startDay > daysInMonth(startYear, rec.startMonth)) { + const rolled = new Date(startYear, rec.startMonth, 1); // month index rec.startMonth == next month + startY = rolled.getFullYear(); + startM = rolled.getMonth() + 1; + startD = 1; + } + + // end = new DateTime(endYear, endMonth, endDay); on an out-of-range day clamp to the month length. + const endY = endYear; + const endM = rec.endMonth; + let endD = rec.endDay; + const endMonthLength = daysInMonth(endYear, rec.endMonth); + if (rec.endDay > endMonthLength) { + endD = endMonthLength; + } + + let startKey = dateKey(startY, startM, startD); + let endKey = dateKey(endY, endM, endD); + const dKey = dateKey(year, month, day); + + if (reverse) { + [startKey, endKey] = [endKey, startKey]; + if (dKey > startKey && dKey < endKey) { + return false; + } + } else if (dKey < startKey || dKey > endKey) { + return false; + } + } + + if (!rec.daysOfWeek.includes(WEEKDAY_NAMES[date.getDay()])) { + return false; + } + if (!rec.daysOfMonth.includes(day)) { + return false; + } + if (!rec.monthsOfYear.includes(month)) { + return false; + } + + return true; +} + +// The index of the first template (in priority order) that applies to `date`, or -1 if none — +// mirrors the Blazor DateRangeChanged loop (ordered by Index, first AppliesToDate match wins). +export function firstMatchingIndex(items: RecurrenceLimits[], date: Date): number { + for (let i = 0; i < items.length; i += 1) { + if (appliesToDate(items[i], date)) { + return i; + } + } + return -1; +} From 606d50bb8d1f3dbbecaa6397bd8fdf16aae0dd89 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:35:43 +0200 Subject: [PATCH 4/7] feat(spa): collection custom-order reorder UI + all-kind add picker (#211) Adds PUT /api/collections/{id}/custom-order support (updateCollectionCustomOrder) and a reorder mode in ManualItemsView: loads every page of a manual collection (so the wholesale-replace PUT never drops items), lets the user move items with up/down icon buttons, and saves/cancels. Reorder is offered for any manual collection with useCustomPlaybackOrder on, not just movies-only (server/enumerator already support any kind). Widens the add-items picker (ADDABLE_TYPE_LIST, MEDIA_KIND_FILTERS, toAddItemsRequest) from 4 to all 10 addable media kinds; the default "All" search fan-out stays Movie/Show/Artist (seasons excluded per #180), with the new kinds reachable via their specific filter, mirroring Blazor's per-kind list pages. --- web/src/api/collections.test.ts | 32 +++- web/src/api/collections.ts | 50 +++-- web/src/screens/CollectionsScreen.test.tsx | 145 +++++++++++++- web/src/screens/CollectionsScreen.tsx | 213 +++++++++++++++++++-- 4 files changed, 411 insertions(+), 29 deletions(-) diff --git a/web/src/api/collections.test.ts b/web/src/api/collections.test.ts index 61fe660d8..9aa26d35e 100644 --- a/web/src/api/collections.test.ts +++ b/web/src/api/collections.test.ts @@ -12,6 +12,7 @@ import { removeItemFromCollection, toAddItemsRequest, updateCollection, + updateCollectionCustomOrder, updateSmartCollection } from './collections'; import type { LibraryBrowseItem } from './libraryBrowse'; @@ -184,6 +185,17 @@ describe('collections api client', () => { expect(url.searchParams.get('pageNum')).toBe('0'); expect(url.searchParams.get('pageSize')).toBe('100'); }); + + it('updateCollectionCustomOrder PUTs the full ordered mediaItemIds array', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); + + await updateCollectionCustomOrder(7, [30, 10, 20]); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/collections/7/custom-order'); + expect(init).toMatchObject({ method: 'PUT' }); + expect(JSON.parse(String(init?.body))).toEqual({ mediaItemIds: [30, 10, 20] }); + }); }); describe('toAddItemsRequest bucket mapping', () => { @@ -202,6 +214,24 @@ describe('toAddItemsRequest bucket mapping', () => { expect(result.artistIds).toEqual([40]); }); + it('routes the remaining six addable kinds into their own buckets (#211)', () => { + const result = toAddItemsRequest([ + browseItem(50, 'Episode'), + browseItem(51, 'MusicVideo'), + browseItem(52, 'Song'), + browseItem(53, 'OtherVideo'), + browseItem(54, 'Image'), + browseItem(55, 'RemoteStream') + ]); + + expect(result.episodeIds).toEqual([50]); + expect(result.musicVideoIds).toEqual([51]); + expect(result.songIds).toEqual([52]); + expect(result.otherVideoIds).toEqual([53]); + expect(result.imageIds).toEqual([54]); + expect(result.remoteStreamIds).toEqual([55]); + }); + it('skips kinds that are not addable media items (collections, playlists, etc.)', () => { const result = toAddItemsRequest([ browseItem(1, 'Collection'), @@ -218,7 +248,7 @@ describe('toAddItemsRequest bucket mapping', () => { expect(result.artistIds).toEqual([]); }); - it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => { + it('emptyAddItemsRequest starts every bucket empty', () => { const empty = emptyAddItemsRequest(); expect(empty.episodeIds).toEqual([]); diff --git a/web/src/api/collections.ts b/web/src/api/collections.ts index 5c476e545..f22d4b7ca 100644 --- a/web/src/api/collections.ts +++ b/web/src/api/collections.ts @@ -7,6 +7,7 @@ export type SmartCollection = components['schemas']['SmartCollectionViewModel']; export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest']; export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest']; export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest']; +export type UpdateCollectionCustomOrderRequest = components['schemas']['UpdateCollectionCustomOrderRequest']; export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest']; export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest']; @@ -41,7 +42,9 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi } // Lists a manual collection's full contents (all media kinds), paged. Backed by -// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. +// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. When the +// collection's useCustomPlaybackOrder is true, items come back ordered by CustomIndex (nulls +// last, then title); otherwise title order. export function getCollectionItems( id: number, pageNum = 0, @@ -54,6 +57,14 @@ export function getCollectionItems( return request(`/api/collections/${id}/items?${params.toString()}`); } +// Replaces a manual collection's custom order wholesale: the CustomIndex of each media item is +// derived from its position in `mediaItemIds`, so a partial array silently drops the items left +// out of it (#211). Callers must submit the full ordered id list. +export function updateCollectionCustomOrder(id: number, mediaItemIds: number[]): Promise { + const body: UpdateCollectionCustomOrderRequest = { mediaItemIds }; + return request(`/api/collections/${id}/custom-order`, { body, method: 'PUT' }); +} + /* ---------- smart collections ---------- */ export function getSmartCollections(): Promise { @@ -78,11 +89,11 @@ export function deleteSmartCollection(id: number): Promise { /* ---------- add-items bucket mapping ---------- */ -// The add-items request buckets media-item ids by kind. The library-browse search only -// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are -// the only buckets reachable from the picker. The remaining buckets (episodes, music -// videos, songs, images, other videos, remote streams) can't be produced by browse and -// are left empty here. See CollectionsScreen for the honest note about this limit. +// The add-items request buckets media-item ids by kind. All 10 addable kinds (Movie / +// TelevisionShow / TelevisionSeason / Artist / Episode / MusicVideo / Song / OtherVideo / +// Image / RemoteStream) are reachable from the picker via `toAddItemsRequest` below — the +// default "All" fan-out only searches Movie/TelevisionShow/Artist (see CollectionsScreen's +// DEFAULT_SEARCH_KINDS), but every kind can be found by picking its specific filter. export function emptyAddItemsRequest(): AddItemsToCollectionRequest { return { artistIds: [], @@ -98,11 +109,10 @@ export function emptyAddItemsRequest(): AddItemsToCollectionRequest { }; } -// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show / -// Season / Artist are the only kinds library-browse can return as concrete media; each -// carries its media-item id in `id` (which equals `mediaItemId` for these types, since -// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since -// the server validates each bucket against that entity type (a Show id in movieIds fails +// Buckets a set of browse results into an AddItemsToCollectionRequest. Each addable kind +// carries its media-item id in `id` (which equals `mediaItemId` for these types, since they +// are all MediaItem subclasses). Ids must go into their type-specific bucket, since the +// server validates each bucket against that entity type (a Show id in movieIds fails // validation). Any other kind (collections, smart/multi/rerun collections, playlists) is // not an addable media item and is skipped. export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest { @@ -122,6 +132,24 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect case 'Artist': requestBody.artistIds?.push(item.id); break; + case 'Episode': + requestBody.episodeIds?.push(item.id); + break; + case 'MusicVideo': + requestBody.musicVideoIds?.push(item.id); + break; + case 'Song': + requestBody.songIds?.push(item.id); + break; + case 'OtherVideo': + requestBody.otherVideoIds?.push(item.id); + break; + case 'Image': + requestBody.imageIds?.push(item.id); + break; + case 'RemoteStream': + requestBody.remoteStreamIds?.push(item.id); + break; default: break; } diff --git a/web/src/screens/CollectionsScreen.test.tsx b/web/src/screens/CollectionsScreen.test.tsx index 3d262eb9c..466a5ec78 100644 --- a/web/src/screens/CollectionsScreen.test.tsx +++ b/web/src/screens/CollectionsScreen.test.tsx @@ -276,7 +276,13 @@ describe('CollectionsScreen', () => { Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' }, TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' }, TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' }, - Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' } + Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }, + Episode: { id: 5, mediaType: 'Episode', title: 'The Pilot Episode' }, + MusicVideo: { id: 6, mediaType: 'MusicVideo', title: 'Music Video Reel' }, + Song: { id: 7, mediaType: 'Song', title: 'Song of the Sea' }, + OtherVideo: { id: 8, mediaType: 'OtherVideo', title: 'Home Video Clip' }, + Image: { id: 9, mediaType: 'Image', title: 'Poster Art' }, + RemoteStream: { id: 10, mediaType: 'RemoteStream', title: 'Live Feed' } }; function mockAddItemsApi() { @@ -358,4 +364,141 @@ describe('CollectionsScreen', () => { .some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason'); expect(seasonCall).toBe(true); }); + + it('the picker note explains the "All" fan-out and the reachable extra kinds (#211)', async () => { + mockAddItemsApi(); + + const dialog = await openAddItemsDialog(); + + expect( + within(dialog).getByText( + /searches movies, shows and artists; pick a specific kind to add seasons, episodes, music videos, songs, other videos, images or remote streams\./ + ) + ).toBeInTheDocument(); + }); + + it('selecting a newly-addable kind filter (Song) surfaces it and buckets it correctly on add', async () => { + const fetchMock = mockAddItemsApi(); + + const dialog = await openAddItemsDialog(); + fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), { + target: { value: 'a' } + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Song' })); + + expect(await within(dialog).findByText('Song of the Sea')).toBeInTheDocument(); + + const songCall = fetchMock.mock.calls + .map(([u]) => u.toString()) + .filter((u) => u.startsWith('/api/library/browse')) + .some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'Song'); + expect(songCall).toBe(true); + + fireEvent.click(within(dialog).getByText('Song of the Sea')); + fireEvent.click(within(dialog).getByRole('button', { name: /Add 1 item/ })); + + await waitFor(() => { + const addCall = fetchMock.mock.calls.find( + ([u, init]) => u === '/api/collections/1/items' && (init?.method ?? '').toUpperCase() === 'POST' + ); + expect(addCall).toBeDefined(); + expect(JSON.parse(String(addCall?.[1]?.body)).songIds).toEqual([7]); + }); + }); + + /* ---------- reorder mode (#211) ---------- */ + + function reorderPages(id: number) { + return { + [`/api/collections/${id}/items:0`]: { + page: [ + { id: 100, mediaItemId: 100, mediaType: 'Movie', title: 'Alpha' }, + { id: 200, mediaItemId: 200, mediaType: 'Movie', title: 'Bravo' } + ], + totalCount: 3 + }, + [`/api/collections/${id}/items:1`]: { + page: [{ id: 300, mediaItemId: 300, mediaType: 'Movie', title: 'Charlie' }], + totalCount: 3 + } + }; + } + + function mockReorderApi() { + const pages = reorderPages(2); + + return mockApi({ + onRequest: (url, method) => { + if (/^\/api\/collections\/2\/items/.test(url) && method === 'GET') { + const pageNum = new URL(url, 'http://localhost').searchParams.get('pageNum') ?? '0'; + const key = `/api/collections/2/items:${pageNum}`; + return jsonResponse(pages[key] ?? { page: [], totalCount: 0 }); + } + + return null; + } + }); + } + + it('reorder mode loads every page, supports moving items, and saves the full ordered array', async () => { + const fetchMock = mockReorderApi(); + + render(); + await screen.findByText('Kids'); + + fireEvent.click(screen.getByText('Kids')); + expect(await screen.findByText('Alpha')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reorder' })); + + // Reorder mode fetches every page (0 then 1) before entering, so all three items render. + expect(await screen.findByRole('button', { name: 'Save order' })).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + + const itemsCallCount = () => + fetchMock.mock.calls.filter(([u]) => /^\/api\/collections\/2\/items/.test(u.toString())).length; + // Initial load (page 0) + enterReorder's page 0 + page 1 = 3 GETs minimum. + expect(itemsCallCount()).toBeGreaterThanOrEqual(3); + + // Add/remove actions are disabled while reordering. + expect(screen.getByRole('button', { name: 'Add items' })).toBeDisabled(); + + // Move "Bravo" (index 1) down, so the order becomes Alpha, Charlie, Bravo. + fireEvent.click(screen.getByRole('button', { name: 'Move Bravo down' })); + + fireEvent.click(screen.getByRole('button', { name: 'Save order' })); + + await waitFor(() => { + const putCall = fetchMock.mock.calls.find( + ([u, init]) => u === '/api/collections/2/custom-order' && (init?.method ?? '').toUpperCase() === 'PUT' + ); + expect(putCall).toBeDefined(); + expect(JSON.parse(String(putCall?.[1]?.body))).toEqual({ mediaItemIds: [100, 300, 200] }); + }); + + // Exits reorder mode and refreshes the normal paged view. + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Save order' })).not.toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Add items' })).not.toBeDisabled(); + }); + + it('canceling reorder mode discards local moves without saving', async () => { + const fetchMock = mockReorderApi(); + + render(); + await screen.findByText('Kids'); + fireEvent.click(screen.getByText('Kids')); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByRole('button', { name: 'Reorder' })); + await screen.findByRole('button', { name: 'Save order' }); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByRole('button', { name: 'Save order' })).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some(([u]) => u === '/api/collections/2/custom-order') + ).toBe(false); + }); }); diff --git a/web/src/screens/CollectionsScreen.tsx b/web/src/screens/CollectionsScreen.tsx index d2a2fe16c..4e4b68e87 100644 --- a/web/src/screens/CollectionsScreen.tsx +++ b/web/src/screens/CollectionsScreen.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + ArrowDown, ArrowLeft, + ArrowUp, Check, FolderTree, Info, @@ -37,6 +39,7 @@ import { removeItemFromCollection, toAddItemsRequest, updateCollection, + updateCollectionCustomOrder, updateSmartCollection, type LibraryBrowseItem, type MediaCollection, @@ -47,12 +50,23 @@ import { TYPE_LABEL } from '../media/mediaKinds'; type Tab = 'manual' | 'smart'; // Every kind that can be added to a manual collection from the picker. -const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']; +const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [ + 'Movie', + 'TelevisionShow', + 'TelevisionSeason', + 'Artist', + 'Episode', + 'MusicVideo', + 'Song', + 'OtherVideo', + 'Image', + 'RemoteStream' +]; const ADDABLE_TYPES = new Set(ADDABLE_TYPE_LIST); // The default fan-out excludes seasons so a multi-season show doesn't flood the -// results with per-season rows (issue #180); seasons stay reachable via the -// explicit media-kind filter below. +// results with per-season rows (issue #180); seasons (and the other narrower kinds +// added for #211) stay reachable via the explicit media-kind filter below. const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist']; type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType']; @@ -62,7 +76,13 @@ const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [ { label: 'Movies', value: 'Movie' }, { label: 'Shows', value: 'TelevisionShow' }, { label: 'Seasons', value: 'TelevisionSeason' }, - { label: 'Artists', value: 'Artist' } + { label: 'Artists', value: 'Artist' }, + { label: TYPE_LABEL.Episode, value: 'Episode' }, + { label: TYPE_LABEL.MusicVideo, value: 'MusicVideo' }, + { label: TYPE_LABEL.Song, value: 'Song' }, + { label: TYPE_LABEL.OtherVideo, value: 'OtherVideo' }, + { label: TYPE_LABEL.Image, value: 'Image' }, + { label: TYPE_LABEL.RemoteStream, value: 'RemoteStream' } ]; function sortByName(items: T[]): T[] { @@ -434,9 +454,8 @@ function AddItemsDialog({ ))}

- Add movies, shows, seasons and artists. “All” searches movies, shows and artists; pick - “Seasons” to find a specific season. Episodes, music, images and other item kinds can’t be - added from here yet. + “All” searches movies, shows and artists; pick a specific kind to add seasons, episodes, + music videos, songs, other videos, images or remote streams.

{error && ( @@ -490,6 +509,10 @@ function ManualItemsView({ const [error, setError] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [removing, setRemoving] = useState(null); + const [reordering, setReordering] = useState(false); + const [reorderItems, setReorderItems] = useState(null); + const [reorderLoading, setReorderLoading] = useState(false); + const [reorderSaving, setReorderSaving] = useState(false); const activeRef = useRef(true); // No synchronous setState here: `loading` starts true and flips false in `finally`, so @@ -554,6 +577,98 @@ function ManualItemsView({ } }; + // The custom-order PUT replaces the order wholesale from the submitted array, so a + // partial list would silently drop the items left out of it — every page must be loaded + // before reorder mode is entered. If any page fails to load, bail out without entering + // reorder mode (the error surfaces via the normal `error` banner). + const enterReorder = async () => { + setReorderLoading(true); + setError(null); + + try { + const first = await getCollectionItems(collection.id, 0, PAGE_SIZE); + let all = first.page ?? []; + const totalCount = first.totalCount ?? all.length; + let pageNum = 1; + + while (all.length < totalCount) { + const next = await getCollectionItems(collection.id, pageNum, PAGE_SIZE); + const nextPage = next.page ?? []; + + if (nextPage.length === 0) { + break; + } + + all = [...all, ...nextPage]; + pageNum += 1; + } + + if (activeRef.current) { + setReorderItems(all); + setReordering(true); + } + } catch (reorderLoadError) { + if (activeRef.current) { + setError(messageFromCollectionError(reorderLoadError, 'Unable to load items for reordering')); + } + } finally { + if (activeRef.current) { + setReorderLoading(false); + } + } + }; + + const moveItem = (index: number, direction: -1 | 1) => { + setReorderItems((current) => { + if (!current) { + return current; + } + + const target = index + direction; + if (target < 0 || target >= current.length) { + return current; + } + + const next = [...current]; + [next[index], next[target]] = [next[target], next[index]]; + return next; + }); + }; + + const cancelReorder = () => { + setReordering(false); + setReorderItems(null); + setError(null); + }; + + const saveOrder = async () => { + if (!reorderItems) { + return; + } + + setReorderSaving(true); + setError(null); + + try { + const mediaItemIds = reorderItems.map((item) => item.mediaItemId ?? item.id); + await updateCollectionCustomOrder(collection.id, mediaItemIds); + + if (activeRef.current) { + setReordering(false); + setReorderItems(null); + load(); + } + } catch (saveError) { + if (activeRef.current) { + setError(messageFromCollectionError(saveError, 'Unable to save order')); + } + } finally { + if (activeRef.current) { + setReorderSaving(false); + } + } + }; + return (
@@ -562,7 +677,24 @@ function ManualItemsView({ {collection.name} - + )} +
@@ -575,7 +707,41 @@ function ManualItemsView({ )} - {loading ? ( + {reordering && reorderItems ? ( + reorderItems.length === 0 ? ( +
No items in this collection.
+ ) : ( + reorderItems.map((item, index) => ( +
+
+ )) + ) + ) : loading ? (
Loading items… @@ -609,15 +775,30 @@ function ManualItemsView({ )} - {!loading && items.length < total && ( + {reordering ? (
- - Showing {items.length} of {total} - - + {reorderItems?.length ?? 0} items +
+ + +
+ ) : ( + !loading && + items.length < total && ( +
+ + Showing {items.length} of {total} + + +
+ ) )} Date: Thu, 9 Jul 2026 22:40:08 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(spa):=20small=20parity=20nits=20?= =?UTF-8?q?=E2=80=94=20block=20copy,=20watermark=20copy,=20trash=20select-?= =?UTF-8?q?all,=20trakt=20copy=20fix=20(#213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BlocksScreen: add copy-to-group dialog (mirrors TemplatesScreen), wiring the already-existing copyBlock() API client - WatermarksScreen: add copy via /add?from={id} prefill (mirrors FFmpegProfilesScreen), no new endpoint needed - TrashScreen: add Select all / Clear selection to the action bar; bump PAGE_SIZE to the search API's max (100) — true paging needs a page param the API doesn't have yet - TraktListsScreen: fix stale callout text — "View matched items" now opens the SPA's own search screen, not the Classic UI docs/decisions.md: record the YAML-validator paste-textarea deviation, the channel-number prompt-renumber deviation, extend the "table not calendar" convention to the deco-templates editor, and document the trash 100-item cap. --- docs/decisions.md | 37 ++++++ web/src/screens/BlocksScreen.test.tsx | 28 +++++ web/src/screens/BlocksScreen.tsx | 59 ++++++++++ web/src/screens/TraktListsScreen.tsx | 2 +- web/src/screens/TrashScreen.test.tsx | 132 ++++++++++++++++++++++ web/src/screens/TrashScreen.tsx | 30 ++++- web/src/screens/WatermarksScreen.test.tsx | 98 ++++++++++++++++ web/src/screens/WatermarksScreen.tsx | 37 ++++-- 8 files changed, 409 insertions(+), 14 deletions(-) create mode 100644 web/src/screens/TrashScreen.test.tsx create mode 100644 web/src/screens/WatermarksScreen.test.tsx diff --git a/docs/decisions.md b/docs/decisions.md index 2242b9ccc..56cf12e76 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -143,3 +143,40 @@ so the spec matches the wire format by construction. A contract test the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read `fFmpegProfile` and always showed "Unassigned"). Issue #198. + +## 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path + +The legacy Blazor YAML playout validator took a **server-side file path** (read directly off the +container's filesystem). The SPA's `YamlValidatorScreen.tsx` instead uses a paste `