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/Commands/AddProgramScheduleItemHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs index ab4da04d9..1ec7ceb71 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItemHandler.cs @@ -60,7 +60,15 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase, await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken); } - return ProjectToViewModel(item); + // reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics + // join rows with only their foreign-key ids set, so the tracked entities have null Watermark / + // GraphicsElement navs that ProjectToViewModel dereferences (would 500 on POST — see #229). + ProgramScheduleItem persisted = await dbContext.ProgramScheduleItems + .Filter(psi => psi.Id == item.Id) + .IncludeScheduleItemDetails() + .SingleAsync(cancellationToken); + + return ProjectToViewModel(persisted); } private static async Task> Validate( diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs index b149a2bec..b24a8a038 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs @@ -56,7 +56,16 @@ public class ReplaceProgramScheduleItemsHandler( await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken); } - return programSchedule.Items.Map(ProjectToViewModel); + // reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics + // join rows with only their foreign-key ids set, so the tracked entities have null Watermark / + // GraphicsElement navs that ProjectToViewModel dereferences (would 500 on PUT — see #229). + List persisted = await dbContext.ProgramScheduleItems + .Filter(psi => psi.ProgramScheduleId == programSchedule.Id) + .IncludeScheduleItemDetails() + .OrderBy(i => i.Index) + .ToListAsync(cancellationToken); + + return persisted.Map(ProjectToViewModel).ToList(); } private static async Task> Validate( diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemQueryExtensions.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemQueryExtensions.cs new file mode 100644 index 000000000..141cb1510 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemQueryExtensions.cs @@ -0,0 +1,48 @@ +using ErsatzTV.Core.Domain; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.ProgramSchedules; + +internal static class ProgramScheduleItemQueryExtensions +{ + /// + /// The single source of truth for the navigation graph a needs before + /// it can be projected via . The mapper dereferences the + /// Watermark / GraphicsElement navs of each join row without a null guard, so any handler + /// that projects freshly-persisted items MUST reload through this include chain (see #229 — the write + /// path used to project the tracked-but-unloaded graph and threw a NullReferenceException, surfacing as a + /// 500 on PUT/POST whenever an item carried a watermark or graphics element). + /// + public static IQueryable IncludeScheduleItemDetails(this IQueryable query) => + query + .Include(i => i.Collection) + .Include(i => i.MultiCollection) + .Include(i => i.SmartCollection) + .Include(i => i.RerunCollection) + .Include(i => i.Playlist) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Season).SeasonMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Season).Show) + .ThenInclude(s => s.ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Show).ShowMetadata) + .ThenInclude(sm => sm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Artist).ArtistMetadata) + .ThenInclude(am => am.Artwork) + .Include(i => i.PreRollFiller) + .Include(i => i.MidRollFiller) + .Include(i => i.PostRollFiller) + .Include(i => i.TailFiller) + .Include(i => i.FallbackFiller) + .Include(i => i.ProgramScheduleItemWatermarks) + .ThenInclude(i => i.Watermark) + .Include(i => i.ProgramScheduleItemGraphicsElements) + .ThenInclude(i => i.GraphicsElement); +} diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs index 1d4c5e7ef..9006003b6 100644 --- a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs @@ -20,36 +20,8 @@ public class GetProgramScheduleItemsHandler(IDbContextFactory dbConte return await dbContext.ProgramScheduleItems .Filter(psi => psi.ProgramScheduleId == request.Id) - .Include(i => i.Collection) - .Include(i => i.MultiCollection) - .Include(i => i.SmartCollection) - .Include(i => i.RerunCollection) - .Include(i => i.Playlist) - .Include(i => i.MediaItem) - .ThenInclude(i => (i as Movie).MovieMetadata) - .ThenInclude(mm => mm.Artwork) - .Include(i => i.MediaItem) - .ThenInclude(i => (i as Season).SeasonMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(i => i.MediaItem) - .ThenInclude(i => (i as Season).Show) - .ThenInclude(s => s.ShowMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(i => i.MediaItem) - .ThenInclude(i => (i as Show).ShowMetadata) - .ThenInclude(sm => sm.Artwork) - .Include(i => i.MediaItem) - .ThenInclude(i => (i as Artist).ArtistMetadata) - .ThenInclude(am => am.Artwork) - .Include(i => i.PreRollFiller) - .Include(i => i.MidRollFiller) - .Include(i => i.PostRollFiller) - .Include(i => i.TailFiller) - .Include(i => i.FallbackFiller) - .Include(i => i.ProgramScheduleItemWatermarks) - .ThenInclude(i => i.Watermark) - .Include(i => i.ProgramScheduleItemGraphicsElements) - .ThenInclude(i => i.GraphicsElement) + .IncludeScheduleItemDetails() + .OrderBy(i => i.Index) .ToListAsync(cancellationToken) .Map(programScheduleItems => programScheduleItems.Map(ProjectToViewModel) .Map(psi => EnforceProperties(maybeProgramSchedule, psi)).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/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs new file mode 100644 index 000000000..edb5c9aad --- /dev/null +++ b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemResponseRoundTripTests.cs @@ -0,0 +1,450 @@ +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.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Flexible); + 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); + // Seeded with DiscardToFillAttempts = 5, but the write path's FixDiscardToFillAttempts zeroes + // it for any order other than Random/Shuffle (Marathon here) — a deliberate server-side + // normalization. Pinning it here keeps the lossless round-trip honest: A already holds 0, so + // the re-submit maps 0 → 0 and B stays equal. + duration.DiscardToFillAttempts.ShouldBe(0); + + 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 + { + StartType = StartType.Fixed, + StartTime = TimeSpan.FromHours(20), + FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible, + 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 + }); +} diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemWriteProjectionTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemWriteProjectionTests.cs new file mode 100644 index 000000000..6602fc198 --- /dev/null +++ b/ErsatzTV.Tests/Application/ProgramSchedules/ScheduleItemWriteProjectionTests.cs @@ -0,0 +1,167 @@ +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.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.ProgramSchedules; + +/// +/// Regression tests for #229. The write path (Replace = PUT, Add = POST) used to project freshly-persisted +/// items whose watermark/graphics join rows only carry foreign-key ids — the Watermark / GraphicsElement navs +/// are null, and Mapper.ProjectToViewModel dereferences them without a guard, throwing an NRE that +/// surfaced as a 500. The pre-existing round-trip test masked this because the Replace handler returned a +/// lazy LanguageExt Map sequence and that test only checked .IsRight — it never enumerated +/// the projected items, so the deferred NRE never fired. These tests force enumeration of the write-path +/// response (exactly as the controller's .ToList() / single-item serialization does) and seed the +/// watermark/graphics entities through a separate context so the handler's fresh factory context has +/// nothing pre-tracked (no accidental change-tracker fix-up). +/// +[TestFixture] +public class ScheduleItemWriteProjectionTests +{ + 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 Replace_PutResponse_Should_Carry_Watermark_And_Graphics_Names() + { + int scheduleId = await SeedReferences(); + + var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker); + Either> result = await handler.Handle( + new ReplaceProgramScheduleItems(scheduleId, [ItemWithGraphics(0)]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown")); + + // Force enumeration exactly like the controller (`items.Select(...).ToList()`) — this is what threw the NRE. + List items = result.RightToSeq().Head().ToList(); + ProgramScheduleItemViewModel item = items.Single(); + item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]); + item.GraphicsElements.Count.ShouldBe(2); + item.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n)); + } + + [Test] + public async Task Add_PostResponse_Should_Carry_Watermark_And_Graphics_Names() + { + int scheduleId = await SeedReferences(); + + var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker); + Either result = await handler.Handle( + AddItemWithGraphics(scheduleId), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown")); + + ProgramScheduleItemViewModel item = result.RightToSeq().Head(); + item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]); + item.GraphicsElements.Count.ShouldBe(2); + } + + [Test] + public async Task Get_Should_Return_Items_In_Index_Order_Regardless_Of_Id_Order() + { + int scheduleId; + await using (TvContext ctx = _db.CreateContext()) + { + var schedule = new ProgramSchedule + { + Name = "Shuffled", Items = [], Playouts = [], ProgramScheduleAlternates = [] + }; + ctx.ProgramSchedules.Add(schedule); + await ctx.SaveChangesAsync(); + scheduleId = schedule.Id; + + // Insert with ids deliberately out of Index order: id 10 -> Index 2, id 11 -> Index 0, id 12 -> Index 1. + // Before the fix the GET returned id order (2, 0, 1); after, it must return Index order (0, 1, 2). + ctx.ProgramScheduleItems.Add(OneItemRow(10, scheduleId, index: 2)); + ctx.ProgramScheduleItems.Add(OneItemRow(11, scheduleId, index: 0)); + ctx.ProgramScheduleItems.Add(OneItemRow(12, scheduleId, index: 1)); + await ctx.SaveChangesAsync(); + } + + var getHandler = new GetProgramScheduleItemsHandler(_db.Factory); + List items = + await getHandler.Handle(new GetProgramScheduleItems(scheduleId), CancellationToken.None); + + items.Select(i => i.Index).ShouldBe([0, 1, 2]); + } + + private static ProgramScheduleItemOne OneItemRow(int id, int scheduleId, int index) => + new() + { + Id = id, + ProgramScheduleId = scheduleId, + Index = index, + CollectionType = CollectionType.Collection, + CollectionId = 1, + PlaybackOrder = PlaybackOrder.Shuffle, + ProgramScheduleItemWatermarks = [], + ProgramScheduleItemGraphicsElements = [] + }; + + private async Task SeedReferences() + { + await using TvContext ctx = _db.CreateContext(); + ctx.Collections.Add(new Collection { Id = 1, Name = "First Collection" }); + ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" }); + ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" }); + ctx.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image }); + ctx.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image }); + + var schedule = new ProgramSchedule + { + Name = "WM Schedule", Items = [], Playouts = [], ProgramScheduleAlternates = [] + }; + ctx.ProgramSchedules.Add(schedule); + await ctx.SaveChangesAsync(); + return schedule.Id; + } + + private static ReplaceProgramScheduleItem ItemWithGraphics(int index) => + new( + index, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One, + CollectionType.Collection, CollectionId: 1, 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: [1, 2], GraphicsElementIds: [1, 2], + PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null, + SubtitleMode: null); + + private static AddProgramScheduleItem AddItemWithGraphics(int scheduleId) => + new( + scheduleId, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One, + CollectionType.Collection, CollectionId: 1, 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: [1, 2], GraphicsElementIds: [1, 2], + PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null, + SubtitleMode: null); +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index cd25e7760..67b84c74e 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; @@ -59,6 +62,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/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.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/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", 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 && diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index e675e1ec2..e70f1a833 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; @@ -47,6 +48,22 @@ public class ChannelController( 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}")] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 164f12521..b2366655f 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": [ @@ -4560,7 +4640,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", @@ -5228,6 +5318,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": [ @@ -9745,17 +9875,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" } } } @@ -9829,17 +9959,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" } } } @@ -9935,7 +10065,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } }, @@ -9943,7 +10073,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } }, @@ -9951,7 +10081,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + "$ref": "#/components/schemas/ScheduleItemResponseModel" } } } @@ -17786,42 +17916,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": { @@ -17837,76 +17935,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" } } }, @@ -17951,32 +17979,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", @@ -18113,6 +18115,27 @@ } } }, + "LanguageCodeResponseModel": { + "required": [ + "code", + "englishName" + ], + "type": "object", + "properties": { + "code": { + "type": [ + "null", + "string" + ] + }, + "englishName": { + "type": [ + "null", + "string" + ] + } + } + }, "LibraryBrowseItemResponseModel": { "required": [ "id", @@ -18962,30 +18985,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", @@ -19009,69 +19008,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", @@ -19081,14 +19017,14 @@ ], "type": "string" }, - "NamedMediaItemViewModel": { + "NamedIdResponseModel": { "required": [ - "mediaItemId", + "id", "name" ], "type": "object", "properties": { - "mediaItemId": { + "id": { "type": "integer", "format": "int32" }, @@ -19533,34 +19469,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", @@ -20299,205 +20207,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", @@ -20886,53 +20595,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", @@ -21296,6 +20958,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", @@ -23297,91 +23377,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", @@ -23461,6 +23456,9 @@ { "name": "Images" }, + { + "name": "Languages" + }, { "name": "Libraries" }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index ee373a97f..52375bffa 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -58,7 +58,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 @@ -214,6 +231,17 @@ items), index items from **array order** in the request body rather than trustin index/order field. Exemplar: `ReplaceScheduleItemsRequest.ToCommand(scheduleId)` — `Items.Select((item, index) => item.ToReplaceCommand(index))`. +**Project the write-path response through the same include chain the GET uses — never off the +freshly-built graph.** After `SaveChanges`, a command's entities carry only the foreign-key ids you set +(e.g. `ProgramScheduleItemWatermark.WatermarkId`); their reference navs are null, and any mapper that +dereferences one unguarded throws an NRE that surfaces as a 500. Reload with the read-side includes +before mapping. Exemplars: `ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler` reload +via `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()` (the one include chain shared with +`GetProgramScheduleItemsHandler`). Also beware LanguageExt `Map` is **lazy** — returning +`items.Map(ProjectToViewModel)` defers the projection, so a test that only checks `.IsRight` won't catch +the NRE; the controller's `.ToList()`/serialization does (regression: `ScheduleItemWriteProjectionTests`). +GET handlers that feed an ordered list must also `.OrderBy(i => i.Index)` — id order is not index order. + ## 8. Known API warts (don't "fix" without discussion — they're deliberate synthesized rows) `GET /api/blocks` and `GET /api/templates` (via `GetAllBlocksHandler` / `GetAllTemplatesHandler` in diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index d05500748..7b26b012c 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -62,15 +62,15 @@ redirect). > | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — | > | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — | > | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — | -> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 | +> | Channel editor | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #212 | > | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — | > | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — | > | Media browse/detail (read paths + image browser) | PARITY-OK | — | > | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — | -> | Schedules editors | **disproven — moved to Section 3** | **#207** | +> | Schedules editors | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** | > | Media sources | **disproven — moved to Section 3** | **#202** | > -> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 is SHOULD-FIX (#213 closed 2026-07-11). +> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11. Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet. @@ -84,7 +84,7 @@ been added to the redirect map yet. | Blazor route | Blazor file | SPA route | Notes | |---|---|---|---| -| `/channels/{Id:int?}` | `ChannelEditor.razor` | `/app/edit-channel/{id}` | allowSubPaths | +| `/channels/{Id:int?}` | `ChannelEditor.razor` | `/app/edit-channel/{id}` | allowSubPaths; external logo URL field (mutual exclusion with upload), enumerated language/credits-template/stream-selector pickers, and bare-channel create (`ChannelsScreen`'s "New blank channel") landed #212 | | `/channels/numbers` | `ChannelNumbers.razor` | `/app/channels` | merged into channels table | | `/search` | `Search.razor` | `/app/search` | | | `/system/logs` | `Logs.razor` | `/app/logs` | | @@ -167,20 +167,25 @@ connection parameters, path replacements, and library enable/disable have **no S write REST API** (`MediaSourcesController` is GET-only; the "Add Source" button is a hardcoded disabled stub). A fresh install could not connect any library without Blazor. -### Schedule editors — #207 (found by the #203 mutation-depth sweep, 2026-07-09) +### Schedule editors — #207 DONE (2026-07-11) -| Blazor route | Blazor file | Blocking issue | -|---|---|---| -| `/schedules/{Id:int}`, `/schedules/add`, `/schedules/{Id:int}/items` | `ScheduleEditor.razor`, `ScheduleItemsEditor.razor` | **#207** | +| Blazor route | Blazor file | SPA route | Status | +|---|---|---|---| +| `/schedules/{Id:int}`, `/schedules/add`, `/schedules/{Id:int}/items` | `ScheduleEditor.razor`, `ScheduleItemsEditor.razor` | `/app/schedules` (`SchedulesScreen`) | **PARITY-OK** | -Previously (wrongly) listed in Section 2 as "merged into schedules screen": the SPA schedules -screen is a near read-only viewer — no schedule create/edit/delete, every per-item inspector -control hard-coded disabled (~35 Blazor fields unchangeable), add-item is defaults-only. The -REST endpoints exist and are unused; only item delete/reorder and the schedule switcher work. +Was (correctly, until #207) a near read-only viewer — no schedule create/edit/delete, every per-item +inspector control hard-coded disabled (~35 Blazor fields unchangeable), add-item defaults-only. +**Rebuilt in #207** (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`): full schedule +CRUD (create via TopBar/in-screen, edit properties, delete) + full item mutation — every Blazor field, +option list, enable-gate and forced-reset, encoded in the pure `itemRules.ts` (exhaustively unit-tested) +and applied in the inspector. Uses a **draft / explicit-Save** model over the single destructive +`PUT .../items` replace (not the old instant-persist), with Discard + a dirty navigation guard — see +`decisions.md` 2026-07-11 (a)/(b)/(c) and `spa-conventions.md` §8. The capability matrix (issue #207) +maps each Blazor field → SPA control → request key → test. ### Remaining mutation-depth gaps inside Section 2 rows -Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor), +Tracked as #91 phase (b) gates without moving whole rows — #212 DONE 2026-07-11 (channel editor), #213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as `ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on diff --git a/docs/decisions.md b/docs/decisions.md index 0c8779c60..d74609ce1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -262,6 +262,26 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t 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. + ## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215) Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight @@ -299,7 +319,71 @@ is "no mutation during a build", matching Blazor's edit-disable. Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()` to keep the diff small). Convention recorded in `api-conventions.md` §3a. -## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file +## 2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved + +The ChicoryTV schedules editor (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`, +issue #207) rebuilds the schedule-item lineup to full mutation parity with the legacy Blazor editor. +Three deliberate decisions: + +**(a) Draft model with one explicit Save, replacing instant-persist.** All add/edit/copy/remove/ +reorder mutate a **local draft list only**; a single **Save** issues one +`PUT /api/schedules/{id}/items` (the replace endpoint). This is intentional because that PUT is +**destructive server-side** — it deletes and recreates every item row (new ids) and triggers playout +rebuilds — so batching edits into one flush (vs. the old per-action POST/DELETE/PUT) minimizes churn +and gives the user a Discard/dirty affordance. On 422/network error the draft is kept and the error +surfaced; on success the draft is replaced with the server response. A **Discard** action and a dirty +guard (native `confirm()` on schedule-switch + in-app nav via `navigationGuard.ts`, plus a +`beforeunload` listener) protect the draft. See `docs/spa-conventions.md` §8. This retires the old +inline `ScheduleScreen` in `App.tsx` and its instant-persist add/delete/reorder tests. + +**(b) Copy item deep-copies ALL source references, including multi/smart/rerun collections.** Blazor's +`CopyItem` omitted the multi-collection / smart-collection / rerun-collection references when +duplicating an item (copying only the plain collection/media-item/playlist refs) — a latent bug. The +SPA's `copyDraftItem` (`web/src/schedules/itemRules.ts`) copies every source field + display name, so +copying a MultiCollection/SmartCollection/Rerun item preserves its source. Deliberate deviation +fixing the Blazor omission. + +**(c) The shuffled-schedule GET normalization (`EnforceProperties`) is preserved lossiness, matching +Blazor.** When a schedule has `ShuffleScheduleItems`, `GET .../items` still rewrites startType→Dynamic, +Flood→One, and Playlist/Rerun playbackOrder→None (and zeroes discardToFillAttempts for non-random +Duration items). The SPA does **not** fight this — it hides the Fixed start type and Flood playout +mode from the option lists (and disables the reorder arrows) for shuffled schedules, mirroring Blazor, +so a GET→edit→PUT round-trip stays consistent with the server's read-side normalization. Issue #207. + +## 2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212) + +Two Blazor-parity decisions closing the channel-editor gaps (`ChannelEditor.razor` + +`ChannelEditViewModel`): + +1. **Bare-channel create lives on the channels list, not a form-first route.** Blazor's + `/channels` (no `Id`) is a full add form; the SPA instead adds a "New blank channel" action next + to "Add Channel" on `ChannelsScreen` (`web/src/App.tsx`) that POSTs `CreateChannelRequest` with + Blazor's computed add-mode defaults (`(max existing int-parsed channel number) + 1`, `name: "New + Channel"`, `group: "ErsatzTV"`, `ffmpegProfileId` = `GET /api/settings/ffmpeg`'s + `defaultFFmpegProfileId`, `streamingMode: "TransportStreamHybrid"`, `isEnabled`/`showInEpg: true`, + every other field at its C# `default(T)` — see `ChannelEditor.razor`'s `else` branch for the + source of truth) directly, then navigates to `/app/edit-channel/{id}` for the rest of the fields. + This is a deliberate equivalent, not a parity gap: it reuses the existing full editor instead of + duplicating its ~20 fields into a second form. "Add Channel" (`/app/new-channel`, the + library-to-lineup `ChannelBuilder` flow) is unrelated and untouched. +2. **External logo URL wins over an uploaded logo, mirroring + `ChannelEditViewModel.ToUpdate/ToCreate`.** The channel's logo is `ArtworkContentTypeModel { + path, contentType, isExternalUrl }`; on hydration, `isExternalUrl: true` populates a separate + "External logo URL" field and the uploaded-logo draft state is treated as empty (`EMPTY_LOGO = + { path: '', contentType: '' }` in `ChannelEditScreen.tsx`) so the two never disagree. On submit, + a non-blank URL always wins: `logo: { path: url, contentType: '' }`, exactly matching + `ExternalLogoUrl`'s precedence in the C# view model. Uploading a file clears the URL field (the + last-set field wins, Blazor parity via `UploadLogo`'s `_model.ExternalLogoUrl = null`). The URL + is validated as http(s) client-side before save is enabled (Blazor has no equivalent validation; + added because the field is free text with no server-side format check surfaced to the SPA). + +Also landed with #212: `preferredAudioLanguageCode`/`preferredSubtitleLanguageCode`, +`musicVideoCreditsTemplate`, and `streamSelector` moved from free-text `Input`s to `Select`s fed by +`GET /api/languages` / `/api/channels/music-video-credits-templates` / +`/api/channels/stream-selectors`. Each keeps the channel's currently-stored value selectable even if +it's absent from the reference list (`optionsKeepingCurrent` in `ChannelEditScreen.tsx`) so loading +an existing channel never silently changes the value out from under an unmanaged language code or a +template/selector file removed from disk since save.## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file With multiple sessions/agents working the repo in parallel, the old protocol — every session wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue + 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/docs/spa-conventions.md b/docs/spa-conventions.md index 7fff75c26..d95b89efa 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -201,7 +201,25 @@ Also run `npm run check:api` if you touched anything OpenAPI-relevant (see `api- — it regenerates `src/api/generated/v1.d.ts` and fails the build if it's out of sync with what's committed. -## 8. Review checklist — temporal semantics +## 8. Unsaved-changes navigation guard + +Screens with a **draft / explicit-Save** model (edits accumulate locally, one Save flushes them — +e.g. the schedules editor, `web/src/screens/SchedulesScreen.tsx`) must guard against losing the +draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam: + +- The screen **registers a guard** on mount: `registerNavigationGuard(() => !dirtyRef.current || + window.confirm(...))`, returning the unregister fn from the mount `useEffect` for cleanup. Only one + guard is active at a time (the mounted screen); a stale unregister only clears its own guard. +- `App.tsx`'s `navigate` handler calls `canLeaveCurrentScreen()` before `pushState` — a `false` + return aborts the in-app sidebar/nav click. This covers React-router-less in-app navigation only. +- The screen **also** guards the three paths the module can't see — a **schedule switch** and any + in-screen action that would replace the draft use their own `window.confirm(...)`, and a + `beforeunload` listener (installed while `dirty`) covers full-page unloads (reload / close tab / + external link). + +Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so +`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time. +## 9. Review checklist — temporal semantics - **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change, unmount, StrictMode double-invoke) and *which* render/request does it still own?** A debounce timer diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index fd6beb59d..e9d6289d4 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -52,7 +52,7 @@ describe('ChicoryTV SPA scaffold', () => { fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); expect(screen.getByRole('heading', { name: 'Schedules' })).toBeInTheDocument(); - expect(await screen.findByText('No schedules returned')).toBeInTheDocument(); + expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0); expect(window.location.pathname).toBe('/app/schedules'); }); @@ -742,6 +742,113 @@ describe('ChicoryTV SPA scaffold', () => { ); }); + it('creates a blank channel with Blazor-parity defaults and navigates to its editor', async () => { + mockDashboardApi({ + channels: [ + { + ffmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + }, + { + ffmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: true, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + } + ], + createChannelResponse: { id: 42 }, + ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 7 }) + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + await waitFor(() => + expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' })) + ); + + const body = requestBodyFor('/api/channels'); + expect(body).toMatchObject({ + categories: null, + fallbackFillerId: null, + ffmpegProfileId: 7, + group: 'ErsatzTV', + idleBehavior: 'StopOnDisconnect', + isEnabled: true, + logo: { path: null, contentType: null }, + mirrorSourceChannelId: null, + musicVideoCreditsMode: 'None', + musicVideoCreditsTemplate: null, + name: 'New Channel', + number: '25', + playoutMode: 'Continuous', + playoutOffset: null, + playoutSource: 'Generated', + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + showInEpg: true, + slugSeconds: null, + songVideoMode: 'Default', + streamingMode: 'TransportStreamHybrid', + streamSelector: null, + streamSelectorMode: 'Default', + subtitleMode: 'None', + transcodeMode: 'OnDemand', + watermarkId: null + }); + + await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/42')); + }); + + it('surfaces a 422 error from bare-channel create without navigating', async () => { + mockDashboardApi({ + channels: [ + { + ffmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + } + ], + createChannelFailure: { detail: 'A channel with that number already exists', status: 422, title: 'Validation failed' } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'New blank channel' })); + + expect(await screen.findByText('A channel with that number already exists')).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/channels'); + }); + it('clears the selection when the channel view filter changes', async () => { mockDashboardApi({ channels: [ @@ -798,22 +905,10 @@ describe('ChicoryTV SPA scaffold', () => { expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument(); }); - it('renders the Schedule editor from live schedule APIs', async () => { + it('renders the extracted Schedules editor from live schedule APIs (#207)', async () => { mockDashboardApi({ - scheduleItems: [ - scheduleItem({ durationEstimate: '01:30:00', id: 11, name: 'Saturday Cartoons' }), - scheduleItem({ - collection: { id: 6, name: 'Station IDs' }, - collectionType: 'Collection', - durationEstimate: null, - guideMode: 'Filler', - id: 12, - name: 'Station IDs', - playoutMode: 'Flood' - }) - ], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })], - scheduleItemsTotalDuration: '01:30:00' + scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })], + schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] }); render(); @@ -821,293 +916,37 @@ describe('ChicoryTV SPA scaffold', () => { fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument(); - expect(screen.getByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument(); + expect(await screen.findByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument(); expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0); - expect(screen.getAllByText('01:30:00').length).toBeGreaterThan(0); - expect(screen.getAllByText('Station IDs').length).toBeGreaterThan(0); - expect(screen.getByText('unknown')).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Content' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getAllByDisplayValue('Prime Time Cartoons').length).toBeGreaterThan(0); expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object)); expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object)); - expect(window.fetch).toHaveBeenCalledWith('/api/collections', expect.any(Object)); - expect(window.fetch).not.toHaveBeenCalledWith('/api/languages', expect.any(Object)); + // the flat editor fetches its own pickers (issue #207) + expect(window.fetch).toHaveBeenCalledWith('/api/languages', expect.any(Object)); }); - it('fetches the schedules screen data exactly once on mount', async () => { + it('opens the create-schedule dialog from the TopBar Add Schedule button', async () => { mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] }); render(); fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument(); - expect(fetchCount('/api/schedules')).toBe(1); + + // TopBar primary action for the schedules route reads "Add Schedule". + fireEvent.click(screen.getByRole('button', { name: 'Add Schedule' })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByLabelText('Name')).toBeInTheDocument(); }); - it('shows the Schedule editor error state when a picker endpoint fails', async () => { - vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { - const path = input.toString(); - - if (path === '/api/schedules') { - return Promise.resolve(jsonResponse([schedule({ id: 5, name: 'Prime Time Cartoons' })])); - } - - if (path === '/api/schedules/5/items') { - return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null })); - } - - if (path === '/api/collections') { - return Promise.resolve(jsonResponse({ - detail: 'Collections service is unavailable', - status: 500, - title: 'Internal error' - }, 500)); - } - - return Promise.resolve(jsonResponse([])); - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - - expect(await screen.findByText('Collections service is unavailable')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); - }); - - it('shows the Schedule editor loading state', async () => { - vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { - const path = input.toString(); - - if (path === '/api/schedules') { - return new Promise(() => {}); - } - - return Promise.resolve(jsonResponse([])); - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - - expect(await screen.findByText('Loading schedules')).toBeInTheDocument(); - }); - - it('shows Schedule editor API errors and retries 404 parent handling', async () => { - mockDashboardApi({ - scheduleItemFailuresBeforeSuccess: 1, - scheduleItemFailure: { - detail: 'Schedule 5 was not found', - status: 404, - title: 'Not found' - }, - schedules: [schedule({ id: 5, name: 'Missing Schedule' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - - expect(await screen.findByText('Schedule 5 was not found')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Retry' })); - - expect(await screen.findByText('No schedule items')).toBeInTheDocument(); - expect(fetchCount('/api/schedules/5/items')).toBe(2); - }); - - it('shows an empty Schedule editor state when no schedule exists', async () => { + it('shows the empty Schedules state when no schedule exists', async () => { mockDashboardApi({ schedules: [] }); render(); fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findByText('No schedules returned')).toBeInTheDocument(); - }); - - it('shows an empty Schedule editor lineup for a schedule with no items', async () => { - mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Empty Schedule' })] }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - - expect(await screen.findByText('No schedule items')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument(); - }); - - it('reorders schedule items with keyboard controls and sends the exact replace-all body', async () => { - const first = scheduleItem({ id: 11, name: 'Saturday Cartoons' }); - const second = scheduleItem({ - collection: { id: 6, name: 'Station IDs' }, - durationEstimate: null, - guideMode: 'Filler', - id: 12, - name: 'Station IDs', - playoutMode: 'Flood' - }); - - mockDashboardApi({ - replaceScheduleItemsResponse: [second, first], - scheduleItems: [first, second], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); - - const collectionsFetchesBeforeReorder = fetchCount('/api/collections'); - - fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({ - body: JSON.stringify({ items: [scheduleItemRequest(second), scheduleItemRequest(first)] }), - method: 'PUT' - })); - }); - const lineup = screen.getByRole('list', { name: 'Schedule lineup' }); - const rows = within(lineup).getAllByRole('listitem'); - expect(rows[0]).toHaveTextContent('Station IDs'); - expect(rows[1]).toHaveTextContent('Saturday Cartoons'); - expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeReorder); - }); - - it('rolls back schedule reorder and shows ProblemDetails on replace failure', async () => { - mockDashboardApi({ - mutationFailures: { - '/api/schedules/5/items': { - detail: 'Schedule items are locked', - status: 422, - title: 'Validation failed' - } - }, - scheduleItems: [ - scheduleItem({ id: 11, name: 'Saturday Cartoons' }), - scheduleItem({ collection: { id: 6, name: 'Station IDs' }, id: 12, name: 'Station IDs' }) - ], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); - - fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' })); - - expect(await screen.findByText('Schedule items are locked')).toBeInTheDocument(); - const lineup = screen.getByRole('list', { name: 'Schedule lineup' }); - expect(within(lineup).getAllByRole('listitem')[0]).toHaveTextContent('Saturday Cartoons'); - }); - - it('adds schedule items using the selected (non-first) collection', async () => { - const added = scheduleItem({ collection: { id: 4, name: 'Nature Docs' }, id: 20, name: 'Nature Docs' }); - mockDashboardApi({ - addScheduleItemResponse: added, - collections: [ - collection({ id: 3, name: 'Movie Mix' }), - collection({ id: 4, name: 'Nature Docs' }) - ], - scheduleItems: [], - scheduleItemsAfterAdd: [added], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findByText('No schedule items')).toBeInTheDocument(); - - const collectionsFetchesBeforeAdd = fetchCount('/api/collections'); - - fireEvent.change(screen.getByRole('combobox', { name: 'Collection for new item' }), { - target: { value: '4' } - }); - fireEvent.click(screen.getByRole('button', { name: 'Add item' })); - - expect(await screen.findAllByText('Nature Docs')).not.toHaveLength(0); - const expectedBody = scheduleItemRequest(added); - expect(expectedBody.collectionId).toBe(4); - expect(expectedBody.collectionType).toBe('Collection'); - expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({ - body: JSON.stringify(expectedBody), - method: 'POST' - })); - expect(fetchCount('/api/schedules/5/items')).toBeGreaterThan(2); - expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeAdd); - }); - - it('shows ProblemDetails when adding a schedule item fails', async () => { - mockDashboardApi({ - collections: [collection({ id: 3, name: 'Movie Mix' })], - mutationFailures: { - '/api/schedules/5/items': { - detail: 'Collection is required', - status: 422, - title: 'Validation failed' - } - }, - scheduleItems: [], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findByText('No schedule items')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Add item' })); - - expect(await screen.findByText('Collection is required')).toBeInTheDocument(); - }); - - it('deletes schedule items and refetches after success', async () => { - mockDashboardApi({ - confirm: true, - scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })], - scheduleItemsAfterDelete: [], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); - - fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' })); - - expect(await screen.findByText('No schedule items')).toBeInTheDocument(); - expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items/11', expect.objectContaining({ - method: 'DELETE' - })); - }); - - it('shows ProblemDetails when deleting a schedule item fails', async () => { - mockDashboardApi({ - confirm: true, - mutationFailures: { - '/api/schedules/5/items/11': { - detail: 'Item is in use by an active playout', - status: 422, - title: 'Validation failed' - } - }, - scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })], - schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Schedules' })); - expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0); - - fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' })); - - expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument(); + expect((await screen.findAllByRole('button', { name: 'Add schedule' })).length).toBeGreaterThan(0); }); it('renders the Playouts monitor from live playout and channel state APIs', async () => { @@ -3420,16 +3259,6 @@ function schedule(overrides: Record = {}): Record = {}): Record { - return { - collectionType: 'Collection', - id: 2, - name: 'Saturday Cartoons', - useCustomPlaybackOrder: false, - ...overrides - }; -} - function library(overrides: Record = {}): Record { return { id: 31, @@ -3661,107 +3490,67 @@ function playoutItem(overrides: Record = {}): Record = {}): Record { - const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) }; - + // Flat ScheduleItemResponseModel shape (issue #126/#207). return { - collection, - collectionType: 'Collection', - customTitle: null, - discardToFillAttempts: null, - durationEstimate: '00:25:00', - fallbackFiller: null, - fillWithGroupMode: 'None', - fixedStartTimeBehavior: null, - graphicsElements: [], - guideMode: 'Normal', id: 11, index: 0, - marathonBatchSize: null, + startType: 'Dynamic', + startTime: null, + fixedStartTimeBehavior: null, + playoutMode: 'One', + collectionType: 'Collection', + collectionId: 2, + multiCollectionId: null, + smartCollectionId: null, + rerunCollectionId: null, + mediaItemId: null, + playlistId: null, + searchTitle: null, + searchQuery: null, + playbackOrder: 'Shuffle', marathonGroupBy: 'None', marathonShuffleGroups: false, marathonShuffleItems: false, - mediaItem: null, - midRollFiller: null, + marathonBatchSize: null, + fillWithGroupMode: 'None', + multipleMode: null, multipleCount: null, - multipleMode: 'Count', - multiCollection: null, - name: collection.name, - playbackOrder: 'Shuffle', - playlist: null, playoutDuration: null, - playoutMode: 'One', - postRollFiller: null, + tailMode: null, + discardToFillAttempts: null, + customTitle: null, + guideMode: 'Normal', + preRollFillerId: null, + midRollFillerId: null, + postRollFillerId: null, + tailFillerId: null, + fallbackFillerId: null, + watermarkIds: [], + graphicsElementIds: [], preferredAudioLanguageCode: null, preferredAudioTitle: null, preferredSubtitleLanguageCode: null, - preRollFiller: null, - rerunCollection: null, - searchQuery: null, - searchTitle: null, - smartCollection: null, - startTime: null, - startType: 'Dynamic', subtitleMode: null, - tailFiller: null, - tailMode: 'None', - watermarkIds: [], + collectionName: 'Saturday Cartoons', + multiCollectionName: null, + smartCollectionName: null, + rerunCollectionName: null, + playlistName: null, + playlistGroupId: null, + mediaItemName: null, + preRollFillerName: null, + midRollFillerName: null, + postRollFillerName: null, + tailFillerName: null, + fallbackFillerName: null, watermarks: [], + graphicsElements: [], + name: 'Saturday Cartoons', + durationEstimate: '00:25:00', ...overrides }; } -function scheduleItemRequest(item: Record): Record { - const collection = item.collection as { id?: number } | null | undefined; - const multiCollection = item.multiCollection as { id?: number } | null | undefined; - const smartCollection = item.smartCollection as { id?: number } | null | undefined; - const rerunCollection = item.rerunCollection as { id?: number } | null | undefined; - const mediaItem = item.mediaItem as { id?: number } | null | undefined; - const playlist = item.playlist as { id?: number } | null | undefined; - const watermarks = item.watermarks as Array<{ id: number }> | null | undefined; - const graphicsElements = item.graphicsElements as Array<{ id: number }> | null | undefined; - const fillerId = (value: unknown) => (value as { id?: number } | null | undefined)?.id ?? null; - - return { - collectionId: collection?.id ?? null, - collectionType: item.collectionType ?? 'Collection', - customTitle: item.customTitle ?? null, - discardToFillAttempts: item.discardToFillAttempts ?? null, - fallbackFillerId: fillerId(item.fallbackFiller), - fillWithGroupMode: item.fillWithGroupMode ?? 'None', - fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null, - graphicsElementIds: 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: mediaItem?.id ?? null, - midRollFillerId: fillerId(item.midRollFiller), - multipleCount: item.multipleCount ?? null, - multipleMode: item.multipleMode ?? 'Count', - multiCollectionId: multiCollection?.id ?? null, - playbackOrder: item.playbackOrder ?? 'Shuffle', - playlistId: playlist?.id ?? null, - playoutDuration: item.playoutDuration ?? null, - playoutMode: item.playoutMode ?? 'One', - postRollFillerId: fillerId(item.postRollFiller), - preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null, - preferredAudioTitle: item.preferredAudioTitle ?? null, - preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null, - preRollFillerId: fillerId(item.preRollFiller), - rerunCollectionId: rerunCollection?.id ?? null, - searchQuery: item.searchQuery ?? null, - searchTitle: item.searchTitle ?? null, - smartCollectionId: smartCollection?.id ?? null, - startTime: item.startTime ?? null, - startType: item.startType ?? 'Dynamic', - subtitleMode: item.subtitleMode ?? null, - tailFillerId: fillerId(item.tailFiller), - tailMode: item.tailMode ?? 'None', - watermarkIds: watermarks?.map((watermark) => watermark.id) ?? [], - }; -} - function mockDashboardApi({ addScheduleItemResponse = null, artworkUploadFailure = null, @@ -3770,6 +3559,8 @@ function mockDashboardApi({ channels = [], channelStates = [], channelTemplates = [], + createChannelFailure = null, + createChannelResponse = null, createTemplateResponse = null, defaultChannelTemplate = null, playoutAlternateSchedules = [], @@ -3837,6 +3628,8 @@ function mockDashboardApi({ channels?: unknown[]; channelStates?: unknown[]; channelTemplates?: unknown[]; + createChannelFailure?: { detail?: string; status?: number; title?: string } | null; + createChannelResponse?: unknown; createTemplateResponse?: unknown; defaultChannelTemplate?: unknown; playoutAlternateSchedules?: unknown[]; @@ -3923,6 +3716,13 @@ function mockDashboardApi({ vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { const path = input.toString(); + if (path === '/api/channels' && (init?.method ?? 'GET') === 'POST') { + if (createChannelFailure) { + return Promise.resolve(jsonResponse(createChannelFailure, createChannelFailure.status ?? 422)); + } + return Promise.resolve(jsonResponse(createChannelResponse ?? { id: 99 }, 201)); + } + if (path === '/api/channels') { return Promise.resolve(jsonResponse(channels)); } @@ -4040,7 +3840,7 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(multiCollections)); } - if (path === '/api/filler-presets') { + if (path.startsWith('/api/filler-presets')) { return Promise.resolve(jsonResponse(fillerPresets)); } @@ -4048,6 +3848,23 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(watermarks)); } + // Schedule-editor pickers (issue #207): empty defaults are enough for the App-level route tests. + if (path.startsWith('/api/rerun-collections')) { + return Promise.resolve(jsonResponse({ totalCount: 0, page: [] })); + } + + if (path === '/api/playlists/groups') { + return Promise.resolve(jsonResponse([])); + } + + if (path === '/api/graphics-elements') { + return Promise.resolve(jsonResponse([])); + } + + if (path === '/api/languages') { + return Promise.resolve(jsonResponse([])); + } + if (path === '/api/media-sources') { if (remainingMediaSourcesFailures > 0) { remainingMediaSourcesFailures -= 1; diff --git a/web/src/App.tsx b/web/src/App.tsx index fa5478147..f8f5f7fb4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,13 +3,11 @@ import { useMemo, useRef, useState, - type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'; import { - ArrowDownWideNarrow, Bell, Boxes, Palette, @@ -23,7 +21,6 @@ import { CircleHelp, Clock, ClipboardCopy, - Copy, Crosshair, Film, FileImage, @@ -31,7 +28,6 @@ import { Folder, FolderInput, FolderTree, - GripVertical, Hash, HardDrive, Info, @@ -55,12 +51,10 @@ import { Search, Server, Settings, - Shuffle, SlidersHorizontal, Sparkles, Stamp, Stethoscope, - Timer, Trash2, TriangleAlert, Tv @@ -76,6 +70,8 @@ import { MediaBrowseScreen } from './screens/MediaBrowseScreen'; import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen'; import { PlaylistsScreen } from './screens/PlaylistsScreen'; import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen'; +import { SchedulesScreen } from './screens/SchedulesScreen'; +import { canLeaveCurrentScreen } from './navigationGuard'; import { ArtistDetailScreen, MovieDetailScreen, @@ -117,8 +113,7 @@ import { Spinner, Stat, StatusDot, - Switch, - Tabs + Switch } from './components'; import { useChannelsQuery, @@ -132,19 +127,17 @@ import { erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext, resetChannelPlayout, + createChannel, deleteChannel, getDecos, + getFfmpegSettings, ApiError, messageFromError, - addScheduleItem, - deleteScheduleItem, getSchedules, resetAllPlayouts, - replaceScheduleItems, updatePlayoutDefaultDeco, updatePlayoutDetails, usePlayoutsScreenQuery, - useScheduleScreenQuery, useDashboardHealthQuery, useDashboardQuery, useDashboardVersionQuery, @@ -156,23 +149,20 @@ import { type ChannelSummary, type ChannelGuideChannel, type ChannelGuideProgramme, + type CreateChannelRequest, type CreatePlayoutRequest, type DashboardChannel, type DecoListItem, - type SchedulePickerData, type DashboardChannelState, type DashboardHealthQueryState, type LibraryScanStatus, type MediaSource, type MediaSourceLibrary, - type MediaCollection, type PlayoutDetail, type PlayoutScheduleKind, type ProgramSchedule, - type ProgramScheduleItem, type PlayoutItem, type PlayoutSummary, - type ScheduleItemRequest } from './api'; import { applyDesignSystemTheme, @@ -906,7 +896,18 @@ function TopBar({ route }: { route: ScreenRoute | null }) { TB - + ); } @@ -1268,6 +1269,7 @@ function ChannelsScreen() { const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [mutationError, setMutationError] = useState(null); const [mutating, setMutating] = useState(false); + const [creatingBlank, setCreatingBlank] = useState(false); if (query.status === 'loading') { return ; @@ -1398,6 +1400,63 @@ function ChannelsScreen() { void refreshAfterMutation(() => deleteChannel(channel.id)); }; + // Blazor parity (ChannelEditor.razor "add" branch): a blank channel is created with these + // defaults, then the editor opens for the rest of the fields. Distinct from "Add Channel", + // which is the library-to-lineup ChannelBuilder flow (untouched here). + const createBlankChannel = async () => { + if (creatingBlank) { + return; + } + + setMutationError(null); + setCreatingBlank(true); + + try { + const ffmpegSettings = await getFfmpegSettings(); + const maxNumber = channels.reduce((max, channel) => { + const parsed = Number.parseInt(channel.number.split('.')[0] ?? '', 10); + return Number.isFinite(parsed) ? Math.max(max, parsed) : max; + }, 0); + + const body: CreateChannelRequest = { + categories: null, + fallbackFillerId: null, + ffmpegProfileId: ffmpegSettings.defaultFFmpegProfileId, + group: 'ErsatzTV', + idleBehavior: 'StopOnDisconnect', + isEnabled: true, + logo: { path: null, contentType: null }, + mirrorSourceChannelId: null, + musicVideoCreditsMode: 'None', + musicVideoCreditsTemplate: null, + name: 'New Channel', + number: String(maxNumber + 1), + playoutMode: 'Continuous', + playoutOffset: null, + playoutSource: 'Generated', + preferredAudioLanguageCode: null, + preferredAudioTitle: null, + preferredSubtitleLanguageCode: null, + showInEpg: true, + slugSeconds: null, + songVideoMode: 'Default', + streamingMode: 'TransportStreamHybrid', + streamSelector: null, + streamSelectorMode: 'Default', + subtitleMode: 'None', + transcodeMode: 'OnDemand', + watermarkId: null + }; + + const created = await createChannel(body); + navigateToPath(`/app/edit-channel/${created.id}`); + } catch (error: unknown) { + setMutationError(messageFromError(error)); + } finally { + setCreatingBlank(false); + } + }; + if (channels.length === 0) { return ; } @@ -1424,6 +1483,16 @@ function ChannelsScreen() { {onAirCount} on air + )} @@ -1595,630 +1664,10 @@ function ChannelTableRow({ ); } -type ScheduleInspectorTab = 'content' | 'playback' | 'filler' | 'overrides'; - -function SchedulesLoadingState() { - return ( - -
- - Loading schedules -
-
- ); -} - -function SchedulesErrorState({ error, refresh }: { error: string; refresh: () => void }) { - return ( - Schedules unavailable} - subtitle="Live API request failed" - actions={} - > -
- API request failed - {error} -
-
- ); -} - -function SchedulesEmptyState() { - return ( - No schedules} subtitle="The API returned an empty schedule list."> -
No schedules returned
-
- ); -} - -function ScheduleScreen() { - const query = useScheduleScreenQuery(); - const [selectedItemId, setSelectedItemId] = useState(null); - const [selectedCollectionId, setSelectedCollectionId] = useState(null); - const [dragItemId, setDragItemId] = useState(null); - const [overItemId, setOverItemId] = useState(null); - const [inspectorTab, setInspectorTab] = useState('content'); - const [mutationError, setMutationError] = useState(null); - const [mutating, setMutating] = useState(false); - const mutatingRef = useRef(false); - - if (query.status === 'loading') { - return ; - } - - if (query.status === 'error') { - return ; - } - - const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data; - const { itemsLoading, setActiveSchedule, setItems } = query; - - if (!activeSchedule) { - return ; - } - - const orderedItems = sortedScheduleItems(items); - const selectedItem = orderedItems.find((item) => item.id === selectedItemId) ?? orderedItems[0] ?? null; - const effectiveSelectedItemId = selectedItem?.id ?? null; - const selectedIndex = selectedItem ? orderedItems.findIndex((item) => item.id === selectedItem.id) : -1; - const collectionOptions = sortedCollections(pickers.collections); - const effectiveCollectionId = collectionOptions.some((collection) => collection.id === selectedCollectionId) - ? selectedCollectionId - : collectionOptions[0]?.id ?? null; - - const setMutatingState = (value: boolean) => { - mutatingRef.current = value; - setMutating(value); - }; - - const refetchItemsAfterMutation = async (operation: () => Promise) => { - setMutationError(null); - setMutatingState(true); - - try { - await operation(); - setActiveSchedule(activeSchedule.id); - } catch (error: unknown) { - setMutationError(messageFromError(error)); - } finally { - setMutatingState(false); - } - }; - - const persistOrder = async (nextItems: ProgramScheduleItem[]) => { - const previousSelectedId = effectiveSelectedItemId; - setSelectedItemId((current) => current ?? previousSelectedId ?? nextItems[0]?.id ?? null); - - try { - setMutationError(null); - setMutatingState(true); - const result = await replaceScheduleItems(activeSchedule.id, { - items: nextItems.map(scheduleItemToRequest) - }); - setSelectedItemId(previousSelectedId ?? result[0]?.id ?? null); - setItems(result); - } catch (error: unknown) { - setSelectedItemId(previousSelectedId); - setMutationError(messageFromError(error)); - } finally { - setMutatingState(false); - } - }; - - const moveItem = (itemId: number, direction: -1 | 1) => { - if (mutatingRef.current) { - return; - } - - const index = orderedItems.findIndex((item) => item.id === itemId); - const nextIndex = index + direction; - - if (index < 0 || nextIndex < 0 || nextIndex >= orderedItems.length) { - return; - } - - const nextItems = [...orderedItems]; - const [moved] = nextItems.splice(index, 1); - nextItems.splice(nextIndex, 0, moved); - void persistOrder(nextItems); - }; - - const dropItem = (targetId: number) => { - if (dragItemId == null || dragItemId === targetId || mutatingRef.current) { - setDragItemId(null); - setOverItemId(null); - return; - } - - const from = orderedItems.findIndex((item) => item.id === dragItemId); - const to = orderedItems.findIndex((item) => item.id === targetId); - - if (from >= 0 && to >= 0) { - const nextItems = [...orderedItems]; - const [moved] = nextItems.splice(from, 1); - nextItems.splice(to, 0, moved); - void persistOrder(nextItems); - } - - setDragItemId(null); - setOverItemId(null); - }; - - const addItem = () => { - const collection = collectionOptions.find((candidate) => candidate.id === effectiveCollectionId); - - if (!collection) { - setMutationError('A collection is required before adding a schedule item'); - return; - } - - void refetchItemsAfterMutation(async () => { - const added = await addScheduleItem(activeSchedule.id, newScheduleItemRequest(collection)); - setSelectedItemId(added.id ?? null); - }); - }; - - const deleteItem = (item: ProgramScheduleItem) => { - const itemId = item.id; - - if (itemId == null || !window.confirm(`Remove ${scheduleItemName(item)}?`)) { - return; - } - - void refetchItemsAfterMutation(async () => { - await deleteScheduleItem(activeSchedule.id, itemId); - setSelectedItemId(null); - }); - }; - - return ( -
-
- -
-

{activeSchedule.name ?? 'Unnamed schedule'}

-

- {schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs {totalDurationEstimate ?? 'unknown'} -

-
- setSelectedCollectionId(Number(event.target.value))} - options={pickerOptions(collectionOptions)} - size="sm" - value={`${effectiveCollectionId ?? ''}`} - /> - -
- - {orderedItems.length === 0 ? ( -
No schedule items
- ) : ( -
- {orderedItems.map((item, index) => ( - - ))} -
- )} - - - setInspectorTab(value as ScheduleInspectorTab)} - /> - - - ); -} - -function ScheduleItemBlock({ - active, - dragging, - isFirst, - isLast, - isOver, - item, - mutating, - onDelete, - onDrop, - onMove, - onOver, - onSelect, - onStartDrag -}: { - active: boolean; - dragging: boolean; - isFirst: boolean; - isLast: boolean; - isOver: boolean; - item: ProgramScheduleItem; - mutating: boolean; - onDelete: (item: ProgramScheduleItem) => void; - onDrop: (targetId: number) => void; - onMove: (itemId: number, direction: -1 | 1) => void; - onOver: (itemId: number | null) => void; - onSelect: (itemId: number | null) => void; - onStartDrag: (itemId: number | null) => void; -}) { - const itemId = item.id ?? null; - const name = scheduleItemName(item); - const fill = scheduleFillDescriptor(item); - const fixed = item.startType === 'Fixed'; - const filler = item.guideMode === 'Filler'; - - const onDragOver = (event: DragEvent) => { - event.preventDefault(); - onOver(itemId); - }; - - return ( -
- -
{ - onStartDrag(null); - onOver(null); - }} - onDragOver={onDragOver} - onDragStart={() => onStartDrag(itemId)} - onDrop={() => { - if (itemId != null) { - onDrop(itemId); - } - }} - > - -
- - {filler && Hidden from guide} - {item.durationEstimate ?? 'unknown'} -
-
- itemId != null && onMove(itemId, -1)} size="sm" title={`Move ${name} up`}> - - itemId != null && onMove(itemId, 1)} size="sm" title={`Move ${name} down`}> - - onDelete(item)} size="sm" title={`Remove ${name}`}> - -
-
-
- ); -} - -function ScheduleInspector({ - item, - itemIndex, - mutating, - onDelete, - onTabChange, - pickers, - schedule, - tab -}: { - item: ProgramScheduleItem | null; - itemIndex: number; - mutating: boolean; - onDelete: (item: ProgramScheduleItem) => void; - onTabChange: (value: string) => void; - pickers: SchedulePickerData; - schedule: ProgramSchedule; - tab: ScheduleInspectorTab; -}) { - if (!item) { - return ( -
-
Select or add a schedule item
-
- ); - } - - const name = scheduleItemName(item); - const fill = scheduleFillDescriptor(item); - - return ( -
-
- -
- {name} - Item {itemIndex + 1} · {formatScheduleCollectionType(item.collectionType)} -
- onDelete(item)} title="Remove selected item"> - -
-
- {item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : 'Dynamic'} - {formatScheduleEnum(item.playbackOrder ?? 'Shuffle')} - {fill.label} -
- -
- {tab === 'content' && ( - <> -
- -
- - -
- -
- - - )} - {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: