From d7782dccacb96dafedefeac4da6a089ec7bee9d3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:40:47 +0200 Subject: [PATCH 01/17] feat(api): flat schedule-item DTO + discovery endpoints (#126 #207 #212) Task A (#126): new non-polymorphic ScheduleItemResponseModel / ScheduleItemsResponseModel in Core/Api/Scheduling, plus shared NamedIdResponseModel. ScheduleItemResponseMapper flattens the One/Flood/Multiple/Duration VM hierarchy. ScheduleController GET/POST/PUT items now return the flat DTOs. Task B: GET /api/languages (LanguagesController + LanguageCodeResponseModel); GET /api/channels/music-video-credits-templates and GET /api/channels/stream-selectors; FillerKind added to FillerPresetResponseModel with optional ?fillerKind= filter on GET /api/filler-presets. Co-Authored-By: Claude Fable 5 --- ErsatzTV.Application/Filler/Mapper.cs | 2 +- .../Queries/GetAllFillerPresetsForApi.cs | 3 +- .../GetAllFillerPresetsForApiHandler.cs | 10 +- .../ScheduleItemResponseMapper.cs | 109 ++++++++++++++++++ .../Api/Filler/FillerPresetResponseModel.cs | 4 +- .../MediaItems/LanguageCodeResponseModel.cs | 3 + ErsatzTV.Core/Api/NamedIdResponseModel.cs | 8 ++ .../Scheduling/ScheduleItemResponseModel.cs | 70 +++++++++++ .../Scheduling/ScheduleItemsResponseModel.cs | 12 ++ .../Filler/FillerPresetHandlerTests.cs | 24 +++- .../FillerPresetControllerTests.cs | 19 ++- ErsatzTV/Controllers/Api/ChannelController.cs | 17 +++ .../Controllers/Api/FillerPresetController.cs | 8 +- .../Controllers/Api/LanguagesController.cs | 25 ++++ .../Controllers/Api/ScheduleController.cs | 18 ++- 15 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs create mode 100644 ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs create mode 100644 ErsatzTV.Core/Api/NamedIdResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs create mode 100644 ErsatzTV/Controllers/Api/LanguagesController.cs diff --git a/ErsatzTV.Application/Filler/Mapper.cs b/ErsatzTV.Application/Filler/Mapper.cs index 19ef528c1..da46733ba 100644 --- a/ErsatzTV.Application/Filler/Mapper.cs +++ b/ErsatzTV.Application/Filler/Mapper.cs @@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler; internal static class Mapper { internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => - new(fillerPreset.Id, fillerPreset.Name); + new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind); internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) => new( diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs index 3252aae1f..8001d901d 100644 --- a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApi.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain.Filler; namespace ErsatzTV.Application.Filler; -public record GetAllFillerPresetsForApi : IRequest>; +public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest>; diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs index b0c6edaf6..80f3dd504 100644 --- a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsForApiHandler.cs @@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory dbCon CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - List fillerPresets = await dbContext.FillerPresets - .AsNoTracking() - .ToListAsync(cancellationToken); + IQueryable query = dbContext.FillerPresets.AsNoTracking(); + if (request.FillerKind is { } fillerKind) + { + query = query.Where(fp => fp.FillerKind == fillerKind); + } + + List fillerPresets = await query.ToListAsync(cancellationToken); return fillerPresets.Map(ProjectToResponseModel).ToList(); } } diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs new file mode 100644 index 000000000..abdfb0634 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemResponseMapper.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using ErsatzTV.Core.Api; +using ErsatzTV.Core.Api.Scheduling; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules; + +/// +/// Maps the polymorphic hierarchy to the flat, +/// fully-described API DTO (issue #126). Subtype-specific +/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are +/// read by pattern-matching the concrete VM type; they are null for other subtypes. +/// +public static class ScheduleItemResponseMapper +{ + public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item) + { + MultipleMode? multipleMode = null; + string multipleCount = null; + TimeSpan? playoutDuration = null; + TailMode? tailMode = null; + int? discardToFillAttempts = null; + + switch (item) + { + case ProgramScheduleItemMultipleViewModel multiple: + multipleMode = multiple.MultipleMode; + multipleCount = multiple.Count; + break; + case ProgramScheduleItemDurationViewModel duration: + playoutDuration = duration.PlayoutDuration; + tailMode = duration.TailMode; + discardToFillAttempts = duration.DiscardToFillAttempts; + break; + } + + List watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List(); + List graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List(); + List watermarks = + item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList() + ?? new List(); + List graphicsElements = + item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList() + ?? new List(); + + return new ScheduleItemResponseModel( + item.Id, + item.Index, + item.StartType, + item.StartTime, + item.FixedStartTimeBehavior, + item.PlayoutMode, + item.CollectionType, + item.Collection?.Id, + item.MultiCollection?.Id, + item.SmartCollection?.Id, + item.RerunCollection?.Id, + item.MediaItem?.MediaItemId, + item.Playlist?.Id, + item.SearchTitle, + item.SearchQuery, + item.PlaybackOrder, + item.MarathonGroupBy, + item.MarathonShuffleGroups, + item.MarathonShuffleItems, + item.MarathonBatchSize, + item.FillWithGroupMode, + multipleMode, + multipleCount, + playoutDuration, + tailMode, + discardToFillAttempts, + item.CustomTitle, + item.GuideMode, + item.PreRollFiller?.Id, + item.MidRollFiller?.Id, + item.PostRollFiller?.Id, + item.TailFiller?.Id, + item.FallbackFiller?.Id, + watermarkIds, + graphicsElementIds, + item.PreferredAudioLanguageCode, + item.PreferredAudioTitle, + item.PreferredSubtitleLanguageCode, + item.SubtitleMode, + item.Collection?.Name, + item.MultiCollection?.Name, + item.SmartCollection?.Name, + item.RerunCollection?.Name, + item.Playlist?.Name, + item.Playlist?.PlaylistGroupId, + item.MediaItem?.Name, + item.PreRollFiller?.Name, + item.MidRollFiller?.Name, + item.PostRollFiller?.Name, + item.TailFiller?.Name, + item.FallbackFiller?.Name, + watermarks, + graphicsElements, + item.Name, + item.DurationEstimate); + } + + public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) => + new( + vm.Items.Map(ProjectToResponseModel).ToList(), + vm.TotalDurationEstimate); +} diff --git a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs index a237a0b3b..8ac01485a 100644 --- a/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs +++ b/ErsatzTV.Core/Api/Filler/FillerPresetResponseModel.cs @@ -1,3 +1,5 @@ +using ErsatzTV.Core.Domain.Filler; + namespace ErsatzTV.Core.Api.Filler; -public record FillerPresetResponseModel(int Id, string Name); +public record FillerPresetResponseModel(int Id, string Name, FillerKind FillerKind); diff --git a/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs b/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs new file mode 100644 index 000000000..d57b9c316 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaItems/LanguageCodeResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaItems; + +public record LanguageCodeResponseModel(string Code, string EnglishName); diff --git a/ErsatzTV.Core/Api/NamedIdResponseModel.cs b/ErsatzTV.Core/Api/NamedIdResponseModel.cs new file mode 100644 index 000000000..db19e49c2 --- /dev/null +++ b/ErsatzTV.Core/Api/NamedIdResponseModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api; + +/// +/// A lightweight {id, name} pair used when an API response needs to embed a reference to a +/// named entity (e.g. a schedule item's watermarks or graphics elements) without projecting +/// the full entity DTO. +/// +public record NamedIdResponseModel(int Id, string Name); diff --git a/ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs new file mode 100644 index 000000000..07be61189 --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/ScheduleItemResponseModel.cs @@ -0,0 +1,70 @@ +#nullable enable +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Scheduling; + +namespace ErsatzTV.Core.Api.Scheduling; + +/// +/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the +/// Application-layer ProgramScheduleItemViewModel hierarchy (One/Flood/Multiple/Duration +/// subtypes), every subtype-specific field is a nullable member here so the OpenAPI schema fully +/// describes the shape. The mutation fields are named 1:1 with +/// ScheduleItemRequest so a GET can be mapped losslessly back to a PUT/POST request. +/// +public record ScheduleItemResponseModel( + int Id, + int Index, + StartType StartType, + TimeSpan? StartTime, + FixedStartTimeBehavior? FixedStartTimeBehavior, + PlayoutMode PlayoutMode, + CollectionType CollectionType, + int? CollectionId, + int? MultiCollectionId, + int? SmartCollectionId, + int? RerunCollectionId, + int? MediaItemId, + int? PlaylistId, + string? SearchTitle, + string? SearchQuery, + PlaybackOrder PlaybackOrder, + MarathonGroupBy MarathonGroupBy, + bool MarathonShuffleGroups, + bool MarathonShuffleItems, + int? MarathonBatchSize, + FillWithGroupMode FillWithGroupMode, + MultipleMode? MultipleMode, + string? MultipleCount, + TimeSpan? PlayoutDuration, + TailMode? TailMode, + int? DiscardToFillAttempts, + string? CustomTitle, + GuideMode GuideMode, + int? PreRollFillerId, + int? MidRollFillerId, + int? PostRollFillerId, + int? TailFillerId, + int? FallbackFillerId, + List WatermarkIds, + List GraphicsElementIds, + string? PreferredAudioLanguageCode, + string? PreferredAudioTitle, + string? PreferredSubtitleLanguageCode, + ChannelSubtitleMode? SubtitleMode, + string? CollectionName, + string? MultiCollectionName, + string? SmartCollectionName, + string? RerunCollectionName, + string? PlaylistName, + int? PlaylistGroupId, + string? MediaItemName, + string? PreRollFillerName, + string? MidRollFillerName, + string? PostRollFillerName, + string? TailFillerName, + string? FallbackFillerName, + List Watermarks, + List GraphicsElements, + string? Name, + TimeSpan? DurationEstimate); diff --git a/ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs b/ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs new file mode 100644 index 000000000..3e7ea789b --- /dev/null +++ b/ErsatzTV.Core/Api/Scheduling/ScheduleItemsResponseModel.cs @@ -0,0 +1,12 @@ +#nullable enable +using System.Collections.Generic; + +namespace ErsatzTV.Core.Api.Scheduling; + +/// +/// GET envelope for a schedule's items. Carries the flat +/// list plus a best-effort total runtime estimate (null when unbounded/unknown). +/// +public record ScheduleItemsResponseModel( + List Items, + TimeSpan? TotalDurationEstimate); diff --git a/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs index 6c810cffd..57b8783b9 100644 --- a/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Filler/FillerPresetHandlerTests.cs @@ -32,8 +32,24 @@ public class FillerPresetHandlerTests await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None); result.Count.ShouldBe(2); - result.ShouldContain(new FillerPresetResponseModel(1, "Intro")); - result.ShouldContain(new FillerPresetResponseModel(2, "Outro")); + result.ShouldContain(new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll)); + result.ShouldContain(new FillerPresetResponseModel(2, "Outro", FillerKind.PreRoll)); + } + + [Test] + public async Task GetAllFillerPresetsForApi_Should_Filter_By_FillerKind() + { + await SeedPreset(1, "Intro", FillerKind.PreRoll); + await SeedPreset(2, "Mid", FillerKind.MidRoll); + await SeedPreset(3, "Outro", FillerKind.PostRoll); + + var handler = new GetAllFillerPresetsForApiHandler(_db.Factory); + + List result = + await handler.Handle(new GetAllFillerPresetsForApi(FillerKind.MidRoll), CancellationToken.None); + + result.Count.ShouldBe(1); + result.ShouldContain(new FillerPresetResponseModel(2, "Mid", FillerKind.MidRoll)); } [Test] @@ -47,14 +63,14 @@ public class FillerPresetHandlerTests result.ShouldBeEmpty(); } - private async Task SeedPreset(int id, string name) + private async Task SeedPreset(int id, string name, FillerKind fillerKind = FillerKind.PreRoll) { await using TvContext context = _db.CreateContext(); context.FillerPresets.Add(new FillerPreset { Id = id, Name = name, - FillerKind = FillerKind.PreRoll, + FillerKind = fillerKind, FillerMode = FillerMode.Duration, CollectionType = CollectionType.Collection }); diff --git a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs index 01e6df84f..b49f8e6bd 100644 --- a/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/FillerPresetControllerTests.cs @@ -63,17 +63,30 @@ public class FillerPresetControllerTests { List models = [ - new FillerPresetResponseModel(1, "Intro"), - new FillerPresetResponseModel(2, "Outro") + new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll), + new FillerPresetResponseModel(2, "Outro", FillerKind.PostRoll) ]; _mediator.Send(Arg.Any(), Arg.Any()) .Returns(models); - List result = await _controller.GetAll(CancellationToken.None); + List result = await _controller.GetAll(null, CancellationToken.None); result.ShouldBe(models); } + [Test] + public async Task GetAll_Should_Forward_FillerKind_Filter() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([]); + + await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.FillerKind == FillerKind.MidRoll), + Arg.Any()); + } + [Test] public async Task GetById_Should_Return_200_For_Some() { diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 9082d7f3b..a699f2051 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Playouts; +using ErsatzTV.Application.Templates; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; @@ -43,6 +44,22 @@ public class ChannelController(ChannelWriter workerCh CancellationToken cancellationToken) => await mediator.Send(new GetChannelGuideData(start, end), cancellationToken); + [HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")] + [Tags("Channels")] + [EndpointSummary("Get available music video credits template names")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) => + await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken); + + [HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")] + [Tags("Channels")] + [EndpointSummary("Get available channel stream selector names")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetStreamSelectors(CancellationToken cancellationToken) => + await mediator.Send(new GetChannelStreamSelectors(), cancellationToken); + [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] [Tags("Channels")] [EndpointSummary("Get a channel by id")] diff --git a/ErsatzTV/Controllers/Api/FillerPresetController.cs b/ErsatzTV/Controllers/Api/FillerPresetController.cs index d07ee035a..8fa51aabb 100644 --- a/ErsatzTV/Controllers/Api/FillerPresetController.cs +++ b/ErsatzTV/Controllers/Api/FillerPresetController.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Filler; +using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase [HttpGet("/api/filler-presets", Name = "GetFillerPresets")] [Tags("Filler Presets")] [EndpointSummary("Get all filler presets")] + [EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] - public async Task> GetAll(CancellationToken cancellationToken) => - await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken); + public async Task> GetAll( + [FromQuery] FillerKind? fillerKind, + CancellationToken cancellationToken) => + await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken); [HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")] [Tags("Filler Presets")] diff --git a/ErsatzTV/Controllers/Api/LanguagesController.cs b/ErsatzTV/Controllers/Api/LanguagesController.cs new file mode 100644 index 000000000..069caddb0 --- /dev/null +++ b/ErsatzTV/Controllers/Api/LanguagesController.cs @@ -0,0 +1,25 @@ +using System.Linq; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Api.MediaItems; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class LanguagesController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/languages", Name = "GetLanguages")] + [Tags("Languages")] + [EndpointSummary("Get all available language codes")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetLanguages(CancellationToken cancellationToken) + { + List languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken); + return languages + .Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName)) + .ToList(); + } +} diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index 2a8d6f5ac..0a46e4e16 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Linq; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Extensions; using MediatR; using Microsoft.AspNetCore.Http; @@ -104,7 +106,7 @@ public class ScheduleController(IMediator mediator) : ControllerBase "nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " + "derived from referenced collection/media runtimes and are null when unbounded or unknown.")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { @@ -116,14 +118,14 @@ public class ScheduleController(IMediator mediator) : ControllerBase ProgramScheduleItemsWithDurationViewModel items = await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); - return new OkObjectResult(items); + return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items)); } [HttpPost("/api/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Add a schedule item")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(ProgramScheduleItemViewModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ScheduleItemResponseModel), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task AddItem( @@ -133,14 +135,16 @@ public class ScheduleController(IMediator mediator) : ControllerBase { Either result = await mediator.Send(request.ToAddCommand(id), cancellationToken); - return result.ToCreatedResult(item => $"/api/schedules/{id}/items/{item.Id}", item => item); + return result.ToCreatedResult( + item => $"/api/schedules/{id}/items/{item.Id}", + item => ScheduleItemResponseMapper.ProjectToResponseModel(item)); } [HttpPut("/api/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Replace schedule items")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceItems( @@ -150,7 +154,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase { Either> result = await mediator.Send(request.ToCommand(id), cancellationToken); - return result.ToUpdatedResult(); + return result + .Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList()) + .ToUpdatedResult(); } [HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")] -- 2.47.3 From c5ebe29ce6e5bc69478e7e337fda834d18ae8a9a Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:42:53 +0200 Subject: [PATCH 02/17] test(api): controller tests for flat schedule DTO + discovery endpoints (#126 #207 #212) - ScheduleController GET/POST/PUT items now assert flat ScheduleItemResponseModel shapes - LanguagesControllerTests (route + VM->DTO mapping) - ChannelController tests for music-video-credits-templates + stream-selectors literal routes - FillerPreset controller/handler tests for FillerKind field + ?fillerKind= filter Co-Authored-By: Claude Fable 5 --- .../Controllers/ChannelControllerTests.cs | 37 +++++++++++++ .../Controllers/LanguagesControllerTests.cs | 54 +++++++++++++++++++ .../Controllers/ScheduleControllerTests.cs | 13 +++-- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 9377d2f7c..cb51b7013 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -1,7 +1,10 @@ +using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; +using ErsatzTV.Application.Templates; +using Microsoft.AspNetCore.Mvc.Routing; using ErsatzTV.Application.Playouts; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; @@ -56,6 +59,40 @@ public class ChannelControllerTests result.ShouldBe(new List { state }); } + [Test] + public void MusicVideoCreditsTemplates_And_StreamSelectors_Routes_Are_Literal_And_Precede_Id() + { + MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!; + mvct.GetCustomAttributes(inherit: true).Single().Template + .ShouldBe("/api/channels/music-video-credits-templates"); + + MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!; + selectors.GetCustomAttributes(inherit: true).Single().Template + .ShouldBe("/api/channels/stream-selectors"); + } + + [Test] + public async Task GetMusicVideoCreditsTemplates_Should_Return_Names() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(["Default", "Scroll"]); + + List result = await _controller.GetMusicVideoCreditsTemplates(CancellationToken.None); + + result.ShouldBe(["Default", "Scroll"]); + } + + [Test] + public async Task GetStreamSelectors_Should_Return_Names() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(["selector-a.yml"]); + + List result = await _controller.GetStreamSelectors(CancellationToken.None); + + result.ShouldBe(["selector-a.yml"]); + } + [Test] public async Task Create_Should_Return_201_With_Location_And_Body() { diff --git a/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs b/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs new file mode 100644 index 000000000..3b8536468 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/LanguagesControllerTests.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.MediaItems; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class LanguagesControllerTests +{ + private LanguagesController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new LanguagesController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(LanguagesController).GetMethod(nameof(LanguagesController.GetLanguages))!; + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/languages"); + } + + [Test] + public async Task GetLanguages_Should_Map_Vm_To_ResponseModel() + { + List vms = + [ + new LanguageCodeViewModel("eng", "English"), + new LanguageCodeViewModel("fra", "French") + ]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(vms); + + List result = await _controller.GetLanguages(CancellationToken.None); + + result.ShouldBe( + [ + new LanguageCodeResponseModel("eng", "English"), + new LanguageCodeResponseModel("fra", "French") + ]); + } +} diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs index 91a19e90c..e09792cc3 100644 --- a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -1,8 +1,10 @@ +using System.Linq; using System.Reflection; using ErsatzTV.Application.ProgramSchedules; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; @@ -197,7 +199,10 @@ public class ScheduleControllerTests IActionResult result = await _controller.GetItems(4, CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(response); + var envelope = result.ShouldBeOfType().Value.ShouldBeOfType(); + envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25)); + envelope.Items.Count.ShouldBe(1); + envelope.Items[0].Id.ShouldBe(11); await _mediator.Received(1).Send( Arg.Is(q => q.Id == 4), Arg.Any()); @@ -225,7 +230,7 @@ public class ScheduleControllerTests var created = result.ShouldBeOfType(); created.Location.ShouldBe("/api/schedules/4/items/12"); - created.Value.ShouldBe(item); + created.Value.ShouldBeOfType().Id.ShouldBe(12); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One), Arg.Any()); @@ -254,7 +259,9 @@ public class ScheduleControllerTests new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]), CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(items); + var replaced = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + replaced.Select(i => i.Id).ShouldBe([21, 22]); await _mediator.Received(1).Send( Arg.Is(c => c.ProgramScheduleId == 4 && -- 2.47.3 From b13fb0ffc7235c2428263698ce5a771bc66aba3b Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:50:10 +0200 Subject: [PATCH 03/17] test(api): lossless GET->PUT round-trip + EnforceProperties normalization (#126 #207 #212) Fixed-point round-trip over all schedule-item subtypes (One/Flood/Multiple/ Duration) and field families (fixed-start, preferred audio/subtitle, custom title, marathon, fillers x5, 2 watermarks, 2 graphics, rerun, search) proving the flat ScheduleItemResponseModel reconstructs an identical PUT. Second test documents the deliberate ShuffleScheduleItems normalization (Flood->One, Fixed->Dynamic, Playlist->PlaybackOrder None). Co-Authored-By: Claude Fable 5 --- .../ScheduleItemResponseRoundTripTests.cs | 442 ++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs new file mode 100644 index 000000000..68ad447b8 --- /dev/null +++ b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs @@ -0,0 +1,442 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.Scheduling; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.ProgramSchedules; + +/// +/// Release gate for issue #126: proves the flat produced by +/// the GET endpoint carries enough information to reconstruct an identical PUT (ReplaceProgramScheduleItems) +/// with no loss — a GET → map → PUT → GET fixed point across every subtype and field family. Also documents +/// the deliberate EnforceProperties normalization (shuffle → Dynamic/One/None). +/// +[TestFixture] +public class ScheduleItemResponseRoundTripTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = System.Threading.Channels.Channel.CreateUnbounded().Writer; + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Get_Map_Replace_Get_Should_Be_Lossless_Across_All_Subtypes() + { + int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: false); + + var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); + + // Arrange: establish the initial items through the real write path. + Either> seeded = + await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, BuildSeedItems()), CancellationToken.None); + seeded.IsRight.ShouldBeTrue(seeded.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown")); + + // Act: GET (with durations) → flat DTO envelope A + ScheduleItemsResponseModel envelopeA = await GetItemsEnvelope(scheduleId); + envelopeA.Items.Count.ShouldBe(6); + + // Map every response item back into a Replace command exactly as the request mapping would, then PUT. + // Reconstruct in the item's own (stable) Index order, mirroring how the SPA re-submits the ordered list. + List reconstructed = envelopeA.Items + .OrderBy(item => item.Index) + .Select((item, index) => ToReplaceCommand(item, index)) + .ToList(); + Either> replaced = + await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None); + replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown")); + + // GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids). + ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId); + + envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count); + envelopeB.TotalDurationEstimate.ShouldBe(envelopeA.TotalDurationEstimate); + + // The GET does not guarantee row order, so pair items up by their (stable) Index before comparing. + List orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList(); + List orderedB = envelopeB.Items.OrderBy(i => i.Index).ToList(); + for (var i = 0; i < orderedA.Count; i++) + { + AssertSemanticallyEqual(orderedA[i], orderedB[i]); + } + + // Spot-check that the display/hydration fields actually populated (proves the mapper is non-vacuous). + ScheduleItemResponseModel multiple = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Multiple); + multiple.CollectionName.ShouldBe("Prime Collection"); + multiple.MultipleMode.ShouldBe(MultipleMode.Count); + multiple.MultipleCount.ShouldBe("2 + 1"); + multiple.FillWithGroupMode.ShouldBe(FillWithGroupMode.FillWithShuffledGroups); + multiple.PreRollFillerId.ShouldBe(1); + multiple.PreRollFillerName.ShouldBe("PreRoll"); + multiple.FallbackFillerId.ShouldBe(5); + multiple.WatermarkIds.ShouldBe([1, 2]); + multiple.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]); + multiple.GraphicsElementIds.ShouldBe([1, 2]); + multiple.GraphicsElements.Select(g => g.Id).ShouldBe([1, 2]); + multiple.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n)); + + ScheduleItemResponseModel one = envelopeA.Items.Single(i => + i.PlayoutMode == PlayoutMode.One && i.CollectionType == CollectionType.Collection); + one.StartType.ShouldBe(StartType.Fixed); + one.StartTime.ShouldBe(TimeSpan.FromHours(20)); + one.CustomTitle.ShouldBe("News Hour"); + one.PreferredAudioLanguageCode.ShouldBe("eng"); + one.PreferredAudioTitle.ShouldBe("Director Commentary"); + one.PreferredSubtitleLanguageCode.ShouldBe("fra"); + one.SubtitleMode.ShouldBe(ChannelSubtitleMode.Any); + + ScheduleItemResponseModel duration = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Duration); + duration.SmartCollectionName.ShouldBe("Smart Picks"); + duration.PlayoutDuration.ShouldBe(TimeSpan.FromMinutes(45)); + duration.TailMode.ShouldBe(TailMode.Filler); + duration.TailFillerId.ShouldBe(4); + duration.PlaybackOrder.ShouldBe(PlaybackOrder.Marathon); + duration.MarathonGroupBy.ShouldBe(MarathonGroupBy.Show); + duration.MarathonBatchSize.ShouldBe(4); + + ScheduleItemResponseModel flood = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Flood); + flood.PlaylistName.ShouldBe("My Playlist"); + flood.PlaylistGroupId.ShouldBe(9); + flood.MarathonShuffleGroups.ShouldBeTrue(); + + ScheduleItemResponseModel rerun = envelopeA.Items.Single(i => i.CollectionType == CollectionType.RerunFirstRun); + rerun.RerunCollectionName.ShouldBe("Rerun Bin"); + + ScheduleItemResponseModel search = envelopeA.Items.Single(i => i.CollectionType == CollectionType.SearchQuery); + search.SearchQuery.ShouldBe("genre:comedy"); + search.Name.ShouldBe("Comedy Search"); + } + + [Test] + public async Task Get_Should_Apply_EnforceProperties_Normalization_When_Shuffle_Enabled() + { + int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: true); + + var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); + Either> seeded = await replaceHandler.Handle( + new ReplaceProgramScheduleItems( + scheduleId, + [ + // Fixed-start Flood item on a plain collection. + MakeReplace(0, PlayoutMode.Flood, CollectionType.Collection) with + { + StartTime = TimeSpan.FromHours(6), + CollectionId = 1, + PlaybackOrder = PlaybackOrder.Chronological + }, + // Playlist item carrying a playback order that must be normalized away. + MakeReplace(1, PlayoutMode.One, CollectionType.Playlist) with + { + PlaylistId = 1, + PlaybackOrder = PlaybackOrder.Chronological + } + ]), + CancellationToken.None); + seeded.IsRight.ShouldBeTrue(); + + List items = (await GetItemsEnvelope(scheduleId)).Items; + + ScheduleItemResponseModel floodItem = items.Single(i => i.CollectionType == CollectionType.Collection); + // Flood → One and Fixed → Dynamic are the documented, deliberate rewrites when ShuffleScheduleItems is on. + floodItem.PlayoutMode.ShouldBe(PlayoutMode.One); + floodItem.StartType.ShouldBe(StartType.Dynamic); + + ScheduleItemResponseModel playlistItem = items.Single(i => i.CollectionType == CollectionType.Playlist); + playlistItem.PlaybackOrder.ShouldBe(PlaybackOrder.None); + } + + private async Task GetItemsEnvelope(int scheduleId) + { + var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory); + IMediator mediator = Substitute.For(); + mediator.Send(Arg.Any(), Arg.Any()) + .Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1])); + + IMediaCollectionRepository repo = Substitute.For(); + repo.GetItems(Arg.Any()).Returns(new List()); + + var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo); + ProgramScheduleItemsWithDurationViewModel vm = + await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None); + return ScheduleItemResponseMapper.ProjectToResponseModel(vm); + } + + private static List BuildSeedItems() => + [ + // 0: Fixed-start One on a plain collection, full preferred-audio/subtitle + custom title. + MakeReplace(0, PlayoutMode.One, CollectionType.Collection) with + { + StartTime = TimeSpan.FromHours(20), + CollectionId = 1, + PlaybackOrder = PlaybackOrder.Chronological, + CustomTitle = "News Hour", + PreferredAudioLanguageCode = "eng", + PreferredAudioTitle = "Director Commentary", + PreferredSubtitleLanguageCode = "fra", + SubtitleMode = ChannelSubtitleMode.Any + }, + // 1: Multiple (Count, expression string) on a different collection, group mode + all 5 fillers + 2 wm + 2 ge. + MakeReplace(1, PlayoutMode.Multiple, CollectionType.Collection) with + { + CollectionId = 2, + PlaybackOrder = PlaybackOrder.Shuffle, + FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups, + MultipleMode = MultipleMode.Count, + MultipleCount = "2 + 1", + PreRollFillerId = 1, + MidRollFillerId = 2, + PostRollFillerId = 3, + TailFillerId = 4, + FallbackFillerId = 5, + WatermarkIds = [1, 2], + GraphicsElementIds = [1, 2] + }, + // 2: Duration on a smart collection, Marathon order + tail filler. + MakeReplace(2, PlayoutMode.Duration, CollectionType.SmartCollection) with + { + SmartCollectionId = 1, + PlayoutDuration = TimeSpan.FromMinutes(45), + TailMode = TailMode.Filler, + TailFillerId = 4, + DiscardToFillAttempts = 5, + PlaybackOrder = PlaybackOrder.Marathon, + MarathonGroupBy = MarathonGroupBy.Show, + MarathonShuffleGroups = true, + MarathonShuffleItems = true, + MarathonBatchSize = 4 + }, + // 3: Flood on a playlist (playback order None; MarathonShuffleGroups doubles as shuffle-playlist-items). + MakeReplace(3, PlayoutMode.Flood, CollectionType.Playlist) with + { + PlaylistId = 1, + PlaybackOrder = PlaybackOrder.None, + MarathonShuffleGroups = true + }, + // 4: Rerun-first-run One item. + MakeReplace(4, PlayoutMode.One, CollectionType.RerunFirstRun) with + { + RerunCollectionId = 1, + PlaybackOrder = PlaybackOrder.None + }, + // 5: SearchQuery One item. + MakeReplace(5, PlayoutMode.One, CollectionType.SearchQuery) with + { + SearchTitle = "Comedy Search", + SearchQuery = "genre:comedy", + PlaybackOrder = PlaybackOrder.Shuffle + } + ]; + + private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode, CollectionType collectionType) => + new( + index, + StartType.Dynamic, + StartTime: null, + FixedStartTimeBehavior: null, + playoutMode, + collectionType, + CollectionId: null, + MultiCollectionId: null, + SmartCollectionId: null, + RerunCollectionId: null, + MediaItemId: null, + PlaylistId: null, + SearchTitle: null, + SearchQuery: null, + PlaybackOrder: PlaybackOrder.Shuffle, + MarathonGroupBy: MarathonGroupBy.None, + MarathonShuffleGroups: false, + MarathonShuffleItems: false, + MarathonBatchSize: null, + FillWithGroupMode: FillWithGroupMode.None, + MultipleMode: MultipleMode.Count, + MultipleCount: null, + PlayoutDuration: null, + TailMode: TailMode.None, + DiscardToFillAttempts: null, + CustomTitle: null, + GuideMode: GuideMode.Normal, + PreRollFillerId: null, + MidRollFillerId: null, + PostRollFillerId: null, + TailFillerId: null, + FallbackFillerId: null, + WatermarkIds: [], + GraphicsElementIds: [], + PreferredAudioLanguageCode: null, + PreferredAudioTitle: null, + PreferredSubtitleLanguageCode: null, + SubtitleMode: null); + + // Mirrors the ScheduleItemRequest → ReplaceProgramScheduleItem controller mapping, but sourced from a + // response DTO (the SPA does this same field-for-field copy in TypeScript before a PUT). + private static ReplaceProgramScheduleItem ToReplaceCommand(ScheduleItemResponseModel r, int index) => + new( + index, + r.StartType, + r.StartTime, + r.FixedStartTimeBehavior, + r.PlayoutMode, + r.CollectionType, + r.CollectionId, + r.MultiCollectionId, + r.SmartCollectionId, + r.RerunCollectionId, + r.MediaItemId, + r.PlaylistId, + r.SearchTitle, + r.SearchQuery, + r.PlaybackOrder, + r.MarathonGroupBy, + r.MarathonShuffleGroups, + r.MarathonShuffleItems, + r.MarathonBatchSize, + r.FillWithGroupMode, + r.MultipleMode ?? MultipleMode.Count, + r.MultipleCount, + r.PlayoutDuration, + r.TailMode ?? TailMode.None, + r.DiscardToFillAttempts, + r.CustomTitle, + r.GuideMode, + r.PreRollFillerId, + r.MidRollFillerId, + r.PostRollFillerId, + r.TailFillerId, + r.FallbackFillerId, + r.WatermarkIds, + r.GraphicsElementIds, + r.PreferredAudioLanguageCode, + r.PreferredAudioTitle, + r.PreferredSubtitleLanguageCode, + r.SubtitleMode); + + private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b) + { + b.Index.ShouldBe(a.Index); + b.StartType.ShouldBe(a.StartType); + b.StartTime.ShouldBe(a.StartTime); + b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior); + b.PlayoutMode.ShouldBe(a.PlayoutMode); + b.CollectionType.ShouldBe(a.CollectionType); + b.CollectionId.ShouldBe(a.CollectionId); + b.MultiCollectionId.ShouldBe(a.MultiCollectionId); + b.SmartCollectionId.ShouldBe(a.SmartCollectionId); + b.RerunCollectionId.ShouldBe(a.RerunCollectionId); + b.MediaItemId.ShouldBe(a.MediaItemId); + b.PlaylistId.ShouldBe(a.PlaylistId); + b.SearchTitle.ShouldBe(a.SearchTitle); + b.SearchQuery.ShouldBe(a.SearchQuery); + b.PlaybackOrder.ShouldBe(a.PlaybackOrder); + b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy); + b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups); + b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems); + b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize); + b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode); + b.MultipleMode.ShouldBe(a.MultipleMode); + b.MultipleCount.ShouldBe(a.MultipleCount); + b.PlayoutDuration.ShouldBe(a.PlayoutDuration); + b.TailMode.ShouldBe(a.TailMode); + b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts); + b.CustomTitle.ShouldBe(a.CustomTitle); + b.GuideMode.ShouldBe(a.GuideMode); + b.PreRollFillerId.ShouldBe(a.PreRollFillerId); + b.MidRollFillerId.ShouldBe(a.MidRollFillerId); + b.PostRollFillerId.ShouldBe(a.PostRollFillerId); + b.TailFillerId.ShouldBe(a.TailFillerId); + b.FallbackFillerId.ShouldBe(a.FallbackFillerId); + b.WatermarkIds.ShouldBe(a.WatermarkIds); + b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds); + b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode); + b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle); + b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode); + b.SubtitleMode.ShouldBe(a.SubtitleMode); + b.CollectionName.ShouldBe(a.CollectionName); + b.MultiCollectionName.ShouldBe(a.MultiCollectionName); + b.SmartCollectionName.ShouldBe(a.SmartCollectionName); + b.RerunCollectionName.ShouldBe(a.RerunCollectionName); + b.PlaylistName.ShouldBe(a.PlaylistName); + b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId); + b.MediaItemName.ShouldBe(a.MediaItemName); + b.PreRollFillerName.ShouldBe(a.PreRollFillerName); + b.MidRollFillerName.ShouldBe(a.MidRollFillerName); + b.PostRollFillerName.ShouldBe(a.PostRollFillerName); + b.TailFillerName.ShouldBe(a.TailFillerName); + b.FallbackFillerName.ShouldBe(a.FallbackFillerName); + b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name))); + b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name))); + b.Name.ShouldBe(a.Name); + b.DurationEstimate.ShouldBe(a.DurationEstimate); + } + + private async Task SeedScheduleAndReferences(bool shuffleScheduleItems) + { + await using TvContext context = _db.CreateContext(); + + context.Collections.Add(new Collection { Id = 1, Name = "First Collection" }); + context.Collections.Add(new Collection { Id = 2, Name = "Prime Collection" }); + context.SmartCollections.Add(new SmartCollection { Id = 1, Name = "Smart Picks", Query = "*" }); + context.RerunCollections.Add(new RerunCollection + { + Id = 1, + Name = "Rerun Bin", + CollectionType = CollectionType.Collection + }); + + context.PlaylistGroups.Add(new PlaylistGroup { Id = 9, Name = "Group" }); + context.Playlists.Add(new Playlist { Id = 1, PlaylistGroupId = 9, Name = "My Playlist" }); + + context.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" }); + context.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" }); + context.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image }); + context.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image }); + + AddFiller(context, 1, "PreRoll", FillerKind.PreRoll); + AddFiller(context, 2, "MidRoll", FillerKind.MidRoll); + AddFiller(context, 3, "PostRoll", FillerKind.PostRoll); + AddFiller(context, 4, "Tail", FillerKind.Tail); + AddFiller(context, 5, "Fallback", FillerKind.Fallback); + + var schedule = new ProgramSchedule + { + Name = "Round Trip", + ShuffleScheduleItems = shuffleScheduleItems, + Items = [], + Playouts = [], + ProgramScheduleAlternates = [] + }; + context.ProgramSchedules.Add(schedule); + await context.SaveChangesAsync(); + return schedule.Id; + } + + private static void AddFiller(TvContext context, int id, string name, FillerKind kind) => + context.FillerPresets.Add(new FillerPreset + { + Id = id, + Name = name, + FillerKind = kind, + FillerMode = FillerMode.Count, + Count = 1, + CollectionType = CollectionType.Collection + }); +} -- 2.47.3 From 223797a6cb926a8251c493e2b319d23a246ae3bc Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:54:12 +0200 Subject: [PATCH 04/17] docs+openapi: regenerate v1.json/endpoint-index/types + record flat schedule DTO decision (#126 #207 #212) - Regenerated ErsatzTV/wwwroot/openapi/v1.json, docs/endpoint-index.md, and web/src/api/generated/v1.d.ts for the flat schedule-item DTO + discovery endpoints - docs/decisions.md: flat ScheduleItemResponseModel + NamedIdResponseModel rationale - docs/api-conventions.md: shared {id,name} embed, polymorphic-VM flattening, and optional enum query-filter conventions Co-Authored-By: Claude Fable 5 --- ErsatzTV/wwwroot/openapi/v1.json | 1172 +++++++++++++++--------------- docs/api-conventions.md | 19 +- docs/decisions.md | 20 + docs/endpoint-index.md | 10 +- web/src/api/generated/v1.d.ts | 186 ++--- 5 files changed, 699 insertions(+), 708 deletions(-) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 847e90c5a..38268eea6 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -1158,6 +1158,86 @@ } } }, + "/api/channels/music-video-credits-templates": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get available music video credits template names", + "operationId": "GetMusicVideoCreditsTemplates", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/channels/stream-selectors": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get available channel stream selector names", + "operationId": "GetChannelStreamSelectors", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, "/api/channels/{id}": { "get": { "tags": [ @@ -4540,7 +4620,17 @@ "Filler Presets" ], "summary": "Get all filler presets", + "description": "Optionally filter to a single filler kind via the fillerKind query parameter.", "operationId": "GetFillerPresets", + "parameters": [ + { + "name": "fillerKind", + "in": "query", + "schema": { + "$ref": "#/components/schemas/FillerKind" + } + } + ], "responses": { "200": { "description": "OK", @@ -5208,6 +5298,46 @@ } } }, + "/api/languages": { + "get": { + "tags": [ + "Languages" + ], + "summary": "Get all available language codes", + "operationId": "GetLanguages", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LanguageCodeResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LanguageCodeResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LanguageCodeResponseModel" + } + } + } + } + } + } + } + }, "/api/libraries/scan-status": { "get": { "tags": [ @@ -9528,17 +9658,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" + "$ref": "#/components/schemas/ScheduleItemsResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" + "$ref": "#/components/schemas/ScheduleItemsResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" + "$ref": "#/components/schemas/ScheduleItemsResponseModel" } } } @@ -9612,17 +9742,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } } @@ -9718,7 +9848,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } }, @@ -9726,7 +9856,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } }, @@ -9734,7 +9864,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } } @@ -17569,42 +17699,10 @@ } }, "FillerPresetResponseModel": { - "required": [ - "id", - "name" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - } - } - }, - "FillerPresetViewModel": { "required": [ "id", "name", - "fillerKind", - "fillerMode", - "duration", - "count", - "padToNearestMinute", - "allowWatermarks", - "collectionType", - "collectionId", - "mediaItemId", - "multiCollectionId", - "smartCollectionId", - "playlist", - "expression", - "useChaptersAsMediaItems" + "fillerKind" ], "type": "object", "properties": { @@ -17620,76 +17718,6 @@ }, "fillerKind": { "$ref": "#/components/schemas/FillerKind" - }, - "fillerMode": { - "$ref": "#/components/schemas/FillerMode" - }, - "duration": { - "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", - "type": [ - "null", - "string" - ] - }, - "count": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "padToNearestMinute": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "allowWatermarks": { - "type": "boolean" - }, - "collectionType": { - "$ref": "#/components/schemas/CollectionType" - }, - "collectionId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "mediaItemId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "multiCollectionId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "smartCollectionId": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "playlist": { - "$ref": "#/components/schemas/PlaylistViewModel" - }, - "expression": { - "type": [ - "null", - "string" - ] - }, - "useChaptersAsMediaItems": { - "type": "boolean" } } }, @@ -17734,32 +17762,6 @@ } } }, - "GraphicsElementViewModel": { - "required": [ - "id", - "name", - "fileName" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "fileName": { - "type": [ - "null", - "string" - ] - } - } - }, "GuideMode": { "enum": [ "Normal", @@ -17896,6 +17898,27 @@ } } }, + "LanguageCodeResponseModel": { + "required": [ + "code", + "englishName" + ], + "type": "object", + "properties": { + "code": { + "type": [ + "null", + "string" + ] + }, + "englishName": { + "type": [ + "null", + "string" + ] + } + } + }, "LibraryBrowseItemResponseModel": { "required": [ "id", @@ -18738,30 +18761,6 @@ } } }, - "MultiCollectionItemViewModel": { - "required": [ - "multiCollectionId", - "collection", - "scheduleAsGroup", - "playbackOrder" - ], - "type": "object", - "properties": { - "multiCollectionId": { - "type": "integer", - "format": "int32" - }, - "collection": { - "$ref": "#/components/schemas/MediaCollectionViewModel" - }, - "scheduleAsGroup": { - "type": "boolean" - }, - "playbackOrder": { - "$ref": "#/components/schemas/PlaybackOrder" - } - } - }, "MultiCollectionResponseModel": { "required": [ "id", @@ -18785,69 +18784,6 @@ } } }, - "MultiCollectionSmartItemViewModel": { - "required": [ - "multiCollectionId", - "smartCollection", - "scheduleAsGroup", - "playbackOrder" - ], - "type": "object", - "properties": { - "multiCollectionId": { - "type": "integer", - "format": "int32" - }, - "smartCollection": { - "$ref": "#/components/schemas/SmartCollectionViewModel" - }, - "scheduleAsGroup": { - "type": "boolean" - }, - "playbackOrder": { - "$ref": "#/components/schemas/PlaybackOrder" - } - } - }, - "MultiCollectionViewModel": { - "required": [ - "id", - "name", - "items", - "smartItems" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "items": { - "type": [ - "null", - "array" - ], - "items": { - "$ref": "#/components/schemas/MultiCollectionItemViewModel" - } - }, - "smartItems": { - "type": [ - "null", - "array" - ], - "items": { - "$ref": "#/components/schemas/MultiCollectionSmartItemViewModel" - } - } - } - }, "MultipleMode": { "enum": [ "Count", @@ -18857,14 +18793,14 @@ ], "type": "string" }, - "NamedMediaItemViewModel": { + "NamedIdResponseModel": { "required": [ - "mediaItemId", + "id", "name" ], "type": "object", "properties": { - "mediaItemId": { + "id": { "type": "integer", "format": "int32" }, @@ -19309,34 +19245,6 @@ } } }, - "PlaylistViewModel": { - "required": [ - "id", - "playlistGroupId", - "name", - "isSystem" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "playlistGroupId": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "isSystem": { - "type": "boolean" - } - } - }, "PlayoutAlternateScheduleItemRequest": { "required": [ "id", @@ -20071,205 +19979,6 @@ } } }, - "ProgramScheduleItemsWithDurationViewModel": { - "required": [ - "items", - "totalDurationEstimate" - ], - "type": "object", - "properties": { - "items": { - "type": [ - "null", - "array" - ], - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } - }, - "totalDurationEstimate": { - "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", - "type": [ - "null", - "string" - ] - } - } - }, - "ProgramScheduleItemViewModel": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "index": { - "type": "integer", - "format": "int32" - }, - "startType": { - "$ref": "#/components/schemas/StartType" - }, - "startTime": { - "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", - "type": [ - "null", - "string" - ] - }, - "fixedStartTimeBehavior": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/FixedStartTimeBehavior" - } - ] - }, - "playoutMode": { - "$ref": "#/components/schemas/PlayoutMode" - }, - "collectionType": { - "$ref": "#/components/schemas/CollectionType" - }, - "collection": { - "$ref": "#/components/schemas/MediaCollectionViewModel" - }, - "multiCollection": { - "$ref": "#/components/schemas/MultiCollectionViewModel" - }, - "smartCollection": { - "$ref": "#/components/schemas/SmartCollectionViewModel" - }, - "rerunCollection": { - "$ref": "#/components/schemas/RerunCollectionViewModel" - }, - "playlist": { - "$ref": "#/components/schemas/PlaylistViewModel" - }, - "mediaItem": { - "$ref": "#/components/schemas/NamedMediaItemViewModel" - }, - "searchTitle": { - "type": [ - "null", - "string" - ] - }, - "searchQuery": { - "type": [ - "null", - "string" - ] - }, - "playbackOrder": { - "$ref": "#/components/schemas/PlaybackOrder" - }, - "marathonGroupBy": { - "$ref": "#/components/schemas/MarathonGroupBy" - }, - "marathonShuffleGroups": { - "type": "boolean" - }, - "marathonShuffleItems": { - "type": "boolean" - }, - "marathonBatchSize": { - "type": [ - "null", - "integer" - ], - "format": "int32" - }, - "fillWithGroupMode": { - "$ref": "#/components/schemas/FillWithGroupMode" - }, - "customTitle": { - "type": [ - "null", - "string" - ] - }, - "guideMode": { - "$ref": "#/components/schemas/GuideMode" - }, - "preRollFiller": { - "$ref": "#/components/schemas/FillerPresetViewModel" - }, - "midRollFiller": { - "$ref": "#/components/schemas/FillerPresetViewModel" - }, - "postRollFiller": { - "$ref": "#/components/schemas/FillerPresetViewModel" - }, - "tailFiller": { - "$ref": "#/components/schemas/FillerPresetViewModel" - }, - "fallbackFiller": { - "$ref": "#/components/schemas/FillerPresetViewModel" - }, - "watermarks": { - "type": [ - "null", - "array" - ], - "items": { - "$ref": "#/components/schemas/WatermarkViewModel" - } - }, - "graphicsElements": { - "type": [ - "null", - "array" - ], - "items": { - "$ref": "#/components/schemas/GraphicsElementViewModel" - } - }, - "preferredAudioLanguageCode": { - "type": [ - "null", - "string" - ] - }, - "preferredAudioTitle": { - "type": [ - "null", - "string" - ] - }, - "preferredSubtitleLanguageCode": { - "type": [ - "null", - "string" - ] - }, - "subtitleMode": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ChannelSubtitleMode" - } - ] - }, - "durationEstimate": { - "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", - "type": [ - "null", - "string" - ] - }, - "name": { - "type": [ - "null", - "string" - ] - } - } - }, "ProgramScheduleViewModel": { "required": [ "id", @@ -20658,53 +20367,6 @@ } } }, - "RerunCollectionViewModel": { - "required": [ - "id", - "name", - "collectionType", - "collection", - "multiCollection", - "smartCollection", - "mediaItem", - "firstRunPlaybackOrder", - "rerunPlaybackOrder" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "collectionType": { - "$ref": "#/components/schemas/CollectionType" - }, - "collection": { - "$ref": "#/components/schemas/MediaCollectionViewModel" - }, - "multiCollection": { - "$ref": "#/components/schemas/MultiCollectionViewModel" - }, - "smartCollection": { - "$ref": "#/components/schemas/SmartCollectionViewModel" - }, - "mediaItem": { - "$ref": "#/components/schemas/NamedMediaItemViewModel" - }, - "firstRunPlaybackOrder": { - "$ref": "#/components/schemas/PlaybackOrder" - }, - "rerunPlaybackOrder": { - "$ref": "#/components/schemas/PlaybackOrder" - } - } - }, "ResolutionResponseModel": { "required": [ "id", @@ -21070,6 +20732,424 @@ } } }, + "ScheduleItemResponseModel": { + "required": [ + "id", + "index", + "startType", + "startTime", + "fixedStartTimeBehavior", + "playoutMode", + "collectionType", + "collectionId", + "multiCollectionId", + "smartCollectionId", + "rerunCollectionId", + "mediaItemId", + "playlistId", + "searchTitle", + "searchQuery", + "playbackOrder", + "marathonGroupBy", + "marathonShuffleGroups", + "marathonShuffleItems", + "marathonBatchSize", + "fillWithGroupMode", + "multipleMode", + "multipleCount", + "playoutDuration", + "tailMode", + "discardToFillAttempts", + "customTitle", + "guideMode", + "preRollFillerId", + "midRollFillerId", + "postRollFillerId", + "tailFillerId", + "fallbackFillerId", + "watermarkIds", + "graphicsElementIds", + "preferredAudioLanguageCode", + "preferredAudioTitle", + "preferredSubtitleLanguageCode", + "subtitleMode", + "collectionName", + "multiCollectionName", + "smartCollectionName", + "rerunCollectionName", + "playlistName", + "playlistGroupId", + "mediaItemName", + "preRollFillerName", + "midRollFillerName", + "postRollFillerName", + "tailFillerName", + "fallbackFillerName", + "watermarks", + "graphicsElements", + "name", + "durationEstimate" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "index": { + "type": "integer", + "format": "int32" + }, + "startType": { + "$ref": "#/components/schemas/StartType" + }, + "startTime": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "fixedStartTimeBehavior": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FixedStartTimeBehavior" + } + ] + }, + "playoutMode": { + "$ref": "#/components/schemas/PlayoutMode" + }, + "collectionType": { + "$ref": "#/components/schemas/CollectionType" + }, + "collectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "multiCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "smartCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "rerunCollectionId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "playlistId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "searchTitle": { + "type": [ + "null", + "string" + ] + }, + "searchQuery": { + "type": [ + "null", + "string" + ] + }, + "playbackOrder": { + "$ref": "#/components/schemas/PlaybackOrder" + }, + "marathonGroupBy": { + "$ref": "#/components/schemas/MarathonGroupBy" + }, + "marathonShuffleGroups": { + "type": "boolean" + }, + "marathonShuffleItems": { + "type": "boolean" + }, + "marathonBatchSize": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fillWithGroupMode": { + "$ref": "#/components/schemas/FillWithGroupMode" + }, + "multipleMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/MultipleMode" + } + ] + }, + "multipleCount": { + "type": [ + "null", + "string" + ] + }, + "playoutDuration": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, + "tailMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TailMode" + } + ] + }, + "discardToFillAttempts": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "customTitle": { + "type": [ + "null", + "string" + ] + }, + "guideMode": { + "$ref": "#/components/schemas/GuideMode" + }, + "preRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "midRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "postRollFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "tailFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "fallbackFillerId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "watermarkIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "graphicsElementIds": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "preferredAudioLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "preferredAudioTitle": { + "type": [ + "null", + "string" + ] + }, + "preferredSubtitleLanguageCode": { + "type": [ + "null", + "string" + ] + }, + "subtitleMode": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ChannelSubtitleMode" + } + ] + }, + "collectionName": { + "type": [ + "null", + "string" + ] + }, + "multiCollectionName": { + "type": [ + "null", + "string" + ] + }, + "smartCollectionName": { + "type": [ + "null", + "string" + ] + }, + "rerunCollectionName": { + "type": [ + "null", + "string" + ] + }, + "playlistName": { + "type": [ + "null", + "string" + ] + }, + "playlistGroupId": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "mediaItemName": { + "type": [ + "null", + "string" + ] + }, + "preRollFillerName": { + "type": [ + "null", + "string" + ] + }, + "midRollFillerName": { + "type": [ + "null", + "string" + ] + }, + "postRollFillerName": { + "type": [ + "null", + "string" + ] + }, + "tailFillerName": { + "type": [ + "null", + "string" + ] + }, + "fallbackFillerName": { + "type": [ + "null", + "string" + ] + }, + "watermarks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NamedIdResponseModel" + } + }, + "graphicsElements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NamedIdResponseModel" + } + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "durationEstimate": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, + "ScheduleItemsResponseModel": { + "required": [ + "items", + "totalDurationEstimate" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScheduleItemResponseModel" + } + }, + "totalDurationEstimate": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, "SchedulingPickerOptionResponseModel": { "required": [ "id", @@ -23071,91 +23151,6 @@ ], "type": "string" }, - "WatermarkViewModel": { - "required": [ - "id", - "image", - "name", - "mode", - "imageSource", - "location", - "size", - "width", - "horizontalMargin", - "verticalMargin", - "frequencyMinutes", - "durationSeconds", - "opacity", - "placeWithinSourceContent", - "opacityExpression", - "zIndex" - ], - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "image": { - "$ref": "#/components/schemas/ArtworkContentTypeModel" - }, - "name": { - "type": [ - "null", - "string" - ] - }, - "mode": { - "$ref": "#/components/schemas/ChannelWatermarkMode" - }, - "imageSource": { - "$ref": "#/components/schemas/ChannelWatermarkImageSource" - }, - "location": { - "$ref": "#/components/schemas/WatermarkLocation" - }, - "size": { - "$ref": "#/components/schemas/WatermarkSize" - }, - "width": { - "type": "number", - "format": "double" - }, - "horizontalMargin": { - "type": "number", - "format": "double" - }, - "verticalMargin": { - "type": "number", - "format": "double" - }, - "frequencyMinutes": { - "type": "integer", - "format": "int32" - }, - "durationSeconds": { - "type": "integer", - "format": "int32" - }, - "opacity": { - "type": "integer", - "format": "int32" - }, - "placeWithinSourceContent": { - "type": "boolean" - }, - "opacityExpression": { - "type": [ - "null", - "string" - ] - }, - "zIndex": { - "type": "integer", - "format": "int32" - } - } - }, "XmltvBlockBehavior": { "enum": [ "SplitTimeEvenly", @@ -23235,6 +23230,9 @@ { "name": "Images" }, + { + "name": "Languages" + }, { "name": "Libraries" }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 2a89fe5b0..e3b89e566 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -52,7 +52,24 @@ Exemplars: `ToReplaceCommand(int index)`) method that maps it to the Application-layer command type. - **`ErsatzTV.Application` has no nullable context** (no `` = C# default `disable` for that TFM in this repo — confirms CS8632 would otherwise fire) — do **not** add `?` nullable - annotations to types living there; that's a Core/Api-layer-only convention. + annotations to types living there; that's a Core/Api-layer-only convention. A static mapper that + lives in `ErsatzTV.Application` but returns a Core/Api response DTO with nullable members is fine + (e.g. `ScheduleItemResponseMapper`) — the nullability lives on the DTO record, not the mapper. +- **Shared `{id, name}` embeds**: use `ErsatzTV.Core/Api/NamedIdResponseModel.cs` + (`record NamedIdResponseModel(int Id, string Name)`) when a response DTO needs to embed a list of + named references (e.g. a schedule item's `watermarks` / `graphicsElements`) rather than minting a + one-off `(int, string)` record per domain. +- **Flatten polymorphic VMs for the wire**: when an Application ViewModel is an abstract/polymorphic + record (subtypes carrying extra fields), the OpenAPI schema only captures the base shape — promote + every subtype field to a nullable top-level member on a flat response DTO and pattern-match the + concrete VM in the mapper. Exemplar: `ScheduleItemResponseModel` (issue #126, see + `docs/decisions.md` 2026-07-10). Keep the flat DTO's **mutation** fields named 1:1 with the + matching request DTO so GET→PUT is lossless (guard with a round-trip handler test). +- **Optional enum filter via query param**: to filter a list endpoint by an enum, add a nullable + enum parameter to the query record (default `null`) and bind it with `[FromQuery] TEnum? name` on + the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on + `GET /api/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid + enum value is rejected by model binding (400) — no handler-side guard needed. ## 3. Error mapping diff --git a/docs/decisions.md b/docs/decisions.md index 7dc4f6dec..21253eecd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -225,3 +225,23 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t "Add All" (query-wide) mirrors Blazor's two-step: materialize ids via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add command exists server-side. Issues #208/#209. + +## 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`) + +`GET/POST/PUT /api/schedules/{id}/items` return `ScheduleItemResponseModel` / +`ScheduleItemsResponseModel` (`ErsatzTV.Core/Api/Scheduling/`), **not** the Application-layer +`ProgramScheduleItemViewModel` hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM +only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126). +The flat DTO promotes every subtype field to a nullable top-level member — `multipleMode`, +`multipleCount` (renamed from the VM's `Count`), `playoutDuration`, `tailMode`, +`discardToFillAttempts` — mapped by pattern-matching the concrete VM in +`ScheduleItemResponseMapper` (`ErsatzTV.Application/ProgramSchedules/`). Its **mutation fields are +named 1:1 with `ScheduleItemRequest`** so a GET maps losslessly back to a PUT/POST +(`ScheduleItemResponseRoundTripTests` is the release gate proving the fixed point). It also carries +picker-hydration fields the editor needs: `collectionName`/`smartCollectionName`/…/`playlistName`, +`playlistGroupId` (to preselect the playlist's group), per-filler names, `watermarks` / +`graphicsElements` as `NamedIdResponseModel` lists, the computed `name`, and `durationEstimate`. +`GetProgramScheduleItemsHandler.EnforceProperties` still rewrites StartType→Dynamic, Flood→One and +Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that lossy normalization is +deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel` +(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212. diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 1428189cb..0b50cc62c 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`.* -130 endpoints, 204 operations. +133 endpoints, 207 operations. ## Artists @@ -59,7 +59,9 @@ | POST | `/api/channels/bulk/group` | | Move channels to a group | | POST | `/api/channels/bulk/renumber` | | Renumber channels | | POST | `/api/channels/from-lineup` | CreateChannelFromLineup | Create a channel from a library lineup | +| GET | `/api/channels/music-video-credits-templates` | GetMusicVideoCreditsTemplates | Get available music video credits template names | | GET | `/api/channels/state` | | Get channel runtime state | +| GET | `/api/channels/stream-selectors` | GetChannelStreamSelectors | Get available channel stream selector names | | POST | `/api/channels/{channelNumber}/playout/reset` | | Reset channel playout | | DELETE | `/api/channels/{id}` | | Delete a channel | | GET | `/api/channels/{id}` | GetChannelById | Get a channel by id | @@ -147,6 +149,12 @@ | GET | `/api/images/folders` | GetImageFolders | List image library folders | | PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration | +## Languages + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/languages` | GetLanguages | Get all available language codes | + ## Libraries | Method | Path | Operation | Summary | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 9c9024447..cf8339c6a 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -685,24 +685,7 @@ export interface components { "FillerPresetResponseModel": { "id": number; "name": null | string; - }; - "FillerPresetViewModel": { - "id": number; - "name": null | string; "fillerKind": components["schemas"]["FillerKind"]; - "fillerMode": components["schemas"]["FillerMode"]; - "duration": null | string; - "count": null | number; - "padToNearestMinute": null | number; - "allowWatermarks": boolean; - "collectionType": components["schemas"]["CollectionType"]; - "collectionId": null | number; - "mediaItemId": null | number; - "multiCollectionId": null | number; - "smartCollectionId": null | number; - "playlist": components["schemas"]["PlaylistViewModel"]; - "expression": null | string; - "useChaptersAsMediaItems": boolean; }; "FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups"; "FilterMode": "HardwareIfPossible" | "Software"; @@ -710,11 +693,6 @@ export interface components { "GraphicsElementResponseModel": { "id": number; "name": null | string; - }; - "GraphicsElementViewModel": { - "id": number; - "name": null | string; - "fileName": null | string; }; "GuideMode": "Normal" | "Filler"; "HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp"; @@ -742,6 +720,10 @@ export interface components { "subfolderCount": number; "imageCount": number; "durationSeconds": null | number; + }; + "LanguageCodeResponseModel": { + "code": null | string; + "englishName": null | string; }; "LibraryBrowseItemResponseModel": { "id": number; @@ -889,33 +871,15 @@ export interface components { "name": string; "scheduleAsGroup": boolean; "playbackOrder": components["schemas"]["PlaybackOrder"]; - }; - "MultiCollectionItemViewModel": { - "multiCollectionId": number; - "collection": components["schemas"]["MediaCollectionViewModel"]; - "scheduleAsGroup": boolean; - "playbackOrder": components["schemas"]["PlaybackOrder"]; }; "MultiCollectionResponseModel": { "id": number; "name": string; "items": Array; - }; - "MultiCollectionSmartItemViewModel": { - "multiCollectionId": number; - "smartCollection": components["schemas"]["SmartCollectionViewModel"]; - "scheduleAsGroup": boolean; - "playbackOrder": components["schemas"]["PlaybackOrder"]; - }; - "MultiCollectionViewModel": { - "id": number; - "name": null | string; - "items": null | Array; - "smartItems": null | Array; }; "MultipleMode": "Count" | "CollectionSize" | "PlaylistItemSize" | "MultiEpisodeGroupSize"; - "NamedMediaItemViewModel": { - "mediaItemId": number; + "NamedIdResponseModel": { + "id": number; "name": null | string; }; "NormalizeLoudnessMode": "Off" | "LoudNorm"; @@ -999,12 +963,6 @@ export interface components { "playlistGroupId": number; "name": string; "isSystem": boolean; - }; - "PlaylistViewModel": { - "id": number; - "playlistGroupId": number; - "name": null | string; - "isSystem": boolean; }; "PlayoutAlternateScheduleItemRequest": { "id": number; @@ -1138,48 +1096,6 @@ export interface components { "status"?: null | number; "detail"?: null | string; "instance"?: null | string; - }; - "ProgramScheduleItemsWithDurationViewModel": { - "items": null | Array; - "totalDurationEstimate": null | string; - }; - "ProgramScheduleItemViewModel": { - "id"?: number; - "index"?: number; - "startType"?: components["schemas"]["StartType"]; - "startTime"?: null | string; - "fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"]; - "playoutMode"?: components["schemas"]["PlayoutMode"]; - "collectionType"?: components["schemas"]["CollectionType"]; - "collection"?: components["schemas"]["MediaCollectionViewModel"]; - "multiCollection"?: components["schemas"]["MultiCollectionViewModel"]; - "smartCollection"?: components["schemas"]["SmartCollectionViewModel"]; - "rerunCollection"?: components["schemas"]["RerunCollectionViewModel"]; - "playlist"?: components["schemas"]["PlaylistViewModel"]; - "mediaItem"?: components["schemas"]["NamedMediaItemViewModel"]; - "searchTitle"?: null | string; - "searchQuery"?: null | string; - "playbackOrder"?: components["schemas"]["PlaybackOrder"]; - "marathonGroupBy"?: components["schemas"]["MarathonGroupBy"]; - "marathonShuffleGroups"?: boolean; - "marathonShuffleItems"?: boolean; - "marathonBatchSize"?: null | number; - "fillWithGroupMode"?: components["schemas"]["FillWithGroupMode"]; - "customTitle"?: null | string; - "guideMode"?: components["schemas"]["GuideMode"]; - "preRollFiller"?: components["schemas"]["FillerPresetViewModel"]; - "midRollFiller"?: components["schemas"]["FillerPresetViewModel"]; - "postRollFiller"?: components["schemas"]["FillerPresetViewModel"]; - "tailFiller"?: components["schemas"]["FillerPresetViewModel"]; - "fallbackFiller"?: components["schemas"]["FillerPresetViewModel"]; - "watermarks"?: null | Array; - "graphicsElements"?: null | Array; - "preferredAudioLanguageCode"?: null | string; - "preferredAudioTitle"?: null | string; - "preferredSubtitleLanguageCode"?: null | string; - "subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"]; - "durationEstimate"?: null | string; - "name"?: null | string; }; "ProgramScheduleViewModel": { "id": number; @@ -1249,17 +1165,6 @@ export interface components { "selectedName": null | string; "firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"]; "rerunPlaybackOrder": components["schemas"]["PlaybackOrder"]; - }; - "RerunCollectionViewModel": { - "id": number; - "name": null | string; - "collectionType": components["schemas"]["CollectionType"]; - "collection": components["schemas"]["MediaCollectionViewModel"]; - "multiCollection": components["schemas"]["MultiCollectionViewModel"]; - "smartCollection": components["schemas"]["SmartCollectionViewModel"]; - "mediaItem": components["schemas"]["NamedMediaItemViewModel"]; - "firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"]; - "rerunPlaybackOrder": components["schemas"]["PlaybackOrder"]; }; "ResolutionResponseModel": { "id": number; @@ -1321,6 +1226,67 @@ export interface components { "preferredAudioTitle": null | string; "preferredSubtitleLanguageCode": null | string; "subtitleMode": null | components["schemas"]["ChannelSubtitleMode"]; + }; + "ScheduleItemResponseModel": { + "id": number; + "index": number; + "startType": components["schemas"]["StartType"]; + "startTime": null | string; + "fixedStartTimeBehavior": null | components["schemas"]["FixedStartTimeBehavior"]; + "playoutMode": components["schemas"]["PlayoutMode"]; + "collectionType": components["schemas"]["CollectionType"]; + "collectionId": null | number; + "multiCollectionId": null | number; + "smartCollectionId": null | number; + "rerunCollectionId": null | number; + "mediaItemId": null | number; + "playlistId": null | number; + "searchTitle": null | string; + "searchQuery": null | string; + "playbackOrder": components["schemas"]["PlaybackOrder"]; + "marathonGroupBy": components["schemas"]["MarathonGroupBy"]; + "marathonShuffleGroups": boolean; + "marathonShuffleItems": boolean; + "marathonBatchSize": null | number; + "fillWithGroupMode": components["schemas"]["FillWithGroupMode"]; + "multipleMode": null | components["schemas"]["MultipleMode"]; + "multipleCount": null | string; + "playoutDuration": null | string; + "tailMode": null | components["schemas"]["TailMode"]; + "discardToFillAttempts": null | number; + "customTitle": null | string; + "guideMode": components["schemas"]["GuideMode"]; + "preRollFillerId": null | number; + "midRollFillerId": null | number; + "postRollFillerId": null | number; + "tailFillerId": null | number; + "fallbackFillerId": null | number; + "watermarkIds": Array; + "graphicsElementIds": Array; + "preferredAudioLanguageCode": null | string; + "preferredAudioTitle": null | string; + "preferredSubtitleLanguageCode": null | string; + "subtitleMode": null | components["schemas"]["ChannelSubtitleMode"]; + "collectionName": null | string; + "multiCollectionName": null | string; + "smartCollectionName": null | string; + "rerunCollectionName": null | string; + "playlistName": null | string; + "playlistGroupId": null | number; + "mediaItemName": null | string; + "preRollFillerName": null | string; + "midRollFillerName": null | string; + "postRollFillerName": null | string; + "tailFillerName": null | string; + "fallbackFillerName": null | string; + "watermarks": Array; + "graphicsElements": Array; + "name": null | string; + "durationEstimate": null | string; + }; + "ScheduleItemsResponseModel": { + "items": Array; + "totalDurationEstimate": null | string; }; "SchedulingPickerOptionResponseModel": { "id": number; @@ -1711,24 +1677,6 @@ export interface components { "name": null | string; }; "WatermarkSize": "Scaled" | "ActualSize"; - "WatermarkViewModel": { - "id": number; - "image": components["schemas"]["ArtworkContentTypeModel"]; - "name": null | string; - "mode": components["schemas"]["ChannelWatermarkMode"]; - "imageSource": components["schemas"]["ChannelWatermarkImageSource"]; - "location": components["schemas"]["WatermarkLocation"]; - "size": components["schemas"]["WatermarkSize"]; - "width": number; - "horizontalMargin": number; - "verticalMargin": number; - "frequencyMinutes": number; - "durationSeconds": number; - "opacity": number; - "placeWithinSourceContent": boolean; - "opacityExpression": null | string; - "zIndex": number; - }; "XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes"; "XmltvSettingsResponseModel": { "daysToBuild": number; -- 2.47.3 From 35206b2199bb17069e663c89c3c918b54ae0b4c5 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:57:13 +0200 Subject: [PATCH 05/17] test(api): drop stale WatermarkViewModel serializer-contract case (#126) The flat ScheduleItemResponseModel removed the polymorphic ProgramScheduleItemViewModel from the API surface, which was the only path exposing the Application VMs (WatermarkViewModel, PlaylistViewModel, FillerPresetViewModel, collection VMs, etc.). WatermarkViewModel is no longer in v1.json, so its serializer-contract guard case is moot. ChannelViewModel still covers the schema transformer's VM path. Co-Authored-By: Claude Fable 5 --- .../OpenApiSerializerContractTests.cs | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index 7bd4113de..e990d1c89 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using ErsatzTV.Application.Artworks; using ErsatzTV.Application.Channels; -using ErsatzTV.Application.Watermarks; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Api.MediaItems; using ErsatzTV.Core.Api.Settings; @@ -39,7 +38,10 @@ public class OpenApiSerializerContractTests yield return new TestCaseData(FullyPopulatedChannel(), "ChannelViewModel").SetName("ChannelViewModel"); yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel") .SetName("FFmpegSettingsResponseModel"); - yield return new TestCaseData(FullyPopulatedWatermark(), "WatermarkViewModel").SetName("WatermarkViewModel"); + // WatermarkViewModel was dropped from the API surface by #126: schedule-item endpoints no longer + // expose the polymorphic ProgramScheduleItemViewModel (which transitively pulled in WatermarkViewModel + // and the other collection VMs) — they return the flat ScheduleItemResponseModel, embedding watermarks + // as NamedIdResponseModel. ChannelViewModel below still exercises the VM path of the schema transformer. yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel") .SetName("MediaItemInfoResponseModel"); @@ -119,24 +121,6 @@ public class OpenApiSerializerContractTests default, "script"); - private static WatermarkViewModel FullyPopulatedWatermark() => new( - 1, - new ArtworkContentTypeModel("path", "image/png"), - "Name", - default, - default, - default, - default, - 1.0, - 1.0, - 1.0, - 1, - 1, - 1, - true, - "expression", - 1); - private static MediaItemInfoResponseModel FullyPopulatedMediaItemInfo() => new( 1, "Title", -- 2.47.3 From 5816c31136877462ef2d8d3068d2ee106b4524d3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 00:09:49 +0200 Subject: [PATCH 06/17] feat(spa): pure schedule-item rules module (#207) Blazor-parity gating, option lists and forced-reset transforms for the schedules editor, exhaustively unit-tested (44 cases). No React/fetch. Co-Authored-By: Claude Fable 5 --- web/src/schedules/itemRules.test.ts | 474 ++++++++++++++++++++++++++++ web/src/schedules/itemRules.ts | 465 +++++++++++++++++++++++++++ 2 files changed, 939 insertions(+) create mode 100644 web/src/schedules/itemRules.test.ts create mode 100644 web/src/schedules/itemRules.ts diff --git a/web/src/schedules/itemRules.test.ts b/web/src/schedules/itemRules.test.ts new file mode 100644 index 000000000..2ef990431 --- /dev/null +++ b/web/src/schedules/itemRules.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, it } from 'vitest'; +import { + applyCollectionTypeChange, + applyPlaybackOrderChange, + applyPlayoutModeChange, + copyDraftItem, + type DraftItem, + durationFieldsEnabled, + fillWithGroupModeEligible, + fixedBehaviorEnabled, + fromResponse, + multipleCountEnabled, + multipleModeEnabled, + multipleModeOptions, + newDraftItem, + normalizeForSave, + playbackOrderLabel, + playbackOrderOptions, + playbackOrderSelectDisabled, + playoutModeOptions, + type ScheduleItem, + shufflePlaylistItemsVisible, + startTimeEnabled, + startTypeOptions, + validateDraft, + validateItem +} from './itemRules'; + +function draft(overrides: Partial = {}): DraftItem { + return { ...newDraftItem(), ...overrides }; +} + +describe('playbackOrderOptions', () => { + it('MultiCollection offers Shuffle + ShuffleInOrder only', () => { + expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder']); + }); + + it('Collection / SmartCollection / SearchQuery offer the collection-like set incl. Marathon', () => { + const expected = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon']; + expect(playbackOrderOptions('Collection')).toEqual(expected); + expect(playbackOrderOptions('SmartCollection')).toEqual(expected); + expect(playbackOrderOptions('SearchQuery')).toEqual(expected); + }); + + it('TelevisionShow offers Chronological/SeasonEpisode/Random/Shuffle/MultiEpisodeShuffle', () => { + expect(playbackOrderOptions('TelevisionShow')).toEqual([ + 'Chronological', + 'SeasonEpisode', + 'Random', + 'Shuffle', + 'MultiEpisodeShuffle' + ]); + }); + + it('TelevisionSeason / Artist offer Chronological/Random/Shuffle', () => { + expect(playbackOrderOptions('TelevisionSeason')).toEqual(['Chronological', 'Random', 'Shuffle']); + expect(playbackOrderOptions('Artist')).toEqual(['Chronological', 'Random', 'Shuffle']); + }); + + it('Playlist / Rerun types offer only None', () => { + expect(playbackOrderOptions('Playlist')).toEqual(['None']); + expect(playbackOrderOptions('RerunFirstRun')).toEqual(['None']); + expect(playbackOrderOptions('RerunRerun')).toEqual(['None']); + }); + + it('never offers RandomRotation', () => { + const allTypes: DraftItem['collectionType'][] = [ + 'Collection', + 'TelevisionShow', + 'TelevisionSeason', + 'Artist', + 'MultiCollection', + 'SmartCollection', + 'Playlist', + 'RerunFirstRun', + 'RerunRerun', + 'SearchQuery' + ]; + for (const t of allTypes) { + expect(playbackOrderOptions(t)).not.toContain('RandomRotation'); + } + }); +}); + +describe('playbackOrder selector disable + label', () => { + it('disabled for Playlist and Rerun types only', () => { + expect(playbackOrderSelectDisabled('Playlist')).toBe(true); + expect(playbackOrderSelectDisabled('RerunFirstRun')).toBe(true); + expect(playbackOrderSelectDisabled('RerunRerun')).toBe(true); + expect(playbackOrderSelectDisabled('Collection')).toBe(false); + expect(playbackOrderSelectDisabled('MultiCollection')).toBe(false); + }); + + it('labels None as "Playlist" for a Playlist type, plain otherwise', () => { + expect(playbackOrderLabel('None', 'Playlist')).toBe('Playlist'); + expect(playbackOrderLabel('None', 'RerunFirstRun')).toBe('None'); + expect(playbackOrderLabel('MultiEpisodeShuffle', 'TelevisionShow')).toBe('Multi Episode Shuffle'); + }); +}); + +describe('start + playout mode option lists gate on shuffleScheduleItems', () => { + it('hides Fixed start type when the schedule shuffles items', () => { + expect(startTypeOptions(false)).toEqual(['Dynamic', 'Fixed']); + expect(startTypeOptions(true)).toEqual(['Dynamic']); + }); + + it('hides Flood playout mode when the schedule shuffles items', () => { + expect(playoutModeOptions(false)).toEqual(['Flood', 'One', 'Multiple', 'Duration']); + expect(playoutModeOptions(true)).toEqual(['One', 'Multiple', 'Duration']); + }); +}); + +describe('multipleModeOptions', () => { + it('always offers Count; CollectionSize for non-Playlist, PlaylistItemSize for Playlist', () => { + expect(multipleModeOptions('Collection', 'Shuffle')).toEqual(['Count', 'CollectionSize']); + expect(multipleModeOptions('Playlist', 'None')).toEqual(['Count', 'PlaylistItemSize']); + }); + + it('adds MultiEpisodeGroupSize only for Chronological order', () => { + expect(multipleModeOptions('Collection', 'Chronological')).toEqual([ + 'Count', + 'CollectionSize', + 'MultiEpisodeGroupSize' + ]); + expect(multipleModeOptions('Collection', 'Random')).not.toContain('MultiEpisodeGroupSize'); + }); +}); + +describe('enable gates', () => { + it('start time + fixed behavior enabled only for Fixed', () => { + expect(startTimeEnabled(draft({ startType: 'Fixed' }))).toBe(true); + expect(startTimeEnabled(draft({ startType: 'Dynamic' }))).toBe(false); + expect(fixedBehaviorEnabled(draft({ startType: 'Fixed' }))).toBe(true); + expect(fixedBehaviorEnabled(draft({ startType: 'Dynamic' }))).toBe(false); + }); + + it('multipleMode enabled only for Multiple; multipleCount only for Multiple+Count', () => { + expect(multipleModeEnabled(draft({ playoutMode: 'Multiple' }))).toBe(true); + expect(multipleModeEnabled(draft({ playoutMode: 'One' }))).toBe(false); + expect(multipleCountEnabled(draft({ playoutMode: 'Multiple', multipleMode: 'Count' }))).toBe(true); + expect(multipleCountEnabled(draft({ playoutMode: 'Multiple', multipleMode: 'CollectionSize' }))).toBe(false); + expect(multipleCountEnabled(draft({ playoutMode: 'Duration', multipleMode: 'Count' }))).toBe(false); + }); + + it('duration fields enabled only for Duration', () => { + expect(durationFieldsEnabled(draft({ playoutMode: 'Duration' }))).toBe(true); + expect(durationFieldsEnabled(draft({ playoutMode: 'Flood' }))).toBe(false); + }); + + it('shuffle-playlist-items checkbox visible only for Playlist type', () => { + expect(shufflePlaylistItemsVisible(draft({ collectionType: 'Playlist' }))).toBe(true); + expect(shufflePlaylistItemsVisible(draft({ collectionType: 'Collection' }))).toBe(false); + }); +}); + +describe('fillWithGroupModeEligible', () => { + it('eligible for Multiple/Duration + non-ShuffleInOrder + Collection/Multi/Smart', () => { + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection' }))).toBe(true); + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'MultiCollection' }))).toBe(true); + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'SmartCollection' }))).toBe(true); + }); + + it('ineligible for One/Flood, ShuffleInOrder, or non-collection types', () => { + expect(fillWithGroupModeEligible(draft({ playoutMode: 'One', collectionType: 'Collection' }))).toBe(false); + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Flood', collectionType: 'Collection' }))).toBe(false); + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'ShuffleInOrder', collectionType: 'Collection' }))).toBe(false); + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'TelevisionShow' }))).toBe(false); + expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'None', collectionType: 'Playlist' }))).toBe(false); + }); +}); + +describe('applyCollectionTypeChange', () => { + it('nulls all source refs + names', () => { + const before = draft({ + collectionType: 'Collection', + collectionId: 5, + collectionName: 'A', + multiCollectionId: 6, + multiCollectionName: 'B', + smartCollectionId: 7, + smartCollectionName: 'C', + rerunCollectionId: 8, + rerunCollectionName: 'D', + mediaItemId: 9, + mediaItemName: 'E', + playlistId: 10, + playlistName: 'F', + playlistGroupId: 2, + searchTitle: 'T', + searchQuery: 'Q' + }); + const after = applyCollectionTypeChange(before, 'TelevisionShow'); + expect(after.collectionType).toBe('TelevisionShow'); + for (const key of [ + 'collectionId', + 'multiCollectionId', + 'smartCollectionId', + 'rerunCollectionId', + 'mediaItemId', + 'playlistId', + 'playlistGroupId', + 'searchTitle', + 'searchQuery', + 'collectionName', + 'multiCollectionName', + 'smartCollectionName', + 'rerunCollectionName', + 'playlistName', + 'mediaItemName' + ] as const) { + expect(after[key]).toBeNull(); + } + }); + + it('resets PlaylistItemSize -> Count when leaving Playlist', () => { + const before = draft({ collectionType: 'Playlist', playbackOrder: 'None', multipleMode: 'PlaylistItemSize' }); + expect(applyCollectionTypeChange(before, 'Collection').multipleMode).toBe('Count'); + }); + + it('keeps PlaylistItemSize allowed when moving to Playlist (no reset applied)', () => { + const before = draft({ collectionType: 'Collection', multipleMode: 'CollectionSize' }); + expect(applyCollectionTypeChange(before, 'Playlist').multipleMode).toBe('CollectionSize'); + }); + + it('forces playbackOrder None for Playlist / Rerun types', () => { + expect(applyCollectionTypeChange(draft(), 'Playlist').playbackOrder).toBe('None'); + expect(applyCollectionTypeChange(draft(), 'RerunFirstRun').playbackOrder).toBe('None'); + expect(applyCollectionTypeChange(draft(), 'RerunRerun').playbackOrder).toBe('None'); + }); + + it('forces playbackOrder Shuffle for MultiCollection', () => { + expect(applyCollectionTypeChange(draft({ playbackOrder: 'Chronological' }), 'MultiCollection').playbackOrder).toBe('Shuffle'); + }); + + it('resets fillWithGroupMode when the new type makes it ineligible', () => { + const before = draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' }); + expect(before.fillWithGroupMode).toBe('FillWithOrderedGroups'); + const after = applyCollectionTypeChange(before, 'TelevisionShow'); + expect(after.fillWithGroupMode).toBe('None'); + }); +}); + +describe('applyPlaybackOrderChange', () => { + it('resets MultiEpisodeGroupSize -> Count when not Chronological', () => { + const before = draft({ playbackOrder: 'Chronological', multipleMode: 'MultiEpisodeGroupSize' }); + expect(applyPlaybackOrderChange(before, 'Random').multipleMode).toBe('Count'); + expect(applyPlaybackOrderChange(before, 'Chronological').multipleMode).toBe('MultiEpisodeGroupSize'); + }); + + it('resets marathon fields (except shuffleGroups) when not Marathon', () => { + const before = draft({ + playbackOrder: 'Marathon', + marathonGroupBy: 'Season', + marathonShuffleItems: true, + marathonShuffleGroups: true, + marathonBatchSize: 4 + }); + const after = applyPlaybackOrderChange(before, 'Shuffle'); + expect(after.marathonGroupBy).toBe('None'); + expect(after.marathonShuffleItems).toBe(false); + expect(after.marathonBatchSize).toBeNull(); + expect(after.marathonShuffleGroups).toBe(true); + }); + + it('preserves marathon fields when staying Marathon', () => { + const before = draft({ playbackOrder: 'Marathon', marathonGroupBy: 'Season', marathonBatchSize: 4 }); + const after = applyPlaybackOrderChange(before, 'Marathon'); + expect(after.marathonGroupBy).toBe('Season'); + expect(after.marathonBatchSize).toBe(4); + }); + + it('resets fillWithGroupMode to None for ShuffleInOrder', () => { + const before = draft({ playoutMode: 'Multiple', collectionType: 'Collection', fillWithGroupMode: 'FillWithShuffledGroups' }); + expect(applyPlaybackOrderChange(before, 'ShuffleInOrder').fillWithGroupMode).toBe('None'); + }); +}); + +describe('applyPlayoutModeChange', () => { + it('resets fillWithGroupMode when moving to One/Flood', () => { + const before = draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' }); + expect(applyPlayoutModeChange(before, 'One').fillWithGroupMode).toBe('None'); + expect(applyPlayoutModeChange(before, 'Duration').fillWithGroupMode).toBe('FillWithOrderedGroups'); + }); +}); + +describe('factories', () => { + it('newDraftItem uses the documented defaults', () => { + const item = newDraftItem(); + expect(item).toMatchObject({ + startType: 'Dynamic', + playoutMode: 'One', + playbackOrder: 'Shuffle', + collectionType: 'Collection', + watermarkIds: [], + graphicsElementIds: [] + }); + expect(item._key).toMatch(/^draft-/); + }); + + it('copyDraftItem deep-copies multi/smart/rerun refs and gives a new key + array identity', () => { + const original = draft({ + multiCollectionId: 3, + multiCollectionName: 'M', + smartCollectionId: 4, + smartCollectionName: 'S', + rerunCollectionId: 5, + rerunCollectionName: 'R', + watermarkIds: [1, 2], + graphicsElementIds: [9] + }); + const copy = copyDraftItem(original); + expect(copy.multiCollectionId).toBe(3); + expect(copy.smartCollectionId).toBe(4); + expect(copy.rerunCollectionId).toBe(5); + expect(copy.multiCollectionName).toBe('M'); + expect(copy.rerunCollectionName).toBe('R'); + expect(copy._key).not.toBe(original._key); + expect(copy.watermarkIds).toEqual([1, 2]); + expect(copy.watermarkIds).not.toBe(original.watermarkIds); + expect(copy.graphicsElementIds).not.toBe(original.graphicsElementIds); + }); +}); + +describe('fromResponse', () => { + it('maps watermarks/graphics named embeds to id arrays and copies mutation fields', () => { + const model: ScheduleItem = { + id: 42, + index: 0, + startType: 'Fixed', + startTime: '06:00:00', + fixedStartTimeBehavior: 'Strict', + playoutMode: 'Duration', + collectionType: 'SmartCollection', + collectionId: null, + multiCollectionId: null, + smartCollectionId: 7, + rerunCollectionId: null, + mediaItemId: null, + playlistId: null, + searchTitle: null, + searchQuery: null, + playbackOrder: 'Shuffle', + marathonGroupBy: 'None', + marathonShuffleGroups: false, + marathonShuffleItems: false, + marathonBatchSize: null, + fillWithGroupMode: 'FillWithOrderedGroups', + multipleMode: null, + multipleCount: null, + playoutDuration: '01:00:00', + tailMode: 'Offline', + discardToFillAttempts: 2, + customTitle: 'Nightly', + guideMode: 'Normal', + preRollFillerId: null, + midRollFillerId: null, + postRollFillerId: null, + tailFillerId: null, + fallbackFillerId: null, + watermarkIds: [1, 2], + graphicsElementIds: [3], + preferredAudioLanguageCode: 'eng', + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + subtitleMode: null, + collectionName: null, + multiCollectionName: null, + smartCollectionName: 'Smarts', + rerunCollectionName: null, + playlistName: null, + playlistGroupId: null, + mediaItemName: null, + preRollFillerName: null, + midRollFillerName: null, + postRollFillerName: null, + tailFillerName: null, + fallbackFillerName: null, + watermarks: [ + { id: 1, name: 'W1' }, + { id: 2, name: 'W2' } + ], + graphicsElements: [{ id: 3, name: 'G3' }], + name: 'Smarts', + durationEstimate: '01:00:00' + }; + const item = fromResponse(model); + expect(item.smartCollectionId).toBe(7); + expect(item.smartCollectionName).toBe('Smarts'); + expect(item.watermarkIds).toEqual([1, 2]); + expect(item.graphicsElementIds).toEqual([3]); + expect(item.multipleMode).toBe('Count'); // null coalesced + expect(item.tailMode).toBe('Offline'); + expect(item.playoutDuration).toBe('01:00:00'); + }); +}); + +describe('normalizeForSave', () => { + it('nulls startTime + fixedStartTimeBehavior when not Fixed', () => { + const req = normalizeForSave(draft({ startType: 'Dynamic', startTime: '06:00:00', fixedStartTimeBehavior: 'Strict' })); + expect(req.startTime).toBeNull(); + expect(req.fixedStartTimeBehavior).toBeNull(); + }); + + it('keeps startTime + behavior when Fixed', () => { + const req = normalizeForSave(draft({ startType: 'Fixed', startTime: '06:00:00', fixedStartTimeBehavior: 'Flexible' })); + expect(req.startTime).toBe('06:00:00'); + expect(req.fixedStartTimeBehavior).toBe('Flexible'); + }); + + it('nulls multipleCount unless Multiple+Count', () => { + expect(normalizeForSave(draft({ playoutMode: 'One', multipleCount: '5' })).multipleCount).toBeNull(); + expect(normalizeForSave(draft({ playoutMode: 'Multiple', multipleMode: 'CollectionSize', multipleCount: '5' })).multipleCount).toBeNull(); + expect(normalizeForSave(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: '5' })).multipleCount).toBe('5'); + }); + + it('nulls duration fields (tailMode -> None) unless Duration', () => { + const req = normalizeForSave(draft({ playoutMode: 'One', playoutDuration: '01:00:00', tailMode: 'Offline', discardToFillAttempts: 3 })); + expect(req.playoutDuration).toBeNull(); + expect(req.tailMode).toBe('None'); + expect(req.discardToFillAttempts).toBeNull(); + }); + + it('keeps duration fields when Duration', () => { + const req = normalizeForSave(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', tailMode: 'Filler', discardToFillAttempts: 3 })); + expect(req.playoutDuration).toBe('01:00:00'); + expect(req.tailMode).toBe('Filler'); + expect(req.discardToFillAttempts).toBe(3); + }); + + it('resets marathon fields (except shuffleGroups) unless Marathon', () => { + const req = normalizeForSave(draft({ playbackOrder: 'Shuffle', marathonGroupBy: 'Season', marathonShuffleItems: true, marathonShuffleGroups: true, marathonBatchSize: 4 })); + expect(req.marathonGroupBy).toBe('None'); + expect(req.marathonShuffleItems).toBe(false); + expect(req.marathonBatchSize).toBeNull(); + expect(req.marathonShuffleGroups).toBe(true); + }); + + it('forces fillWithGroupMode None when ineligible', () => { + const req = normalizeForSave(draft({ playoutMode: 'One', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' })); + expect(req.fillWithGroupMode).toBe('None'); + }); + + it('does not emit local-only fields', () => { + const req = normalizeForSave(draft()); + expect('_key' in req).toBe(false); + expect('collectionName' in req).toBe(false); + expect('name' in req).toBe(false); + }); +}); + +describe('validation', () => { + it('requires a start time for Fixed items', () => { + expect(validateItem(draft({ startType: 'Fixed', startTime: null }))).toMatch(/start time/i); + expect(validateItem(draft({ startType: 'Fixed', startTime: '06:00:00' }))).toBeNull(); + }); + + it('requires a count expression for Multiple+Count', () => { + expect(validateItem(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: null }))).toMatch(/count/i); + expect(validateItem(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: '3' }))).toBeNull(); + }); + + it('requires playoutDuration + non-negative discard for Duration', () => { + expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: null }))).toMatch(/duration/i); + expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: null }))).toMatch(/discard/i); + expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: -1 }))).toMatch(/discard/i); + expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: 0 }))).toBeNull(); + }); + + it('validateDraft prefixes the offending item index', () => { + const items = [draft(), draft({ startType: 'Fixed', startTime: null })]; + expect(validateDraft(items)).toMatch(/^Item 2:/); + expect(validateDraft([draft()])).toBeNull(); + }); +}); diff --git a/web/src/schedules/itemRules.ts b/web/src/schedules/itemRules.ts new file mode 100644 index 000000000..113a01b4f --- /dev/null +++ b/web/src/schedules/itemRules.ts @@ -0,0 +1,465 @@ +// Pure, exhaustively-tested rules for the schedule-item editor. Encodes the Blazor +// ProgramScheduleItemEditViewModel gating + forced-reset behavior (the parity standard for #207). +// No React, no fetch — every function is a deterministic transform so itemRules.test.ts can pin it. +import type { components } from '../api/generated/v1'; + +export type CollectionType = components['schemas']['CollectionType']; +export type PlaybackOrder = components['schemas']['PlaybackOrder']; +export type PlayoutMode = components['schemas']['PlayoutMode']; +export type MultipleMode = components['schemas']['MultipleMode']; +export type StartType = components['schemas']['StartType']; +export type TailMode = components['schemas']['TailMode']; +export type FillWithGroupMode = components['schemas']['FillWithGroupMode']; +export type MarathonGroupBy = components['schemas']['MarathonGroupBy']; +export type GuideMode = components['schemas']['GuideMode']; +export type FixedStartTimeBehavior = components['schemas']['FixedStartTimeBehavior']; +export type SubtitleMode = components['schemas']['ChannelSubtitleMode']; +export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest']; +export type ScheduleItem = components['schemas']['ScheduleItemResponseModel']; +export type NamedId = components['schemas']['NamedIdResponseModel']; + +// A local, editable schedule item. Carries every ScheduleItemRequest mutation field (named 1:1 with +// the DTO) plus display-only hydration the search-based pickers need to render the current selection +// without re-fetching, plus a stable local key for React + selection. `index` is intentionally NOT a +// field — array order is the source of truth (server indexes from array position on PUT). +export interface DraftItem extends ScheduleItemRequest { + _key: string; + collectionName: null | string; + multiCollectionName: null | string; + smartCollectionName: null | string; + rerunCollectionName: null | string; + playlistName: null | string; + playlistGroupId: null | number; + mediaItemName: null | string; + name: null | string; + durationEstimate: null | string; +} + +let keyCounter = 0; +export function nextDraftKey(): string { + keyCounter += 1; + return `draft-${keyCounter}`; +} + +// ---- Option lists -------------------------------------------------------- + +const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder']; +const COLLECTION_LIKE_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon']; +const TV_SHOW_ORDERS: PlaybackOrder[] = ['Chronological', 'SeasonEpisode', 'Random', 'Shuffle', 'MultiEpisodeShuffle']; +const SEASON_ARTIST_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle']; +const NONE_ORDERS: PlaybackOrder[] = ['None']; + +// Never offered anywhere (Blazor parity): RandomRotation. +export function playbackOrderOptions(collectionType: CollectionType): PlaybackOrder[] { + switch (collectionType) { + case 'MultiCollection': + return MULTI_COLLECTION_ORDERS; + case 'TelevisionShow': + return TV_SHOW_ORDERS; + case 'TelevisionSeason': + case 'Artist': + return SEASON_ARTIST_ORDERS; + case 'Playlist': + case 'RerunFirstRun': + case 'RerunRerun': + return NONE_ORDERS; + case 'Collection': + case 'SmartCollection': + case 'SearchQuery': + return COLLECTION_LIKE_ORDERS; + default: + return COLLECTION_LIKE_ORDERS; + } +} + +// The playback-order selector is disabled entirely for Playlist / Rerun collection types. +export function playbackOrderSelectDisabled(collectionType: CollectionType): boolean { + return collectionType === 'Playlist' || collectionType === 'RerunFirstRun' || collectionType === 'RerunRerun'; +} + +// The lone `None` option is labeled "Playlist" for a Playlist collection type (Blazor parity). +export function playbackOrderLabel(order: PlaybackOrder, collectionType: CollectionType): string { + if (order === 'None' && collectionType === 'Playlist') { + return 'Playlist'; + } + return humanizeEnum(order); +} + +export const MARATHON_GROUP_BY_OPTIONS: MarathonGroupBy[] = ['Album', 'Artist', 'Season', 'Show', 'Director']; +export const TAIL_MODE_OPTIONS: TailMode[] = ['None', 'Offline', 'Filler']; +export const GUIDE_MODE_OPTIONS: GuideMode[] = ['Normal', 'Filler']; +export const FILL_WITH_GROUP_MODE_OPTIONS: FillWithGroupMode[] = ['None', 'FillWithOrderedGroups', 'FillWithShuffledGroups']; +// subtitleMode is nullable ("Inherit" = null). +export const SUBTITLE_MODE_OPTIONS: (SubtitleMode | null)[] = [null, 'None', 'Forced', 'Default', 'Any']; +export const COLLECTION_TYPE_OPTIONS: CollectionType[] = [ + 'Collection', + 'TelevisionShow', + 'TelevisionSeason', + 'Artist', + 'MultiCollection', + 'SmartCollection', + 'Playlist', + 'RerunFirstRun', + 'RerunRerun', + 'SearchQuery' +]; + +export function startTypeOptions(shuffleScheduleItems: boolean): StartType[] { + return shuffleScheduleItems ? ['Dynamic'] : ['Dynamic', 'Fixed']; +} + +export function playoutModeOptions(shuffleScheduleItems: boolean): PlayoutMode[] { + return shuffleScheduleItems ? ['One', 'Multiple', 'Duration'] : ['Flood', 'One', 'Multiple', 'Duration']; +} + +export function multipleModeOptions(collectionType: CollectionType, playbackOrder: PlaybackOrder): MultipleMode[] { + const options: MultipleMode[] = ['Count']; + options.push(collectionType === 'Playlist' ? 'PlaylistItemSize' : 'CollectionSize'); + if (playbackOrder === 'Chronological') { + options.push('MultiEpisodeGroupSize'); + } + return options; +} + +// ---- Enable gates (UI disables the control, save nulls the value) -------- + +export function startTimeEnabled(item: DraftItem): boolean { + return item.startType === 'Fixed'; +} + +export function fixedBehaviorEnabled(item: DraftItem): boolean { + return item.startType === 'Fixed'; +} + +export function multipleModeEnabled(item: DraftItem): boolean { + return item.playoutMode === 'Multiple'; +} + +export function multipleCountEnabled(item: DraftItem): boolean { + return item.playoutMode === 'Multiple' && item.multipleMode === 'Count'; +} + +export function durationFieldsEnabled(item: DraftItem): boolean { + return item.playoutMode === 'Duration'; +} + +export function marathonVisible(item: DraftItem): boolean { + return item.playbackOrder === 'Marathon'; +} + +// Playlist collections show a "Shuffle Playlist Items" checkbox bound to marathonShuffleGroups +// (dual-purpose column — Blazor parity). +export function shufflePlaylistItemsVisible(item: DraftItem): boolean { + return item.collectionType === 'Playlist'; +} + +// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder != ShuffleInOrder && +// collectionType in {Collection, MultiCollection, SmartCollection}. +export function fillWithGroupModeEligible(item: DraftItem): boolean { + const modeOk = item.playoutMode === 'Multiple' || item.playoutMode === 'Duration'; + const orderOk = item.playbackOrder !== 'ShuffleInOrder'; + const typeOk = + item.collectionType === 'Collection' || + item.collectionType === 'MultiCollection' || + item.collectionType === 'SmartCollection'; + return modeOk && orderOk && typeOk; +} + +// ---- Forced-reset transforms -------------------------------------------- + +function enforceFillWithGroup(item: DraftItem): DraftItem { + if (!fillWithGroupModeEligible(item) && item.fillWithGroupMode !== 'None') { + return { ...item, fillWithGroupMode: 'None' }; + } + return item; +} + +// Changing the collection type nulls every source ref (+ its display name) and re-seeds +// multipleMode / playbackOrder per the Blazor rules, then reconciles fillWithGroup eligibility. +export function applyCollectionTypeChange(item: DraftItem, newType: CollectionType): DraftItem { + let next: DraftItem = { + ...item, + collectionType: newType, + collectionId: null, + multiCollectionId: null, + smartCollectionId: null, + rerunCollectionId: null, + mediaItemId: null, + playlistId: null, + searchTitle: null, + searchQuery: null, + collectionName: null, + multiCollectionName: null, + smartCollectionName: null, + rerunCollectionName: null, + playlistName: null, + playlistGroupId: null, + mediaItemName: null + }; + + if (newType !== 'Playlist' && next.multipleMode === 'PlaylistItemSize') { + next = { ...next, multipleMode: 'Count' }; + } + + if (newType === 'Playlist' || newType === 'RerunFirstRun' || newType === 'RerunRerun') { + next = applyPlaybackOrderChange(next, 'None'); + } else if (newType === 'MultiCollection') { + next = applyPlaybackOrderChange(next, 'Shuffle'); + } else { + next = enforceFillWithGroup(next); + } + + return next; +} + +// Changing playback order resets MultiEpisodeGroupSize when not Chronological, resets the marathon +// fields when not Marathon (marathonShuffleGroups is deliberately NOT reset — it is dual-purpose), +// then reconciles fillWithGroup eligibility. +export function applyPlaybackOrderChange(item: DraftItem, newOrder: PlaybackOrder): DraftItem { + let next: DraftItem = { ...item, playbackOrder: newOrder }; + + if (newOrder !== 'Chronological' && next.multipleMode === 'MultiEpisodeGroupSize') { + next = { ...next, multipleMode: 'Count' }; + } + + if (newOrder !== 'Marathon') { + next = { + ...next, + marathonGroupBy: 'None', + marathonShuffleItems: false, + marathonBatchSize: null + }; + } + + return enforceFillWithGroup(next); +} + +// A change to playoutMode can flip fillWithGroup eligibility (Multiple/Duration only). +export function applyPlayoutModeChange(item: DraftItem, newMode: PlayoutMode): DraftItem { + return enforceFillWithGroup({ ...item, playoutMode: newMode }); +} + +// ---- Factories ----------------------------------------------------------- + +// Add-item defaults: startType Dynamic, playoutMode One, playbackOrder Shuffle, +// collectionType Collection, empty watermarks/graphics. +export function newDraftItem(): DraftItem { + return { + _key: nextDraftKey(), + startType: 'Dynamic', + startTime: null, + fixedStartTimeBehavior: null, + playoutMode: 'One', + collectionType: 'Collection', + collectionId: null, + multiCollectionId: null, + smartCollectionId: null, + rerunCollectionId: null, + mediaItemId: null, + playlistId: null, + searchTitle: null, + searchQuery: null, + playbackOrder: 'Shuffle', + marathonGroupBy: 'None', + marathonShuffleGroups: false, + marathonShuffleItems: false, + marathonBatchSize: null, + fillWithGroupMode: 'None', + multipleMode: 'Count', + multipleCount: null, + playoutDuration: null, + tailMode: 'None', + discardToFillAttempts: null, + customTitle: null, + guideMode: 'Normal', + preRollFillerId: null, + midRollFillerId: null, + postRollFillerId: null, + tailFillerId: null, + fallbackFillerId: null, + watermarkIds: [], + graphicsElementIds: [], + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + subtitleMode: null, + collectionName: null, + multiCollectionName: null, + smartCollectionName: null, + rerunCollectionName: null, + playlistName: null, + playlistGroupId: null, + mediaItemName: null, + name: null, + durationEstimate: null + }; +} + +// Deep copy of ALL fields, including the multi/smart/rerun collection references (+ names) that the +// Blazor CopyItem omitted — deliberate bug fix, see docs/decisions.md 2026-07-11. +export function copyDraftItem(item: DraftItem): DraftItem { + return { + ...item, + _key: nextDraftKey(), + watermarkIds: [...(item.watermarkIds ?? [])], + graphicsElementIds: [...(item.graphicsElementIds ?? [])] + }; +} + +// Maps a loaded GET response item into an editable draft (straight field copy — the DTO's mutation +// fields are named 1:1 with the request). +export function fromResponse(model: ScheduleItem): DraftItem { + return { + _key: nextDraftKey(), + startType: model.startType, + startTime: model.startTime, + fixedStartTimeBehavior: model.fixedStartTimeBehavior, + playoutMode: model.playoutMode, + collectionType: model.collectionType, + collectionId: model.collectionId, + multiCollectionId: model.multiCollectionId, + smartCollectionId: model.smartCollectionId, + rerunCollectionId: model.rerunCollectionId, + mediaItemId: model.mediaItemId, + playlistId: model.playlistId, + searchTitle: model.searchTitle, + searchQuery: model.searchQuery, + playbackOrder: model.playbackOrder, + marathonGroupBy: model.marathonGroupBy, + marathonShuffleGroups: model.marathonShuffleGroups, + marathonShuffleItems: model.marathonShuffleItems, + marathonBatchSize: model.marathonBatchSize, + fillWithGroupMode: model.fillWithGroupMode, + multipleMode: model.multipleMode ?? 'Count', + multipleCount: model.multipleCount, + playoutDuration: model.playoutDuration, + tailMode: model.tailMode ?? 'None', + discardToFillAttempts: model.discardToFillAttempts, + customTitle: model.customTitle, + guideMode: model.guideMode, + preRollFillerId: model.preRollFillerId, + midRollFillerId: model.midRollFillerId, + postRollFillerId: model.postRollFillerId, + tailFillerId: model.tailFillerId, + fallbackFillerId: model.fallbackFillerId, + watermarkIds: (model.watermarks ?? []).map((w) => w.id), + graphicsElementIds: (model.graphicsElements ?? []).map((g) => g.id), + preferredAudioLanguageCode: model.preferredAudioLanguageCode, + preferredAudioTitle: model.preferredAudioTitle, + preferredSubtitleLanguageCode: model.preferredSubtitleLanguageCode, + subtitleMode: model.subtitleMode, + collectionName: model.collectionName, + multiCollectionName: model.multiCollectionName, + smartCollectionName: model.smartCollectionName, + rerunCollectionName: model.rerunCollectionName, + playlistName: model.playlistName, + playlistGroupId: model.playlistGroupId, + mediaItemName: model.mediaItemName, + name: model.name, + durationEstimate: model.durationEstimate + }; +} + +// ---- Save projection ----------------------------------------------------- + +// Projects a draft to the request body, applying Blazor's getter-gating: gated-off values are +// nulled (or defaulted to the enum's None) so the payload matches what a non-editing Blazor VM emits. +export function normalizeForSave(item: DraftItem): ScheduleItemRequest { + const isFixed = item.startType === 'Fixed'; + const isMultipleCount = item.playoutMode === 'Multiple' && item.multipleMode === 'Count'; + const isDuration = item.playoutMode === 'Duration'; + const isMarathon = item.playbackOrder === 'Marathon'; + const fillWithGroupMode = fillWithGroupModeEligible(item) ? item.fillWithGroupMode : 'None'; + + return { + startType: item.startType, + startTime: isFixed ? item.startTime : null, + fixedStartTimeBehavior: isFixed ? item.fixedStartTimeBehavior : null, + playoutMode: item.playoutMode, + collectionType: item.collectionType, + collectionId: item.collectionId, + multiCollectionId: item.multiCollectionId, + smartCollectionId: item.smartCollectionId, + rerunCollectionId: item.rerunCollectionId, + mediaItemId: item.mediaItemId, + playlistId: item.playlistId, + searchTitle: item.searchTitle, + searchQuery: item.searchQuery, + playbackOrder: item.playbackOrder, + marathonGroupBy: isMarathon ? item.marathonGroupBy : 'None', + marathonShuffleGroups: item.marathonShuffleGroups, + marathonShuffleItems: isMarathon ? item.marathonShuffleItems : false, + marathonBatchSize: isMarathon ? item.marathonBatchSize : null, + fillWithGroupMode, + multipleMode: item.multipleMode, + multipleCount: isMultipleCount ? item.multipleCount : null, + playoutDuration: isDuration ? item.playoutDuration : null, + tailMode: isDuration ? item.tailMode : 'None', + discardToFillAttempts: isDuration ? item.discardToFillAttempts : null, + customTitle: item.customTitle, + guideMode: item.guideMode, + preRollFillerId: item.preRollFillerId, + midRollFillerId: item.midRollFillerId, + postRollFillerId: item.postRollFillerId, + tailFillerId: item.tailFillerId, + fallbackFillerId: item.fallbackFillerId, + watermarkIds: item.watermarkIds ?? [], + graphicsElementIds: item.graphicsElementIds ?? [], + preferredAudioLanguageCode: item.preferredAudioLanguageCode, + preferredAudioTitle: item.preferredAudioTitle, + preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode, + subtitleMode: item.subtitleMode + }; +} + +// ---- Client-side validation (mirrors the server) ------------------------- + +// Returns a human message for the first blocking problem, or null when the item is submittable. +export function validateItem(item: DraftItem): null | string { + if (item.startType === 'Fixed' && !item.startTime) { + return 'Fixed start items require a start time'; + } + if (item.playoutMode === 'Multiple' && item.multipleMode === 'Count' && !item.multipleCount) { + return 'Multiple / Count items require a count expression'; + } + if (item.playoutMode === 'Duration') { + if (!item.playoutDuration) { + return 'Duration items require a playout duration'; + } + if (item.discardToFillAttempts == null || item.discardToFillAttempts < 0) { + return 'Duration items require discard-to-fill attempts (0 or more)'; + } + } + return null; +} + +export function validateDraft(items: DraftItem[]): null | string { + for (let i = 0; i < items.length; i += 1) { + const message = validateItem(items[i]); + if (message) { + return `Item ${i + 1}: ${message}`; + } + } + return null; +} + +// ---- Display helpers ----------------------------------------------------- + +export function humanizeEnum(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); +} + +export function draftItemLabel(item: DraftItem): string { + return ( + item.name ?? + item.collectionName ?? + item.multiCollectionName ?? + item.smartCollectionName ?? + item.rerunCollectionName ?? + item.playlistName ?? + item.mediaItemName ?? + item.searchTitle ?? + item.searchQuery ?? + 'Unnamed item' + ); +} -- 2.47.3 From 5d0ad8b0d72ad29039079beb4531da209947dce5 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 00:15:41 +0200 Subject: [PATCH 07/17] feat(api): add languages, music-video-credits-templates, stream-selectors, createChannel clients (#212) New web/src/api/languages.ts module (getLanguages) plus channels.ts additions (getMusicVideoCreditsTemplates, getChannelStreamSelectors, createChannel) for the channel-editor gaps in #212. Each has URL-building tests. --- web/src/api/channels.test.ts | 76 ++++++++- web/src/api/channels.ts | 16 ++ web/src/api/index.ts | 1 + web/src/api/languages.test.ts | 33 ++++ web/src/api/languages.ts | 20 +++ web/src/screens/ChannelEditScreen.test.tsx | 183 ++++++++++++++++++++- web/src/screens/ChannelEditScreen.tsx | 179 +++++++++++++++++--- 7 files changed, 480 insertions(+), 28 deletions(-) create mode 100644 web/src/api/languages.test.ts create mode 100644 web/src/api/languages.ts diff --git a/web/src/api/channels.test.ts b/web/src/api/channels.test.ts index 81b73e8d6..aa0cd2512 100644 --- a/web/src/api/channels.test.ts +++ b/web/src/api/channels.test.ts @@ -1,5 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels'; +import { + createChannel, + getChannelById, + getChannelStreamSelectors, + getMusicVideoCreditsTemplates, + updateChannel, + type CreateChannelRequest, + type UpdateChannelRequest +} from './channels'; const sampleChannel = { id: 5, @@ -90,3 +98,69 @@ describe('updateChannel', () => { expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' }); }); }); + +describe('createChannel', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('POSTs the request body to /api/channels and returns the created view model', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify(sampleChannel), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }) + ); + + const body = { ...sampleChannel, id: undefined } as unknown as CreateChannelRequest; + + await expect(createChannel(body)).resolves.toMatchObject({ id: 5 }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/channels'); + expect((init?.method ?? '').toUpperCase()).toBe('POST'); + }); +}); + +describe('getMusicVideoCreditsTemplates', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('GETs /api/channels/music-video-credits-templates', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify(['default']), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }) + ); + + await expect(getMusicVideoCreditsTemplates()).resolves.toEqual(['default']); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/channels/music-video-credits-templates'); + }); +}); + +describe('getChannelStreamSelectors', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('GETs /api/channels/stream-selectors', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify(['selector.py']), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }) + ); + + await expect(getChannelStreamSelectors()).resolves.toEqual(['selector.py']); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/channels/stream-selectors'); + }); +}); diff --git a/web/src/api/channels.ts b/web/src/api/channels.ts index 00da8a20f..6ecb4cfb4 100644 --- a/web/src/api/channels.ts +++ b/web/src/api/channels.ts @@ -11,6 +11,7 @@ export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChan export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest']; export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest']; export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel']; +export type CreateChannelRequest = components['schemas']['CreateChannelRequest']; export interface ChannelsScreenData { channels: ChannelSummary[]; @@ -46,6 +47,21 @@ export function updateChannel(channelId: number, body: UpdateChannelRequest): Pr }); } +export function createChannel(body: CreateChannelRequest): Promise { + return request('/api/channels', { + body, + method: 'POST' + }); +} + +export function getMusicVideoCreditsTemplates(): Promise { + return request('/api/channels/music-video-credits-templates'); +} + +export function getChannelStreamSelectors(): Promise { + return request('/api/channels/stream-selectors'); +} + export async function getChannelsScreenData(): Promise { const [channels, channelStates] = await Promise.all([ getChannels(), diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 44f27363b..7462359dc 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -12,6 +12,7 @@ export * from './ffmpegProfiles'; export * from './fillerPresets'; export * from './guide'; export * from './imageFolders'; +export * from './languages'; export * from './libraries'; export * from './libraryBrowse'; export * from './logs'; diff --git a/web/src/api/languages.test.ts b/web/src/api/languages.test.ts new file mode 100644 index 000000000..4e97dc5d3 --- /dev/null +++ b/web/src/api/languages.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getLanguages } from './languages'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +describe('getLanguages', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('GETs /api/languages and returns the list', async () => { + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse([{ code: 'eng', englishName: 'English' }]) + ); + + await expect(getLanguages()).resolves.toEqual([{ code: 'eng', englishName: 'English' }]); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/languages'); + expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + }); + + it('rejects with the ApiError status on failure', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse({ status: 500, title: 'Internal Server Error' }, 500) + ); + + await expect(getLanguages()).rejects.toMatchObject({ status: 500 }); + }); +}); diff --git a/web/src/api/languages.ts b/web/src/api/languages.ts new file mode 100644 index 000000000..7ea8a93d8 --- /dev/null +++ b/web/src/api/languages.ts @@ -0,0 +1,20 @@ +import { ApiError, request } from './client'; +import type { components } from './generated/v1'; + +export type LanguageCode = components['schemas']['LanguageCodeResponseModel']; + +export function getLanguages(): Promise { + return request('/api/languages'); +} + +export function messageFromLanguagesError(error: unknown, fallback = 'Unable to load languages'): string { + if (error instanceof ApiError) { + return error.detail ?? error.message; + } + + if (error instanceof Error) { + return error.message; + } + + return fallback; +} diff --git a/web/src/screens/ChannelEditScreen.test.tsx b/web/src/screens/ChannelEditScreen.test.tsx index e12e0f46c..df5e86c1c 100644 --- a/web/src/screens/ChannelEditScreen.test.tsx +++ b/web/src/screens/ChannelEditScreen.test.tsx @@ -50,23 +50,27 @@ function json(body: unknown, status = 200): Response { } interface FetchOptions { + channelOverrides?: Record; channelStatus?: number; onPut?: (body: unknown) => void; + putResponseOverrides?: Record; } -function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) { +function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putResponseOverrides = {} }: FetchOptions = {}) { + const loadedChannel = { ...channel, ...channelOverrides }; + return vi.spyOn(window, 'fetch').mockImplementation((input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; const method = (init?.method ?? 'GET').toUpperCase(); if (url === '/api/channels/5' && method === 'PUT') { onPut?.(JSON.parse(init?.body as string)); - return Promise.resolve(json({ ...channel, name: 'Renamed' })); + return Promise.resolve(json({ ...loadedChannel, name: 'Renamed', ...putResponseOverrides })); } if (url === '/api/channels/5') { return channelStatus === 200 - ? Promise.resolve(json(channel)) + ? Promise.resolve(json(loadedChannel)) : Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus)); } @@ -86,6 +90,18 @@ function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) { return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }])); } + if (url === '/api/languages') { + return Promise.resolve(json([{ code: 'eng', englishName: 'English' }])); + } + + if (url === '/api/channels/music-video-credits-templates') { + return Promise.resolve(json(['default'])); + } + + if (url === '/api/channels/stream-selectors') { + return Promise.resolve(json(['selector.py'])); + } + return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404)); }); } @@ -158,4 +174,165 @@ describe('ChannelEditScreen', () => { expect(await screen.findByText('Channel not found.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument(); }); + + describe('external logo URL (Blazor ChannelEditor parity)', () => { + it('hydrates the URL field from an external-URL logo and treats the uploaded logo as empty', async () => { + mockApi({ + channelOverrides: { logo: { path: 'https://example.com/old-logo.png', contentType: '', isExternalUrl: true } } + }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); + + expect(await screen.findByDisplayValue('https://example.com/old-logo.png')).toBeInTheDocument(); + // The preview shows the external URL, not an uploaded-logo path. + expect(screen.getByRole('img', { name: 'Cartoons' })).toHaveAttribute('src', 'https://example.com/old-logo.png'); + }); + + it('sends an external URL as the logo, winning over any previously uploaded logo, on save', async () => { + const puts: unknown[] = []; + mockApi({ + channelOverrides: { logo: { path: 'iptv/logos/uploaded.png', contentType: 'image/png' } }, + onPut: (body) => puts.push(body) + }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); + + const urlInput = screen.getByPlaceholderText('https://example.com/logo.png'); + fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } }); + + const saveButton = await screen.findByRole('button', { name: 'Save changes' }); + fireEvent.click(saveButton); + + await waitFor(() => expect(puts).toHaveLength(1)); + + expect(puts[0]).toMatchObject({ + logo: { path: 'https://example.com/new-logo.png', contentType: '' } + }); + }); + + it('shows an inline error and blocks save for a non-http(s) URL', async () => { + mockApi(); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); + + const urlInput = screen.getByPlaceholderText('https://example.com/logo.png'); + fireEvent.change(urlInput, { target: { value: 'not-a-url' } }); + + expect(await screen.findByText('Enter a valid http(s) URL')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + }); + + it('clears the external URL field when a file is uploaded', async () => { + mockApi(); + vi.spyOn(window, 'fetch').mockImplementation((input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + const method = (init?.method ?? 'GET').toUpperCase(); + + if (url === '/api/artwork/uploads' && method === 'POST') { + return Promise.resolve(json({ path: 'iptv/logos/new.png', contentType: 'image/png' })); + } + + if (url === '/api/channels/5') { + return Promise.resolve(json(channel)); + } + + if (url === '/api/ffmpeg/profiles') { + return Promise.resolve(json([{ id: 1, name: 'Default profile' }])); + } + + if (url === '/api/watermarks') { + return Promise.resolve(json([{ id: 2, name: 'Corner bug' }])); + } + + if (url === '/api/filler-presets') { + return Promise.resolve(json([{ id: 3, name: 'Bumpers' }])); + } + + if (url === '/api/channels') { + return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }])); + } + + if (url === '/api/languages') { + return Promise.resolve(json([{ code: 'eng', englishName: 'English' }])); + } + + if (url === '/api/channels/music-video-credits-templates') { + return Promise.resolve(json(['default'])); + } + + if (url === '/api/channels/stream-selectors') { + return Promise.resolve(json(['selector.py'])); + } + + return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404)); + }); + + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Branding/ })); + + const urlInput = screen.getByPlaceholderText('https://example.com/logo.png'); + fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } }); + expect(urlInput).toHaveValue('https://example.com/new-logo.png'); + + const file = new File(['x'], 'logo.png', { type: 'image/png' }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => expect(urlInput).toHaveValue('')); + }); + }); + + describe('picker fields (languages, credits template, stream selector)', () => { + it('keeps a preferred audio language selectable even when missing from the language list', async () => { + mockApi({ channelOverrides: { preferredAudioLanguageCode: 'spa' } }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ })); + + const select = await screen.findByDisplayValue('spa'); + expect(select).toBeInTheDocument(); + expect(select.tagName).toBe('SELECT'); + }); + + it('populates the preferred audio language select from GET /api/languages', async () => { + mockApi(); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ })); + + // Both the preferred-audio and preferred-subtitle language selects populate from the + // same GET /api/languages list. + expect((await screen.findAllByRole('option', { name: 'English' })).length).toBe(2); + }); + + it('keeps a music video credits template selectable even when missing from the template list', async () => { + mockApi({ channelOverrides: { musicVideoCreditsMode: 'GenerateSubtitles', musicVideoCreditsTemplate: 'legacy-template' } }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Music video/ })); + + expect(await screen.findByDisplayValue('legacy-template')).toBeInTheDocument(); + }); + + it('keeps a custom stream selector selectable even when missing from the selector list', async () => { + mockApi({ channelOverrides: { streamSelectorMode: 'Custom', streamSelector: 'legacy-selector.py' } }); + render(); + + await screen.findByDisplayValue('Cartoons'); + fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ })); + + expect(await screen.findByDisplayValue('legacy-selector.py')).toBeInTheDocument(); + }); + }); }); diff --git a/web/src/screens/ChannelEditScreen.tsx b/web/src/screens/ChannelEditScreen.tsx index 3d1904cb3..928f1d8bd 100644 --- a/web/src/screens/ChannelEditScreen.tsx +++ b/web/src/screens/ChannelEditScreen.tsx @@ -17,8 +17,11 @@ import { ApiError, getChannelById, getChannels, + getChannelStreamSelectors, getFFmpegProfiles, getFillerPresets, + getLanguages, + getMusicVideoCreditsTemplates, getWatermarks, messageFromError, updateChannel, @@ -27,6 +30,7 @@ import { type ChannelSummary, type FFmpegProfile, type FillerPreset, + type LanguageCode, type UpdateChannelRequest, type Watermark } from '../api'; @@ -111,6 +115,12 @@ function channelIdFromPathname(pathname: string): number | null { return Number.isInteger(id) && id > 0 ? id : null; } +// Blazor parity (ChannelEditor.razor + ChannelEditViewModel.ToUpdate): an external logo URL is +// tracked as separate form state from the uploaded-logo ArtworkContentTypeModel, and wins over it +// on submit. When hydrating a channel whose logo IS an external URL, the uploaded-logo state is +// treated as empty so the two fields never disagree. +const EMPTY_LOGO: NonNullable = { path: '', contentType: '' }; + // The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName // fields, so hydration is a straight field copy. function draftFromChannel(channel: Channel): UpdateChannelRequest { @@ -121,7 +131,7 @@ function draftFromChannel(channel: Channel): UpdateChannelRequest { categories: channel.categories, ffmpegProfileId: channel.ffmpegProfileId, slugSeconds: channel.slugSeconds, - logo: channel.logo, + logo: channel.logo?.isExternalUrl ? EMPTY_LOGO : channel.logo, streamSelectorMode: channel.streamSelectorMode, streamSelector: channel.streamSelector, preferredAudioLanguageCode: channel.preferredAudioLanguageCode, @@ -149,6 +159,64 @@ function numberOrEmpty(value: null | number | undefined): string { return value == null ? '' : String(value); } +function externalLogoUrlFromChannel(channel: Channel): string { + return channel.logo?.isExternalUrl ? (channel.logo.path ?? '') : ''; +} + +// Blazor parity: the URL field only needs to look like http(s) when non-blank; blank means +// "no external URL" and is always valid. +function isValidExternalUrl(value: string): boolean { + const trimmed = value.trim(); + + if (!trimmed) { + return true; + } + + try { + const url = new URL(trimmed); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +interface StringOption { + label: string; + value: string; +} + +// Builds a "(none)" + list Select's options, keeping the channel's currently-stored value +// selectable even if it's absent from the reference list (e.g. a language the media library +// doesn't use, or a template/selector file that's been removed from disk since save) so loading +// an existing channel never silently changes the value. +function optionsKeepingCurrent( + entries: StringOption[], + current: null | string | undefined, + noneLabel = '(none)' +): StringOption[] { + const options: StringOption[] = [{ label: noneLabel, value: '' }, ...entries]; + + if (current && !options.some((option) => option.value === current)) { + options.push({ label: current, value: current }); + } + + return options; +} + +function languageOptions(languages: LanguageCode[], current: null | string | undefined): StringOption[] { + return optionsKeepingCurrent( + languages.map((language) => ({ label: language.englishName ?? language.code ?? '', value: language.code ?? '' })), + current + ); +} + +function stringListOptions(values: string[], current: null | string | undefined): StringOption[] { + return optionsKeepingCurrent( + values.map((value) => ({ label: value, value })), + current + ); +} + function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] { return [...channels].sort((left, right) => { const leftNumber = Number.parseFloat(left.number); @@ -207,6 +275,9 @@ interface ReferenceData { fillerPresets: FillerPreset[]; watermarks: Watermark[]; ffmpegProfiles: FFmpegProfile[]; + languages: LanguageCode[]; + musicVideoCreditsTemplates: string[]; + streamSelectors: string[]; } /* ---------- panes ---------- */ @@ -431,9 +502,11 @@ function StreamingPane({ } function SelectionPane({ + data, draft, set }: { + data: ReferenceData; draft: UpdateChannelRequest; set: (patch: Partial) => void; }) { @@ -458,13 +531,12 @@ function SelectionPane({ {isDefault ? ( <> - - + + set({ streamSelector: event.target.value || null })} + options={stringListOptions(data.streamSelectors, draft.streamSelector)} size="sm" - style={{ fontFamily: 'var(--font-mono)' }} value={draft.streamSelector ?? ''} /> @@ -518,9 +589,11 @@ function SelectionPane({ } function MusicPane({ + data, draft, set }: { + data: ReferenceData; draft: UpdateChannelRequest; set: (patch: Partial) => void; }) { @@ -544,12 +617,12 @@ function MusicPane({ /> - set({ musicVideoCreditsTemplate: event.target.value || null })} + options={stringListOptions(data.musicVideoCreditsTemplates, draft.musicVideoCreditsTemplate)} size="sm" - style={{ fontFamily: 'var(--font-mono)' }} value={draft.musicVideoCreditsTemplate ?? ''} /> @@ -573,24 +646,31 @@ function MusicPane({ function BrandingPane({ data, draft, + externalLogoUrl, logoPreview, + onChangeExternalLogoUrl, onUploadLogo, set, uploadError, - uploading + uploading, + urlError }: { data: ReferenceData; draft: UpdateChannelRequest; + externalLogoUrl: string; logoPreview: string | null; + onChangeExternalLogoUrl: (value: string) => void; onUploadLogo: (file: File) => void; set: (patch: Partial) => void; uploadError: string | null; uploading: boolean; + urlError: string | null; }) { const fileInputRef = useRef(null); const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect'; const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null; - const previewSrc = logoPreview ?? existingLogo; + const trimmedUrl = externalLogoUrl.trim(); + const previewSrc = trimmedUrl ? externalLogoUrl : (logoPreview ?? existingLogo); return ( @@ -629,6 +709,20 @@ function BrandingPane({ /> + + onChangeExternalLogoUrl(event.target.value)} + placeholder="https://example.com/logo.png" + size="sm" + value={externalLogoUrl} + /> + setActiveSchedule(Number(event.target.value))} - options={scheduleOptions(schedules)} - size="sm" - value={`${activeSchedule.id}`} - /> - - - - {mutationError && ( -
-
- )} - -
-
-
- Lineup · drag to reorder - {itemsLoading && } - - -
- - -
- -
- - - )} - {tab === 'playback' && ( - <> - -
- - -
-
- {fill.icon} - {playbackExplanation(item)} -
- - )} - {tab === 'filler' && ( -
- - - - - -
- )} -
- - ); -} - -function sortedScheduleItems(items: ProgramScheduleItem[]): ProgramScheduleItem[] { - return [...items].sort((left, right) => (left.index ?? 0) - (right.index ?? 0)); -} - -function scheduleItemToRequest(item: ProgramScheduleItem): ScheduleItemRequest { - return { - collectionId: item.collection?.id ?? null, - collectionType: item.collectionType ?? 'Collection', - customTitle: item.customTitle ?? null, - discardToFillAttempts: item.discardToFillAttempts ?? null, - fallbackFillerId: item.fallbackFiller?.id ?? null, - fillWithGroupMode: item.fillWithGroupMode ?? 'None', - fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null, - graphicsElementIds: item.graphicsElements?.map((element) => element.id) ?? [], - guideMode: item.guideMode ?? 'Normal', - marathonBatchSize: item.marathonBatchSize ?? null, - marathonGroupBy: item.marathonGroupBy ?? 'None', - marathonShuffleGroups: item.marathonShuffleGroups ?? false, - marathonShuffleItems: item.marathonShuffleItems ?? false, - mediaItemId: item.mediaItem?.mediaItemId ?? null, - midRollFillerId: item.midRollFiller?.id ?? null, - multipleCount: item.multipleCount ?? item.count ?? null, - multipleMode: item.multipleMode ?? 'Count', - multiCollectionId: item.multiCollection?.id ?? null, - playbackOrder: item.playbackOrder ?? 'Shuffle', - playlistId: item.playlist?.id ?? null, - playoutDuration: item.playoutDuration ?? null, - playoutMode: item.playoutMode ?? 'One', - postRollFillerId: item.postRollFiller?.id ?? null, - preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null, - preferredAudioTitle: item.preferredAudioTitle ?? null, - preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null, - preRollFillerId: item.preRollFiller?.id ?? null, - rerunCollectionId: item.rerunCollection?.id ?? null, - searchQuery: item.searchQuery ?? null, - searchTitle: item.searchTitle ?? null, - smartCollectionId: item.smartCollection?.id ?? null, - startTime: item.startTime ?? null, - startType: item.startType ?? 'Dynamic', - subtitleMode: item.subtitleMode ?? null, - tailFillerId: item.tailFiller?.id ?? null, - tailMode: item.tailMode ?? 'None', - watermarkIds: item.watermarks?.map((watermark) => watermark.id) ?? [] - }; -} - -function newScheduleItemRequest(collection: MediaCollection): ScheduleItemRequest { - return { - collectionId: collection.id, - collectionType: collection.collectionType ?? 'Collection', - customTitle: null, - discardToFillAttempts: null, - fallbackFillerId: null, - fillWithGroupMode: 'None', - fixedStartTimeBehavior: null, - graphicsElementIds: [], - guideMode: 'Normal', - marathonBatchSize: null, - marathonGroupBy: 'None', - marathonShuffleGroups: false, - marathonShuffleItems: false, - mediaItemId: null, - midRollFillerId: null, - multipleCount: null, - multipleMode: 'Count', - multiCollectionId: null, - playbackOrder: 'Shuffle', - playlistId: null, - playoutDuration: null, - playoutMode: 'One', - postRollFillerId: null, - preferredAudioLanguageCode: null, - preferredAudioTitle: null, - preferredSubtitleLanguageCode: null, - preRollFillerId: null, - rerunCollectionId: null, - searchQuery: null, - searchTitle: null, - smartCollectionId: null, - startTime: null, - startType: 'Dynamic', - subtitleMode: null, - tailFillerId: null, - tailMode: 'None', - watermarkIds: [] - }; -} - -function scheduleItemName(item: ProgramScheduleItem): string { - return item.name ?? item.collection?.name ?? item.multiCollection?.name ?? item.smartCollection?.name ?? item.searchTitle ?? item.searchQuery ?? 'Unnamed item'; -} - -function collectionIdForItem(item: ProgramScheduleItem): number | null { - return item.collection?.id ?? item.multiCollection?.id ?? item.smartCollection?.id ?? null; -} - -function sortedCollections(collections: MediaCollection[]): MediaCollection[] { - return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? '')); -} - -function scheduleOptions(schedules: ProgramSchedule[]): Array<{ label: string; value: string }> { - return schedules.map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` })); -} - -function scheduleFillDescriptor(item: ProgramScheduleItem): { icon: ReactNode; label: string } { - if (item.playoutMode === 'Flood') { - return { icon: