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;