feat: schedules editor full mutation depth + channel-editor gaps (#207, #212, #126) #229

Merged
timothy merged 22 commits from feat/207-212-schedules-channel-depth into main 2026-07-11 12:05:53 +02:00
53 changed files with 5772 additions and 2104 deletions
+1 -1
View File
@@ -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(
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>;
@@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
.AsNoTracking()
.ToListAsync(cancellationToken);
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
if (request.FillerKind is { } fillerKind)
{
query = query.Where(fp => fp.FillerKind == fillerKind);
}
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList();
}
}
@@ -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<Validation<BaseError, ProgramSchedule>> Validate(
@@ -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<ProgramScheduleItem> 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<Validation<BaseError, ProgramSchedule>> Validate(
@@ -0,0 +1,48 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
internal static class ProgramScheduleItemQueryExtensions
{
/// <summary>
/// The single source of truth for the navigation graph a <see cref="ProgramScheduleItem" /> needs before
/// it can be projected via <see cref="Mapper.ProjectToViewModel" />. The mapper dereferences the
/// <c>Watermark</c> / <c>GraphicsElement</c> 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).
/// </summary>
public static IQueryable<ProgramScheduleItem> IncludeScheduleItemDetails(this IQueryable<ProgramScheduleItem> 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);
}
@@ -20,36 +20,8 @@ public class GetProgramScheduleItemsHandler(IDbContextFactory<TvContext> 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());
@@ -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;
/// <summary>
/// Maps the polymorphic <see cref="ProgramScheduleItemViewModel" /> hierarchy to the flat,
/// fully-described <see cref="ScheduleItemResponseModel" /> 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.
/// </summary>
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<int> watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List<int>();
List<int> graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List<int>();
List<NamedIdResponseModel> watermarks =
item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList()
?? new List<NamedIdResponseModel>();
List<NamedIdResponseModel> graphicsElements =
item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList()
?? new List<NamedIdResponseModel>();
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);
}
@@ -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);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.MediaItems;
public record LanguageCodeResponseModel(string Code, string EnglishName);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api;
/// <summary>
/// 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.
/// </summary>
public record NamedIdResponseModel(int Id, string Name);
@@ -0,0 +1,70 @@
#nullable enable
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the
/// Application-layer <c>ProgramScheduleItemViewModel</c> 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
/// <c>ScheduleItemRequest</c> so a GET can be mapped losslessly back to a PUT/POST request.
/// </summary>
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<int> WatermarkIds,
List<int> 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<NamedIdResponseModel> Watermarks,
List<NamedIdResponseModel> GraphicsElements,
string? Name,
TimeSpan? DurationEstimate);
@@ -0,0 +1,12 @@
#nullable enable
using System.Collections.Generic;
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// GET envelope for a schedule's items. Carries the flat <see cref="ScheduleItemResponseModel" />
/// list plus a best-effort total runtime estimate (null when unbounded/unknown).
/// </summary>
public record ScheduleItemsResponseModel(
List<ScheduleItemResponseModel> Items,
TimeSpan? TotalDurationEstimate);
@@ -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<FillerPresetResponseModel> 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
});
@@ -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;
/// <summary>
/// Release gate for issue #126: proves the flat <see cref="ScheduleItemResponseModel" /> 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 <c>EnforceProperties</c> normalization (shuffle → Dynamic/One/None).
/// </summary>
[TestFixture]
public class ScheduleItemResponseRoundTripTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().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<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ReplaceProgramScheduleItem> reconstructed = envelopeA.Items
.OrderBy(item => item.Index)
.Select((item, index) => ToReplaceCommand(item, index))
.ToList();
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ScheduleItemResponseModel> orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList();
List<ScheduleItemResponseModel> 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<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ScheduleItemResponseModel> 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<ScheduleItemsResponseModel> GetItemsEnvelope(int scheduleId)
{
var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory);
IMediator mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1]));
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.GetItems(Arg.Any<int>()).Returns(new List<MediaItem>());
var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo);
ProgramScheduleItemsWithDurationViewModel vm =
await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None);
return ScheduleItemResponseMapper.ProjectToResponseModel(vm);
}
private static List<ReplaceProgramScheduleItem> 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<int> 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
});
}
@@ -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;
/// <summary>
/// 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 <c>Mapper.ProjectToViewModel</c> 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
/// <b>lazy</b> LanguageExt <c>Map</c> sequence and that test only checked <c>.IsRight</c> — 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 <c>.ToList()</c> / single-item serialization does) and seed the
/// watermark/graphics entities through a <b>separate</b> context so the handler's fresh factory context has
/// nothing pre-tracked (no accidental change-tracker fix-up).
/// </summary>
[TestFixture]
public class ScheduleItemWriteProjectionTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().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<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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<ProgramScheduleItemViewModel> 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<BaseError, ProgramScheduleItemViewModel> 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<ProgramScheduleItemViewModel> 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<int> 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);
}
@@ -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<ChannelStateResponseModel> { state });
}
[Test]
public void MusicVideoCreditsTemplates_And_StreamSelectors_Routes_Are_Literal_And_Precede_Id()
{
MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!;
mvct.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/channels/music-video-credits-templates");
MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!;
selectors.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/channels/stream-selectors");
}
[Test]
public async Task GetMusicVideoCreditsTemplates_Should_Return_Names()
{
_mediator.Send(Arg.Any<GetMusicVideoCreditTemplates>(), Arg.Any<CancellationToken>())
.Returns(["Default", "Scroll"]);
List<string> result = await _controller.GetMusicVideoCreditsTemplates(CancellationToken.None);
result.ShouldBe(["Default", "Scroll"]);
}
[Test]
public async Task GetStreamSelectors_Should_Return_Names()
{
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
.Returns(["selector-a.yml"]);
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
result.ShouldBe(["selector-a.yml"]);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -63,17 +63,30 @@ public class FillerPresetControllerTests
{
List<FillerPresetResponseModel> 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<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
List<FillerPresetResponseModel> result = await _controller.GetAll(null, CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetAll_Should_Forward_FillerKind_Filter()
{
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetAllFillerPresetsForApi>(q => q.FillerKind == FillerKind.MidRoll),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
@@ -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<IMediator>();
_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<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/languages");
}
[Test]
public async Task GetLanguages_Should_Map_Vm_To_ResponseModel()
{
List<LanguageCodeViewModel> vms =
[
new LanguageCodeViewModel("eng", "English"),
new LanguageCodeViewModel("fra", "French")
];
_mediator.Send(Arg.Any<GetAllLanguageCodes>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<LanguageCodeResponseModel> result = await _controller.GetLanguages(CancellationToken.None);
result.ShouldBe(
[
new LanguageCodeResponseModel("eng", "English"),
new LanguageCodeResponseModel("fra", "French")
]);
}
}
@@ -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",
@@ -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<OkObjectResult>().Value.ShouldBe(response);
var envelope = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ScheduleItemsResponseModel>();
envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25));
envelope.Items.Count.ShouldBe(1);
envelope.Items[0].Id.ShouldBe(11);
await _mediator.Received(1).Send(
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
Arg.Any<CancellationToken>());
@@ -225,7 +230,7 @@ public class ScheduleControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/schedules/4/items/12");
created.Value.ShouldBe(item);
created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
await _mediator.Received(1).Send(
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
Arg.Any<CancellationToken>());
@@ -254,7 +259,9 @@ public class ScheduleControllerTests
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
var replaced = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<ScheduleItemResponseModel>>();
replaced.Select(i => i.Id).ShouldBe([21, 22]);
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c =>
c.ProgramScheduleId == 4 &&
@@ -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<string>), StatusCodes.Status200OK)]
public async Task<List<string>> 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<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
@@ -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<FillerPresetResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
public async Task<List<FillerPresetResponseModel>> GetAll(
[FromQuery] FillerKind? fillerKind,
CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
[Tags("Filler Presets")]
@@ -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<LanguageCodeResponseModel>), StatusCodes.Status200OK)]
public async Task<List<LanguageCodeResponseModel>> GetLanguages(CancellationToken cancellationToken)
{
List<LanguageCodeViewModel> languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken);
return languages
.Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName))
.ToList();
}
}
+12 -6
View File
@@ -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<IActionResult> 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<IActionResult> AddItem(
@@ -133,14 +135,16 @@ public class ScheduleController(IMediator mediator) : ControllerBase
{
Either<BaseError, ProgramScheduleItemViewModel> 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<ProgramScheduleItemViewModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceItems(
@@ -150,7 +154,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
{
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> 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}")]
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -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 `<Nullable>` = 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
+18 -13
View File
@@ -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
+85 -1
View File
@@ -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 +
+9 -1
View File
@@ -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 |
+19 -1
View File
@@ -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
+196 -379
View File
@@ -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(<App />);
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(<App />);
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(<App />);
@@ -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(<App />);
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(<App />);
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<Response>(() => {});
}
return Promise.resolve(jsonResponse([]));
});
render(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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<string, unknown> = {}): Record<string, unkno
};
}
function collection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
collectionType: 'Collection',
id: 2,
name: 'Saturday Cartoons',
useCustomPlaybackOrder: false,
...overrides
};
}
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 31,
@@ -3661,107 +3490,67 @@ function playoutItem(overrides: Record<string, unknown> = {}): Record<string, un
}
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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;
+93 -650
View File
@@ -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 }) {
<span className="ctv-avatar">TB</span>
</div>
<Button startIcon={<Plus aria-hidden="true" size={15} />}>{primaryAction}</Button>
<Button
startIcon={<Plus aria-hidden="true" size={15} />}
onClick={() => {
// Screens opt in to the TopBar primary action by listening for this event keyed on their
// route id (e.g. SchedulesScreen opens its create dialog on 'schedules').
if (route) {
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: route.id }));
}
}}
>
{primaryAction}
</Button>
</header>
);
}
@@ -1268,6 +1269,7 @@ function ChannelsScreen() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const [creatingBlank, setCreatingBlank] = useState(false);
if (query.status === 'loading') {
return <ChannelsLoadingState />;
@@ -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 <ChannelsEmptyState />;
}
@@ -1424,6 +1483,16 @@ function ChannelsScreen() {
</div>
<span className="ctv-channels-spacer" />
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
<Button
disabled={creatingBlank}
loading={creatingBlank}
onClick={() => void createBlankChannel()}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
New blank channel
</Button>
<Button size="sm" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => navigateToPath('/app/new-channel')}>Add Channel</Button>
</>
)}
@@ -1595,630 +1664,10 @@ function ChannelTableRow({
);
}
type ScheduleInspectorTab = 'content' | 'playback' | 'filler' | 'overrides';
function SchedulesLoadingState() {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading schedules</span>
</div>
</Card>
);
}
function SchedulesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Schedules unavailable</h2>}
subtitle="Live API request failed"
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
>
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{error}</strong>
</div>
</Card>
);
}
function SchedulesEmptyState() {
return (
<Card title={<h2>No schedules</h2>} subtitle="The API returned an empty schedule list.">
<div className="ctv-dashboard-empty">No schedules returned</div>
</Card>
);
}
function ScheduleScreen() {
const query = useScheduleScreenQuery();
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
const [dragItemId, setDragItemId] = useState<number | null>(null);
const [overItemId, setOverItemId] = useState<number | null>(null);
const [inspectorTab, setInspectorTab] = useState<ScheduleInspectorTab>('content');
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const mutatingRef = useRef(false);
if (query.status === 'loading') {
return <SchedulesLoadingState />;
}
if (query.status === 'error') {
return <SchedulesErrorState error={query.error} refresh={query.refresh} />;
}
const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data;
const { itemsLoading, setActiveSchedule, setItems } = query;
if (!activeSchedule) {
return <SchedulesEmptyState />;
}
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<void>) => {
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 (
<div className="ctv-schedule-screen">
<section className="ctv-schedule-header">
<span className="ctv-schedule-header-icon"><CalendarClock aria-hidden="true" size={18} /></span>
<div>
<h2>{activeSchedule.name ?? 'Unnamed schedule'}</h2>
<p>
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs <code>{totalDurationEstimate ?? 'unknown'}</code>
</p>
</div>
<Select
disabled={mutating}
label="Active schedule"
onChange={(event) => setActiveSchedule(Number(event.target.value))}
options={scheduleOptions(schedules)}
size="sm"
value={`${activeSchedule.id}`}
/>
<Button disabled startIcon={<Play aria-hidden="true" size={15} />} variant="secondary">Preview playout</Button>
</section>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
<div className="ctv-schedule-grid">
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
<div className="ctv-schedule-panel-head">
<span>Lineup · drag to reorder</span>
{itemsLoading && <Spinner size={13} tone="muted" />}
<Select
disabled={mutating || collectionOptions.length === 0}
label="Collection for new item"
onChange={(event) => setSelectedCollectionId(Number(event.target.value))}
options={pickerOptions(collectionOptions)}
size="sm"
value={`${effectiveCollectionId ?? ''}`}
/>
<Button disabled={mutating} onClick={addItem} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="ghost">Add item</Button>
</div>
{orderedItems.length === 0 ? (
<div className="ctv-schedule-empty">No schedule items</div>
) : (
<div className="ctv-schedule-lineup" role="list" aria-label="Schedule lineup">
{orderedItems.map((item, index) => (
<ScheduleItemBlock
active={item.id === selectedItem?.id}
dragging={item.id === dragItemId}
isFirst={index === 0}
isLast={index === orderedItems.length - 1}
isOver={item.id === overItemId && dragItemId != null && dragItemId !== item.id}
item={item}
key={item.id ?? index}
mutating={mutating}
onDelete={deleteItem}
onDrop={dropItem}
onMove={moveItem}
onOver={setOverItemId}
onSelect={setSelectedItemId}
onStartDrag={setDragItemId}
/>
))}
</div>
)}
</section>
<ScheduleInspector
item={selectedItem}
itemIndex={selectedIndex}
mutating={mutating}
onDelete={deleteItem}
pickers={pickers}
schedule={activeSchedule}
tab={inspectorTab}
onTabChange={(value) => setInspectorTab(value as ScheduleInspectorTab)}
/>
</div>
</div>
);
}
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<HTMLDivElement>) => {
event.preventDefault();
onOver(itemId);
};
return (
<div className="ctv-schedule-lineup-row" role="listitem">
<div className="ctv-schedule-rail" aria-hidden="true">
<span className={fixed ? 'ctv-schedule-rail-dot-fixed' : 'ctv-schedule-rail-dot'} />
<code>{fixed ? formatScheduleTime(item.startTime) : 'flows'}</code>
</div>
<div
aria-roledescription="draggable schedule item"
className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}${dragging ? ' ctv-schedule-block-dragging' : ''}${isOver ? ' ctv-schedule-block-over' : ''}`}
draggable={!mutating}
onDragEnd={() => {
onStartDrag(null);
onOver(null);
}}
onDragOver={onDragOver}
onDragStart={() => onStartDrag(itemId)}
onDrop={() => {
if (itemId != null) {
onDrop(itemId);
}
}}
>
<button
type="button"
className="ctv-schedule-block-main"
aria-current={active ? 'true' : undefined}
onClick={() => onSelect(itemId)}
>
<GripVertical aria-hidden="true" size={16} />
<ChannelLogo name={name} size={38} />
<span className="ctv-schedule-block-title">
<strong>{name}</strong>
<small>{formatScheduleCollectionType(item.collectionType)}</small>
</span>
<span className="ctv-schedule-fill-chip">
{fill.icon}
<code>{fill.label}</code>
</span>
</button>
<div className="ctv-schedule-block-meta">
<span><ArrowDownWideNarrow aria-hidden="true" size={13} />{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</span>
{filler && <Badge tone="neutral">Hidden from guide</Badge>}
<span className="ctv-schedule-block-duration">{item.durationEstimate ?? 'unknown'}</span>
</div>
<div className="ctv-schedule-block-actions">
<IconButton disabled={mutating || isFirst || itemId == null} onClick={() => itemId != null && onMove(itemId, -1)} size="sm" title={`Move ${name} up`}>
<ChevronDown className="ctv-schedule-up-icon" aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={mutating || isLast || itemId == null} onClick={() => itemId != null && onMove(itemId, 1)} size="sm" title={`Move ${name} down`}>
<ChevronDown aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled={mutating || itemId == null} onClick={() => onDelete(item)} size="sm" title={`Remove ${name}`}>
<Trash2 aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
</div>
);
}
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 (
<section className="ctv-schedule-inspector">
<div className="ctv-schedule-empty">Select or add a schedule item</div>
</section>
);
}
const name = scheduleItemName(item);
const fill = scheduleFillDescriptor(item);
return (
<section className="ctv-schedule-inspector" aria-label="Schedule item inspector">
<div className="ctv-schedule-inspector-head">
<ChannelLogo name={name} size={34} />
<div>
<strong>{name}</strong>
<span>Item {itemIndex + 1} · {formatScheduleCollectionType(item.collectionType)}</span>
</div>
<IconButton disabled={mutating || item.id == null} onClick={() => onDelete(item)} title="Remove selected item">
<Trash2 aria-hidden="true" size={15} />
</IconButton>
</div>
<div className="ctv-schedule-inspector-badges">
<Badge tone={item.startType === 'Fixed' ? 'accent' : 'neutral'} dot>{item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : 'Dynamic'}</Badge>
<Badge tone="neutral">{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</Badge>
<Badge tone="neutral">{fill.label}</Badge>
</div>
<Tabs
value={tab}
onChange={onTabChange}
tabs={[
{ value: 'content', label: 'Content' },
{ value: 'playback', label: 'Playback' },
{ value: 'filler', label: 'Filler' },
{ value: 'overrides', label: 'Overrides' }
]}
/>
<div className="ctv-schedule-inspector-body">
{tab === 'content' && (
<>
<div className="ctv-schedule-form-grid">
<Select disabled label="Start type" value={item.startType ?? 'Dynamic'} options={['Dynamic', 'Fixed']} />
<Input disabled label="Start time" value={item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : ''} placeholder="dynamic" />
</div>
<Select disabled label="Collection type" value={item.collectionType ?? 'Collection'} options={[item.collectionType ?? 'Collection']} />
<Select
disabled
label="Collection"
value={`${collectionIdForItem(item) ?? ''}`}
options={pickerOptions(pickers.collections)}
/>
<Input disabled label="Custom title" value={item.customTitle ?? ''} placeholder="Optional guide title" />
<div className="ctv-schedule-form-grid">
<Select disabled label="Guide mode" value={item.guideMode ?? 'Normal'} options={['Normal', 'Filler']} />
<Input disabled label="Schedule" value={schedule.name ?? 'Unnamed schedule'} />
</div>
<Checkbox disabled checked={schedule.keepMultiPartEpisodesTogether} label="Keep multi-part together" />
</>
)}
{tab === 'playback' && (
<>
<Select disabled label="Playback order" value={item.playbackOrder ?? 'Shuffle'} options={[item.playbackOrder ?? 'Shuffle']} />
<Select disabled label="Playout mode" value={item.playoutMode ?? 'One'} options={['Flood', 'One', 'Multiple', 'Duration']} />
<div className="ctv-schedule-form-grid">
<Input disabled label="Multiple count" value={item.multipleCount ?? item.count ?? ''} placeholder="n/a" />
<Input disabled label="Duration estimate" value={item.durationEstimate ?? 'unknown'} />
</div>
<div className="ctv-schedule-note">
{fill.icon}
<span>{playbackExplanation(item)}</span>
</div>
</>
)}
{tab === 'filler' && (
<div className="ctv-schedule-form-grid">
<Select disabled label="Pre-roll" value={item.preRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Mid-roll" value={item.midRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Post-roll" value={item.postRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Fallback" value={item.fallbackFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Tail" value={item.tailFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
</div>
)}
{tab === 'overrides' && (
<div className="ctv-schedule-form-grid">
<Select disabled label="Watermarks" value={item.watermarks?.[0]?.name ?? 'Inherit'} options={['Inherit', ...namedPickerLabels(pickers.watermarks)]} />
<Select disabled label="Subtitle mode" value={item.subtitleMode ?? 'Inherit'} options={['Inherit', 'None', 'Any', 'Forced', 'Default']} />
<Input disabled label="Preferred audio" value={item.preferredAudioLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
<Input disabled label="Preferred subtitle" value={item.preferredSubtitleLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
</div>
)}
</div>
</section>
);
}
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: <GripVertical aria-hidden="true" size={13} />, label: 'Fills to next' };
}
if (item.playoutMode === 'Multiple') {
return { icon: <Copy aria-hidden="true" size={13} />, label: `x${item.multipleCount ?? item.count ?? '?'}` };
}
if (item.playoutMode === 'Duration') {
return { icon: <Timer aria-hidden="true" size={13} />, label: item.durationEstimate ?? 'Duration' };
}
return { icon: <Shuffle aria-hidden="true" size={13} />, label: '1 item' };
}
function playbackExplanation(item: ProgramScheduleItem): string {
if (item.playoutMode === 'Flood') {
return 'Plays until the next fixed-start item.';
}
if (item.playoutMode === 'Multiple') {
return `Plays ${item.multipleCount ?? item.count ?? 'multiple'} items, then advances.`;
}
if (item.playoutMode === 'Duration') {
return 'Plays for a fixed duration.';
}
return 'Plays exactly one item, then advances.';
}
function formatScheduleTime(value: string | null | undefined): string {
return value?.slice(0, 5) ?? 'dynamic';
}
function formatScheduleEnum(value: string): string {
return value.replace(/([a-z])([A-Z])/g, '$1 $2');
}
function formatScheduleCollectionType(value: string | undefined): string {
return formatScheduleEnum(value ?? 'Collection');
}
const EPG_SLOT_MINUTES = 30;
const EPG_SLOT_WIDTH = 156;
const EPG_RAIL_WIDTH = 196;
@@ -3746,18 +3195,6 @@ function formatDailyRebuild(value: string | null | undefined): string {
return value ? value.slice(0, 5) : 'manual';
}
function pickerOptions(items: MediaCollection[]): Array<{ label: string; value: string }> {
return items.map((item) => ({ label: item.name ?? `Collection ${item.id}`, value: `${item.id}` }));
}
function namedPickerLabels(items: unknown[]): string[] {
return items.map((item) => (item as { name?: null | string }).name ?? 'Unnamed');
}
function fillerOptions(items: unknown[]): string[] {
return ['None', ...namedPickerLabels(items)];
}
function PlaceholderScreen({ route }: { route: ScreenRoute }) {
return (
<div className="ctv-screen-stack">
@@ -3902,7 +3339,7 @@ function ScreenContent({
}
if (route.id === 'schedules') {
return <ScheduleScreen />;
return <SchedulesScreen />;
}
if (route.id === 'blocks') {
@@ -4030,6 +3467,12 @@ export function App() {
return;
}
// A screen with unsaved changes may veto navigation (e.g. the schedules editor prompts via
// navigationGuard). See docs/spa-conventions.md §8.
if (!canLeaveCurrentScreen()) {
return;
}
window.history.pushState(null, '', routeHref(nextRoute));
setActiveRoute(nextRoute);
};
+75 -1
View File
@@ -1,5 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels';
import {
createChannel,
getChannelById,
getChannelStreamSelectors,
getMusicVideoCreditsTemplates,
updateChannel,
type CreateChannelRequest,
type UpdateChannelRequest
} from './channels';
const sampleChannel = {
id: 5,
@@ -90,3 +98,69 @@ describe('updateChannel', () => {
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
});
});
describe('createChannel', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs the request body to /api/channels and returns the created view model', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleChannel), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
const body = { ...sampleChannel, id: undefined } as unknown as CreateChannelRequest;
await expect(createChannel(body)).resolves.toMatchObject({ id: 5 });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels');
expect((init?.method ?? '').toUpperCase()).toBe('POST');
});
});
describe('getMusicVideoCreditsTemplates', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/channels/music-video-credits-templates', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(['default']), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getMusicVideoCreditsTemplates()).resolves.toEqual(['default']);
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/music-video-credits-templates');
});
});
describe('getChannelStreamSelectors', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/channels/stream-selectors', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(['selector.py']), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getChannelStreamSelectors()).resolves.toEqual(['selector.py']);
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/channels/stream-selectors');
});
});
+16
View File
@@ -11,6 +11,7 @@ export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChan
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
export type CreateChannelRequest = components['schemas']['CreateChannelRequest'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
@@ -46,6 +47,21 @@ export function updateChannel(channelId: number, body: UpdateChannelRequest): Pr
});
}
export function createChannel(body: CreateChannelRequest): Promise<Channel> {
return request<Channel>('/api/channels', {
body,
method: 'POST'
});
}
export function getMusicVideoCreditsTemplates(): Promise<string[]> {
return request<string[]>('/api/channels/music-video-credits-templates');
}
export function getChannelStreamSelectors(): Promise<string[]> {
return request<string[]>('/api/channels/stream-selectors');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
+67 -119
View File
@@ -685,24 +685,7 @@ export interface components {
"FillerPresetResponseModel": {
"id": number;
"name": null | string;
};
"FillerPresetViewModel": {
"id": number;
"name": null | string;
"fillerKind": components["schemas"]["FillerKind"];
"fillerMode": components["schemas"]["FillerMode"];
"duration": null | string;
"count": null | number;
"padToNearestMinute": null | number;
"allowWatermarks": boolean;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"mediaItemId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"playlist": components["schemas"]["PlaylistViewModel"];
"expression": null | string;
"useChaptersAsMediaItems": boolean;
};
"FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups";
"FilterMode": "HardwareIfPossible" | "Software";
@@ -710,11 +693,6 @@ export interface components {
"GraphicsElementResponseModel": {
"id": number;
"name": null | string;
};
"GraphicsElementViewModel": {
"id": number;
"name": null | string;
"fileName": null | string;
};
"GuideMode": "Normal" | "Filler";
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
@@ -742,6 +720,10 @@ export interface components {
"subfolderCount": number;
"imageCount": number;
"durationSeconds": null | number;
};
"LanguageCodeResponseModel": {
"code": null | string;
"englishName": null | string;
};
"LibraryBrowseItemResponseModel": {
"id": number;
@@ -890,33 +872,15 @@ export interface components {
"name": string;
"scheduleAsGroup": boolean;
"playbackOrder": components["schemas"]["PlaybackOrder"];
};
"MultiCollectionItemViewModel": {
"multiCollectionId": number;
"collection": components["schemas"]["MediaCollectionViewModel"];
"scheduleAsGroup": boolean;
"playbackOrder": components["schemas"]["PlaybackOrder"];
};
"MultiCollectionResponseModel": {
"id": number;
"name": string;
"items": Array<components["schemas"]["MultiCollectionItemResponseModel"]>;
};
"MultiCollectionSmartItemViewModel": {
"multiCollectionId": number;
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
"scheduleAsGroup": boolean;
"playbackOrder": components["schemas"]["PlaybackOrder"];
};
"MultiCollectionViewModel": {
"id": number;
"name": null | string;
"items": null | Array<components["schemas"]["MultiCollectionItemViewModel"]>;
"smartItems": null | Array<components["schemas"]["MultiCollectionSmartItemViewModel"]>;
};
"MultipleMode": "Count" | "CollectionSize" | "PlaylistItemSize" | "MultiEpisodeGroupSize";
"NamedMediaItemViewModel": {
"mediaItemId": number;
"NamedIdResponseModel": {
"id": number;
"name": null | string;
};
"NormalizeLoudnessMode": "Off" | "LoudNorm";
@@ -1000,12 +964,6 @@ export interface components {
"playlistGroupId": number;
"name": string;
"isSystem": boolean;
};
"PlaylistViewModel": {
"id": number;
"playlistGroupId": number;
"name": null | string;
"isSystem": boolean;
};
"PlayoutAlternateScheduleItemRequest": {
"id": number;
@@ -1140,48 +1098,6 @@ export interface components {
"status"?: null | number;
"detail"?: null | string;
"instance"?: null | string;
};
"ProgramScheduleItemsWithDurationViewModel": {
"items": null | Array<components["schemas"]["ProgramScheduleItemViewModel"]>;
"totalDurationEstimate": null | string;
};
"ProgramScheduleItemViewModel": {
"id"?: number;
"index"?: number;
"startType"?: components["schemas"]["StartType"];
"startTime"?: null | string;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
"playoutMode"?: components["schemas"]["PlayoutMode"];
"collectionType"?: components["schemas"]["CollectionType"];
"collection"?: components["schemas"]["MediaCollectionViewModel"];
"multiCollection"?: components["schemas"]["MultiCollectionViewModel"];
"smartCollection"?: components["schemas"]["SmartCollectionViewModel"];
"rerunCollection"?: components["schemas"]["RerunCollectionViewModel"];
"playlist"?: components["schemas"]["PlaylistViewModel"];
"mediaItem"?: components["schemas"]["NamedMediaItemViewModel"];
"searchTitle"?: null | string;
"searchQuery"?: null | string;
"playbackOrder"?: components["schemas"]["PlaybackOrder"];
"marathonGroupBy"?: components["schemas"]["MarathonGroupBy"];
"marathonShuffleGroups"?: boolean;
"marathonShuffleItems"?: boolean;
"marathonBatchSize"?: null | number;
"fillWithGroupMode"?: components["schemas"]["FillWithGroupMode"];
"customTitle"?: null | string;
"guideMode"?: components["schemas"]["GuideMode"];
"preRollFiller"?: components["schemas"]["FillerPresetViewModel"];
"midRollFiller"?: components["schemas"]["FillerPresetViewModel"];
"postRollFiller"?: components["schemas"]["FillerPresetViewModel"];
"tailFiller"?: components["schemas"]["FillerPresetViewModel"];
"fallbackFiller"?: components["schemas"]["FillerPresetViewModel"];
"watermarks"?: null | Array<components["schemas"]["WatermarkViewModel"]>;
"graphicsElements"?: null | Array<components["schemas"]["GraphicsElementViewModel"]>;
"preferredAudioLanguageCode"?: null | string;
"preferredAudioTitle"?: null | string;
"preferredSubtitleLanguageCode"?: null | string;
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
"durationEstimate"?: null | string;
"name"?: null | string;
};
"ProgramScheduleViewModel": {
"id": number;
@@ -1251,17 +1167,6 @@ export interface components {
"selectedName": null | string;
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
};
"RerunCollectionViewModel": {
"id": number;
"name": null | string;
"collectionType": components["schemas"]["CollectionType"];
"collection": components["schemas"]["MediaCollectionViewModel"];
"multiCollection": components["schemas"]["MultiCollectionViewModel"];
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
"mediaItem": components["schemas"]["NamedMediaItemViewModel"];
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
};
"ResolutionResponseModel": {
"id": number;
@@ -1323,6 +1228,67 @@ export interface components {
"preferredAudioTitle": null | string;
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
};
"ScheduleItemResponseModel": {
"id": number;
"index": number;
"startType": components["schemas"]["StartType"];
"startTime": null | string;
"fixedStartTimeBehavior": null | components["schemas"]["FixedStartTimeBehavior"];
"playoutMode": components["schemas"]["PlayoutMode"];
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"rerunCollectionId": null | number;
"mediaItemId": null | number;
"playlistId": null | number;
"searchTitle": null | string;
"searchQuery": null | string;
"playbackOrder": components["schemas"]["PlaybackOrder"];
"marathonGroupBy": components["schemas"]["MarathonGroupBy"];
"marathonShuffleGroups": boolean;
"marathonShuffleItems": boolean;
"marathonBatchSize": null | number;
"fillWithGroupMode": components["schemas"]["FillWithGroupMode"];
"multipleMode": null | components["schemas"]["MultipleMode"];
"multipleCount": null | string;
"playoutDuration": null | string;
"tailMode": null | components["schemas"]["TailMode"];
"discardToFillAttempts": null | number;
"customTitle": null | string;
"guideMode": components["schemas"]["GuideMode"];
"preRollFillerId": null | number;
"midRollFillerId": null | number;
"postRollFillerId": null | number;
"tailFillerId": null | number;
"fallbackFillerId": null | number;
"watermarkIds": Array<number>;
"graphicsElementIds": Array<number>;
"preferredAudioLanguageCode": null | string;
"preferredAudioTitle": null | string;
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
"collectionName": null | string;
"multiCollectionName": null | string;
"smartCollectionName": null | string;
"rerunCollectionName": null | string;
"playlistName": null | string;
"playlistGroupId": null | number;
"mediaItemName": null | string;
"preRollFillerName": null | string;
"midRollFillerName": null | string;
"postRollFillerName": null | string;
"tailFillerName": null | string;
"fallbackFillerName": null | string;
"watermarks": Array<components["schemas"]["NamedIdResponseModel"]>;
"graphicsElements": Array<components["schemas"]["NamedIdResponseModel"]>;
"name": null | string;
"durationEstimate": null | string;
};
"ScheduleItemsResponseModel": {
"items": Array<components["schemas"]["ScheduleItemResponseModel"]>;
"totalDurationEstimate": null | string;
};
"SchedulingPickerOptionResponseModel": {
"id": number;
@@ -1713,24 +1679,6 @@ export interface components {
"name": null | string;
};
"WatermarkSize": "Scaled" | "ActualSize";
"WatermarkViewModel": {
"id": number;
"image": components["schemas"]["ArtworkContentTypeModel"];
"name": null | string;
"mode": components["schemas"]["ChannelWatermarkMode"];
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
"location": components["schemas"]["WatermarkLocation"];
"size": components["schemas"]["WatermarkSize"];
"width": number;
"horizontalMargin": number;
"verticalMargin": number;
"frequencyMinutes": number;
"durationSeconds": number;
"opacity": number;
"placeWithinSourceContent": boolean;
"opacityExpression": null | string;
"zIndex": number;
};
"XmltvBlockBehavior": "SplitTimeEvenly" | "UseActualTimes";
"XmltvSettingsResponseModel": {
"daysToBuild": number;
+1
View File
@@ -12,6 +12,7 @@ export * from './ffmpegProfiles';
export * from './fillerPresets';
export * from './guide';
export * from './imageFolders';
export * from './languages';
export * from './libraries';
export * from './libraryBrowse';
export * from './logs';
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getLanguages } from './languages';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
describe('getLanguages', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/languages and returns the list', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse([{ code: 'eng', englishName: 'English' }])
);
await expect(getLanguages()).resolves.toEqual([{ code: 'eng', englishName: 'English' }]);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/languages');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ status: 500, title: 'Internal Server Error' }, 500)
);
await expect(getLanguages()).rejects.toMatchObject({ status: 500 });
});
});
+20
View File
@@ -0,0 +1,20 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type LanguageCode = components['schemas']['LanguageCodeResponseModel'];
export function getLanguages(): Promise<LanguageCode[]> {
return request<LanguageCode[]>('/api/languages');
}
export function messageFromLanguagesError(error: unknown, fallback = 'Unable to load languages'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+49 -247
View File
@@ -1,277 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import { getCollections, getSmartCollections, type MediaCollection, type SmartCollection } from './collections';
import { getFillerPresets, getWatermarks } from './pickers';
import type { FillerPreset, Watermark } from './pickers';
// FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *`
// name in api/index.ts. LanguageCode/getLanguages moved to ./languages (also re-exported via
// api/index.ts) and are no longer defined here — see docs/decisions.md 2026-07-11 (#212).
import type { FillerPreset } from './pickers';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
// once the schema is fixed to describe the concrete subtypes.
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
count?: null | string;
discardToFillAttempts?: null | number;
multipleCount?: null | string;
multipleMode?: components['schemas']['MultipleMode'];
playoutDuration?: null | string;
tailMode?: components['schemas']['TailMode'];
};
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
// The schedule item DTO is FLAT (issue #126/#207): every subtype/mutation field is a nullable
// top-level member, and its mutation fields are named 1:1 with ScheduleItemRequest so a loaded
// item maps back to a PUT/POST body by a straight field copy. See docs/decisions.md 2026-07-10.
export type ScheduleItem = components['schemas']['ScheduleItemResponseModel'];
export type ScheduleItemsResponse = components['schemas']['ScheduleItemsResponseModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type { FillerPreset, Watermark };
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
export type CreateScheduleRequest = components['schemas']['CreateScheduleRequest'];
export type UpdateScheduleRequest = components['schemas']['UpdateScheduleRequest'];
export type NamedId = components['schemas']['NamedIdResponseModel'];
export type FillerKind = components['schemas']['FillerKind'];
export interface SchedulePickerData {
collections: MediaCollection[];
fillerPresets: FillerPreset[];
smartCollections: SmartCollection[];
watermarks: Watermark[];
}
export interface ScheduleScreenData {
activeSchedule: ProgramSchedule | null;
items: ProgramScheduleItem[];
pickers: SchedulePickerData;
schedules: ProgramSchedule[];
totalDurationEstimate: string | null;
}
export type ScheduleScreenQueryState =
| {
data: ScheduleScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActiveSchedule: (scheduleId: number) => void;
setItems: (items: ProgramScheduleItem[]) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ScheduleScreenState =
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// ---- Schedule CRUD -------------------------------------------------------
export function getSchedules(): Promise<ProgramSchedule[]> {
return request<ProgramSchedule[]>('/api/schedules');
}
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
export function getScheduleById(id: number): Promise<ProgramSchedule> {
return request<ProgramSchedule>(`/api/schedules/${id}`);
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'POST'
});
export function createSchedule(body: CreateScheduleRequest): Promise<ProgramSchedule> {
return request<ProgramSchedule>('/api/schedules', { body, method: 'POST' });
}
export function updateSchedule(id: number, body: UpdateScheduleRequest): Promise<ProgramSchedule> {
return request<ProgramSchedule>(`/api/schedules/${id}`, { body, method: 'PUT' });
}
export function deleteSchedule(id: number): Promise<void> {
return request<void>(`/api/schedules/${id}`, { method: 'DELETE' });
}
// ---- Schedule items ------------------------------------------------------
export function getScheduleItems(scheduleId: number): Promise<ScheduleItemsResponse> {
return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> {
return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' });
}
// Destructive replace: the server deletes+recreates every item row (new ids) and triggers playout
// rebuilds. The editor batches all local draft edits into this single call. See docs/decisions.md.
export function replaceScheduleItems(
scheduleId: number,
body: ReplaceScheduleItemsRequest
): Promise<ProgramScheduleItem[]> {
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT'
});
): Promise<ScheduleItem[]> {
return request<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, { body, method: 'PUT' });
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
method: 'DELETE'
});
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, { method: 'DELETE' });
}
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
const schedules = await getSchedules();
const activeSchedule = schedules[0] ?? null;
// ---- Pickers -------------------------------------------------------------
// getLanguages lives in ./languages (imported above); re-exported implicitly via api/index.ts.
if (!activeSchedule) {
return {
activeSchedule: null,
items: [],
pickers: emptyPickerData(),
schedules,
totalDurationEstimate: null
};
}
const [itemsEnvelope, pickers] = await Promise.all([
getScheduleItems(activeSchedule.id),
getSchedulePickerData()
]);
return {
activeSchedule,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
pickers,
schedules,
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
};
export function getFillerPresetsByKind(fillerKind: FillerKind): Promise<FillerPreset[]> {
return request<FillerPreset[]>(`/api/filler-presets?fillerKind=${encodeURIComponent(fillerKind)}`);
}
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
const [state, setState] = useState<ScheduleScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const activeScheduleIdRef = useRef<number | null>(null);
// The search-backed source pickers (searchTelevisionShows/…/searchMultiCollections) already live in
// ./blocks — the inspector imports them from '../api' directly.
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
setState({ data, error: null, itemsLoading: false, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActiveSchedule = useCallback((scheduleId: number) => {
activeScheduleIdRef.current = scheduleId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
?? current.data.activeSchedule;
return {
data: { ...current.data, activeSchedule: nextActiveSchedule },
error: null,
itemsLoading: true,
status: 'success'
};
});
getScheduleItems(scheduleId)
.then((itemsEnvelope) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState({
data: null,
error: messageFromScheduleError(error, 'Unable to load schedule items'),
status: 'error'
});
});
}, []);
const setItems = useCallback((items: ProgramScheduleItem[]) => {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items },
error: null,
itemsLoading: false,
status: 'success'
};
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActiveSchedule,
setItems,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
async function getSchedulePickerData(): Promise<SchedulePickerData> {
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
getCollections(),
getSmartCollections(),
getFillerPresets(),
getWatermarks()
]);
return {
collections,
fillerPresets,
smartCollections,
watermarks
};
}
function emptyPickerData(): SchedulePickerData {
return {
collections: [],
fillerPresets: [],
smartCollections: [],
watermarks: []
};
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
export function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { canLeaveCurrentScreen, registerNavigationGuard } from './navigationGuard';
afterEach(() => {
// ensure no guard leaks between tests
const unregister = registerNavigationGuard(() => true);
unregister();
});
describe('navigationGuard', () => {
it('allows navigation when no guard is registered', () => {
expect(canLeaveCurrentScreen()).toBe(true);
});
it('consults the registered guard', () => {
const guard = vi.fn(() => false);
const unregister = registerNavigationGuard(guard);
expect(canLeaveCurrentScreen()).toBe(false);
expect(guard).toHaveBeenCalledTimes(1);
unregister();
expect(canLeaveCurrentScreen()).toBe(true);
});
it('unregister only clears its own guard', () => {
const first = registerNavigationGuard(() => false);
const secondGuard = vi.fn(() => false);
registerNavigationGuard(secondGuard);
first(); // stale unregister must not clear the second guard
expect(canLeaveCurrentScreen()).toBe(false);
expect(secondGuard).toHaveBeenCalled();
});
});
+28
View File
@@ -0,0 +1,28 @@
// A tiny shared guard so a screen with unsaved changes can veto in-app navigation. A screen
// registers a predicate (returns true when it is safe to leave, e.g. after a confirm()); App.tsx's
// nav handler consults `canLeaveCurrentScreen()` before pushing a new route. Only one guard is
// active at a time (the mounted screen) — registering returns an unregister fn for cleanup.
//
// This does NOT cover full-page unloads (reload / close tab / external link) — a screen additionally
// installs its own `beforeunload` listener while dirty for that path.
type NavigationGuard = () => boolean;
let activeGuard: NavigationGuard | null = null;
export function registerNavigationGuard(guard: NavigationGuard): () => void {
activeGuard = guard;
return () => {
if (activeGuard === guard) {
activeGuard = null;
}
};
}
// Returns true when navigation may proceed. With no guard registered, always true.
export function canLeaveCurrentScreen(): boolean {
if (!activeGuard) {
return true;
}
return activeGuard();
}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Button, Checkbox, Dialog, Input, Select } from '../components';
import {
createSchedule,
type CreateScheduleRequest,
messageFromScheduleError,
type ProgramSchedule,
updateSchedule
} from '../api';
export interface ScheduleFormProps {
mode: 'create' | 'edit';
schedule: ProgramSchedule | null;
onSaved: (schedule: ProgramSchedule) => void;
onClose: () => void;
}
// Create / edit the six schedule properties. treatCollectionsAsShows is disabled (and the server
// coerces it false) unless keepMultiPartEpisodesTogether is on. Unique-name 422 is surfaced inline.
export function ScheduleForm({ mode, schedule, onSaved, onClose }: ScheduleFormProps) {
const [name, setName] = useState(schedule?.name ?? '');
const [keepMultiPart, setKeepMultiPart] = useState(schedule?.keepMultiPartEpisodesTogether ?? false);
const [treatAsShows, setTreatAsShows] = useState(schedule?.treatCollectionsAsShows ?? false);
const [shuffle, setShuffle] = useState(schedule?.shuffleScheduleItems ?? false);
const [randomStart, setRandomStart] = useState(schedule?.randomStartPoint ?? false);
const [fixedBehavior, setFixedBehavior] = useState<'Strict' | 'Flexible'>(schedule?.fixedStartTimeBehavior ?? 'Strict');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const submit = () => {
const trimmed = name.trim();
if (!trimmed) {
setError('Name is required');
return;
}
if (trimmed.length > 50) {
setError('Name must be 50 characters or fewer');
return;
}
const body: CreateScheduleRequest = {
name: trimmed,
keepMultiPartEpisodesTogether: keepMultiPart,
treatCollectionsAsShows: keepMultiPart ? treatAsShows : false,
shuffleScheduleItems: shuffle,
randomStartPoint: randomStart,
fixedStartTimeBehavior: fixedBehavior
};
setSaving(true);
setError(null);
const request = mode === 'create' ? createSchedule(body) : updateSchedule(schedule!.id, body);
request
.then((saved) => {
setSaving(false);
onSaved(saved);
})
.catch((err: unknown) => {
setSaving(false);
setError(messageFromScheduleError(err, 'Unable to save schedule'));
});
};
return (
<Dialog
open
onClose={onClose}
title={mode === 'create' ? 'Add schedule' : 'Edit schedule'}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
</>
}
>
<div className="ctv-schedule-form-stack">
<Input label="Name" value={name} error={error} onChange={(e) => setName(e.target.value)} />
<Checkbox
label="Keep multi-part episodes together"
checked={keepMultiPart}
onChange={(v) => {
setKeepMultiPart(v);
if (!v) {
setTreatAsShows(false);
}
}}
/>
<Checkbox
label="Treat collections as shows"
checked={treatAsShows}
disabled={!keepMultiPart}
onChange={setTreatAsShows}
/>
<Checkbox label="Shuffle schedule items" checked={shuffle} onChange={setShuffle} />
<p className="ctv-field-help">Shuffling disables fixed start times and flood mode.</p>
<Checkbox label="Random start point" checked={randomStart} onChange={setRandomStart} />
<Select
label="Fixed start time behavior"
value={fixedBehavior}
onChange={(e) => setFixedBehavior(e.target.value as 'Strict' | 'Flexible')}
options={['Strict', 'Flexible']}
/>
</div>
</Dialog>
);
}
+489
View File
@@ -0,0 +1,489 @@
import { useEffect, useRef, useState } from 'react';
import { Checkbox, Input, Select } from '../components';
import {
getPlaylists,
type FillerPreset,
type GraphicsElement,
type LanguageCode,
type Playlist,
type PlaylistGroup,
type RerunCollection,
type SchedulingPickerOption,
searchArtists,
searchCollections,
searchMultiCollections,
searchSmartCollections,
searchTelevisionSeasons,
searchTelevisionShows,
type Watermark
} from '../api';
import { MultiSelect, SearchPicker } from './pickers';
import {
applyCollectionTypeChange,
applyPlaybackOrderChange,
applyPlayoutModeChange,
COLLECTION_TYPE_OPTIONS,
type DraftItem,
durationFieldsEnabled,
fillWithGroupModeEligible,
FILL_WITH_GROUP_MODE_OPTIONS,
fixedBehaviorEnabled,
GUIDE_MODE_OPTIONS,
humanizeEnum,
MARATHON_GROUP_BY_OPTIONS,
marathonVisible,
multipleCountEnabled,
multipleModeEnabled,
multipleModeOptions,
type PlayoutMode,
playbackOrderLabel,
playbackOrderOptions,
playbackOrderSelectDisabled,
playoutModeOptions,
shufflePlaylistItemsVisible,
startTimeEnabled,
startTypeOptions,
SUBTITLE_MODE_OPTIONS,
TAIL_MODE_OPTIONS
} from './itemRules';
export interface SchedulePickerData {
rerunCollections: RerunCollection[];
playlistGroups: PlaylistGroup[];
watermarks: Watermark[];
graphicsElements: GraphicsElement[];
languages: LanguageCode[];
fillerPreRoll: FillerPreset[];
fillerMidRoll: FillerPreset[];
fillerPostRoll: FillerPreset[];
fillerTail: FillerPreset[];
fillerFallback: FillerPreset[];
}
const SEARCH_BY_TYPE: Partial<Record<DraftItem['collectionType'], (q: string) => Promise<SchedulingPickerOption[]>>> = {
Collection: searchCollections,
MultiCollection: searchMultiCollections,
SmartCollection: searchSmartCollections,
TelevisionShow: searchTelevisionShows,
TelevisionSeason: searchTelevisionSeasons,
Artist: searchArtists
};
function enumOptions(values: readonly string[]): Array<{ value: string; label: string }> {
return values.map((value) => ({ value, label: humanizeEnum(value) }));
}
function fillerOptions(presets: FillerPreset[]): Array<{ value: string; label: string }> {
return [{ value: '', label: '(none)' }, ...presets.map((p) => ({ value: `${p.id}`, label: p.name ?? `Filler ${p.id}` }))];
}
// Hours+minutes -> "hh:mm:00", clamped to [1 minute, 24 hours].
function toDurationString(hours: number, minutes: number): string {
const total = Math.min(Math.max(hours * 60 + minutes, 1), 24 * 60);
const h = Math.floor(total / 60);
const m = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
}
function parseDuration(value: null | string): { hours: number; minutes: number } {
if (!value) {
return { hours: 0, minutes: 0 };
}
const [h, m] = value.split(':');
return { hours: Number(h) || 0, minutes: Number(m) || 0 };
}
export interface ScheduleItemInspectorProps {
item: DraftItem;
shuffleScheduleItems: boolean;
pickers: SchedulePickerData;
// When true, the whole inspector is non-interactive (an items load is in flight/failed, or a Save
// is in flight) — interaction is suppressed so no edit is dropped by the Save .then (F4). The
// parent's mutate() also no-ops in this state; this is the matching visual/pointer block.
disabled?: boolean;
onChange: (next: DraftItem) => void;
}
export function ScheduleItemInspector({ item, shuffleScheduleItems, pickers, disabled, onChange }: ScheduleItemInspectorProps) {
const patch = (changes: Partial<DraftItem>) => onChange({ ...item, ...changes });
const duration = parseDuration(item.playoutDuration);
return (
<div
className="ctv-schedule-inspector-body"
aria-disabled={disabled || undefined}
style={disabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
>
<SourceSection item={item} onChange={onChange} pickers={pickers} />
<fieldset className="ctv-schedule-fieldset">
<legend>Start</legend>
<div className="ctv-schedule-form-grid">
<Select
label="Start type"
value={item.startType}
onChange={(e) => patch({ startType: e.target.value as DraftItem['startType'] })}
options={enumOptions(startTypeOptions(shuffleScheduleItems))}
/>
<Input
label="Start time"
type="time"
value={item.startTime ? item.startTime.slice(0, 5) : ''}
disabled={!startTimeEnabled(item)}
onChange={(e) => patch({ startTime: e.target.value ? `${e.target.value}:00` : null })}
/>
<Select
label="Fixed start behavior"
value={item.fixedStartTimeBehavior ?? ''}
disabled={!fixedBehaviorEnabled(item)}
onChange={(e) =>
patch({ fixedStartTimeBehavior: e.target.value ? (e.target.value as DraftItem['fixedStartTimeBehavior']) : null })
}
options={[{ value: '', label: 'Inherit' }, ...enumOptions(['Strict', 'Flexible'])]}
/>
</div>
</fieldset>
<fieldset className="ctv-schedule-fieldset">
<legend>Playback</legend>
<div className="ctv-schedule-form-grid">
<Select
label="Playback order"
value={item.playbackOrder}
disabled={playbackOrderSelectDisabled(item.collectionType)}
onChange={(e) => onChange(applyPlaybackOrderChange(item, e.target.value as DraftItem['playbackOrder']))}
options={playbackOrderOptions(item.collectionType).map((order) => ({
value: order,
label: playbackOrderLabel(order, item.collectionType)
}))}
/>
<Select
label="Playout mode"
value={item.playoutMode}
onChange={(e) => onChange(applyPlayoutModeChange(item, e.target.value as PlayoutMode))}
options={enumOptions(playoutModeOptions(shuffleScheduleItems))}
/>
</div>
{marathonVisible(item) && (
<div className="ctv-schedule-form-grid">
<Select
label="Marathon group by"
value={item.marathonGroupBy}
onChange={(e) => patch({ marathonGroupBy: e.target.value as DraftItem['marathonGroupBy'] })}
options={enumOptions(MARATHON_GROUP_BY_OPTIONS)}
/>
<Input
label="Batch size (empty plays all)"
type="number"
value={item.marathonBatchSize == null ? '' : `${item.marathonBatchSize}`}
onChange={(e) => patch({ marathonBatchSize: e.target.value ? Number(e.target.value) : null })}
/>
<Checkbox
label="Shuffle groups"
checked={item.marathonShuffleGroups}
onChange={(v) => patch({ marathonShuffleGroups: v })}
/>
<Checkbox
label="Shuffle items"
checked={item.marathonShuffleItems}
onChange={(v) => patch({ marathonShuffleItems: v })}
/>
</div>
)}
{shufflePlaylistItemsVisible(item) && (
<Checkbox
label="Shuffle Playlist Items"
checked={item.marathonShuffleGroups}
onChange={(v) => patch({ marathonShuffleGroups: v })}
/>
)}
<div className="ctv-schedule-form-grid">
<Select
label="Multiple mode"
value={item.multipleMode}
disabled={!multipleModeEnabled(item)}
onChange={(e) => patch({ multipleMode: e.target.value as DraftItem['multipleMode'] })}
options={enumOptions(multipleModeOptions(item.collectionType, item.playbackOrder))}
/>
<Input
label="Multiple count"
value={item.multipleCount ?? ''}
disabled={!multipleCountEnabled(item)}
placeholder="expression may use 'count' and 'random'"
onChange={(e) => patch({ multipleCount: e.target.value || null })}
/>
</div>
{durationFieldsEnabled(item) && (
<>
<div className="ctv-schedule-form-grid">
<Input
label="Duration (hours)"
type="number"
value={`${duration.hours}`}
onChange={(e) => patch({ playoutDuration: toDurationString(Number(e.target.value) || 0, duration.minutes) })}
/>
<Input
label="Duration (minutes)"
type="number"
value={`${duration.minutes}`}
onChange={(e) => patch({ playoutDuration: toDurationString(duration.hours, Number(e.target.value) || 0) })}
/>
</div>
<div className="ctv-schedule-form-grid">
<Select
label="Tail mode"
value={item.tailMode}
onChange={(e) => patch({ tailMode: e.target.value as DraftItem['tailMode'] })}
options={enumOptions(TAIL_MODE_OPTIONS)}
/>
<Input
label="Discard to fill attempts"
type="number"
value={item.discardToFillAttempts == null ? '' : `${item.discardToFillAttempts}`}
onChange={(e) => patch({ discardToFillAttempts: e.target.value ? Number(e.target.value) : 0 })}
/>
</div>
</>
)}
<Select
label="Fill with group mode"
value={item.fillWithGroupMode}
disabled={!fillWithGroupModeEligible(item)}
onChange={(e) => patch({ fillWithGroupMode: e.target.value as DraftItem['fillWithGroupMode'] })}
options={enumOptions(FILL_WITH_GROUP_MODE_OPTIONS)}
/>
</fieldset>
<fieldset className="ctv-schedule-fieldset">
<legend>Filler</legend>
<div className="ctv-schedule-form-grid">
<Select label="Pre-roll" value={`${item.preRollFillerId ?? ''}`} onChange={(e) => patch({ preRollFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerPreRoll)} />
<Select label="Mid-roll" value={`${item.midRollFillerId ?? ''}`} onChange={(e) => patch({ midRollFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerMidRoll)} />
<Select label="Post-roll" value={`${item.postRollFillerId ?? ''}`} onChange={(e) => patch({ postRollFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerPostRoll)} />
<Select label="Tail" value={`${item.tailFillerId ?? ''}`} onChange={(e) => patch({ tailFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerTail)} />
<Select label="Fallback" value={`${item.fallbackFillerId ?? ''}`} onChange={(e) => patch({ fallbackFillerId: e.target.value ? Number(e.target.value) : null })} options={fillerOptions(pickers.fillerFallback)} />
</div>
</fieldset>
<fieldset className="ctv-schedule-fieldset">
<legend>Overrides</legend>
<Input label="Custom title" value={item.customTitle ?? ''} placeholder="Optional guide title" onChange={(e) => patch({ customTitle: e.target.value || null })} />
<Select
label="Guide mode"
value={item.guideMode}
onChange={(e) => patch({ guideMode: e.target.value as DraftItem['guideMode'] })}
options={enumOptions(GUIDE_MODE_OPTIONS)}
/>
<MultiSelect
label="Watermarks"
options={pickers.watermarks.map((w) => ({ id: w.id, name: w.name ?? `Watermark ${w.id}` }))}
selectedIds={item.watermarkIds ?? []}
onChange={(ids) => patch({ watermarkIds: ids })}
/>
<MultiSelect
label="Graphics elements"
options={pickers.graphicsElements.map((g) => ({ id: g.id, name: g.name ?? `Graphics ${g.id}` }))}
selectedIds={item.graphicsElementIds ?? []}
onChange={(ids) => patch({ graphicsElementIds: ids })}
/>
<div className="ctv-schedule-form-grid">
<Select
label="Preferred audio language"
value={item.preferredAudioLanguageCode ?? ''}
onChange={(e) => patch({ preferredAudioLanguageCode: e.target.value || null })}
options={[{ value: '', label: '(none)' }, ...pickers.languages.map((l) => ({ value: l.code ?? '', label: l.englishName ?? l.code ?? '' }))]}
/>
<Input label="Preferred audio title" value={item.preferredAudioTitle ?? ''} onChange={(e) => patch({ preferredAudioTitle: e.target.value || null })} />
</div>
<div className="ctv-schedule-form-grid">
<Select
label="Preferred subtitle language"
value={item.preferredSubtitleLanguageCode ?? ''}
onChange={(e) => patch({ preferredSubtitleLanguageCode: e.target.value || null })}
options={[{ value: '', label: '(none)' }, ...pickers.languages.map((l) => ({ value: l.code ?? '', label: l.englishName ?? l.code ?? '' }))]}
/>
<Select
label="Subtitle mode"
value={item.subtitleMode ?? ''}
onChange={(e) => patch({ subtitleMode: e.target.value ? (e.target.value as DraftItem['subtitleMode']) : null })}
options={SUBTITLE_MODE_OPTIONS.map((mode) => ({ value: mode ?? '', label: mode == null ? 'Inherit' : mode }))}
/>
</div>
</fieldset>
</div>
);
}
// Collection type + the source picker that varies by type.
function SourceSection({
item,
onChange,
pickers
}: {
item: DraftItem;
onChange: (next: DraftItem) => void;
pickers: SchedulePickerData;
}) {
return (
<fieldset className="ctv-schedule-fieldset">
<legend>Source</legend>
<Select
label="Collection type"
value={item.collectionType}
onChange={(e) => onChange(applyCollectionTypeChange(item, e.target.value as DraftItem['collectionType']))}
options={enumOptions(COLLECTION_TYPE_OPTIONS)}
/>
<SourcePicker item={item} onChange={onChange} pickers={pickers} />
</fieldset>
);
}
function SourcePicker({
item,
onChange,
pickers
}: {
item: DraftItem;
onChange: (next: DraftItem) => void;
pickers: SchedulePickerData;
}) {
const type = item.collectionType;
if (type === 'SearchQuery') {
return (
<>
<Input label="Search title" value={item.searchTitle ?? ''} onChange={(e) => onChange({ ...item, searchTitle: e.target.value || null })} />
<Input label="Search query" value={item.searchQuery ?? ''} onChange={(e) => onChange({ ...item, searchQuery: e.target.value || null })} />
</>
);
}
if (type === 'Playlist') {
return <PlaylistPicker item={item} onChange={onChange} groups={pickers.playlistGroups} />;
}
if (type === 'RerunFirstRun' || type === 'RerunRerun') {
return (
<Select
label="Rerun collection"
value={`${item.rerunCollectionId ?? ''}`}
onChange={(e) => {
const id = e.target.value ? Number(e.target.value) : null;
const name = pickers.rerunCollections.find((c) => c.id === id)?.name ?? null;
onChange({ ...item, rerunCollectionId: id, rerunCollectionName: name });
}}
options={[{ value: '', label: '(none)' }, ...pickers.rerunCollections.map((c) => ({ value: `${c.id}`, label: c.name ?? `Rerun ${c.id}` }))]}
/>
);
}
const search = SEARCH_BY_TYPE[type];
if (!search) {
return null;
}
// Media-item-backed types store mediaItemId; collection-backed store the matching id field.
const isMediaItem = type === 'TelevisionShow' || type === 'TelevisionSeason' || type === 'Artist';
const selectedId = isMediaItem
? item.mediaItemId
: type === 'Collection'
? item.collectionId
: type === 'MultiCollection'
? item.multiCollectionId
: item.smartCollectionId;
const selectedName = isMediaItem
? item.mediaItemName
: type === 'Collection'
? item.collectionName
: type === 'MultiCollection'
? item.multiCollectionName
: item.smartCollectionName;
const setSelection = (id: null | number, name: null | string) => {
if (isMediaItem) {
onChange({ ...item, mediaItemId: id, mediaItemName: name });
} else if (type === 'Collection') {
onChange({ ...item, collectionId: id, collectionName: name });
} else if (type === 'MultiCollection') {
onChange({ ...item, multiCollectionId: id, multiCollectionName: name });
} else {
onChange({ ...item, smartCollectionId: id, smartCollectionName: name });
}
};
return (
<SearchPicker
label={humanizeEnum(type)}
selectedId={selectedId ?? null}
selectedName={selectedName ?? null}
search={search}
onSelect={(id, name) => setSelection(id, name)}
onClear={() => setSelection(null, null)}
/>
);
}
function PlaylistPicker({
item,
onChange,
groups
}: {
item: DraftItem;
onChange: (next: DraftItem) => void;
groups: PlaylistGroup[];
}) {
const [groupId, setGroupId] = useState<null | number>(item.playlistGroupId ?? null);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const seqRef = useRef(0);
// Fetch playlists for the selected group. When no group is selected the Select is disabled, so a
// stale list is harmless and we avoid a synchronous setState in the effect body (spa-conventions §3).
useEffect(() => {
if (groupId == null) {
return;
}
const seq = ++seqRef.current;
getPlaylists(groupId)
.then((items) => {
if (seqRef.current === seq) {
setPlaylists(items);
}
})
.catch(() => {
if (seqRef.current === seq) {
setPlaylists([]);
}
});
}, [groupId]);
return (
<div className="ctv-schedule-form-grid">
<Select
label="Playlist group"
value={`${groupId ?? ''}`}
onChange={(e) => {
const nextGroup = e.target.value ? Number(e.target.value) : null;
setGroupId(nextGroup);
onChange({ ...item, playlistGroupId: nextGroup, playlistId: null, playlistName: null });
}}
options={[{ value: '', label: '(select group)' }, ...groups.map((g) => ({ value: `${g.id}`, label: g.name ?? `Group ${g.id}` }))]}
/>
<Select
label="Playlist"
value={`${item.playlistId ?? ''}`}
disabled={groupId == null}
onChange={(e) => {
const id = e.target.value ? Number(e.target.value) : null;
const name = playlists.find((p) => p.id === id)?.name ?? null;
onChange({ ...item, playlistId: id, playlistName: name });
}}
options={[{ value: '', label: '(none)' }, ...playlists.map((p) => ({ value: `${p.id}`, label: p.name ?? `Playlist ${p.id}` }))]}
/>
</div>
);
}
+528
View File
@@ -0,0 +1,528 @@
import { describe, expect, it } from 'vitest';
import {
applyCollectionTypeChange,
applyPlaybackOrderChange,
applyPlayoutModeChange,
copyDraftItem,
type DraftItem,
durationFieldsEnabled,
fillWithGroupModeEligible,
fixedBehaviorEnabled,
fromResponse,
multipleCountEnabled,
multipleModeEnabled,
multipleModeOptions,
newDraftItem,
normalizeForSave,
playbackOrderLabel,
playbackOrderOptions,
playbackOrderSelectDisabled,
playoutModeOptions,
type ScheduleItem,
shufflePlaylistItemsVisible,
startTimeEnabled,
startTypeOptions,
validateDraft,
validateItem
} from './itemRules';
function draft(overrides: Partial<DraftItem> = {}): DraftItem {
return { ...newDraftItem(), ...overrides };
}
describe('playbackOrderOptions', () => {
it('MultiCollection offers Shuffle + ShuffleInOrder only', () => {
expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder']);
});
it('Collection / SmartCollection / SearchQuery offer the collection-like set incl. Marathon', () => {
const expected = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
expect(playbackOrderOptions('Collection')).toEqual(expected);
expect(playbackOrderOptions('SmartCollection')).toEqual(expected);
expect(playbackOrderOptions('SearchQuery')).toEqual(expected);
});
it('TelevisionShow offers Chronological/SeasonEpisode/Random/Shuffle/MultiEpisodeShuffle', () => {
expect(playbackOrderOptions('TelevisionShow')).toEqual([
'Chronological',
'SeasonEpisode',
'Random',
'Shuffle',
'MultiEpisodeShuffle'
]);
});
it('TelevisionSeason / Artist offer Chronological/Random/Shuffle', () => {
expect(playbackOrderOptions('TelevisionSeason')).toEqual(['Chronological', 'Random', 'Shuffle']);
expect(playbackOrderOptions('Artist')).toEqual(['Chronological', 'Random', 'Shuffle']);
});
it('Playlist / Rerun types offer only None', () => {
expect(playbackOrderOptions('Playlist')).toEqual(['None']);
expect(playbackOrderOptions('RerunFirstRun')).toEqual(['None']);
expect(playbackOrderOptions('RerunRerun')).toEqual(['None']);
});
it('never offers RandomRotation', () => {
const allTypes: DraftItem['collectionType'][] = [
'Collection',
'TelevisionShow',
'TelevisionSeason',
'Artist',
'MultiCollection',
'SmartCollection',
'Playlist',
'RerunFirstRun',
'RerunRerun',
'SearchQuery'
];
for (const t of allTypes) {
expect(playbackOrderOptions(t)).not.toContain('RandomRotation');
}
});
});
describe('playbackOrder selector disable + label', () => {
it('disabled for Playlist and Rerun types only', () => {
expect(playbackOrderSelectDisabled('Playlist')).toBe(true);
expect(playbackOrderSelectDisabled('RerunFirstRun')).toBe(true);
expect(playbackOrderSelectDisabled('RerunRerun')).toBe(true);
expect(playbackOrderSelectDisabled('Collection')).toBe(false);
expect(playbackOrderSelectDisabled('MultiCollection')).toBe(false);
});
it('labels None as "Playlist" for a Playlist type, plain otherwise', () => {
expect(playbackOrderLabel('None', 'Playlist')).toBe('Playlist');
expect(playbackOrderLabel('None', 'RerunFirstRun')).toBe('None');
expect(playbackOrderLabel('MultiEpisodeShuffle', 'TelevisionShow')).toBe('Multi Episode Shuffle');
});
});
describe('start + playout mode option lists gate on shuffleScheduleItems', () => {
it('hides Fixed start type when the schedule shuffles items', () => {
expect(startTypeOptions(false)).toEqual(['Dynamic', 'Fixed']);
expect(startTypeOptions(true)).toEqual(['Dynamic']);
});
it('hides Flood playout mode when the schedule shuffles items', () => {
expect(playoutModeOptions(false)).toEqual(['Flood', 'One', 'Multiple', 'Duration']);
expect(playoutModeOptions(true)).toEqual(['One', 'Multiple', 'Duration']);
});
});
describe('multipleModeOptions', () => {
it('always offers Count; CollectionSize for non-Playlist, PlaylistItemSize for Playlist', () => {
expect(multipleModeOptions('Collection', 'Shuffle')).toEqual(['Count', 'CollectionSize']);
expect(multipleModeOptions('Playlist', 'None')).toEqual(['Count', 'PlaylistItemSize']);
});
it('adds MultiEpisodeGroupSize only for Chronological order', () => {
expect(multipleModeOptions('Collection', 'Chronological')).toEqual([
'Count',
'CollectionSize',
'MultiEpisodeGroupSize'
]);
expect(multipleModeOptions('Collection', 'Random')).not.toContain('MultiEpisodeGroupSize');
});
});
describe('enable gates', () => {
it('start time + fixed behavior enabled only for Fixed', () => {
expect(startTimeEnabled(draft({ startType: 'Fixed' }))).toBe(true);
expect(startTimeEnabled(draft({ startType: 'Dynamic' }))).toBe(false);
expect(fixedBehaviorEnabled(draft({ startType: 'Fixed' }))).toBe(true);
expect(fixedBehaviorEnabled(draft({ startType: 'Dynamic' }))).toBe(false);
});
it('multipleMode enabled only for Multiple; multipleCount only for Multiple+Count', () => {
expect(multipleModeEnabled(draft({ playoutMode: 'Multiple' }))).toBe(true);
expect(multipleModeEnabled(draft({ playoutMode: 'One' }))).toBe(false);
expect(multipleCountEnabled(draft({ playoutMode: 'Multiple', multipleMode: 'Count' }))).toBe(true);
expect(multipleCountEnabled(draft({ playoutMode: 'Multiple', multipleMode: 'CollectionSize' }))).toBe(false);
expect(multipleCountEnabled(draft({ playoutMode: 'Duration', multipleMode: 'Count' }))).toBe(false);
});
it('duration fields enabled only for Duration', () => {
expect(durationFieldsEnabled(draft({ playoutMode: 'Duration' }))).toBe(true);
expect(durationFieldsEnabled(draft({ playoutMode: 'Flood' }))).toBe(false);
});
it('shuffle-playlist-items checkbox visible only for Playlist type', () => {
expect(shufflePlaylistItemsVisible(draft({ collectionType: 'Playlist' }))).toBe(true);
expect(shufflePlaylistItemsVisible(draft({ collectionType: 'Collection' }))).toBe(false);
});
});
describe('fillWithGroupModeEligible', () => {
it('eligible for Multiple/Duration + non-ShuffleInOrder + Collection/Multi/Smart', () => {
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection' }))).toBe(true);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'MultiCollection' }))).toBe(true);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'SmartCollection' }))).toBe(true);
});
it('ineligible for One/Flood, ShuffleInOrder, or non-collection types', () => {
expect(fillWithGroupModeEligible(draft({ playoutMode: 'One', collectionType: 'Collection' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Flood', collectionType: 'Collection' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'ShuffleInOrder', collectionType: 'Collection' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'TelevisionShow' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'None', collectionType: 'Playlist' }))).toBe(false);
});
});
describe('applyCollectionTypeChange', () => {
it('nulls all source refs + names', () => {
const before = draft({
collectionType: 'Collection',
collectionId: 5,
collectionName: 'A',
multiCollectionId: 6,
multiCollectionName: 'B',
smartCollectionId: 7,
smartCollectionName: 'C',
rerunCollectionId: 8,
rerunCollectionName: 'D',
mediaItemId: 9,
mediaItemName: 'E',
playlistId: 10,
playlistName: 'F',
playlistGroupId: 2,
searchTitle: 'T',
searchQuery: 'Q'
});
const after = applyCollectionTypeChange(before, 'TelevisionShow');
expect(after.collectionType).toBe('TelevisionShow');
for (const key of [
'collectionId',
'multiCollectionId',
'smartCollectionId',
'rerunCollectionId',
'mediaItemId',
'playlistId',
'playlistGroupId',
'searchTitle',
'searchQuery',
'collectionName',
'multiCollectionName',
'smartCollectionName',
'rerunCollectionName',
'playlistName',
'mediaItemName'
] as const) {
expect(after[key]).toBeNull();
}
});
it('resets PlaylistItemSize -> Count when leaving Playlist', () => {
const before = draft({ collectionType: 'Playlist', playbackOrder: 'None', multipleMode: 'PlaylistItemSize' });
expect(applyCollectionTypeChange(before, 'Collection').multipleMode).toBe('Count');
});
it('resets CollectionSize -> Count when moving to Playlist (invalid there)', () => {
// CollectionSize is not offered for a Playlist type (which offers PlaylistItemSize), so it must
// snap to the first valid mode — otherwise the native <select> shows "Count" while state kept
// "CollectionSize" (F2 repro).
const before = draft({ collectionType: 'Collection', multipleMode: 'CollectionSize' });
expect(applyCollectionTypeChange(before, 'Playlist').multipleMode).toBe('Count');
});
it('keeps PlaylistItemSize when moving to Playlist (valid there)', () => {
const before = draft({ collectionType: 'Collection', multipleMode: 'PlaylistItemSize' });
expect(applyCollectionTypeChange(before, 'Playlist').multipleMode).toBe('PlaylistItemSize');
});
it('F2: Collection+Marathon -> TelevisionShow drops Marathon (order + fields reconciled)', () => {
// Repro: TelevisionShow does not offer Marathon; a stale playbackOrder:"Marathon" left the
// select displaying "Chronological" while Marathon fields stayed visible.
const before = draft({
collectionType: 'Collection',
playbackOrder: 'Marathon',
marathonGroupBy: 'Season',
marathonShuffleItems: true,
marathonBatchSize: 4
});
const after = applyCollectionTypeChange(before, 'TelevisionShow');
expect(playbackOrderOptions('TelevisionShow')).not.toContain('Marathon');
expect(after.playbackOrder).toBe('Chronological'); // first TelevisionShow order
expect(after.marathonGroupBy).toBe('None');
expect(after.marathonShuffleItems).toBe(false);
expect(after.marathonBatchSize).toBeNull();
});
it('F2 invariant: for every from/to type pair, playbackOrder and multipleMode land in the valid sets', () => {
const allTypes: DraftItem['collectionType'][] = [
'Collection',
'TelevisionShow',
'TelevisionSeason',
'Artist',
'MultiCollection',
'SmartCollection',
'Playlist',
'RerunFirstRun',
'RerunRerun',
'SearchQuery'
];
const seedModes: DraftItem['multipleMode'][] = ['Count', 'CollectionSize', 'PlaylistItemSize', 'MultiEpisodeGroupSize'];
for (const from of allTypes) {
for (const order of playbackOrderOptions(from)) {
for (const mode of seedModes) {
const before = draft({ collectionType: from, playbackOrder: order, multipleMode: mode });
for (const to of allTypes) {
const after = applyCollectionTypeChange(before, to);
expect(playbackOrderOptions(to)).toContain(after.playbackOrder);
expect(multipleModeOptions(to, after.playbackOrder)).toContain(after.multipleMode);
}
}
}
}
});
it('forces playbackOrder None for Playlist / Rerun types', () => {
expect(applyCollectionTypeChange(draft(), 'Playlist').playbackOrder).toBe('None');
expect(applyCollectionTypeChange(draft(), 'RerunFirstRun').playbackOrder).toBe('None');
expect(applyCollectionTypeChange(draft(), 'RerunRerun').playbackOrder).toBe('None');
});
it('forces playbackOrder Shuffle for MultiCollection', () => {
expect(applyCollectionTypeChange(draft({ playbackOrder: 'Chronological' }), 'MultiCollection').playbackOrder).toBe('Shuffle');
});
it('resets fillWithGroupMode when the new type makes it ineligible', () => {
const before = draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' });
expect(before.fillWithGroupMode).toBe('FillWithOrderedGroups');
const after = applyCollectionTypeChange(before, 'TelevisionShow');
expect(after.fillWithGroupMode).toBe('None');
});
});
describe('applyPlaybackOrderChange', () => {
it('resets MultiEpisodeGroupSize -> Count when not Chronological', () => {
const before = draft({ playbackOrder: 'Chronological', multipleMode: 'MultiEpisodeGroupSize' });
expect(applyPlaybackOrderChange(before, 'Random').multipleMode).toBe('Count');
expect(applyPlaybackOrderChange(before, 'Chronological').multipleMode).toBe('MultiEpisodeGroupSize');
});
it('resets marathon fields (except shuffleGroups) when not Marathon', () => {
const before = draft({
playbackOrder: 'Marathon',
marathonGroupBy: 'Season',
marathonShuffleItems: true,
marathonShuffleGroups: true,
marathonBatchSize: 4
});
const after = applyPlaybackOrderChange(before, 'Shuffle');
expect(after.marathonGroupBy).toBe('None');
expect(after.marathonShuffleItems).toBe(false);
expect(after.marathonBatchSize).toBeNull();
expect(after.marathonShuffleGroups).toBe(true);
});
it('preserves marathon fields when staying Marathon', () => {
const before = draft({ playbackOrder: 'Marathon', marathonGroupBy: 'Season', marathonBatchSize: 4 });
const after = applyPlaybackOrderChange(before, 'Marathon');
expect(after.marathonGroupBy).toBe('Season');
expect(after.marathonBatchSize).toBe(4);
});
it('resets fillWithGroupMode to None for ShuffleInOrder', () => {
const before = draft({ playoutMode: 'Multiple', collectionType: 'Collection', fillWithGroupMode: 'FillWithShuffledGroups' });
expect(applyPlaybackOrderChange(before, 'ShuffleInOrder').fillWithGroupMode).toBe('None');
});
});
describe('applyPlayoutModeChange', () => {
it('resets fillWithGroupMode when moving to One/Flood', () => {
const before = draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' });
expect(applyPlayoutModeChange(before, 'One').fillWithGroupMode).toBe('None');
expect(applyPlayoutModeChange(before, 'Duration').fillWithGroupMode).toBe('FillWithOrderedGroups');
});
});
describe('factories', () => {
it('newDraftItem uses the documented defaults', () => {
const item = newDraftItem();
expect(item).toMatchObject({
startType: 'Dynamic',
playoutMode: 'One',
playbackOrder: 'Shuffle',
collectionType: 'Collection',
watermarkIds: [],
graphicsElementIds: []
});
expect(item._key).toMatch(/^draft-/);
});
it('copyDraftItem deep-copies multi/smart/rerun refs and gives a new key + array identity', () => {
const original = draft({
multiCollectionId: 3,
multiCollectionName: 'M',
smartCollectionId: 4,
smartCollectionName: 'S',
rerunCollectionId: 5,
rerunCollectionName: 'R',
watermarkIds: [1, 2],
graphicsElementIds: [9]
});
const copy = copyDraftItem(original);
expect(copy.multiCollectionId).toBe(3);
expect(copy.smartCollectionId).toBe(4);
expect(copy.rerunCollectionId).toBe(5);
expect(copy.multiCollectionName).toBe('M');
expect(copy.rerunCollectionName).toBe('R');
expect(copy._key).not.toBe(original._key);
expect(copy.watermarkIds).toEqual([1, 2]);
expect(copy.watermarkIds).not.toBe(original.watermarkIds);
expect(copy.graphicsElementIds).not.toBe(original.graphicsElementIds);
});
});
describe('fromResponse', () => {
it('maps watermarks/graphics named embeds to id arrays and copies mutation fields', () => {
const model: ScheduleItem = {
id: 42,
index: 0,
startType: 'Fixed',
startTime: '06:00:00',
fixedStartTimeBehavior: 'Strict',
playoutMode: 'Duration',
collectionType: 'SmartCollection',
collectionId: null,
multiCollectionId: null,
smartCollectionId: 7,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
playbackOrder: 'Shuffle',
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
fillWithGroupMode: 'FillWithOrderedGroups',
multipleMode: null,
multipleCount: null,
playoutDuration: '01:00:00',
tailMode: 'Offline',
discardToFillAttempts: 2,
customTitle: 'Nightly',
guideMode: 'Normal',
preRollFillerId: null,
midRollFillerId: null,
postRollFillerId: null,
tailFillerId: null,
fallbackFillerId: null,
watermarkIds: [1, 2],
graphicsElementIds: [3],
preferredAudioLanguageCode: 'eng',
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
subtitleMode: null,
collectionName: null,
multiCollectionName: null,
smartCollectionName: 'Smarts',
rerunCollectionName: null,
playlistName: null,
playlistGroupId: null,
mediaItemName: null,
preRollFillerName: null,
midRollFillerName: null,
postRollFillerName: null,
tailFillerName: null,
fallbackFillerName: null,
watermarks: [
{ id: 1, name: 'W1' },
{ id: 2, name: 'W2' }
],
graphicsElements: [{ id: 3, name: 'G3' }],
name: 'Smarts',
durationEstimate: '01:00:00'
};
const item = fromResponse(model);
expect(item.smartCollectionId).toBe(7);
expect(item.smartCollectionName).toBe('Smarts');
expect(item.watermarkIds).toEqual([1, 2]);
expect(item.graphicsElementIds).toEqual([3]);
expect(item.multipleMode).toBe('Count'); // null coalesced
expect(item.tailMode).toBe('Offline');
expect(item.playoutDuration).toBe('01:00:00');
});
});
describe('normalizeForSave', () => {
it('nulls startTime + fixedStartTimeBehavior when not Fixed', () => {
const req = normalizeForSave(draft({ startType: 'Dynamic', startTime: '06:00:00', fixedStartTimeBehavior: 'Strict' }));
expect(req.startTime).toBeNull();
expect(req.fixedStartTimeBehavior).toBeNull();
});
it('keeps startTime + behavior when Fixed', () => {
const req = normalizeForSave(draft({ startType: 'Fixed', startTime: '06:00:00', fixedStartTimeBehavior: 'Flexible' }));
expect(req.startTime).toBe('06:00:00');
expect(req.fixedStartTimeBehavior).toBe('Flexible');
});
it('nulls multipleCount unless Multiple+Count', () => {
expect(normalizeForSave(draft({ playoutMode: 'One', multipleCount: '5' })).multipleCount).toBeNull();
expect(normalizeForSave(draft({ playoutMode: 'Multiple', multipleMode: 'CollectionSize', multipleCount: '5' })).multipleCount).toBeNull();
expect(normalizeForSave(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: '5' })).multipleCount).toBe('5');
});
it('nulls duration fields (tailMode -> None) unless Duration', () => {
const req = normalizeForSave(draft({ playoutMode: 'One', playoutDuration: '01:00:00', tailMode: 'Offline', discardToFillAttempts: 3 }));
expect(req.playoutDuration).toBeNull();
expect(req.tailMode).toBe('None');
expect(req.discardToFillAttempts).toBeNull();
});
it('keeps duration fields when Duration', () => {
const req = normalizeForSave(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', tailMode: 'Filler', discardToFillAttempts: 3 }));
expect(req.playoutDuration).toBe('01:00:00');
expect(req.tailMode).toBe('Filler');
expect(req.discardToFillAttempts).toBe(3);
});
it('resets marathon fields (except shuffleGroups) unless Marathon', () => {
const req = normalizeForSave(draft({ playbackOrder: 'Shuffle', marathonGroupBy: 'Season', marathonShuffleItems: true, marathonShuffleGroups: true, marathonBatchSize: 4 }));
expect(req.marathonGroupBy).toBe('None');
expect(req.marathonShuffleItems).toBe(false);
expect(req.marathonBatchSize).toBeNull();
expect(req.marathonShuffleGroups).toBe(true);
});
it('forces fillWithGroupMode None when ineligible', () => {
const req = normalizeForSave(draft({ playoutMode: 'One', collectionType: 'Collection', fillWithGroupMode: 'FillWithOrderedGroups' }));
expect(req.fillWithGroupMode).toBe('None');
});
it('does not emit local-only fields', () => {
const req = normalizeForSave(draft());
expect('_key' in req).toBe(false);
expect('collectionName' in req).toBe(false);
expect('name' in req).toBe(false);
});
});
describe('validation', () => {
it('requires a start time for Fixed items', () => {
expect(validateItem(draft({ startType: 'Fixed', startTime: null }))).toMatch(/start time/i);
expect(validateItem(draft({ startType: 'Fixed', startTime: '06:00:00' }))).toBeNull();
});
it('requires a count expression for Multiple+Count', () => {
expect(validateItem(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: null }))).toMatch(/count/i);
expect(validateItem(draft({ playoutMode: 'Multiple', multipleMode: 'Count', multipleCount: '3' }))).toBeNull();
});
it('requires playoutDuration + non-negative discard for Duration', () => {
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: null }))).toMatch(/duration/i);
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: null }))).toMatch(/discard/i);
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: -1 }))).toMatch(/discard/i);
expect(validateItem(draft({ playoutMode: 'Duration', playoutDuration: '01:00:00', discardToFillAttempts: 0 }))).toBeNull();
});
it('validateDraft prefixes the offending item index', () => {
const items = [draft(), draft({ startType: 'Fixed', startTime: null })];
expect(validateDraft(items)).toMatch(/^Item 2:/);
expect(validateDraft([draft()])).toBeNull();
});
});
+472
View File
@@ -0,0 +1,472 @@
// Pure, exhaustively-tested rules for the schedule-item editor. Encodes the Blazor
// ProgramScheduleItemEditViewModel gating + forced-reset behavior (the parity standard for #207).
// No React, no fetch — every function is a deterministic transform so itemRules.test.ts can pin it.
import type { components } from '../api/generated/v1';
export type CollectionType = components['schemas']['CollectionType'];
export type PlaybackOrder = components['schemas']['PlaybackOrder'];
export type PlayoutMode = components['schemas']['PlayoutMode'];
export type MultipleMode = components['schemas']['MultipleMode'];
export type StartType = components['schemas']['StartType'];
export type TailMode = components['schemas']['TailMode'];
export type FillWithGroupMode = components['schemas']['FillWithGroupMode'];
export type MarathonGroupBy = components['schemas']['MarathonGroupBy'];
export type GuideMode = components['schemas']['GuideMode'];
export type FixedStartTimeBehavior = components['schemas']['FixedStartTimeBehavior'];
export type SubtitleMode = components['schemas']['ChannelSubtitleMode'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ScheduleItem = components['schemas']['ScheduleItemResponseModel'];
export type NamedId = components['schemas']['NamedIdResponseModel'];
// A local, editable schedule item. Carries every ScheduleItemRequest mutation field (named 1:1 with
// the DTO) plus display-only hydration the search-based pickers need to render the current selection
// without re-fetching, plus a stable local key for React + selection. `index` is intentionally NOT a
// field — array order is the source of truth (server indexes from array position on PUT).
export interface DraftItem extends ScheduleItemRequest {
_key: string;
collectionName: null | string;
multiCollectionName: null | string;
smartCollectionName: null | string;
rerunCollectionName: null | string;
playlistName: null | string;
playlistGroupId: null | number;
mediaItemName: null | string;
name: null | string;
durationEstimate: null | string;
}
let keyCounter = 0;
export function nextDraftKey(): string {
keyCounter += 1;
return `draft-${keyCounter}`;
}
// ---- Option lists --------------------------------------------------------
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder'];
const COLLECTION_LIKE_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
const TV_SHOW_ORDERS: PlaybackOrder[] = ['Chronological', 'SeasonEpisode', 'Random', 'Shuffle', 'MultiEpisodeShuffle'];
const SEASON_ARTIST_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle'];
const NONE_ORDERS: PlaybackOrder[] = ['None'];
// Never offered anywhere (Blazor parity): RandomRotation.
export function playbackOrderOptions(collectionType: CollectionType): PlaybackOrder[] {
switch (collectionType) {
case 'MultiCollection':
return MULTI_COLLECTION_ORDERS;
case 'TelevisionShow':
return TV_SHOW_ORDERS;
case 'TelevisionSeason':
case 'Artist':
return SEASON_ARTIST_ORDERS;
case 'Playlist':
case 'RerunFirstRun':
case 'RerunRerun':
return NONE_ORDERS;
case 'Collection':
case 'SmartCollection':
case 'SearchQuery':
return COLLECTION_LIKE_ORDERS;
default:
return COLLECTION_LIKE_ORDERS;
}
}
// The playback-order selector is disabled entirely for Playlist / Rerun collection types.
export function playbackOrderSelectDisabled(collectionType: CollectionType): boolean {
return collectionType === 'Playlist' || collectionType === 'RerunFirstRun' || collectionType === 'RerunRerun';
}
// The lone `None` option is labeled "Playlist" for a Playlist collection type (Blazor parity).
export function playbackOrderLabel(order: PlaybackOrder, collectionType: CollectionType): string {
if (order === 'None' && collectionType === 'Playlist') {
return 'Playlist';
}
return humanizeEnum(order);
}
export const MARATHON_GROUP_BY_OPTIONS: MarathonGroupBy[] = ['Album', 'Artist', 'Season', 'Show', 'Director'];
export const TAIL_MODE_OPTIONS: TailMode[] = ['None', 'Offline', 'Filler'];
export const GUIDE_MODE_OPTIONS: GuideMode[] = ['Normal', 'Filler'];
export const FILL_WITH_GROUP_MODE_OPTIONS: FillWithGroupMode[] = ['None', 'FillWithOrderedGroups', 'FillWithShuffledGroups'];
// subtitleMode is nullable ("Inherit" = null).
export const SUBTITLE_MODE_OPTIONS: (SubtitleMode | null)[] = [null, 'None', 'Forced', 'Default', 'Any'];
export const COLLECTION_TYPE_OPTIONS: CollectionType[] = [
'Collection',
'TelevisionShow',
'TelevisionSeason',
'Artist',
'MultiCollection',
'SmartCollection',
'Playlist',
'RerunFirstRun',
'RerunRerun',
'SearchQuery'
];
export function startTypeOptions(shuffleScheduleItems: boolean): StartType[] {
return shuffleScheduleItems ? ['Dynamic'] : ['Dynamic', 'Fixed'];
}
export function playoutModeOptions(shuffleScheduleItems: boolean): PlayoutMode[] {
return shuffleScheduleItems ? ['One', 'Multiple', 'Duration'] : ['Flood', 'One', 'Multiple', 'Duration'];
}
export function multipleModeOptions(collectionType: CollectionType, playbackOrder: PlaybackOrder): MultipleMode[] {
const options: MultipleMode[] = ['Count'];
options.push(collectionType === 'Playlist' ? 'PlaylistItemSize' : 'CollectionSize');
if (playbackOrder === 'Chronological') {
options.push('MultiEpisodeGroupSize');
}
return options;
}
// ---- Enable gates (UI disables the control, save nulls the value) --------
export function startTimeEnabled(item: DraftItem): boolean {
return item.startType === 'Fixed';
}
export function fixedBehaviorEnabled(item: DraftItem): boolean {
return item.startType === 'Fixed';
}
export function multipleModeEnabled(item: DraftItem): boolean {
return item.playoutMode === 'Multiple';
}
export function multipleCountEnabled(item: DraftItem): boolean {
return item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
}
export function durationFieldsEnabled(item: DraftItem): boolean {
return item.playoutMode === 'Duration';
}
export function marathonVisible(item: DraftItem): boolean {
return item.playbackOrder === 'Marathon';
}
// Playlist collections show a "Shuffle Playlist Items" checkbox bound to marathonShuffleGroups
// (dual-purpose column — Blazor parity).
export function shufflePlaylistItemsVisible(item: DraftItem): boolean {
return item.collectionType === 'Playlist';
}
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder != ShuffleInOrder &&
// collectionType in {Collection, MultiCollection, SmartCollection}.
export function fillWithGroupModeEligible(item: DraftItem): boolean {
const modeOk = item.playoutMode === 'Multiple' || item.playoutMode === 'Duration';
const orderOk = item.playbackOrder !== 'ShuffleInOrder';
const typeOk =
item.collectionType === 'Collection' ||
item.collectionType === 'MultiCollection' ||
item.collectionType === 'SmartCollection';
return modeOk && orderOk && typeOk;
}
// ---- Forced-reset transforms --------------------------------------------
function enforceFillWithGroup(item: DraftItem): DraftItem {
if (!fillWithGroupModeEligible(item) && item.fillWithGroupMode !== 'None') {
return { ...item, fillWithGroupMode: 'None' };
}
return item;
}
// Changing the collection type nulls every source ref (+ its display name) and re-seeds
// multipleMode / playbackOrder per the Blazor rules, then reconciles fillWithGroup eligibility.
export function applyCollectionTypeChange(item: DraftItem, newType: CollectionType): DraftItem {
let next: DraftItem = {
...item,
collectionType: newType,
collectionId: null,
multiCollectionId: null,
smartCollectionId: null,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
collectionName: null,
multiCollectionName: null,
smartCollectionName: null,
rerunCollectionName: null,
playlistName: null,
playlistGroupId: null,
mediaItemName: null
};
// Reconcile playbackOrder into the set the new type offers. If the current order is not offered
// (e.g. Marathon on a Collection → TelevisionShow, or any order → Playlist/Rerun which only offer
// None), snap to the first offered order via applyPlaybackOrderChange so state matches what the
// native <select> renders — a stale invalid order would otherwise display the first option while
// state kept the old value (F2 / docs/decisions.md). applyPlaybackOrderChange also clears the now-
// orphaned Marathon/MultiEpisode fields.
const orderOptions = playbackOrderOptions(newType);
if (!orderOptions.includes(next.playbackOrder)) {
next = applyPlaybackOrderChange(next, orderOptions[0]);
}
// Reconcile multipleMode into the valid set for the new (type, order) state — e.g. CollectionSize
// is invalid after switching to Playlist (which offers PlaylistItemSize), and PlaylistItemSize is
// invalid when leaving Playlist. Snap to the first valid mode when the current one is not offered.
const modeOptions = multipleModeOptions(next.collectionType, next.playbackOrder);
if (!modeOptions.includes(next.multipleMode)) {
next = { ...next, multipleMode: modeOptions[0] };
}
return enforceFillWithGroup(next);
}
// Changing playback order resets MultiEpisodeGroupSize when not Chronological, resets the marathon
// fields when not Marathon (marathonShuffleGroups is deliberately NOT reset — it is dual-purpose),
// then reconciles fillWithGroup eligibility.
export function applyPlaybackOrderChange(item: DraftItem, newOrder: PlaybackOrder): DraftItem {
let next: DraftItem = { ...item, playbackOrder: newOrder };
if (newOrder !== 'Chronological' && next.multipleMode === 'MultiEpisodeGroupSize') {
next = { ...next, multipleMode: 'Count' };
}
if (newOrder !== 'Marathon') {
next = {
...next,
marathonGroupBy: 'None',
marathonShuffleItems: false,
marathonBatchSize: null
};
}
return enforceFillWithGroup(next);
}
// A change to playoutMode can flip fillWithGroup eligibility (Multiple/Duration only).
export function applyPlayoutModeChange(item: DraftItem, newMode: PlayoutMode): DraftItem {
return enforceFillWithGroup({ ...item, playoutMode: newMode });
}
// ---- Factories -----------------------------------------------------------
// Add-item defaults: startType Dynamic, playoutMode One, playbackOrder Shuffle,
// collectionType Collection, empty watermarks/graphics.
export function newDraftItem(): DraftItem {
return {
_key: nextDraftKey(),
startType: 'Dynamic',
startTime: null,
fixedStartTimeBehavior: null,
playoutMode: 'One',
collectionType: 'Collection',
collectionId: null,
multiCollectionId: null,
smartCollectionId: null,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
playbackOrder: 'Shuffle',
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
fillWithGroupMode: 'None',
multipleMode: 'Count',
multipleCount: null,
playoutDuration: null,
tailMode: 'None',
discardToFillAttempts: null,
customTitle: null,
guideMode: 'Normal',
preRollFillerId: null,
midRollFillerId: null,
postRollFillerId: null,
tailFillerId: null,
fallbackFillerId: null,
watermarkIds: [],
graphicsElementIds: [],
preferredAudioLanguageCode: null,
preferredAudioTitle: null,
preferredSubtitleLanguageCode: null,
subtitleMode: null,
collectionName: null,
multiCollectionName: null,
smartCollectionName: null,
rerunCollectionName: null,
playlistName: null,
playlistGroupId: null,
mediaItemName: null,
name: null,
durationEstimate: null
};
}
// Deep copy of ALL fields, including the multi/smart/rerun collection references (+ names) that the
// Blazor CopyItem omitted — deliberate bug fix, see docs/decisions.md 2026-07-11.
export function copyDraftItem(item: DraftItem): DraftItem {
return {
...item,
_key: nextDraftKey(),
watermarkIds: [...(item.watermarkIds ?? [])],
graphicsElementIds: [...(item.graphicsElementIds ?? [])]
};
}
// Maps a loaded GET response item into an editable draft (straight field copy — the DTO's mutation
// fields are named 1:1 with the request).
export function fromResponse(model: ScheduleItem): DraftItem {
return {
_key: nextDraftKey(),
startType: model.startType,
startTime: model.startTime,
fixedStartTimeBehavior: model.fixedStartTimeBehavior,
playoutMode: model.playoutMode,
collectionType: model.collectionType,
collectionId: model.collectionId,
multiCollectionId: model.multiCollectionId,
smartCollectionId: model.smartCollectionId,
rerunCollectionId: model.rerunCollectionId,
mediaItemId: model.mediaItemId,
playlistId: model.playlistId,
searchTitle: model.searchTitle,
searchQuery: model.searchQuery,
playbackOrder: model.playbackOrder,
marathonGroupBy: model.marathonGroupBy,
marathonShuffleGroups: model.marathonShuffleGroups,
marathonShuffleItems: model.marathonShuffleItems,
marathonBatchSize: model.marathonBatchSize,
fillWithGroupMode: model.fillWithGroupMode,
multipleMode: model.multipleMode ?? 'Count',
multipleCount: model.multipleCount,
playoutDuration: model.playoutDuration,
tailMode: model.tailMode ?? 'None',
discardToFillAttempts: model.discardToFillAttempts,
customTitle: model.customTitle,
guideMode: model.guideMode,
preRollFillerId: model.preRollFillerId,
midRollFillerId: model.midRollFillerId,
postRollFillerId: model.postRollFillerId,
tailFillerId: model.tailFillerId,
fallbackFillerId: model.fallbackFillerId,
watermarkIds: (model.watermarks ?? []).map((w) => w.id),
graphicsElementIds: (model.graphicsElements ?? []).map((g) => g.id),
preferredAudioLanguageCode: model.preferredAudioLanguageCode,
preferredAudioTitle: model.preferredAudioTitle,
preferredSubtitleLanguageCode: model.preferredSubtitleLanguageCode,
subtitleMode: model.subtitleMode,
collectionName: model.collectionName,
multiCollectionName: model.multiCollectionName,
smartCollectionName: model.smartCollectionName,
rerunCollectionName: model.rerunCollectionName,
playlistName: model.playlistName,
playlistGroupId: model.playlistGroupId,
mediaItemName: model.mediaItemName,
name: model.name,
durationEstimate: model.durationEstimate
};
}
// ---- Save projection -----------------------------------------------------
// Projects a draft to the request body, applying Blazor's getter-gating: gated-off values are
// nulled (or defaulted to the enum's None) so the payload matches what a non-editing Blazor VM emits.
export function normalizeForSave(item: DraftItem): ScheduleItemRequest {
const isFixed = item.startType === 'Fixed';
const isMultipleCount = item.playoutMode === 'Multiple' && item.multipleMode === 'Count';
const isDuration = item.playoutMode === 'Duration';
const isMarathon = item.playbackOrder === 'Marathon';
const fillWithGroupMode = fillWithGroupModeEligible(item) ? item.fillWithGroupMode : 'None';
return {
startType: item.startType,
startTime: isFixed ? item.startTime : null,
fixedStartTimeBehavior: isFixed ? item.fixedStartTimeBehavior : null,
playoutMode: item.playoutMode,
collectionType: item.collectionType,
collectionId: item.collectionId,
multiCollectionId: item.multiCollectionId,
smartCollectionId: item.smartCollectionId,
rerunCollectionId: item.rerunCollectionId,
mediaItemId: item.mediaItemId,
playlistId: item.playlistId,
searchTitle: item.searchTitle,
searchQuery: item.searchQuery,
playbackOrder: item.playbackOrder,
marathonGroupBy: isMarathon ? item.marathonGroupBy : 'None',
marathonShuffleGroups: item.marathonShuffleGroups,
marathonShuffleItems: isMarathon ? item.marathonShuffleItems : false,
marathonBatchSize: isMarathon ? item.marathonBatchSize : null,
fillWithGroupMode,
multipleMode: item.multipleMode,
multipleCount: isMultipleCount ? item.multipleCount : null,
playoutDuration: isDuration ? item.playoutDuration : null,
tailMode: isDuration ? item.tailMode : 'None',
discardToFillAttempts: isDuration ? item.discardToFillAttempts : null,
customTitle: item.customTitle,
guideMode: item.guideMode,
preRollFillerId: item.preRollFillerId,
midRollFillerId: item.midRollFillerId,
postRollFillerId: item.postRollFillerId,
tailFillerId: item.tailFillerId,
fallbackFillerId: item.fallbackFillerId,
watermarkIds: item.watermarkIds ?? [],
graphicsElementIds: item.graphicsElementIds ?? [],
preferredAudioLanguageCode: item.preferredAudioLanguageCode,
preferredAudioTitle: item.preferredAudioTitle,
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode,
subtitleMode: item.subtitleMode
};
}
// ---- Client-side validation (mirrors the server) -------------------------
// Returns a human message for the first blocking problem, or null when the item is submittable.
export function validateItem(item: DraftItem): null | string {
if (item.startType === 'Fixed' && !item.startTime) {
return 'Fixed start items require a start time';
}
if (item.playoutMode === 'Multiple' && item.multipleMode === 'Count' && !item.multipleCount) {
return 'Multiple / Count items require a count expression';
}
if (item.playoutMode === 'Duration') {
if (!item.playoutDuration) {
return 'Duration items require a playout duration';
}
if (item.discardToFillAttempts == null || item.discardToFillAttempts < 0) {
return 'Duration items require discard-to-fill attempts (0 or more)';
}
}
return null;
}
export function validateDraft(items: DraftItem[]): null | string {
for (let i = 0; i < items.length; i += 1) {
const message = validateItem(items[i]);
if (message) {
return `Item ${i + 1}: ${message}`;
}
}
return null;
}
// ---- Display helpers -----------------------------------------------------
export function humanizeEnum(value: string): string {
return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
}
export function draftItemLabel(item: DraftItem): string {
return (
item.name ??
item.collectionName ??
item.multiCollectionName ??
item.smartCollectionName ??
item.rerunCollectionName ??
item.playlistName ??
item.mediaItemName ??
item.searchTitle ??
item.searchQuery ??
'Unnamed item'
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from 'react';
import { Check, Search, X } from 'lucide-react';
import { Spinner } from '../components';
import type { SchedulingPickerOption } from '../api';
// A debounced async autocomplete for the search-backed source pickers (shows/seasons/artists and
// collection/multi/smart searches). Renders the current selection (label) with a clear button, an
// input that queries `search(query)` after a 250ms debounce, and a results list.
export interface SearchPickerProps {
label: string;
selectedId: null | number;
selectedName: null | string;
search: (query: string) => Promise<SchedulingPickerOption[]>;
onSelect: (id: number, name: string) => void;
onClear: () => void;
disabled?: boolean;
placeholder?: string;
}
export function SearchPicker({
label,
selectedId,
selectedName,
search,
onSelect,
onClear,
disabled = false,
placeholder = 'Search…'
}: SearchPickerProps) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [results, setResults] = useState<SchedulingPickerOption[]>([]);
const [loading, setLoading] = useState(false);
const seqRef = useRef(0);
// All state transitions happen inside the debounce callback (never synchronously in the effect
// body — see spa-conventions.md §3).
useEffect(() => {
if (!open) {
return;
}
const trimmed = query.trim();
const seq = ++seqRef.current;
const handle = window.setTimeout(() => {
if (trimmed.length === 0) {
if (seqRef.current === seq) {
setResults([]);
setLoading(false);
}
return;
}
setLoading(true);
search(trimmed)
.then((items) => {
if (seqRef.current === seq) {
setResults(items);
setLoading(false);
}
})
.catch(() => {
if (seqRef.current === seq) {
setResults([]);
setLoading(false);
}
});
}, trimmed.length === 0 ? 0 : 250);
return () => window.clearTimeout(handle);
}, [query, open, search]);
return (
<div className="ctv-field ctv-field-full">
<span className="ctv-field-label">{label}</span>
{selectedId != null && !open ? (
<div className="ctv-picker-selected">
<span>{selectedName ?? `#${selectedId}`}</span>
{!disabled && (
<button type="button" aria-label={`Clear ${label}`} className="ctv-picker-clear" onClick={onClear}>
<X aria-hidden="true" size={14} />
</button>
)}
{!disabled && (
<button type="button" className="ctv-picker-change" onClick={() => setOpen(true)}>
Change
</button>
)}
</div>
) : (
<div className="ctv-picker">
<span className="ctv-field-frame ctv-field-frame-sm">
<span className="ctv-field-icon"><Search aria-hidden="true" size={14} /></span>
<input
className="ctv-input"
type="search"
value={query}
disabled={disabled}
placeholder={placeholder}
aria-label={`${label} search`}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setOpen(true)}
/>
{loading && <span className="ctv-field-trailing"><Spinner size={13} tone="muted" /></span>}
</span>
{open && results.length > 0 && (
<ul className="ctv-picker-results" role="listbox" aria-label={`${label} results`}>
{results.map((option) => (
<li key={option.id}>
<button
type="button"
role="option"
aria-selected={option.id === selectedId}
className="ctv-picker-result"
onClick={() => {
onSelect(option.id, option.name);
setQuery('');
setResults([]);
setOpen(false);
}}
>
{option.name}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
export interface MultiSelectOption {
id: number;
name: string;
}
// A clearable multi-select rendered as a toggle list (watermarks + graphics elements). Emits the
// full next id array on each toggle.
export interface MultiSelectProps {
label: string;
options: MultiSelectOption[];
selectedIds: number[];
onChange: (ids: number[]) => void;
disabled?: boolean;
emptyLabel?: string;
}
export function MultiSelect({
label,
options,
selectedIds,
onChange,
disabled = false,
emptyLabel = 'None available'
}: MultiSelectProps) {
const toggle = (id: number) => {
if (disabled) {
return;
}
onChange(selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id]);
};
return (
<div className="ctv-field ctv-field-full">
<span className="ctv-field-label">{label}</span>
{options.length === 0 ? (
<div className="ctv-picker-empty">{emptyLabel}</div>
) : (
<div className="ctv-multiselect" role="group" aria-label={label}>
{options.map((option) => {
const active = selectedIds.includes(option.id);
return (
<button
key={option.id}
type="button"
aria-pressed={active}
disabled={disabled}
className={`ctv-multiselect-chip${active ? ' ctv-multiselect-chip-active' : ''}`}
onClick={() => toggle(option.id)}
>
{active && <Check aria-hidden="true" size={12} />}
{option.name}
</button>
);
})}
</div>
)}
</div>
);
}
+180 -3
View File
@@ -50,23 +50,27 @@ function json(body: unknown, status = 200): Response {
}
interface FetchOptions {
channelOverrides?: Record<string, unknown>;
channelStatus?: number;
onPut?: (body: unknown) => void;
putResponseOverrides?: Record<string, unknown>;
}
function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
function mockApi({ channelOverrides = {}, channelStatus = 200, onPut, putResponseOverrides = {} }: FetchOptions = {}) {
const loadedChannel = { ...channel, ...channelOverrides };
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/channels/5' && method === 'PUT') {
onPut?.(JSON.parse(init?.body as string));
return Promise.resolve(json({ ...channel, name: 'Renamed' }));
return Promise.resolve(json({ ...loadedChannel, name: 'Renamed', ...putResponseOverrides }));
}
if (url === '/api/channels/5') {
return channelStatus === 200
? Promise.resolve(json(channel))
? Promise.resolve(json(loadedChannel))
: Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus));
}
@@ -86,6 +90,18 @@ function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
if (url === '/api/languages') {
return Promise.resolve(json([{ code: 'eng', englishName: 'English' }]));
}
if (url === '/api/channels/music-video-credits-templates') {
return Promise.resolve(json(['default']));
}
if (url === '/api/channels/stream-selectors') {
return Promise.resolve(json(['selector.py']));
}
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
});
}
@@ -158,4 +174,165 @@ describe('ChannelEditScreen', () => {
expect(await screen.findByText('Channel not found.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument();
});
describe('external logo URL (Blazor ChannelEditor parity)', () => {
it('hydrates the URL field from an external-URL logo and treats the uploaded logo as empty', async () => {
mockApi({
channelOverrides: { logo: { path: 'https://example.com/old-logo.png', contentType: '', isExternalUrl: true } }
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
expect(await screen.findByDisplayValue('https://example.com/old-logo.png')).toBeInTheDocument();
// The preview shows the external URL, not an uploaded-logo path.
expect(screen.getByRole('img', { name: 'Cartoons' })).toHaveAttribute('src', 'https://example.com/old-logo.png');
});
it('sends an external URL as the logo, winning over any previously uploaded logo, on save', async () => {
const puts: unknown[] = [];
mockApi({
channelOverrides: { logo: { path: 'iptv/logos/uploaded.png', contentType: 'image/png' } },
onPut: (body) => puts.push(body)
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
fireEvent.click(saveButton);
await waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0]).toMatchObject({
logo: { path: 'https://example.com/new-logo.png', contentType: '' }
});
});
it('shows an inline error and blocks save for a non-http(s) URL', async () => {
mockApi();
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'not-a-url' } });
expect(await screen.findByText('Enter a valid http(s) URL')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
});
it('clears the external URL field when a file is uploaded', async () => {
mockApi();
vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
const method = (init?.method ?? 'GET').toUpperCase();
if (url === '/api/artwork/uploads' && method === 'POST') {
return Promise.resolve(json({ path: 'iptv/logos/new.png', contentType: 'image/png' }));
}
if (url === '/api/channels/5') {
return Promise.resolve(json(channel));
}
if (url === '/api/ffmpeg/profiles') {
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
}
if (url === '/api/watermarks') {
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
}
if (url === '/api/filler-presets') {
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
}
if (url === '/api/channels') {
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
}
if (url === '/api/languages') {
return Promise.resolve(json([{ code: 'eng', englishName: 'English' }]));
}
if (url === '/api/channels/music-video-credits-templates') {
return Promise.resolve(json(['default']));
}
if (url === '/api/channels/stream-selectors') {
return Promise.resolve(json(['selector.py']));
}
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
});
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Branding/ }));
const urlInput = screen.getByPlaceholderText('https://example.com/logo.png');
fireEvent.change(urlInput, { target: { value: 'https://example.com/new-logo.png' } });
expect(urlInput).toHaveValue('https://example.com/new-logo.png');
const file = new File(['x'], 'logo.png', { type: 'image/png' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => expect(urlInput).toHaveValue(''));
});
});
describe('picker fields (languages, credits template, stream selector)', () => {
it('keeps a preferred audio language selectable even when missing from the language list', async () => {
mockApi({ channelOverrides: { preferredAudioLanguageCode: 'spa' } });
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ }));
const select = await screen.findByDisplayValue('spa');
expect(select).toBeInTheDocument();
expect(select.tagName).toBe('SELECT');
});
it('populates the preferred audio language select from GET /api/languages', async () => {
mockApi();
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ }));
// Both the preferred-audio and preferred-subtitle language selects populate from the
// same GET /api/languages list.
expect((await screen.findAllByRole('option', { name: 'English' })).length).toBe(2);
});
it('keeps a music video credits template selectable even when missing from the template list', async () => {
mockApi({ channelOverrides: { musicVideoCreditsMode: 'GenerateSubtitles', musicVideoCreditsTemplate: 'legacy-template' } });
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Music video/ }));
expect(await screen.findByDisplayValue('legacy-template')).toBeInTheDocument();
});
it('keeps a custom stream selector selectable even when missing from the selector list', async () => {
mockApi({ channelOverrides: { streamSelectorMode: 'Custom', streamSelector: 'legacy-selector.py' } });
render(<ChannelEditScreen />);
await screen.findByDisplayValue('Cartoons');
fireEvent.click(screen.getByRole('button', { name: /^Stream selection/ }));
expect(await screen.findByDisplayValue('legacy-selector.py')).toBeInTheDocument();
});
});
});
+155 -24
View File
@@ -17,8 +17,11 @@ import {
ApiError,
getChannelById,
getChannels,
getChannelStreamSelectors,
getFFmpegProfiles,
getFillerPresets,
getLanguages,
getMusicVideoCreditsTemplates,
getWatermarks,
messageFromError,
updateChannel,
@@ -27,6 +30,7 @@ import {
type ChannelSummary,
type FFmpegProfile,
type FillerPreset,
type LanguageCode,
type UpdateChannelRequest,
type Watermark
} from '../api';
@@ -111,6 +115,12 @@ function channelIdFromPathname(pathname: string): number | null {
return Number.isInteger(id) && id > 0 ? id : null;
}
// Blazor parity (ChannelEditor.razor + ChannelEditViewModel.ToUpdate): an external logo URL is
// tracked as separate form state from the uploaded-logo ArtworkContentTypeModel, and wins over it
// on submit. When hydrating a channel whose logo IS an external URL, the uploaded-logo state is
// treated as empty so the two fields never disagree.
const EMPTY_LOGO: NonNullable<UpdateChannelRequest['logo']> = { path: '', contentType: '' };
// The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName
// fields, so hydration is a straight field copy.
function draftFromChannel(channel: Channel): UpdateChannelRequest {
@@ -121,7 +131,7 @@ function draftFromChannel(channel: Channel): UpdateChannelRequest {
categories: channel.categories,
ffmpegProfileId: channel.ffmpegProfileId,
slugSeconds: channel.slugSeconds,
logo: channel.logo,
logo: channel.logo?.isExternalUrl ? EMPTY_LOGO : channel.logo,
streamSelectorMode: channel.streamSelectorMode,
streamSelector: channel.streamSelector,
preferredAudioLanguageCode: channel.preferredAudioLanguageCode,
@@ -149,6 +159,64 @@ function numberOrEmpty(value: null | number | undefined): string {
return value == null ? '' : String(value);
}
function externalLogoUrlFromChannel(channel: Channel): string {
return channel.logo?.isExternalUrl ? (channel.logo.path ?? '') : '';
}
// Blazor parity: the URL field only needs to look like http(s) when non-blank; blank means
// "no external URL" and is always valid.
function isValidExternalUrl(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {
return true;
}
try {
const url = new URL(trimmed);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
interface StringOption {
label: string;
value: string;
}
// Builds a "(none)" + list Select's options, keeping the channel's currently-stored value
// selectable even if it's absent from the reference list (e.g. a language the media library
// doesn't use, or a template/selector file that's been removed from disk since save) so loading
// an existing channel never silently changes the value.
function optionsKeepingCurrent(
entries: StringOption[],
current: null | string | undefined,
noneLabel = '(none)'
): StringOption[] {
const options: StringOption[] = [{ label: noneLabel, value: '' }, ...entries];
if (current && !options.some((option) => option.value === current)) {
options.push({ label: current, value: current });
}
return options;
}
function languageOptions(languages: LanguageCode[], current: null | string | undefined): StringOption[] {
return optionsKeepingCurrent(
languages.map((language) => ({ label: language.englishName ?? language.code ?? '', value: language.code ?? '' })),
current
);
}
function stringListOptions(values: string[], current: null | string | undefined): StringOption[] {
return optionsKeepingCurrent(
values.map((value) => ({ label: value, value })),
current
);
}
function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((left, right) => {
const leftNumber = Number.parseFloat(left.number);
@@ -207,6 +275,9 @@ interface ReferenceData {
fillerPresets: FillerPreset[];
watermarks: Watermark[];
ffmpegProfiles: FFmpegProfile[];
languages: LanguageCode[];
musicVideoCreditsTemplates: string[];
streamSelectors: string[];
}
/* ---------- panes ---------- */
@@ -431,9 +502,11 @@ function StreamingPane({
}
function SelectionPane({
data,
draft,
set
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
@@ -458,13 +531,12 @@ function SelectionPane({
</Row>
{isDefault ? (
<>
<Row help="ISO 639-2 code, e.g. eng. Blank keeps the file order." label="Preferred audio language">
<Input
<Row help="Blank keeps the file order." label="Preferred audio language">
<Select
fullWidth
onChange={(event) => set({ preferredAudioLanguageCode: event.target.value || null })}
placeholder="eng"
options={languageOptions(data.languages, draft.preferredAudioLanguageCode)}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.preferredAudioLanguageCode ?? ''}
/>
</Row>
@@ -476,13 +548,12 @@ function SelectionPane({
value={draft.preferredAudioTitle ?? ''}
/>
</Row>
<Row help="ISO 639-2 code, e.g. eng. Blank disables preference." label="Preferred subtitle language">
<Input
<Row help="Blank disables preference." label="Preferred subtitle language">
<Select
fullWidth
onChange={(event) => set({ preferredSubtitleLanguageCode: event.target.value || null })}
placeholder="eng"
options={languageOptions(data.languages, draft.preferredSubtitleLanguageCode)}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.preferredSubtitleLanguageCode ?? ''}
/>
</Row>
@@ -503,11 +574,11 @@ function SelectionPane({
</>
) : (
<Row help="Name of a custom stream-selector script in the config folder." label="Stream selector">
<Input
<Select
fullWidth
onChange={(event) => set({ streamSelector: event.target.value || null })}
options={stringListOptions(data.streamSelectors, draft.streamSelector)}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.streamSelector ?? ''}
/>
</Row>
@@ -518,9 +589,11 @@ function SelectionPane({
}
function MusicPane({
data,
draft,
set
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
set: (patch: Partial<UpdateChannelRequest>) => void;
}) {
@@ -544,12 +617,12 @@ function MusicPane({
/>
</Row>
<Row help="Name of the credits template. Only used when credits are generated." label="Credits template">
<Input
<Select
disabled={!creditsOn}
fullWidth
onChange={(event) => set({ musicVideoCreditsTemplate: event.target.value || null })}
options={stringListOptions(data.musicVideoCreditsTemplates, draft.musicVideoCreditsTemplate)}
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={draft.musicVideoCreditsTemplate ?? ''}
/>
</Row>
@@ -573,24 +646,31 @@ function MusicPane({
function BrandingPane({
data,
draft,
externalLogoUrl,
logoPreview,
onChangeExternalLogoUrl,
onUploadLogo,
set,
uploadError,
uploading
uploading,
urlError
}: {
data: ReferenceData;
draft: UpdateChannelRequest;
externalLogoUrl: string;
logoPreview: string | null;
onChangeExternalLogoUrl: (value: string) => void;
onUploadLogo: (file: File) => void;
set: (patch: Partial<UpdateChannelRequest>) => void;
uploadError: string | null;
uploading: boolean;
urlError: string | null;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null;
const previewSrc = logoPreview ?? existingLogo;
const trimmedUrl = externalLogoUrl.trim();
const previewSrc = trimmedUrl ? externalLogoUrl : (logoPreview ?? existingLogo);
return (
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
@@ -629,6 +709,20 @@ function BrandingPane({
/>
</div>
</Row>
<Row
control={340}
help="An external image URL wins over an uploaded logo when both are set."
label="External logo URL"
>
<Input
error={urlError}
fullWidth
onChange={(event) => onChangeExternalLogoUrl(event.target.value)}
placeholder="https://example.com/logo.png"
size="sm"
value={externalLogoUrl}
/>
</Row>
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
<Select
disabled={hlsDirect}
@@ -681,6 +775,9 @@ export function ChannelEditScreen() {
const [saved, setSaved] = useState<UpdateChannelRequest | null>(null);
const [section, setSection] = useState<SectionId>('general');
const [externalLogoUrl, setExternalLogoUrl] = useState('');
const [savedExternalLogoUrl, setSavedExternalLogoUrl] = useState('');
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -696,18 +793,30 @@ export function ChannelEditScreen() {
let active = true;
Promise.all([getChannelById(channelId), getFFmpegProfiles(), getWatermarks(), getFillerPresets(), getChannels()])
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels]) => {
Promise.all([
getChannelById(channelId),
getFFmpegProfiles(),
getWatermarks(),
getFillerPresets(),
getChannels(),
getLanguages(),
getMusicVideoCreditsTemplates(),
getChannelStreamSelectors()
])
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels, languages, musicVideoCreditsTemplates, streamSelectors]) => {
if (!active) {
return;
}
const initial = draftFromChannel(channel);
const initialExternalLogoUrl = externalLogoUrlFromChannel(channel);
setDraft(initial);
setSaved(initial);
setExternalLogoUrl(initialExternalLogoUrl);
setSavedExternalLogoUrl(initialExternalLogoUrl);
setLoaded({
channelId,
data: { channels, ffmpegProfiles, fillerPresets, watermarks },
data: { channels, ffmpegProfiles, fillerPresets, languages, musicVideoCreditsTemplates, streamSelectors, watermarks },
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
});
})
@@ -770,6 +879,8 @@ export function ChannelEditScreen() {
setDraft((current) =>
current ? { ...current, logo: { path: uploaded.path, contentType: uploaded.contentType } } : current
);
// Blazor parity: uploading a file clears any external URL (the last-set field wins).
setExternalLogoUrl('');
const reader = new FileReader();
reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null);
@@ -781,13 +892,21 @@ export function ChannelEditScreen() {
}
};
const changeExternalLogoUrl = (value: string) => {
setJustSaved(false);
setExternalLogoUrl(value);
};
const nameValid = Boolean(draft.name?.trim());
const numberValid = Boolean(draft.number?.trim());
const valid = nameValid && numberValid;
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
const urlValid = isValidExternalUrl(externalLogoUrl);
const valid = nameValid && numberValid && urlValid;
const dirty = JSON.stringify(draft) !== JSON.stringify(saved) || externalLogoUrl !== savedExternalLogoUrl;
const urlError = urlValid ? null : 'Enter a valid http(s) URL';
const discard = () => {
setDraft(saved);
setExternalLogoUrl(savedExternalLogoUrl);
setLogoPreview(null);
setSaveError(null);
};
@@ -801,10 +920,18 @@ export function ChannelEditScreen() {
setSaveError(null);
try {
const updated = await updateChannel(loaded.channelId, draft);
const trimmedUrl = externalLogoUrl.trim();
const body: UpdateChannelRequest = {
...draft,
logo: trimmedUrl ? { path: trimmedUrl, contentType: '' } : draft.logo
};
const updated = await updateChannel(loaded.channelId, body);
const next = draftFromChannel(updated);
const nextExternalLogoUrl = externalLogoUrlFromChannel(updated);
setSaved(next);
setDraft(next);
setSavedExternalLogoUrl(nextExternalLogoUrl);
setExternalLogoUrl(nextExternalLogoUrl);
setLogoPreview(null);
setJustSaved(true);
window.setTimeout(() => setJustSaved(false), 1800);
@@ -850,17 +977,20 @@ export function ChannelEditScreen() {
<PlayoutPane channelId={loaded.channelId} data={loaded.data} draft={draft} playoutLocked={loaded.playoutLocked} set={set} />
)}
{section === 'streaming' && <StreamingPane data={loaded.data} draft={draft} set={set} />}
{section === 'selection' && <SelectionPane draft={draft} set={set} />}
{section === 'music' && <MusicPane draft={draft} set={set} />}
{section === 'selection' && <SelectionPane data={loaded.data} draft={draft} set={set} />}
{section === 'music' && <MusicPane data={loaded.data} draft={draft} set={set} />}
{section === 'branding' && (
<BrandingPane
data={loaded.data}
draft={draft}
externalLogoUrl={externalLogoUrl}
logoPreview={logoPreview}
onChangeExternalLogoUrl={changeExternalLogoUrl}
onUploadLogo={(file) => void uploadLogo(file)}
set={set}
uploadError={uploadError}
uploading={uploading}
urlError={urlError}
/>
)}
</div>
@@ -876,7 +1006,8 @@ export function ChannelEditScreen() {
) : (
<>
<span className="ctv-settings-savebar-text">Unsaved changes</span>
{!valid && <Badge tone="neutral">Name and number are required</Badge>}
{!nameValid || !numberValid ? <Badge tone="neutral">Name and number are required</Badge> : null}
{!urlValid && <Badge tone="neutral">Logo URL must be http(s)</Badge>}
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
<span className="ctv-settings-savebar-divider" />
<Button onClick={discard} size="sm" variant="ghost">
+420
View File
@@ -0,0 +1,420 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SchedulesScreen } from './SchedulesScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const schedule = {
id: 1,
name: 'Prime',
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
shuffleScheduleItems: false,
randomStartPoint: false,
fixedStartTimeBehavior: 'Strict'
};
function responseItem(overrides: Record<string, unknown> = {}) {
return {
id: 10,
index: 0,
startType: 'Dynamic',
startTime: null,
fixedStartTimeBehavior: null,
playoutMode: 'One',
collectionType: 'Collection',
collectionId: 5,
multiCollectionId: null,
smartCollectionId: null,
rerunCollectionId: null,
mediaItemId: null,
playlistId: null,
searchTitle: null,
searchQuery: null,
playbackOrder: 'Shuffle',
marathonGroupBy: 'None',
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
fillWithGroupMode: 'None',
multipleMode: null,
multipleCount: null,
playoutDuration: 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,
subtitleMode: null,
collectionName: 'Comedies',
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: 'Comedies',
durationEstimate: '00:30:00',
...overrides
};
}
interface MockOptions {
schedules?: unknown[];
items?: unknown[];
onRequest?: (url: string, method: string, body: unknown) => Response | null;
}
interface MockHandle {
fetchSpy: ReturnType<typeof vi.spyOn>;
requests: Array<{ url: string; method: string; body: unknown }>;
}
function mockApi(options: MockOptions = {}): MockHandle {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const schedules = options.schedules ?? [schedule];
const items = options.items ?? [responseItem()];
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
requests.push({ url, method, body });
if (options.onRequest) {
const override = options.onRequest(url, method, body);
if (override) {
return Promise.resolve(override);
}
}
if (url === '/api/schedules' && method === 'GET') return Promise.resolve(jsonResponse(schedules));
if (url === '/api/schedules' && method === 'POST') {
return Promise.resolve(jsonResponse({ ...schedule, id: 99, name: (body as { name: string }).name }, 201));
}
if (url.startsWith('/api/rerun-collections')) return Promise.resolve(jsonResponse({ totalCount: 0, page: [] }));
if (url === '/api/playlists/groups') return Promise.resolve(jsonResponse([]));
if (url === '/api/watermarks') return Promise.resolve(jsonResponse([{ id: 7, name: 'Logo' }]));
if (url === '/api/graphics-elements') return Promise.resolve(jsonResponse([]));
if (url === '/api/languages') return Promise.resolve(jsonResponse([{ code: 'eng', englishName: 'English' }]));
if (url.startsWith('/api/filler-presets')) return Promise.resolve(jsonResponse([]));
if (url === '/api/schedules/1/items' && method === 'GET') {
return Promise.resolve(jsonResponse({ items, totalDurationEstimate: '00:30:00' }));
}
if (url === '/api/schedules/1/items' && method === 'PUT') {
// echo back the submitted items as response items (new ids)
const submitted = (body as { items: Record<string, unknown>[] }).items;
return Promise.resolve(jsonResponse(submitted.map((it, i) => responseItem({ ...it, id: 500 + i, index: i, name: `Item ${i}` }))));
}
if (url === '/api/schedules/99/items' && method === 'GET') {
return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null }));
}
if (/^\/api\/schedules\/\d+$/.test(url) && method === 'DELETE') {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(jsonResponse({}, 404));
});
return { fetchSpy, requests };
}
function lastPut(handle: MockHandle): Record<string, unknown>[] {
const puts = handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'PUT');
return (puts[puts.length - 1].body as { items: Record<string, unknown>[] }).items;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
async function renderReady(options: MockOptions = {}) {
const handle = mockApi(options);
render(<SchedulesScreen />);
await waitFor(() => expect(screen.getByLabelText('Schedule lineup').querySelectorAll('[role="listitem"]').length).toBeGreaterThan(0));
return handle;
}
describe('SchedulesScreen — load', () => {
it('renders the lineup for the active schedule', async () => {
await renderReady();
expect(screen.getByRole('heading', { name: 'Prime' })).toBeTruthy();
expect(screen.getByLabelText('Schedule lineup')).toBeTruthy();
});
it('fetches filler presets per kind (5 kinds)', async () => {
const handle = await renderReady();
const kinds = handle.requests.filter((r) => r.url.startsWith('/api/filler-presets')).map((r) => r.url);
for (const kind of ['PreRoll', 'MidRoll', 'PostRoll', 'Tail', 'Fallback']) {
expect(kinds.some((u) => u.includes(`fillerKind=${kind}`))).toBe(true);
}
});
// #229 Bug 2: array position becomes the persisted index on the next PUT, so a GET response whose rows
// arrive out of `index` order must be sorted before ingest — otherwise re-saving silently reshuffles the
// lineup. Rows are deliberately returned index-descending here.
it('ingests items by their index even when the response rows are out of order', async () => {
await renderReady({
items: [
responseItem({ id: 11, index: 1, collectionId: 6, collectionName: 'Dramas', name: 'Dramas' }),
responseItem({ id: 10, index: 0, collectionId: 5, collectionName: 'Comedies', name: 'Comedies' })
]
});
const lineup = screen.getByLabelText('Schedule lineup');
// Each row exposes a "Move <name> down" control; their order reflects draft (== persisted index) order.
const moveButtons = within(lineup)
.getAllByRole('button', { name: /Move .+ down/ })
.map((b) => b.getAttribute('aria-label'));
expect(moveButtons[0]).toMatch(/Comedies/); // index 0 first, despite arriving second in the response
expect(moveButtons[1]).toMatch(/Dramas/);
});
});
describe('SchedulesScreen — draft mutations issue a single PUT on Save', () => {
it('add item then Save → PUT with two items, no other writes', async () => {
const handle = await renderReady();
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
const items = lastPut(handle);
expect(items).toHaveLength(2);
// exactly one PUT, and no POST/DELETE to items endpoints
expect(handle.requests.filter((r) => r.method === 'PUT' && r.url === '/api/schedules/1/items')).toHaveLength(1);
expect(handle.requests.filter((r) => r.url.includes('/items/'))).toHaveLength(0);
});
it('edit a field → PUT reflects it', async () => {
const handle = await renderReady();
const title = screen.getByLabelText('Custom title') as HTMLInputElement;
fireEvent.change(title, { target: { value: 'Nightly' } });
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
expect(lastPut(handle)[0].customTitle).toBe('Nightly');
});
it('copy item deep-copies references into the PUT payload', async () => {
const handle = await renderReady({
items: [responseItem({ collectionType: 'MultiCollection', collectionId: null, multiCollectionId: 8, multiCollectionName: 'Multi', playbackOrder: 'Shuffle', name: 'Multi' })]
});
fireEvent.click(screen.getByRole('button', { name: /Copy Multi/ }));
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
const items = lastPut(handle);
expect(items).toHaveLength(2);
expect(items[0].multiCollectionId).toBe(8);
expect(items[1].multiCollectionId).toBe(8);
});
it('remove item → PUT omits it', async () => {
const handle = await renderReady({ items: [responseItem({ id: 10, collectionId: 5, name: 'Comedies', collectionName: 'Comedies' }), responseItem({ id: 11, index: 1, collectionId: 6, name: 'Dramas', collectionName: 'Dramas' })] });
const lineup = screen.getByLabelText('Schedule lineup');
fireEvent.click(within(lineup).getByRole('button', { name: /Remove Comedies/ }));
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
const items = lastPut(handle);
expect(items).toHaveLength(1);
expect(items[0].collectionId).toBe(6); // Dramas survived
});
it('move item reorders the PUT payload', async () => {
const handle = await renderReady({ items: [responseItem({ id: 10, collectionId: 5, name: 'Comedies', collectionName: 'Comedies' }), responseItem({ id: 11, index: 1, collectionId: 6, name: 'Dramas', collectionName: 'Dramas' })] });
const lineup = screen.getByLabelText('Schedule lineup');
fireEvent.click(within(lineup).getByRole('button', { name: /Move Comedies down/ }));
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
const items = lastPut(handle);
expect(items).toHaveLength(2);
expect(items[0].collectionId).toBe(6); // Dramas moved up
expect(items[1].collectionId).toBe(5); // Comedies moved down
});
});
describe('SchedulesScreen — error + dirty handling', () => {
it('422 on Save keeps the draft dirty and shows the error', async () => {
await renderReady({
onRequest: (url, method) =>
url === '/api/schedules/1/items' && method === 'PUT'
? jsonResponse({ title: 'Invalid', detail: 'Name already used' }, 422)
: null
});
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await screen.findByText('Name already used');
// draft still has 2 items + Save still enabled (dirty)
expect(within(screen.getByLabelText('Schedule lineup')).getAllByRole('listitem')).toHaveLength(2);
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false);
});
it('guards a schedule switch when dirty (confirm=false aborts)', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] });
fireEvent.click(screen.getByRole('button', { name: /Add item/ })); // make dirty
const beforeGets = handle.requests.filter((r) => r.url.includes('/schedules/2/items')).length;
fireEvent.change(screen.getByLabelText('Active schedule'), { target: { value: '2' } });
// confirm returned false → no items fetch for schedule 2
expect(handle.requests.filter((r) => r.url.includes('/schedules/2/items')).length).toBe(beforeGets);
});
});
describe('SchedulesScreen — review fixes (F1/F3/F4/F5)', () => {
it('F1: a failed items load after switching does not leave the prior draft saveable', async () => {
const handle = await renderReady({
schedules: [schedule, { ...schedule, id: 2, name: 'Late' }],
onRequest: (url, method) =>
url === '/api/schedules/2/items' && method === 'GET'
? jsonResponse({ title: 'Boom', detail: 'Load failed' }, 500)
: null
});
// Make schedule 1 dirty (draft lineup differs from server).
fireEvent.change(screen.getByLabelText('Custom title'), { target: { value: 'Nightly' } });
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false);
// Switch to schedule 2 (confirm mocked true); its items GET fails.
fireEvent.change(screen.getByLabelText('Active schedule'), { target: { value: '2' } });
await screen.findByText('Load failed');
// Header moved to schedule 2, but there is no stale schedule-1 lineup, Save is disabled, and no
// PUT could ever be issued (which would have destructively replaced schedule 2 with A's draft).
expect(screen.getByRole('heading', { name: 'Late' })).toBeTruthy();
expect(screen.queryByLabelText('Schedule lineup')).toBeNull();
expect(screen.getByText('No schedule items')).toBeTruthy();
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(true);
expect(handle.requests.some((r) => r.url === '/api/schedules/2/items' && r.method === 'PUT')).toBe(false);
});
it('F3: selecting a different item resets per-item picker state (playlist group)', async () => {
await renderReady({
items: [
responseItem({ id: 10, index: 0, collectionType: 'Playlist', collectionId: null, collectionName: null, playbackOrder: 'None', playlistId: 100, playlistName: 'PL A', playlistGroupId: 1, name: 'PL A' }),
responseItem({ id: 11, index: 1, collectionType: 'Playlist', collectionId: null, collectionName: null, playbackOrder: 'None', playlistId: 200, playlistName: 'PL B', playlistGroupId: 2, name: 'PL B' })
],
onRequest: (url) =>
url === '/api/playlists/groups'
? jsonResponse([{ id: 1, name: 'Group One' }, { id: 2, name: 'Group Two' }])
: url.startsWith('/api/playlists?playlistGroupId=')
? jsonResponse([])
: null
});
const groupSelect = () => screen.getByLabelText('Playlist group') as HTMLSelectElement;
// Item A is selected by default → PlaylistPicker seeded with group 1.
expect(groupSelect().value).toBe('1');
// Select item B; with key={_key} the PlaylistPicker remounts and re-seeds groupId from item B.
const lineup = screen.getByLabelText('Schedule lineup');
fireEvent.click(within(lineup).getByText('PL B').closest('button') as HTMLButtonElement);
expect(groupSelect().value).toBe('2');
});
it('F4: edits during an in-flight Save are blocked (no lost update)', async () => {
let resolvePut: (r: Response) => void = () => {};
const putPromise = new Promise<Response>((res) => {
resolvePut = res;
});
const handle = await renderReady({
onRequest: (url, method) =>
url === '/api/schedules/1/items' && method === 'PUT'
? (putPromise as unknown as Response) // mockApi wraps in Promise.resolve, which adopts this thenable
: null
});
const title = () => screen.getByLabelText('Custom title') as HTMLInputElement;
fireEvent.change(title(), { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.method === 'PUT')).toBe(true));
// Attempt an edit while the Save is in flight — mutate() must no-op, so the input does NOT
// optimistically show 'Second' (without the guard it would, then be silently clobbered by the
// Save .then's setItems — the lost update). The PUT already sent 'First'.
fireEvent.change(title(), { target: { value: 'Second' } });
expect(title().value).toBe('First');
resolvePut(jsonResponse([responseItem({ id: 500, index: 0, customTitle: 'First', name: 'Item 0' })]));
await waitFor(() => expect(title().value).toBe('First'));
// The mid-save edit never produced a second PUT and never survived the reload.
expect(handle.requests.filter((r) => r.method === 'PUT').length).toBe(1);
});
it('F5: creating a schedule while the draft is dirty confirms before switching focus', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const handle = await renderReady();
fireEvent.change(screen.getByLabelText('Custom title'), { target: { value: 'Nightly' } });
fireEvent.click(screen.getByRole('button', { name: /Add schedule/ }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('Name'), { target: { value: 'Weekend' } });
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules' && r.method === 'POST')).toBe(true));
// The auto-switch to the new schedule went through the dirty guard; confirm=false aborted it.
await waitFor(() => expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('unsaved')));
expect(handle.requests.some((r) => r.url === '/api/schedules/99/items')).toBe(false);
});
});
describe('SchedulesScreen — schedule CRUD', () => {
it('create flow POSTs and switches to the new schedule', async () => {
const handle = await renderReady();
fireEvent.click(screen.getByRole('button', { name: /Add schedule/ }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('Name'), { target: { value: 'Weekend' } });
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules' && r.method === 'POST')).toBe(true));
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules/99/items')).toBe(true));
});
it('responds to the TopBar primary-action event by opening create', async () => {
await renderReady();
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: 'schedules' }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy());
expect(within(screen.getByRole('dialog')).getByLabelText('Name')).toBeTruthy();
});
it('delete flow DELETEs the active schedule', async () => {
const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] });
fireEvent.click(screen.getByRole('button', { name: /^Delete$/ }));
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: /^Delete$/ }));
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules/1' && r.method === 'DELETE')).toBe(true));
});
});
describe('SchedulesScreen — conditional rendering', () => {
it('shows Marathon fields only for Marathon order', async () => {
await renderReady({ items: [responseItem({ playbackOrder: 'Marathon', marathonGroupBy: 'Season' })] });
expect(screen.getByLabelText('Marathon group by')).toBeTruthy();
});
it('shows Duration fields only for Duration mode', async () => {
await renderReady({ items: [responseItem({ playoutMode: 'Duration', playoutDuration: '01:00:00', tailMode: 'None', discardToFillAttempts: 0 })] });
expect(screen.getByLabelText('Tail mode')).toBeTruthy();
expect(screen.getByLabelText('Discard to fill attempts')).toBeTruthy();
});
it('hides the Fixed start type when the schedule shuffles items', async () => {
await renderReady({ schedules: [{ ...schedule, shuffleScheduleItems: true }] });
const startType = screen.getByLabelText('Start type') as HTMLSelectElement;
const values = Array.from(startType.options).map((o) => o.value);
expect(values).toEqual(['Dynamic']);
});
});
+590
View File
@@ -0,0 +1,590 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CalendarClock, Copy, Pencil, Plus, RefreshCw, Save, Trash2, TriangleAlert, Undo2 } from 'lucide-react';
import {
Badge,
Button,
Card,
ConfirmDialog,
IconButton,
Select,
Spinner
} from '../components';
import {
deleteSchedule,
getFillerPresetsByKind,
getLanguages,
getPlaylistGroups,
getRerunCollections,
getScheduleItems,
getSchedules,
getGraphicsElements,
getWatermarks,
messageFromScheduleError,
type ProgramSchedule,
replaceScheduleItems
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import {
copyDraftItem,
type DraftItem,
draftItemLabel,
fromResponse,
humanizeEnum,
newDraftItem,
normalizeForSave,
validateDraft
} from '../schedules/itemRules';
import { ScheduleForm } from '../schedules/ScheduleForm';
import { ScheduleItemInspector, type SchedulePickerData } from '../schedules/ScheduleItemInspector';
const DIRTY_PROMPT = 'You have unsaved schedule changes. Discard them?';
type BootState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'ready'; schedules: ProgramSchedule[]; pickers: SchedulePickerData };
async function loadPickerData(): Promise<SchedulePickerData> {
const [rerun, playlistGroups, watermarks, graphicsElements, languages, pre, mid, post, tail, fallback] =
await Promise.all([
getRerunCollections({ pageNum: 1, pageSize: 1000 }),
getPlaylistGroups(),
getWatermarks(),
getGraphicsElements(),
getLanguages(),
getFillerPresetsByKind('PreRoll'),
getFillerPresetsByKind('MidRoll'),
getFillerPresetsByKind('PostRoll'),
getFillerPresetsByKind('Tail'),
getFillerPresetsByKind('Fallback')
]);
return {
rerunCollections: rerun.page ?? [],
playlistGroups,
watermarks,
graphicsElements,
languages,
fillerPreRoll: pre,
fillerMidRoll: mid,
fillerPostRoll: post,
fillerTail: tail,
fillerFallback: fallback
};
}
export function SchedulesScreen() {
const [boot, setBoot] = useState<BootState>({ status: 'loading' });
const [activeId, setActiveId] = useState<number | null>(null);
const [items, setItems] = useState<DraftItem[]>([]);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [itemsLoading, setItemsLoading] = useState(false);
// True only after a successful items GET for the CURRENT activeId. Gates every mutation surface so
// a failed/in-flight load can't leave a stale draft editable (F1) — see guardedSwitch/applySwitch.
const [itemsLoaded, setItemsLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [mutationError, setMutationError] = useState<string | null>(null);
const [form, setForm] = useState<null | 'create' | 'edit'>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const activeRef = useRef(true);
const dirtyRef = useRef(false);
const baselineRef = useRef<DraftItem[]>([]);
const itemSeq = useRef(0);
const setDirtyState = useCallback((value: boolean) => {
dirtyRef.current = value;
setDirty(value);
}, []);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// ---- Bootstrap load (schedules + pickers) ------------------------------
// Only sets state in promise callbacks (never synchronously in the effect body — see
// spa-conventions.md §3). The retry button flips to 'loading' itself before calling this.
const loadBootstrap = useCallback(() => {
Promise.all([getSchedules(), loadPickerData()])
.then(([schedules, pickers]) => {
if (!activeRef.current) {
return;
}
setBoot({ status: 'ready', schedules, pickers });
setActiveId((current) => current ?? schedules[0]?.id ?? null);
})
.catch((error: unknown) => {
if (activeRef.current) {
setBoot({ status: 'error', error: messageFromScheduleError(error, 'Unable to load schedules') });
}
});
}, []);
useEffect(() => {
loadBootstrap();
}, [loadBootstrap]);
// ---- Load items for the active schedule --------------------------------
const loadItems = useCallback((scheduleId: number) => {
const seq = ++itemSeq.current;
getScheduleItems(scheduleId)
.then((response) => {
if (!activeRef.current || itemSeq.current !== seq) {
return;
}
// Defensive: array position becomes the persisted index on the next PUT, so ingest strictly by the
// server-provided `index` rather than trusting response row order (see #229 — the API now orders, but
// the SPA must not silently reshuffle the lineup if that guarantee ever regresses).
const drafts = [...(response.items ?? [])].sort((a, b) => a.index - b.index).map(fromResponse);
baselineRef.current = drafts;
setItems(drafts);
setSelectedKey(drafts[0]?._key ?? null);
setDirtyState(false);
setMutationError(null);
setItemsLoading(false);
setItemsLoaded(true);
})
.catch((error: unknown) => {
if (!activeRef.current || itemSeq.current !== seq) {
return;
}
setMutationError(messageFromScheduleError(error, 'Unable to load schedule items'));
setItemsLoading(false);
setItemsLoaded(false);
});
}, [setDirtyState]);
useEffect(() => {
if (activeId != null) {
loadItems(activeId);
}
}, [activeId, loadItems]);
// ---- Dirty guards ------------------------------------------------------
useEffect(() => registerNavigationGuard(() => !dirtyRef.current || window.confirm(DIRTY_PROMPT)), []);
useEffect(() => {
if (!dirty) {
return;
}
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
// ---- TopBar "Add Schedule" primary action ------------------------------
useEffect(() => {
const handler = (event: Event) => {
if ((event as CustomEvent<string>).detail === 'schedules') {
setForm('create');
}
};
window.addEventListener('ctv:primary-action', handler);
return () => window.removeEventListener('ctv:primary-action', handler);
}, []);
if (boot.status === 'loading') {
return (
<Card>
<div className="ctv-dashboard-state">
<Spinner size={20} tone="accent" />
<span>Loading schedules</span>
</div>
</Card>
);
}
if (boot.status === 'error') {
return (
<Card
title={<h2>Schedules unavailable</h2>}
subtitle="Live API request failed"
actions={<Button onClick={() => { setBoot({ status: 'loading' }); loadBootstrap(); }} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
>
<div className="ctv-dashboard-error">
<span>API request failed</span>
<strong>{boot.error}</strong>
</div>
</Card>
);
}
const { schedules, pickers } = boot;
const activeSchedule = schedules.find((s) => s.id === activeId) ?? null;
const shuffleScheduleItems = activeSchedule?.shuffleScheduleItems ?? false;
const selectedItem = items.find((i) => i._key === selectedKey) ?? items[0] ?? null;
// Editing is only permitted once the CURRENT schedule's items have loaded and while no Save is in
// flight. Blocks the F1 destructive-cross-save (stale draft PUT into a different schedule) and the
// F4 lost-update (edits made during a Save that the .then's setItems would silently discard).
const canEdit = itemsLoaded && !saving;
// Synchronously drop the outgoing schedule's draft state BEFORE switching activeId, so a failed or
// in-flight items GET for the new schedule can never leave the previous (possibly dirty) draft
// visible/saveable (F1). The subsequent load re-arms itemsLoaded on success.
const applySwitch = (scheduleId: number | null) => {
setItems([]);
baselineRef.current = [];
setSelectedKey(null);
setDirtyState(false);
setMutationError(null);
setItemsLoaded(false);
setItemsLoading(scheduleId != null);
setActiveId(scheduleId);
};
const guardedSwitch = (scheduleId: number) => {
if (saving || scheduleId === activeId) {
return;
}
if (dirtyRef.current && !window.confirm(DIRTY_PROMPT)) {
return;
}
applySwitch(scheduleId);
};
const mutate = (nextItems: DraftItem[], nextSelected?: string | null) => {
if (!canEdit) {
return;
}
setItems(nextItems);
setDirtyState(true);
if (nextSelected !== undefined) {
setSelectedKey(nextSelected);
}
};
const updateItem = (next: DraftItem) => {
mutate(items.map((i) => (i._key === next._key ? next : i)));
};
const addItem = () => {
const item = newDraftItem();
mutate([...items, item], item._key);
};
const copyItem = (source: DraftItem) => {
const copy = copyDraftItem(source);
const index = items.findIndex((i) => i._key === source._key);
const next = [...items];
next.splice(index + 1, 0, copy);
mutate(next, copy._key);
};
const removeItem = (target: DraftItem) => {
const index = items.findIndex((i) => i._key === target._key);
const next = items.filter((i) => i._key !== target._key);
const nextSelected = next[Math.min(index, next.length - 1)]?._key ?? null;
mutate(next, nextSelected);
};
const moveItem = (target: DraftItem, direction: -1 | 1) => {
const index = items.findIndex((i) => i._key === target._key);
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= items.length) {
return;
}
const next = [...items];
const [moved] = next.splice(index, 1);
next.splice(nextIndex, 0, moved);
mutate(next);
};
const discard = () => {
if (!dirtyRef.current || window.confirm(DIRTY_PROMPT)) {
const restored = baselineRef.current;
setItems(restored);
setSelectedKey(restored[0]?._key ?? null);
setDirtyState(false);
setMutationError(null);
}
};
const save = () => {
if (activeId == null || saving || !itemsLoaded) {
return;
}
const validationError = validateDraft(items);
if (validationError) {
setMutationError(validationError);
return;
}
setSaving(true);
setMutationError(null);
replaceScheduleItems(activeId, { items: items.map(normalizeForSave) })
.then((response) => {
if (!activeRef.current) {
return;
}
const drafts = [...response].sort((a, b) => a.index - b.index).map(fromResponse);
baselineRef.current = drafts;
setItems(drafts);
setSelectedKey((current) => {
const priorIndex = items.findIndex((i) => i._key === current);
return drafts[priorIndex >= 0 ? priorIndex : 0]?._key ?? null;
});
setDirtyState(false);
setSaving(false);
})
.catch((error: unknown) => {
if (!activeRef.current) {
return;
}
setMutationError(messageFromScheduleError(error, 'Unable to save schedule items'));
setSaving(false);
});
};
const onScheduleSaved = (saved: ProgramSchedule) => {
setForm(null);
setBoot((current) => {
if (current.status !== 'ready') {
return current;
}
const exists = current.schedules.some((s) => s.id === saved.id);
const schedulesNext = exists
? current.schedules.map((s) => (s.id === saved.id ? saved : s))
: [...current.schedules, saved];
return { ...current, schedules: schedulesNext };
});
if (!schedules.some((s) => s.id === saved.id)) {
// New schedule: switch focus to it, but through the same dirty guard as the schedule picker so
// an unsaved draft on the current schedule isn't silently wiped without a confirm (F5).
guardedSwitch(saved.id);
}
};
const onDeleteSchedule = () => {
if (activeId == null) {
return;
}
deleteSchedule(activeId)
.then(() => {
if (!activeRef.current) {
return;
}
setConfirmDelete(false);
setBoot((current) => {
if (current.status !== 'ready') {
return current;
}
return { ...current, schedules: current.schedules.filter((s) => s.id !== activeId) };
});
const remaining = schedules.filter((s) => s.id !== activeId);
// Drop the deleted schedule's draft and load the next one (or clear when none remain) — same
// synchronous state reset as a picker switch so no stale draft survives (F1).
applySwitch(remaining[0]?.id ?? null);
})
.catch((error: unknown) => {
if (activeRef.current) {
setMutationError(messageFromScheduleError(error, 'Unable to delete schedule'));
}
});
};
const canReorder = !shuffleScheduleItems;
return (
<div className="ctv-schedule-screen">
<section className="ctv-schedule-header">
<span className="ctv-schedule-header-icon"><CalendarClock aria-hidden="true" size={18} /></span>
<div>
<h2>{activeSchedule?.name ?? 'No schedule selected'}</h2>
<p>
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {items.length} item{items.length === 1 ? '' : 's'}
{dirty && <> · <strong>unsaved changes</strong></>}
</p>
</div>
{schedules.length > 0 && activeSchedule && (
<Select
label="Active schedule"
size="sm"
disabled={saving}
value={`${activeSchedule.id}`}
onChange={(e) => guardedSwitch(Number(e.target.value))}
options={schedules.map((s) => ({ value: `${s.id}`, label: s.name ?? `Schedule ${s.id}` }))}
/>
)}
<div className="ctv-schedule-header-actions">
<Button size="sm" variant="ghost" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => setForm('create')}>Add schedule</Button>
{activeSchedule && (
<>
<Button size="sm" variant="ghost" startIcon={<Pencil aria-hidden="true" size={14} />} onClick={() => setForm('edit')}>Edit</Button>
<Button size="sm" variant="ghost" startIcon={<Trash2 aria-hidden="true" size={14} />} onClick={() => setConfirmDelete(true)}>Delete</Button>
</>
)}
</div>
</section>
{mutationError && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{mutationError}</span>
</div>
)}
{!activeSchedule ? (
<Card title={<h2>No schedules</h2>} subtitle="Create a schedule to build a lineup.">
<div className="ctv-schedule-empty">
<Button startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => setForm('create')}>Add schedule</Button>
</div>
</Card>
) : (
<>
<div className="ctv-schedule-toolbar">
<Button size="sm" variant="ghost" disabled={!canEdit} startIcon={<Plus aria-hidden="true" size={14} />} onClick={addItem}>Add item</Button>
<div className="ctv-schedule-toolbar-spacer" />
{itemsLoading && <Spinner size={14} tone="muted" />}
<Button size="sm" variant="ghost" disabled={!dirty || saving} startIcon={<Undo2 aria-hidden="true" size={14} />} onClick={discard}>Discard</Button>
<Button size="sm" disabled={!dirty || saving || !itemsLoaded} loading={saving} startIcon={<Save aria-hidden="true" size={14} />} onClick={save}>Save</Button>
</div>
<div className="ctv-schedule-grid">
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
<div className="ctv-schedule-panel-head">
<span>Lineup{canReorder ? ' · use arrows to reorder' : ' · order fixed (shuffled schedule)'}</span>
</div>
{items.length === 0 ? (
<div className="ctv-schedule-empty">No schedule items</div>
) : (
<div className="ctv-schedule-lineup" role="list" aria-label="Schedule lineup">
{items.map((item, index) => (
<LineupRow
key={item._key}
item={item}
index={index}
active={item._key === selectedItem?._key}
canReorder={canReorder}
disabled={!canEdit}
isFirst={index === 0}
isLast={index === items.length - 1}
onSelect={() => setSelectedKey(item._key)}
onCopy={() => copyItem(item)}
onRemove={() => removeItem(item)}
onMove={(dir) => moveItem(item, dir)}
/>
))}
</div>
)}
</section>
<section className="ctv-schedule-inspector" aria-label="Schedule item inspector">
{selectedItem ? (
<>
<div className="ctv-schedule-inspector-head">
<div>
<strong>{draftItemLabel(selectedItem)}</strong>
<span>{humanizeEnum(selectedItem.collectionType)}</span>
</div>
<IconButton title="Copy item" disabled={!canEdit} onClick={() => copyItem(selectedItem)}><Copy aria-hidden="true" size={15} /></IconButton>
<IconButton title="Remove item" disabled={!canEdit} onClick={() => removeItem(selectedItem)}><Trash2 aria-hidden="true" size={15} /></IconButton>
</div>
<ScheduleItemInspector
key={selectedItem._key}
item={selectedItem}
shuffleScheduleItems={shuffleScheduleItems}
pickers={pickers}
disabled={!canEdit}
onChange={updateItem}
/>
</>
) : (
<div className="ctv-schedule-empty">Select or add a schedule item</div>
)}
</section>
</div>
</>
)}
{form && (
<ScheduleForm
mode={form}
schedule={form === 'edit' ? activeSchedule : null}
onSaved={onScheduleSaved}
onClose={() => setForm(null)}
/>
)}
<ConfirmDialog
open={confirmDelete}
tone="danger"
title="Delete schedule"
message={`Delete "${activeSchedule?.name ?? ''}"? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={onDeleteSchedule}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}
function LineupRow({
item,
index,
active,
canReorder,
disabled,
isFirst,
isLast,
onSelect,
onCopy,
onRemove,
onMove
}: {
item: DraftItem;
index: number;
active: boolean;
canReorder: boolean;
disabled: boolean;
isFirst: boolean;
isLast: boolean;
onSelect: () => void;
onCopy: () => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
}) {
const label = draftItemLabel(item);
const fixed = item.startType === 'Fixed';
return (
<div className="ctv-schedule-lineup-row" role="listitem">
<div className="ctv-schedule-rail" aria-hidden="true">
<span className={fixed ? 'ctv-schedule-rail-dot-fixed' : 'ctv-schedule-rail-dot'} />
<code>{fixed ? (item.startTime?.slice(0, 5) ?? '--:--') : 'flows'}</code>
</div>
<div className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}`}>
<button type="button" className="ctv-schedule-block-main" aria-current={active ? 'true' : undefined} onClick={onSelect}>
<span className="ctv-schedule-block-index">{index + 1}</span>
<span className="ctv-schedule-block-title">
<strong>{label}</strong>
<small>{humanizeEnum(item.collectionType)} · {humanizeEnum(item.playoutMode)}</small>
</span>
</button>
<div className="ctv-schedule-block-meta">
<Badge tone="neutral">{humanizeEnum(item.playbackOrder)}</Badge>
{item.guideMode === 'Filler' && <Badge tone="neutral">Hidden from guide</Badge>}
</div>
<div className="ctv-schedule-block-actions">
{canReorder && (
<>
<IconButton size="sm" disabled={isFirst || disabled} title={`Move ${label} up`} onClick={() => onMove(-1)}>
<span aria-hidden="true">↑</span>
</IconButton>
<IconButton size="sm" disabled={isLast || disabled} title={`Move ${label} down`} onClick={() => onMove(1)}>
<span aria-hidden="true">↓</span>
</IconButton>
</>
)}
<IconButton size="sm" disabled={disabled} title={`Copy ${label}`} onClick={onCopy}><Copy aria-hidden="true" size={14} /></IconButton>
<IconButton size="sm" disabled={disabled} title={`Remove ${label}`} onClick={onRemove}><Trash2 aria-hidden="true" size={14} /></IconButton>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -167,5 +167,7 @@ describe('TrashScreen', () => {
await waitFor(() => {
expect(screen.queryByRole('button', { name: /See all/ })).not.toBeInTheDocument();
});
});
// Generous timeout: this test renders a 130-item grid twice; the CI VM has
// hit the default 5s limit (run 686) while local runs pass comfortably.
}, 15000);
});
+182
View File
@@ -1323,6 +1323,188 @@ body {
gap: var(--space-6, 12px);
}
/* --- Schedules editor (#207) --------------------------------------------- */
.ctv-schedule-header-actions {
display: flex;
gap: var(--space-4, 8px);
align-items: center;
}
.ctv-schedule-toolbar {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
padding: var(--space-4, 8px) var(--space-6, 12px);
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-4, 10px);
background: var(--surface-2, rgba(255, 255, 255, 0.02));
}
.ctv-schedule-toolbar-spacer {
flex: 1;
}
.ctv-schedule-block-index {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
font-size: 12px;
font-variant-numeric: tabular-nums;
background: var(--surface-3, rgba(255, 255, 255, 0.06));
color: var(--text-muted, rgba(255, 255, 255, 0.6));
}
.ctv-schedule-fieldset {
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: var(--radius-4, 10px);
padding: var(--space-6, 12px);
display: flex;
flex-direction: column;
gap: var(--space-6, 12px);
min-width: 0;
}
.ctv-schedule-fieldset > legend {
padding: 0 var(--space-4, 8px);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted, rgba(255, 255, 255, 0.6));
}
.ctv-schedule-form-stack {
display: flex;
flex-direction: column;
gap: var(--space-6, 12px);
}
.ctv-field-help {
margin: 0;
font-size: 12px;
color: var(--text-muted, rgba(255, 255, 255, 0.6));
}
/* Search + multi-select pickers */
.ctv-picker {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
}
.ctv-picker-selected {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
padding: 6px 10px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-3, 8px);
background: var(--surface-2, rgba(255, 255, 255, 0.02));
}
.ctv-picker-selected > span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-picker-clear,
.ctv-picker-change {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted, rgba(255, 255, 255, 0.6));
font-size: 12px;
padding: 2px 6px;
border-radius: 6px;
}
.ctv-picker-change:hover,
.ctv-picker-clear:hover {
color: var(--text-strong, #fff);
background: var(--surface-3, rgba(255, 255, 255, 0.06));
}
.ctv-picker-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
margin: 0;
padding: 4px;
list-style: none;
max-height: 240px;
overflow: auto;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-3, 8px);
background: var(--surface-1, #1a1a1f);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.ctv-picker-result {
display: block;
width: 100%;
text-align: left;
padding: 7px 9px;
border: none;
background: none;
color: inherit;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.ctv-picker-result:hover,
.ctv-picker-result[aria-selected='true'] {
background: var(--surface-3, rgba(255, 255, 255, 0.08));
}
.ctv-picker-empty {
padding: 8px 10px;
font-size: 12px;
color: var(--text-muted, rgba(255, 255, 255, 0.6));
border: 1px dashed var(--border-subtle, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-3, 8px);
}
.ctv-multiselect {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ctv-multiselect-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.14));
background: var(--surface-2, rgba(255, 255, 255, 0.02));
color: var(--text-muted, rgba(255, 255, 255, 0.7));
cursor: pointer;
font-size: 12px;
}
.ctv-multiselect-chip-active {
border-color: var(--accent, #6ea8fe);
color: var(--text-strong, #fff);
background: color-mix(in srgb, var(--accent, #6ea8fe) 18%, transparent);
}
.ctv-multiselect-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ctv-schedule-note {
display: flex;
align-items: center;