Merge pull request 'feat: playouts delete/reset/erase + collections custom-order/all-kind adds + parity nits (#210, #211, #213)' (#214) from feat/210-211-playouts-collections into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m10s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m4s

This commit was merged in pull request #214.
This commit is contained in:
2026-07-09 21:17:29 +00:00
41 changed files with 2670 additions and 99 deletions
@@ -16,32 +16,58 @@ public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFac
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool exists = await dbContext.Collections
// A null flag here means the collection row does not exist (the projection yields no row),
// which lets a single query serve both the existence check and the custom-order flag.
bool? useCustomPlaybackOrder = await dbContext.Collections
.AsNoTracking()
.AnyAsync(c => c.Id == request.Id, cancellationToken);
if (!exists)
.Where(c => c.Id == request.Id)
.Select(c => (bool?)c.UseCustomPlaybackOrder)
.SingleOrDefaultAsync(cancellationToken);
if (useCustomPlaybackOrder is null)
{
return new NotFoundError($"Collection {request.Id} does not exist.");
}
// The collection graph is bounded, so load every member id and hydrate them in one shared
// pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported
// because MediaItem ids are globally unique across kinds.
List<int> mediaItemIds = await dbContext.CollectionItems
// The collection graph is bounded, so load every member (with its CustomIndex) and hydrate
// them in one shared pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media
// kinds are supported because MediaItem ids are globally unique across kinds.
var collectionItems = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => ci.MediaItemId)
.Select(ci => new { ci.MediaItemId, ci.CustomIndex })
.ToListAsync(cancellationToken);
List<int> mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList();
List<LibraryBrowseItemResponseModel> all =
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
List<LibraryBrowseItemResponseModel> ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
List<LibraryBrowseItemResponseModel> ordered;
if (useCustomPlaybackOrder.Value)
{
// Custom order: sort by CustomIndex (items without one sort last), then title/id as a
// stable tiebreak.
var customIndexByMediaItemId = collectionItems
.GroupBy(ci => ci.MediaItemId)
.ToDictionary(g => g.Key, g => g.Select(ci => ci.CustomIndex).FirstOrDefault());
ordered = all
.OrderBy(i => customIndexByMediaItemId.TryGetValue(i.Id, out int? customIndex) && customIndex.HasValue
? customIndex.Value
: int.MaxValue)
.ThenBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
}
else
{
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
}
int pageNum = Math.Max(0, request.PageNum);
int pageSize = Math.Clamp(request.PageSize, 1, 100);
+1
View File
@@ -23,6 +23,7 @@ internal static class Mapper
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
playoutItem.Id,
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset,
playoutItem.FinishOffset,
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
@@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
var gap = playoutGaps.Single(g => g.Id == c.Id);
TimeSpan gapDuration = gap.Finish - gap.Start;
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
return new PlayoutItemViewModel(
null,
"UNSCHEDULED",
gap.StartOffset,
gap.FinishOffset,
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutItemSchedulingContext(int PlayoutItemId) : IRequest<Option<string>>;
@@ -0,0 +1,33 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutItemSchedulingContextHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediator mediator)
: IRequestHandler<GetPlayoutItemSchedulingContext, Option<string>>
{
public async Task<Option<string>> Handle(
GetPlayoutItemSchedulingContext request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string serializedContext = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Id == request.PlayoutItemId)
.Select(pi => pi.SchedulingContext)
.SingleOrDefaultAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(serializedContext))
{
return Option<string>.None;
}
// Decode/enrich exactly the way the troubleshooting decode path does, reusing the
// single ProcessSchedulingContext handler so any future format change stays in one place.
return await mediator.Send(new ProcessSchedulingContext(serializedContext), cancellationToken);
}
}
@@ -1,10 +1,13 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
FillerKind? FillerKind);
FillerKind? FillerKind,
bool HasSchedulingContext);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemSchedulingContextResponseModel(string Context);
@@ -10,4 +10,5 @@ public record PlayoutListItemResponseModel(
PlayoutScheduleKind ScheduleKind,
string ScheduleName,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus);
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode);
@@ -0,0 +1,138 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class GetCollectionItemsHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Return_NotFound_For_Missing_Collection()
{
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Order_By_Title_When_Custom_Order_Disabled()
{
await SeedCollectionGraph(useCustomPlaybackOrder: false);
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
page.TotalCount.ShouldBe(3);
page.Page.Select(i => i.Title).ShouldBe(["Alpha", "Beta", "Zeta"]);
}
[Test]
public async Task Handle_Should_Order_By_CustomIndex_With_Nulls_Last_When_Custom_Order_Enabled()
{
await SeedCollectionGraph(useCustomPlaybackOrder: true);
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
// Zeta has CustomIndex 0, Alpha has CustomIndex 1, Beta has no CustomIndex (sorts last).
page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]);
}
private async Task SeedCollectionGraph(bool useCustomPlaybackOrder)
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Library",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = 1,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
Movie alpha = MakeMovie(101, path, "Alpha");
Movie beta = MakeMovie(102, path, "Beta");
Movie zeta = MakeMovie(103, path, "Zeta");
var collection = new Collection
{
Id = 10,
Name = "Manual",
UseCustomPlaybackOrder = useCustomPlaybackOrder,
MediaItems = [],
CollectionItems =
[
new CollectionItem { MediaItemId = 103, CustomIndex = 0 },
new CollectionItem { MediaItemId = 101, CustomIndex = 1 },
new CollectionItem { MediaItemId = 102, CustomIndex = null }
],
MultiCollections = [],
MultiCollectionItems = []
};
context.LocalLibraries.Add(library);
context.Movies.AddRange(alpha, beta, zeta);
context.Collections.Add(collection);
await context.SaveChangesAsync();
}
private static Movie MakeMovie(int id, LibraryPath path, string title) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MovieMetadata =
[
new MovieMetadata
{
Title = title,
SortTitle = title,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }]
};
}
@@ -45,6 +45,66 @@ public class CollectionControllerTests
nameof(CollectionController.RemoveItem),
"DELETE",
"/api/collections/{id:int}/items/{mediaItemId:int}");
ShouldHaveActionRoute(
nameof(CollectionController.UpdateCustomOrder),
"PUT",
"/api/collections/{id:int}/custom-order");
}
[Test]
public async Task UpdateCustomOrder_Should_Return_404_When_Collection_Missing()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.None);
IActionResult result = await _controller.UpdateCustomOrder(
99,
new UpdateCollectionCustomOrderRequest([10, 20]),
CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateCustomOrder_Should_Return_204_And_Assign_Index_From_Order()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
_mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.UpdateCustomOrder(
7,
new UpdateCollectionCustomOrderRequest([30, 10, 20]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateCollectionCustomOrder>(c =>
c.CollectionId == 7 &&
c.MediaItemCustomOrders.Count == 3 &&
c.MediaItemCustomOrders[0].MediaItemId == 30 && c.MediaItemCustomOrders[0].CustomIndex == 0 &&
c.MediaItemCustomOrders[1].MediaItemId == 10 && c.MediaItemCustomOrders[1].CustomIndex == 1 &&
c.MediaItemCustomOrders[2].MediaItemId == 20 && c.MediaItemCustomOrders[2].CustomIndex == 2),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateCustomOrder_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
_mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateCustomOrder(
7,
new UpdateCollectionCustomOrderRequest([10]),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
@@ -191,6 +191,13 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/items", "get", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "422")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")]
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
[TestCase("/api/collections/{id}/custom-order", "put", "404")]
[TestCase("/api/collections/{id}/custom-order", "put", "422")]
[TestCase("/api/artwork/uploads", "post", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "404")]
@@ -57,6 +57,130 @@ public class PlayoutControllerTests
ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/playouts/{id:int}/blocks");
ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/playouts/{id:int}/blocks/{blockId:int}/history");
ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/playouts/history/{id:int}");
ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/playouts/{id:int}/erase-items");
ShouldHaveActionRoute(
nameof(PlayoutController.EraseItemsAndHistory),
"POST",
"/api/playouts/{id:int}/erase-items-and-history");
ShouldHaveActionRoute(
nameof(PlayoutController.GetItemSchedulingContext),
"GET",
"/api/playouts/items/{id:int}/scheduling-context");
}
// ----- Erase items / history -----
[Test]
public async Task EraseItems_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.EraseItems(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.ExternalJson)]
public async Task EraseItems_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task EraseItems_Should_Return_204_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutItems>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.EraseItemsAndHistory(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_422_For_Unsupported_Kind()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.ExternalJson }));
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task EraseItemsAndHistory_Should_Return_204_And_Send_Command_For_Supported_Kind(
PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutHistory>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
// ----- Playout item scheduling context -----
[Test]
public async Task GetItemSchedulingContext_Should_Return_200_With_Decoded_Context()
{
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("{ \"decoded\": true }"));
IActionResult result = await _controller.GetItemSchedulingContext(42, CancellationToken.None);
var context = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<PlayoutItemSchedulingContextResponseModel>();
context.Context.ShouldBe("{ \"decoded\": true }");
await _mediator.Received(1).Send(
Arg.Is<GetPlayoutItemSchedulingContext>(q => q.PlayoutItemId == 42),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItemSchedulingContext_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
IActionResult result = await _controller.GetItemSchedulingContext(404, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
}
[Test]
@@ -364,6 +488,7 @@ public class PlayoutControllerTests
item.ChannelName.ShouldBe("Channel");
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
item.ScheduleName.ShouldBe("Schedule");
item.PlayoutMode.ShouldBe(ChannelPlayoutMode.Continuous);
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
item.BuildStatus.ShouldNotBeNull();
item.BuildStatus.Success.ShouldBeFalse();
@@ -394,6 +519,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var item = new PlayoutItemViewModel(
77,
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
@@ -401,6 +527,7 @@ public class PlayoutControllerTests
string.Empty,
Some(FillerKind.MidRoll));
var gap = new PlayoutItemViewModel(
null,
"UNSCHEDULED",
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
@@ -414,9 +541,11 @@ public class PlayoutControllerTests
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.TotalCount.ShouldBe(2);
result.Page[0].Id.ShouldBe(77);
result.Page[0].Title.ShouldBe("Movie");
result.Page[0].Duration.ShouldBe("1:00:00");
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
result.Page[1].Id.ShouldBeNull();
result.Page[1].Title.ShouldBe("UNSCHEDULED");
result.Page[1].FillerKind.ShouldBeNull();
@@ -426,6 +555,35 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Flag_HasSchedulingContext_Without_Exposing_Raw_Json()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var withContext = new PlayoutItemViewModel(
42,
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
"1:00:00",
"{ \"ScheduleId\": 1 }",
Option<FillerKind>.None);
var withoutContext = withContext with { SchedulingContext = " " };
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutItemsViewModel(2, [withContext, withoutContext]));
IActionResult actionResult = await _controller.GetItems(9, showFiller: false, 0, 100, CancellationToken.None);
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.Page[0].HasSchedulingContext.ShouldBeTrue();
result.Page[1].HasSchedulingContext.ShouldBeFalse();
// The raw JSON blob must never appear on the list DTO.
typeof(PlayoutItemResponseModel).GetProperty("SchedulingContext").ShouldBeNull();
}
[Test]
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
{
@@ -96,6 +96,33 @@ public class CollectionController(IMediator mediator) : ControllerBase
});
}
[HttpPut("/api/collections/{id:int}/custom-order")]
[Tags("Collections")]
[EndpointSummary("Set a collection's custom playback order")]
[EndpointDescription(
"Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position " +
"(the request body has no index field); ids that are not members of the collection are ignored. Set " +
"UseCustomPlaybackOrder on the collection for this order to take effect.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateCustomOrder(
int id,
[Required] [FromBody] UpdateCollectionCustomOrderRequest request,
CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> maybeCollection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
if (maybeCollection.IsNone)
{
return ApiResults.NotFoundProblem();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpDelete("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
+86 -2
View File
@@ -521,6 +521,87 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return Accepted();
}
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
[Tags("Playouts")]
[EndpointSummary("Erase a playout's items")]
[EndpointDescription(
"Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, " +
"preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds " +
"return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential
or PlayoutScheduleKind.Scripted))
{
return BaseError.New("[EraseItems] is only valid for Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ErasePlayoutItems(id), cancellationToken);
return NoContent();
}
[HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")]
[Tags("Playouts")]
[EndpointSummary("Erase a playout's items and history")]
[EndpointDescription(
"Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or " +
"Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
{
return BaseError.New(
"[EraseItemsAndHistory] is only valid for Classic, Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ErasePlayoutHistory(id), cancellationToken);
return NoContent();
}
[HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
[Tags("Playouts")]
[EndpointSummary("Decode a playout item's scheduling context")]
[EndpointDescription(
"Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched " +
"JSON. Returns 404 when the item is missing or has no scheduling context.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutItemSchedulingContextResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItemSchedulingContext(int id, CancellationToken cancellationToken)
{
Option<string> result = await mediator.Send(new GetPlayoutItemSchedulingContext(id), cancellationToken);
return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult();
}
[HttpDelete("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
@@ -649,7 +730,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.ScheduleKind,
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
@@ -661,9 +743,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
new(
vm.Id,
vm.Title,
vm.Start,
vm.Finish,
vm.Duration,
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null),
!string.IsNullOrWhiteSpace(vm.SchedulingContext));
}
@@ -0,0 +1,13 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateCollectionCustomOrderRequest(List<int> MediaItemIds)
{
public UpdateCollectionCustomOrder ToCommand(int collectionId) =>
new(
collectionId,
(MediaItemIds ?? [])
.Select((mediaItemId, index) => new MediaItemCustomOrder(mediaItemId, index))
.ToList());
}
+333 -9
View File
@@ -2793,6 +2793,96 @@
}
}
},
"/api/collections/{id}/custom-order": {
"put": {
"tags": [
"Collections"
],
"summary": "Set a collection's custom playback order",
"description": "Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position (the request body has no index field); ids that are not members of the collection are ignored. Set UseCustomPlaybackOrder on the collection for this order to take effect.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/UpdateCollectionCustomOrderRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/collections/{id}/items/{mediaItemId}": {
"delete": {
"tags": [
@@ -8132,6 +8222,201 @@
}
}
},
"/api/playouts/{id}/erase-items": {
"post": {
"tags": [
"Playouts"
],
"summary": "Erase a playout's items",
"description": "Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds return 422.",
"operationId": "ErasePlayoutItems",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/playouts/{id}/erase-items-and-history": {
"post": {
"tags": [
"Playouts"
],
"summary": "Erase a playout's items and history",
"description": "Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.",
"operationId": "ErasePlayoutItemsAndHistory",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/playouts/items/{id}/scheduling-context": {
"get": {
"tags": [
"Playouts"
],
"summary": "Decode a playout item's scheduling context",
"description": "Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched JSON. Returns 404 when the item is missing or has no scheduling context.",
"operationId": "GetPlayoutItemSchedulingContext",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PlayoutItemSchedulingContextResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/rerun-collections": {
"get": {
"tags": [
@@ -19049,19 +19334,25 @@
},
"PlayoutItemResponseModel": {
"required": [
"id",
"title",
"start",
"finish",
"duration",
"fillerKind"
"fillerKind",
"hasSchedulingContext"
],
"type": "object",
"properties": {
"title": {
"id": {
"type": [
"null",
"string"
]
"integer"
],
"format": "int32"
},
"title": {
"type": "string"
},
"start": {
"type": "string",
@@ -19072,10 +19363,7 @@
"format": "date-time"
},
"duration": {
"type": [
"null",
"string"
]
"type": "string"
},
"fillerKind": {
"oneOf": [
@@ -19086,6 +19374,20 @@
"$ref": "#/components/schemas/FillerKind"
}
]
},
"hasSchedulingContext": {
"type": "boolean"
}
}
},
"PlayoutItemSchedulingContextResponseModel": {
"required": [
"context"
],
"type": "object",
"properties": {
"context": {
"type": "string"
}
}
},
@@ -19097,7 +19399,8 @@
"scheduleKind",
"scheduleName",
"dailyRebuildTime",
"buildStatus"
"buildStatus",
"playoutMode"
],
"type": "object",
"properties": {
@@ -19133,6 +19436,9 @@
"$ref": "#/components/schemas/PlayoutBuildStatusResponseModel"
}
]
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
}
}
},
@@ -21394,6 +21700,24 @@
}
}
},
"UpdateCollectionCustomOrderRequest": {
"required": [
"mediaItemIds"
],
"type": "object",
"properties": {
"mediaItemIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"UpdateCollectionRequest": {
"required": [
"name",
+11 -8
View File
@@ -55,13 +55,13 @@ redirect).
> | Cluster | Verdict | Gap issue |
> |---|---|---|
> | Troubleshooting (4 routes) | PARITY-OK (2 minors) | #213 |
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK except block-copy UI | #213 |
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK (block-copy UI added 2026-07-09) | — |
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
> | Watermarks | copy missing | #213 |
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — |
> | Playout kind-editors + list actions | GAPS (delete/reset unwired, erase, context) | **#210** |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash minors → #213) | — |
> | Collections | GAPS (custom order, add-item kinds) | **#211** |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09) | — |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear added; 100/kind cap → decisions.md) | — |
> | 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 |
> | Channels-numbers / Logs | PARITY-OK / minors | #213 |
> | Search | GAPS SEVERE (display-only, no mutations) | **#208** |
@@ -181,9 +181,12 @@ REST endpoints exist and are unused; only item delete/reorder and the schedule s
### Remaining mutation-depth gaps inside Section 2 rows
Tracked as #91 phase (b) gates without moving whole rows: **#208** (search screen mutations),
**#209** (media browse/detail mutations + per-show scan + episode info/troubleshoot entries),
**#210** (playout delete/reset/erase/context), **#211** (collections custom order + add-item
kinds); SHOULD-FIX: #212 (channel editor), #213 (nits batch).
**#209** (media browse/detail mutations + per-show scan + episode info/troubleshoot entries);
SHOULD-FIX: #212 (channel editor), #213 (remaining nits: logs sort + page-size persistence,
block-history page-size/gating, blocks/templates list filter — all read-only conveniences).
CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar)
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
select-all, and Trakt-note items of #213 landed in the same PR.
## Section 4 — Blazor home / escape hatch
+58
View File
@@ -143,3 +143,61 @@ so the spec matches the wire format by construction. A contract test
the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the
real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read
`fFmpegProfile` and always showed "Unassigned"). Issue #198.
## 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path
The legacy Blazor YAML playout validator took a **server-side file path** (read directly off the
container's filesystem). The SPA's `YamlValidatorScreen.tsx` instead uses a paste `<textarea>` — a
deliberate deviation, not an oversight. The SPA runs entirely client-side against `/api/*` and has
no access to the server's filesystem, so a file-path field would either need a new
filesystem-browsing endpoint or silently fail; pasting the YAML directly is simpler and matches how
every other SPA editor already round-trips content through the API instead of the disk.
## 2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder
The legacy Blazor channel list let you drag-and-drop rows to reorder channel numbers. The SPA
(`App.tsx`) instead offers a "Renumber" action that walks the list and asks for each channel's new
number via a sequence of native `prompt()` calls. Deliberate deviation: drag-to-reorder needs a
dedicated drag library and a bespoke reorder-persistence endpoint; a sequential prompt reuses the
existing per-channel update call and needs no new UI dependency. Revisit only if channel counts grow
large enough that prompt-per-channel becomes tedious.
## 2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor
Extends the 2026-07 "Templates editor in the SPA is a table, not Blazor's drag-calendar" entry
above (not editing that entry — this generalizes it): the deco-templates editor
(`DecoTemplatesScreen.tsx`) follows the same convention, rendering its day/deco assignment as a
table rather than reproducing Blazor's drag-and-drop calendar grid. Same rationale, same
accepted-deviation status — don't "fix" either editor to match Blazor's interaction model without
discussing it first.
## 2026-07-09 — Trash "see all" is capped at 100 items per kind; true paging deferred
`TrashScreen.tsx` requests `GET /api/search?query=state:FileNotFound&pageSize=100` — 100 is
`SearchController`'s `MaxPageSize`, and the endpoint has **no page-number parameter**, so a client
can't page past the first 100 matches of a given media kind. The SPA shows the first 100 per kind
(movies, shows, episodes, etc. are separate groups, so in practice most trash lists fit well within
that per-kind cap); this mirrors the legacy Blazor Trash page, which had the same underlying search
behavior and cap. True paging is deferred until the search API grows a page param — not attempted
here, since it would mean adding a paging contract server-side, out of scope for this pass.
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
(setting the daily rebuild time). The SPA keeps Reset — `POST
/api/channels/{channelNumber}/playout/reset` with no `mode` param, so the server picks the same
default Blazor used (Classic → Refresh, everything else → Reset) — but drops the dedicated
"Schedule reset" button: the daily rebuild time is already editable through the playout's
Edit-details flow, so a second entry point would duplicate an existing capability. Deliberate
deviation, not a lost capability. Issue #210.
## 2026-07-09 — Collection custom order: move up/down buttons, any-kind collections
Blazor reordered collection items with SortableJS drag-and-drop and only enabled custom ordering
for movies-only collections. The SPA (`CollectionsScreen.tsx`) uses per-row **Move up / Move
down** buttons in an explicit reorder mode instead of drag (no new drag dependency; the mode loads
ALL items first because `PUT /api/collections/{id}/custom-order` replaces the whole order from
array position — submitting a partial page would scramble the rest), and does **not** replicate
the movies-only gate: the API and the playback-side `CustomOrderCollectionEnumerator` sort by
`CustomIndex` regardless of item kind, so the SPA offers reorder for any manual collection with
custom order enabled. Issue #211.
+5 -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`.*
125 endpoints, 198 operations.
129 endpoints, 202 operations.
## Artists
@@ -75,6 +75,7 @@
| DELETE | `/api/collections/{id}` | | Delete a collection |
| GET | `/api/collections/{id}` | GetCollectionById | Get a collection by id |
| PUT | `/api/collections/{id}` | | Update a collection |
| PUT | `/api/collections/{id}/custom-order` | | Set a collection's custom playback order |
| GET | `/api/collections/{id}/items` | GetCollectionItems | Get the items in a manual collection |
| POST | `/api/collections/{id}/items` | | Add items to a collection |
| DELETE | `/api/collections/{id}/items/{mediaItemId}` | | Remove an item from a collection |
@@ -221,6 +222,7 @@
| GET | `/api/playouts` | GetPlayouts | List playouts |
| POST | `/api/playouts` | | Create a playout |
| GET | `/api/playouts/history/{id}` | GetPlayoutHistoryDetails | Decode a playout history row |
| GET | `/api/playouts/items/{id}/scheduling-context` | GetPlayoutItemSchedulingContext | Decode a playout item's scheduling context |
| POST | `/api/playouts/reset-all` | ResetAllPlayouts | Reset all playouts |
| GET | `/api/playouts/warnings/count` | GetPlayoutWarningsCount | Count playouts with a failed build |
| DELETE | `/api/playouts/{id}` | | Delete a playout |
@@ -231,6 +233,8 @@
| GET | `/api/playouts/{id}/blocks` | GetPlayoutBlocks | Get the blocks scheduled by a block playout |
| GET | `/api/playouts/{id}/blocks/{blockId}/history` | GetPlayoutBlockHistory | Get a block's playout history |
| PUT | `/api/playouts/{id}/deco` | | Set (or clear) a playout's default deco |
| POST | `/api/playouts/{id}/erase-items` | ErasePlayoutItems | Erase a playout's items |
| POST | `/api/playouts/{id}/erase-items-and-history` | ErasePlayoutItemsAndHistory | Erase a playout's items and history |
| GET | `/api/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout |
| GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
| PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates |
+237 -2
View File
@@ -1897,6 +1897,201 @@ describe('ChicoryTV SPA scaffold', () => {
expect(await screen.findByText('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
});
it('deletes the selected playout with confirmation and refetches', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
const playoutFetchesBeforeDelete = fetchCount('/api/playouts');
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'DELETE' }));
});
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeDelete + 1);
});
it('resets the selected channel playout with confirmation', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/channels/5.1/playout/reset',
expect.objectContaining({ method: 'POST' })
);
});
});
it('shows both erase buttons for a Block playout and posts to the right routes', async () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Erase items' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/playouts/20/erase-items',
expect.objectContaining({ method: 'POST' })
);
});
fireEvent.click(screen.getByRole('button', { name: 'Erase items and history' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/playouts/20/erase-items-and-history',
expect.objectContaining({ method: 'POST' })
);
});
});
it('shows only erase-items-and-history for a Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
});
it('shows no erase buttons for an ExternalJson playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'ExternalJson' }),
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'ExternalJson' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Erase items and history' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
});
it('disables Alternate schedules for an on-demand Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeDisabled();
});
it('enables Alternate schedules for a continuous Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' }),
playouts: { page: [listPlayout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeEnabled();
});
it('opens the scheduling-context dialog for an item that has one', async () => {
mockDashboardApi({
playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
playoutDetails: playout({ id: 20 }),
playoutSchedulingContext: 'SCHEDULING_CONTEXT_BODY',
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Scheduling context' }));
expect(await screen.findByText('SCHEDULING_CONTEXT_BODY')).toBeInTheDocument();
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith(
'/api/playouts/items/99/scheduling-context',
expect.any(Object)
);
});
});
it('shows an error in the scheduling-context dialog when the fetch fails', async () => {
mockDashboardApi({
playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
playoutDetails: playout({ id: 20 }),
playoutSchedulingContextFailure: { detail: 'No scheduling context available', status: 404 },
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Scheduling context' }));
expect(await screen.findByText('No scheduling context available')).toBeInTheDocument();
});
it('does not show a scheduling-context button for items without one', async () => {
mockDashboardApi({
playoutItems: [playoutItem({ hasSchedulingContext: false, id: 99 })],
playoutDetails: playout({ id: 20 }),
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Scheduling context' })).not.toBeInTheDocument();
});
it('filters the playout selector rail by channel name', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
@@ -2611,8 +2806,19 @@ describe('ChicoryTV SPA scaffold', () => {
const { container } = await renderBuilder();
// The initial render already fanned out over the 4 library kinds; only
// count requests made after switching to the Collections tab.
// Wait for the initial library fan-out (Movie/TelevisionShow/Artist) to be
// ISSUED before clearing the mock — renderBuilder only awaits the search
// box, and React flushes passive effects asynchronously, so on a slow
// machine the mount fan-out can otherwise leak past mockClear and pollute
// the post-click assertion below.
await waitFor(() => {
const browseCalls = vi
.mocked(window.fetch)
.mock.calls.filter(([input]) => input.toString().startsWith('/api/library/browse'));
expect(browseCalls.length).toBeGreaterThanOrEqual(3);
});
// Only count requests made after switching to the Collections tab.
vi.mocked(window.fetch).mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
@@ -3576,6 +3782,8 @@ function mockDashboardApi({
playoutItemsFailure = null,
playoutItemsFailuresBeforeSuccess = 0,
playoutItemsTotalCount = null,
playoutSchedulingContext = '{\n "sample": true\n}',
playoutSchedulingContextFailure = null,
playoutWarningsCount = 0,
playouts = { page: [], totalCount: 0 },
prompt = null,
@@ -3641,6 +3849,8 @@ function mockDashboardApi({
playoutItemsFailure?: unknown;
playoutItemsFailuresBeforeSuccess?: number;
playoutItemsTotalCount?: number | null;
playoutSchedulingContext?: string;
playoutSchedulingContextFailure?: { detail?: string; status?: number; title?: string } | null;
playoutWarningsCount?: number;
playouts?: unknown;
prompt?: string | null;
@@ -3903,6 +4113,23 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(playoutTemplateItems));
}
if (path.match(/^\/api\/playouts\/\d+\/erase-items(-and-history)?$/)) {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path.match(/^\/api\/playouts\/items\/\d+\/scheduling-context$/)) {
if (playoutSchedulingContextFailure) {
return Promise.resolve(
jsonResponse(playoutSchedulingContextFailure, playoutSchedulingContextFailure.status ?? 404)
);
}
return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
}
if (path === '/api/templates') {
return Promise.resolve(jsonResponse(templates));
}
@@ -3948,6 +4175,14 @@ function mockDashboardApi({
));
}
if (method === 'DELETE') {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
}
+197 -13
View File
@@ -27,6 +27,7 @@ import {
Crosshair,
Film,
FileImage,
Eraser,
Folder,
FolderInput,
FolderTree,
@@ -126,6 +127,11 @@ import {
bulkMoveChannelsToGroup,
bulkRenumberChannels,
createPlayout,
deletePlayout,
erasePlayoutItems,
erasePlayoutItemsAndHistory,
getPlayoutItemSchedulingContext,
resetChannelPlayout,
deleteChannel,
getDecos,
messageFromError,
@@ -3035,6 +3041,7 @@ function PlayoutsScreen() {
const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [contextItemId, setContextItemId] = useState<number | null>(null);
if (query.status === 'loading') {
return <PlayoutsLoadingState />;
@@ -3073,6 +3080,61 @@ function PlayoutsScreen() {
});
};
const runMutation = (confirmMessage: string, action: () => Promise<unknown>) => {
if (mutatingRef.current || !window.confirm(confirmMessage)) {
return;
}
setMutationError(null);
setMutatingState(true);
action()
.then(() => {
query.refresh();
})
.catch((error: unknown) => {
setMutationError(messageFromError(error));
})
.finally(() => {
setMutatingState(false);
});
};
const resetSelectedChannel = () => {
if (!selectedSummary) {
return;
}
runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
resetChannelPlayout(selectedSummary.channelNumber)
);
};
const deleteSelectedPlayout = () => {
if (!selectedSummary) {
return;
}
runMutation(`Delete the playout for ${selectedSummary.channelName}? This cannot be undone.`, () =>
deletePlayout(selectedSummary.id)
);
};
const eraseSelectedItems = () => {
if (!selectedSummary) {
return;
}
runMutation(`Erase all built items for ${selectedSummary.channelName}?`, () =>
erasePlayoutItems(selectedSummary.id)
);
};
const eraseSelectedItemsAndHistory = () => {
if (!selectedSummary) {
return;
}
runMutation(`Erase all built items and history for ${selectedSummary.channelName}?`, () =>
erasePlayoutItemsAndHistory(selectedSummary.id)
);
};
const submitAddPlayout = (request: CreatePlayoutRequest) => {
setAddBusy(true);
setAddError(null);
@@ -3254,9 +3316,15 @@ function PlayoutsScreen() {
{selectedSummary.scheduleKind === 'Classic' && (
<div className="ctv-playout-detail-actions">
<Button
disabled={selectedSummary.playoutMode === 'OnDemand'}
onClick={() => navigateToPath(`/app/playouts/${selectedSummary.id}/alternate-schedules`)}
size="sm"
startIcon={<CalendarClock aria-hidden="true" size={13} />}
title={
selectedSummary.playoutMode === 'OnDemand'
? 'Alternate schedules are unavailable for on-demand channels'
: undefined
}
variant="secondary"
>
Alternate schedules
@@ -3276,10 +3344,50 @@ function PlayoutsScreen() {
<PlayoutDefaultDecoField playout={playout} onSaved={query.refresh} />
</div>
)}
<div className="ctv-playout-detail-actions" title="No per-playout reset endpoint exists yet">
<Button disabled size="sm" startIcon={<RefreshCw aria-hidden="true" size={13} />} variant="secondary">Reset</Button>
<Button disabled size="sm" startIcon={<Clock aria-hidden="true" size={13} />} variant="ghost">Schedule reset</Button>
<small>Per-playout reset is deferred — the API has no per-playout reset endpoint yet.</small>
<div className="ctv-playout-detail-actions">
<Button
disabled={mutating}
onClick={resetSelectedChannel}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={13} />}
variant="secondary"
>
Reset
</Button>
{selectedSummary.scheduleKind === 'Block' && (
<Button
disabled={mutating}
onClick={eraseSelectedItems}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
variant="ghost"
>
Erase items
</Button>
)}
{(selectedSummary.scheduleKind === 'Classic' ||
selectedSummary.scheduleKind === 'Block' ||
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<Button
disabled={mutating}
onClick={eraseSelectedItemsAndHistory}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
variant="ghost"
>
Erase items and history
</Button>
)}
<Button
disabled={mutating}
onClick={deleteSelectedPlayout}
size="sm"
startIcon={<Trash2 aria-hidden="true" size={13} />}
variant="danger"
>
Delete
</Button>
</div>
</Card>
</div>
@@ -3309,15 +3417,29 @@ function PlayoutsScreen() {
{items.length === 0 ? (
<div className="ctv-schedule-empty">{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No upcoming items'}</div>
) : (
items.map((item, index) => (
<div className={item === nowItem ? 'ctv-playout-upcoming-now' : ''} key={`${item.start}-${index}`} role="listitem">
<code>{formatDateTime(item.start)}</code>
{isFillerItem(item) ? <Sparkles aria-hidden="true" size={13} /> : <Film aria-hidden="true" size={13} />}
<span>{item.title ?? 'Untitled item'}</span>
{isFillerItem(item) && <Badge tone="neutral">Filler</Badge>}
<small>{item.duration ?? 'unknown'}</small>
</div>
))
items.map((item, index) => {
const itemId = item.id;
return (
<div className={item === nowItem ? 'ctv-playout-upcoming-now' : ''} key={`${item.start}-${index}`} role="listitem">
<code>{formatDateTime(item.start)}</code>
{isFillerItem(item) ? <Sparkles aria-hidden="true" size={13} /> : <Film aria-hidden="true" size={13} />}
<span>{item.title ?? 'Untitled item'}</span>
{isFillerItem(item) && <Badge tone="neutral">Filler</Badge>}
<small>{item.duration ?? 'unknown'}</small>
{item.hasSchedulingContext && itemId != null && (
<IconButton
onClick={() => setContextItemId(itemId)}
size="sm"
title="Scheduling context"
variant="ghost"
>
<Info aria-hidden="true" size={13} />
</IconButton>
)}
</div>
);
})
)}
</div>
</Card>
@@ -3344,10 +3466,72 @@ function PlayoutsScreen() {
playout={playout}
/>
)}
<PlayoutSchedulingContextDialog
itemId={contextItemId}
key={contextItemId ?? 'none'}
onClose={() => setContextItemId(null)}
/>
</div>
);
}
function PlayoutSchedulingContextDialog({ itemId, onClose }: { itemId: number | null; onClose: () => void }) {
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'success'; context: string }
| { status: 'error'; error: string }
>({ status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
if (itemId != null) {
getPlayoutItemSchedulingContext(itemId)
.then((result) => {
if (activeRef.current) {
setState({ context: result.context, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ error: messageFromError(error), status: 'error' });
}
});
}
return () => {
activeRef.current = false;
};
}, [itemId]);
return (
<Dialog onClose={onClose} open={itemId != null} title="Scheduling context" width={720}>
{state.status === 'loading' && <Spinner size={16} tone="muted" />}
{state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{state.error}</span>
</div>
)}
{state.status === 'success' && (
<pre
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: 12,
margin: 0,
maxHeight: '60vh',
overflow: 'auto',
whiteSpace: 'pre'
}}
>
{state.context}
</pre>
)}
</Dialog>
);
}
function PlayoutDefaultDecoField({ playout, onSaved }: { playout: PlayoutDetail; onSaved: () => void }) {
const [decos, setDecos] = useState<DecoListItem[] | null>(null);
const [saving, setSaving] = useState(false);
+31 -1
View File
@@ -12,6 +12,7 @@ import {
removeItemFromCollection,
toAddItemsRequest,
updateCollection,
updateCollectionCustomOrder,
updateSmartCollection
} from './collections';
import type { LibraryBrowseItem } from './libraryBrowse';
@@ -184,6 +185,17 @@ describe('collections api client', () => {
expect(url.searchParams.get('pageNum')).toBe('0');
expect(url.searchParams.get('pageSize')).toBe('100');
});
it('updateCollectionCustomOrder PUTs the full ordered mediaItemIds array', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await updateCollectionCustomOrder(7, [30, 10, 20]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/collections/7/custom-order');
expect(init).toMatchObject({ method: 'PUT' });
expect(JSON.parse(String(init?.body))).toEqual({ mediaItemIds: [30, 10, 20] });
});
});
describe('toAddItemsRequest bucket mapping', () => {
@@ -202,6 +214,24 @@ describe('toAddItemsRequest bucket mapping', () => {
expect(result.artistIds).toEqual([40]);
});
it('routes the remaining six addable kinds into their own buckets (#211)', () => {
const result = toAddItemsRequest([
browseItem(50, 'Episode'),
browseItem(51, 'MusicVideo'),
browseItem(52, 'Song'),
browseItem(53, 'OtherVideo'),
browseItem(54, 'Image'),
browseItem(55, 'RemoteStream')
]);
expect(result.episodeIds).toEqual([50]);
expect(result.musicVideoIds).toEqual([51]);
expect(result.songIds).toEqual([52]);
expect(result.otherVideoIds).toEqual([53]);
expect(result.imageIds).toEqual([54]);
expect(result.remoteStreamIds).toEqual([55]);
});
it('skips kinds that are not addable media items (collections, playlists, etc.)', () => {
const result = toAddItemsRequest([
browseItem(1, 'Collection'),
@@ -218,7 +248,7 @@ describe('toAddItemsRequest bucket mapping', () => {
expect(result.artistIds).toEqual([]);
});
it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => {
it('emptyAddItemsRequest starts every bucket empty', () => {
const empty = emptyAddItemsRequest();
expect(empty.episodeIds).toEqual([]);
+39 -11
View File
@@ -7,6 +7,7 @@ export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest'];
export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest'];
export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest'];
export type UpdateCollectionCustomOrderRequest = components['schemas']['UpdateCollectionCustomOrderRequest'];
export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest'];
export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest'];
@@ -41,7 +42,9 @@ export function removeItemFromCollection(id: number, mediaItemId: number): Promi
}
// Lists a manual collection's full contents (all media kinds), paged. Backed by
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape.
// GET /api/collections/{id}/items (#155), which reuses the library-browse item shape. When the
// collection's useCustomPlaybackOrder is true, items come back ordered by CustomIndex (nulls
// last, then title); otherwise title order.
export function getCollectionItems(
id: number,
pageNum = 0,
@@ -54,6 +57,14 @@ export function getCollectionItems(
return request<PagedLibraryBrowseItems>(`/api/collections/${id}/items?${params.toString()}`);
}
// Replaces a manual collection's custom order wholesale: the CustomIndex of each media item is
// derived from its position in `mediaItemIds`, so a partial array silently drops the items left
// out of it (#211). Callers must submit the full ordered id list.
export function updateCollectionCustomOrder(id: number, mediaItemIds: number[]): Promise<void> {
const body: UpdateCollectionCustomOrderRequest = { mediaItemIds };
return request<void>(`/api/collections/${id}/custom-order`, { body, method: 'PUT' });
}
/* ---------- smart collections ---------- */
export function getSmartCollections(): Promise<SmartCollection[]> {
@@ -78,11 +89,11 @@ export function deleteSmartCollection(id: number): Promise<void> {
/* ---------- add-items bucket mapping ---------- */
// The add-items request buckets media-item ids by kind. The library-browse search only
// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are
// the only buckets reachable from the picker. The remaining buckets (episodes, music
// videos, songs, images, other videos, remote streams) can't be produced by browse and
// are left empty here. See CollectionsScreen for the honest note about this limit.
// The add-items request buckets media-item ids by kind. All 10 addable kinds (Movie /
// TelevisionShow / TelevisionSeason / Artist / Episode / MusicVideo / Song / OtherVideo /
// Image / RemoteStream) are reachable from the picker via `toAddItemsRequest` below — the
// default "All" fan-out only searches Movie/TelevisionShow/Artist (see CollectionsScreen's
// DEFAULT_SEARCH_KINDS), but every kind can be found by picking its specific filter.
export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
return {
artistIds: [],
@@ -98,11 +109,10 @@ export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
};
}
// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show /
// Season / Artist are the only kinds library-browse can return as concrete media; each
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since
// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since
// the server validates each bucket against that entity type (a Show id in movieIds fails
// Buckets a set of browse results into an AddItemsToCollectionRequest. Each addable kind
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since they
// are all MediaItem subclasses). Ids must go into their type-specific bucket, since the
// server validates each bucket against that entity type (a Show id in movieIds fails
// validation). Any other kind (collections, smart/multi/rerun collections, playlists) is
// not an addable media item and is skipped.
export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest {
@@ -122,6 +132,24 @@ export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollect
case 'Artist':
requestBody.artistIds?.push(item.id);
break;
case 'Episode':
requestBody.episodeIds?.push(item.id);
break;
case 'MusicVideo':
requestBody.musicVideoIds?.push(item.id);
break;
case 'Song':
requestBody.songIds?.push(item.id);
break;
case 'OtherVideo':
requestBody.otherVideoIds?.push(item.id);
break;
case 'Image':
requestBody.imageIds?.push(item.id);
break;
case 'RemoteStream':
requestBody.remoteStreamIds?.push(item.id);
break;
default:
break;
}
+11 -2
View File
@@ -1044,11 +1044,16 @@ export interface components {
"details": string;
};
"PlayoutItemResponseModel": {
"title": null | string;
"id": null | number;
"title": string;
"start": string;
"finish": string;
"duration": null | string;
"duration": string;
"fillerKind": null | components["schemas"]["FillerKind"];
"hasSchedulingContext": boolean;
};
"PlayoutItemSchedulingContextResponseModel": {
"context": string;
};
"PlayoutListItemResponseModel": {
"id": number;
@@ -1058,6 +1063,7 @@ export interface components {
"scheduleName": string;
"dailyRebuildTime": null | string;
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
};
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
"PlayoutResponseModel": {
@@ -1482,6 +1488,9 @@ export interface components {
"shuffleScheduleItems": boolean;
"randomStartPoint": boolean;
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
};
"UpdateCollectionCustomOrderRequest": {
"mediaItemIds": null | Array<number>;
};
"UpdateCollectionRequest": {
"name": null | string;
+68
View File
@@ -1,10 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createPlayout,
deletePlayout,
erasePlayoutItems,
erasePlayoutItemsAndHistory,
getAlternateSchedules,
getPlayoutItemSchedulingContext,
getPlayoutTemplates,
replaceAlternateSchedules,
replacePlayoutTemplates,
resetChannelPlayout,
updatePlayoutDefaultDeco,
updatePlayoutDetails,
type CreatePlayoutRequest
@@ -17,6 +22,10 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
function noContentResponse(): Response {
return new Response(null, { status: 204 });
}
describe('playouts api client', () => {
beforeEach(() => {
window.localStorage.clear();
@@ -96,6 +105,65 @@ describe('playouts api client', () => {
expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 });
});
it('deletePlayout DELETEs the id route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await expect(deletePlayout(4)).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/4');
expect(init).toMatchObject({ method: 'DELETE' });
});
it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('12.3');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/12.3/playout/reset');
expect(init).toMatchObject({ method: 'POST' });
});
it('resetChannelPlayout url-encodes the channel number', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({}));
await resetChannelPlayout('a b');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('/api/channels/a%20b/playout/reset');
});
it('erasePlayoutItems POSTs to the erase-items route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await erasePlayoutItems(6);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/6/erase-items');
expect(init).toMatchObject({ method: 'POST' });
});
it('erasePlayoutItemsAndHistory POSTs to the erase-items-and-history route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse());
await erasePlayoutItemsAndHistory(6);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/6/erase-items-and-history');
expect(init).toMatchObject({ method: 'POST' });
});
it('getPlayoutItemSchedulingContext GETs the item scheduling-context route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ context: '{ "x": 1 }' }));
await expect(getPlayoutItemSchedulingContext(42)).resolves.toMatchObject({ context: '{ "x": 1 }' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/playouts/items/42/scheduling-context');
expect(init?.method ?? 'GET').toBe('GET');
});
it('getAlternateSchedules GETs the alternate-schedules route', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
+24
View File
@@ -9,6 +9,8 @@ export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind'];
export type ChannelPlayoutMode = components['schemas']['ChannelPlayoutMode'];
export type PlayoutItemSchedulingContext = components['schemas']['PlayoutItemSchedulingContextResponseModel'];
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
@@ -110,6 +112,28 @@ export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function deletePlayout(playoutId: number): Promise<void> {
return request<void>(`/api/playouts/${playoutId}`, { method: 'DELETE' });
}
// Resets the given channel's playout. The server picks the correct default mode per schedule kind
// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent.
export function resetChannelPlayout(channelNumber: string): Promise<void> {
return request<void>(`/api/channels/${encodeURIComponent(channelNumber)}/playout/reset`, { method: 'POST' });
}
export function erasePlayoutItems(playoutId: number): Promise<void> {
return request<void>(`/api/playouts/${playoutId}/erase-items`, { method: 'POST' });
}
export function erasePlayoutItemsAndHistory(playoutId: number): Promise<void> {
return request<void>(`/api/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' });
}
export function getPlayoutItemSchedulingContext(itemId: number): Promise<PlayoutItemSchedulingContext> {
return request<PlayoutItemSchedulingContext>(`/api/playouts/items/${itemId}/scheduling-context`);
}
export function createPlayout(body: CreatePlayoutRequest): Promise<PlayoutDetail> {
return request<PlayoutDetail>('/api/playouts', {
body,
+4 -1
View File
@@ -20,6 +20,7 @@ export interface ButtonProps {
type?: 'button' | 'submit' | 'reset';
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
style?: CSSProperties;
title?: string;
}
export function Button({
@@ -33,13 +34,15 @@ export function Button({
fullWidth = false,
type = 'button',
onClick,
style
style,
title
}: ButtonProps) {
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
title={title}
className={classNames(
'ctv-button',
`ctv-button-${variant}`,
+28
View File
@@ -79,6 +79,9 @@ function mockApi(options: MockOptions = {}) {
jsonResponse([{ title: 'Episode 1', start: '00:00:00', finish: '00:30:00', duration: '00:30:00' }])
);
}
if (url === '/api/blocks/4/copy' && method === 'POST') {
return Promise.resolve(jsonResponse({ id: 5, groupId: 2, groupName: 'Prime', name: 'Morning copy' }, 201));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
@@ -104,6 +107,31 @@ describe('BlocksScreen', () => {
expect(screen.getByText('Morning')).toBeInTheDocument();
});
it('opens the copy dialog and POSTs to the copy route, then refreshes the list', async () => {
mockApi();
render(<BlocksScreen />);
await screen.findByText('Morning');
fireEvent.click(screen.getByRole('button', { name: /Copy block/ }));
const dialog = await screen.findByText('Copy "Morning"');
expect(dialog).toBeInTheDocument();
const fetchMock = vi.spyOn(window, 'fetch');
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
await waitFor(() => {
const call = fetchMock.mock.calls.find(([u, init]) => u === '/api/blocks/4/copy' && init?.method === 'POST');
expect(call).toBeTruthy();
expect(JSON.parse(String(call?.[1]?.body))).toEqual({ blockGroupId: 2, name: 'Morning copy' });
});
await waitFor(() => {
const getCalls = fetchMock.mock.calls.filter(([u, init]) => u === '/api/blocks' && (init?.method ?? 'GET') === 'GET');
expect(getCalls.length).toBeGreaterThan(0);
});
});
it('loads the editor at a sub-path and hydrates duration from minutes', async () => {
window.history.pushState({}, '', '/app/blocks/4');
mockApi({ items: [blockItem({ id: 1, searchTitle: 'News', searchQuery: 'news' })] });
+59
View File
@@ -26,6 +26,7 @@ import {
Spinner
} from '../components';
import {
copyBlock,
createBlock,
createBlockGroup,
deleteBlock,
@@ -313,6 +314,9 @@ function BlockList() {
const [newGroupName, setNewGroupName] = useState('');
const [blockDialogGroup, setBlockDialogGroup] = useState<BlockGroup | null>(null);
const [newBlockName, setNewBlockName] = useState('');
const [copyTarget, setCopyTarget] = useState<Block | null>(null);
const [copyGroupId, setCopyGroupId] = useState('');
const [copyName, setCopyName] = useState('');
const [deleteGroupTarget, setDeleteGroupTarget] = useState<BlockGroup | null>(null);
const [deleteBlockTarget, setDeleteBlockTarget] = useState<Block | null>(null);
const [busy, setBusy] = useState(false);
@@ -376,6 +380,22 @@ function BlockList() {
}
};
const confirmCopy = async () => {
if (!copyTarget || !copyGroupId || !copyName.trim() || busy) {
return;
}
setBusy(true);
try {
await copyBlock(copyTarget.id, { blockGroupId: Number(copyGroupId), name: copyName.trim() });
setCopyTarget(null);
load();
} catch (copyError) {
setError(messageFromBlockError(copyError, 'Unable to copy block'));
} finally {
setBusy(false);
}
};
const confirmDeleteGroup = async () => {
if (!deleteGroupTarget) {
return;
@@ -433,6 +453,7 @@ function BlockList() {
}
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) }));
return (
<div className="ctv-collections">
@@ -514,6 +535,18 @@ function BlockList() {
{block.name}
</button>
<Badge tone="neutral">{block.minutes} min</Badge>
<IconButton
onClick={() => {
setCopyTarget(block);
setCopyGroupId(String(block.groupId));
setCopyName(`${block.name} copy`);
}}
size="sm"
title="Copy block"
variant="ghost"
>
<Copy aria-hidden="true" size={14} />
</IconButton>
<IconButton
onClick={() => setDeleteBlockTarget(block)}
size="sm"
@@ -581,6 +614,32 @@ function BlockList() {
/>
</Dialog>
<Dialog
footer={
<>
<Button onClick={() => setCopyTarget(null)} size="sm" variant="ghost">
Cancel
</Button>
<Button
disabled={!copyGroupId || !copyName.trim() || busy}
loading={busy}
onClick={() => void confirmCopy()}
size="sm"
>
Copy
</Button>
</>
}
onClose={() => setCopyTarget(null)}
open={copyTarget !== null}
title={copyTarget ? `Copy "${copyTarget.name}"` : 'Copy block'}
>
<Select label="Target group" onChange={(event) => setCopyGroupId(event.target.value)} options={groupOptions} value={copyGroupId} />
<div style={{ marginTop: 12 }}>
<Input label="New name" onChange={(event) => setCopyName(event.target.value)} value={copyName} />
</div>
</Dialog>
<ConfirmDialog
busy={busy}
confirmLabel="Delete"
+144 -1
View File
@@ -276,7 +276,13 @@ describe('CollectionsScreen', () => {
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' },
Episode: { id: 5, mediaType: 'Episode', title: 'The Pilot Episode' },
MusicVideo: { id: 6, mediaType: 'MusicVideo', title: 'Music Video Reel' },
Song: { id: 7, mediaType: 'Song', title: 'Song of the Sea' },
OtherVideo: { id: 8, mediaType: 'OtherVideo', title: 'Home Video Clip' },
Image: { id: 9, mediaType: 'Image', title: 'Poster Art' },
RemoteStream: { id: 10, mediaType: 'RemoteStream', title: 'Live Feed' }
};
function mockAddItemsApi() {
@@ -358,4 +364,141 @@ describe('CollectionsScreen', () => {
.some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason');
expect(seasonCall).toBe(true);
});
it('the picker note explains the "All" fan-out and the reachable extra kinds (#211)', async () => {
mockAddItemsApi();
const dialog = await openAddItemsDialog();
expect(
within(dialog).getByText(
/searches movies, shows and artists; pick a specific kind to add seasons, episodes, music videos, songs, other videos, images or remote streams\./
)
).toBeInTheDocument();
});
it('selecting a newly-addable kind filter (Song) surfaces it and buckets it correctly on add', async () => {
const fetchMock = mockAddItemsApi();
const dialog = await openAddItemsDialog();
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
target: { value: 'a' }
});
fireEvent.click(within(dialog).getByRole('button', { name: 'Song' }));
expect(await within(dialog).findByText('Song of the Sea')).toBeInTheDocument();
const songCall = fetchMock.mock.calls
.map(([u]) => u.toString())
.filter((u) => u.startsWith('/api/library/browse'))
.some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'Song');
expect(songCall).toBe(true);
fireEvent.click(within(dialog).getByText('Song of the Sea'));
fireEvent.click(within(dialog).getByRole('button', { name: /Add 1 item/ }));
await waitFor(() => {
const addCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/collections/1/items' && (init?.method ?? '').toUpperCase() === 'POST'
);
expect(addCall).toBeDefined();
expect(JSON.parse(String(addCall?.[1]?.body)).songIds).toEqual([7]);
});
});
/* ---------- reorder mode (#211) ---------- */
function reorderPages(id: number) {
return {
[`/api/collections/${id}/items:0`]: {
page: [
{ id: 100, mediaItemId: 100, mediaType: 'Movie', title: 'Alpha' },
{ id: 200, mediaItemId: 200, mediaType: 'Movie', title: 'Bravo' }
],
totalCount: 3
},
[`/api/collections/${id}/items:1`]: {
page: [{ id: 300, mediaItemId: 300, mediaType: 'Movie', title: 'Charlie' }],
totalCount: 3
}
};
}
function mockReorderApi() {
const pages = reorderPages(2);
return mockApi({
onRequest: (url, method) => {
if (/^\/api\/collections\/2\/items/.test(url) && method === 'GET') {
const pageNum = new URL(url, 'http://localhost').searchParams.get('pageNum') ?? '0';
const key = `/api/collections/2/items:${pageNum}`;
return jsonResponse(pages[key] ?? { page: [], totalCount: 0 });
}
return null;
}
});
}
it('reorder mode loads every page, supports moving items, and saves the full ordered array', async () => {
const fetchMock = mockReorderApi();
render(<CollectionsScreen />);
await screen.findByText('Kids');
fireEvent.click(screen.getByText('Kids'));
expect(await screen.findByText('Alpha')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Reorder' }));
// Reorder mode fetches every page (0 then 1) before entering, so all three items render.
expect(await screen.findByRole('button', { name: 'Save order' })).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
const itemsCallCount = () =>
fetchMock.mock.calls.filter(([u]) => /^\/api\/collections\/2\/items/.test(u.toString())).length;
// Initial load (page 0) + enterReorder's page 0 + page 1 = 3 GETs minimum.
expect(itemsCallCount()).toBeGreaterThanOrEqual(3);
// Add/remove actions are disabled while reordering.
expect(screen.getByRole('button', { name: 'Add items' })).toBeDisabled();
// Move "Bravo" (index 1) down, so the order becomes Alpha, Charlie, Bravo.
fireEvent.click(screen.getByRole('button', { name: 'Move Bravo down' }));
fireEvent.click(screen.getByRole('button', { name: 'Save order' }));
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/collections/2/custom-order' && (init?.method ?? '').toUpperCase() === 'PUT'
);
expect(putCall).toBeDefined();
expect(JSON.parse(String(putCall?.[1]?.body))).toEqual({ mediaItemIds: [100, 300, 200] });
});
// Exits reorder mode and refreshes the normal paged view.
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Save order' })).not.toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Add items' })).not.toBeDisabled();
});
it('canceling reorder mode discards local moves without saving', async () => {
const fetchMock = mockReorderApi();
render(<CollectionsScreen />);
await screen.findByText('Kids');
fireEvent.click(screen.getByText('Kids'));
await screen.findByText('Alpha');
fireEvent.click(screen.getByRole('button', { name: 'Reorder' }));
await screen.findByRole('button', { name: 'Save order' });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByRole('button', { name: 'Save order' })).not.toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([u]) => u === '/api/collections/2/custom-order')
).toBe(false);
});
});
+197 -16
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ArrowDown,
ArrowLeft,
ArrowUp,
Check,
FolderTree,
Info,
@@ -37,6 +39,7 @@ import {
removeItemFromCollection,
toAddItemsRequest,
updateCollection,
updateCollectionCustomOrder,
updateSmartCollection,
type LibraryBrowseItem,
type MediaCollection,
@@ -47,12 +50,23 @@ import { TYPE_LABEL } from '../media/mediaKinds';
type Tab = 'manual' | 'smart';
// Every kind that can be added to a manual collection from the picker.
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = [
'Movie',
'TelevisionShow',
'TelevisionSeason',
'Artist',
'Episode',
'MusicVideo',
'Song',
'OtherVideo',
'Image',
'RemoteStream'
];
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
// The default fan-out excludes seasons so a multi-season show doesn't flood the
// results with per-season rows (issue #180); seasons stay reachable via the
// explicit media-kind filter below.
// results with per-season rows (issue #180); seasons (and the other narrower kinds
// added for #211) stay reachable via the explicit media-kind filter below.
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
@@ -62,7 +76,13 @@ const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
{ label: 'Movies', value: 'Movie' },
{ label: 'Shows', value: 'TelevisionShow' },
{ label: 'Seasons', value: 'TelevisionSeason' },
{ label: 'Artists', value: 'Artist' }
{ label: 'Artists', value: 'Artist' },
{ label: TYPE_LABEL.Episode, value: 'Episode' },
{ label: TYPE_LABEL.MusicVideo, value: 'MusicVideo' },
{ label: TYPE_LABEL.Song, value: 'Song' },
{ label: TYPE_LABEL.OtherVideo, value: 'OtherVideo' },
{ label: TYPE_LABEL.Image, value: 'Image' },
{ label: TYPE_LABEL.RemoteStream, value: 'RemoteStream' }
];
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
@@ -434,9 +454,8 @@ function AddItemsDialog({
))}
</div>
<p className="ctv-collections-picker-note">
Add movies, shows, seasons and artists. &ldquo;All&rdquo; searches movies, shows and artists; pick
&ldquo;Seasons&rdquo; to find a specific season. Episodes, music, images and other item kinds can&rsquo;t be
added from here yet.
&ldquo;All&rdquo; searches movies, shows and artists; pick a specific kind to add seasons, episodes,
music videos, songs, other videos, images or remote streams.
</p>
{error && (
<span className="ctv-field-error" role="alert">
@@ -490,6 +509,10 @@ function ManualItemsView({
const [error, setError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [removing, setRemoving] = useState<number | null>(null);
const [reordering, setReordering] = useState(false);
const [reorderItems, setReorderItems] = useState<LibraryBrowseItem[] | null>(null);
const [reorderLoading, setReorderLoading] = useState(false);
const [reorderSaving, setReorderSaving] = useState(false);
const activeRef = useRef(true);
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
@@ -554,6 +577,98 @@ function ManualItemsView({
}
};
// The custom-order PUT replaces the order wholesale from the submitted array, so a
// partial list would silently drop the items left out of it — every page must be loaded
// before reorder mode is entered. If any page fails to load, bail out without entering
// reorder mode (the error surfaces via the normal `error` banner).
const enterReorder = async () => {
setReorderLoading(true);
setError(null);
try {
const first = await getCollectionItems(collection.id, 0, PAGE_SIZE);
let all = first.page ?? [];
const totalCount = first.totalCount ?? all.length;
let pageNum = 1;
while (all.length < totalCount) {
const next = await getCollectionItems(collection.id, pageNum, PAGE_SIZE);
const nextPage = next.page ?? [];
if (nextPage.length === 0) {
break;
}
all = [...all, ...nextPage];
pageNum += 1;
}
if (activeRef.current) {
setReorderItems(all);
setReordering(true);
}
} catch (reorderLoadError) {
if (activeRef.current) {
setError(messageFromCollectionError(reorderLoadError, 'Unable to load items for reordering'));
}
} finally {
if (activeRef.current) {
setReorderLoading(false);
}
}
};
const moveItem = (index: number, direction: -1 | 1) => {
setReorderItems((current) => {
if (!current) {
return current;
}
const target = index + direction;
if (target < 0 || target >= current.length) {
return current;
}
const next = [...current];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
};
const cancelReorder = () => {
setReordering(false);
setReorderItems(null);
setError(null);
};
const saveOrder = async () => {
if (!reorderItems) {
return;
}
setReorderSaving(true);
setError(null);
try {
const mediaItemIds = reorderItems.map((item) => item.mediaItemId ?? item.id);
await updateCollectionCustomOrder(collection.id, mediaItemIds);
if (activeRef.current) {
setReordering(false);
setReorderItems(null);
load();
}
} catch (saveError) {
if (activeRef.current) {
setError(messageFromCollectionError(saveError, 'Unable to save order'));
}
} finally {
if (activeRef.current) {
setReorderSaving(false);
}
}
};
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -562,7 +677,24 @@ function ManualItemsView({
</Button>
<span className="ctv-collections-detail-title">{collection.name}</span>
<span className="ctv-channels-spacer" />
<Button onClick={() => setPickerOpen(true)} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
{collection.useCustomPlaybackOrder && !reordering && (
<Button
disabled={reorderLoading}
loading={reorderLoading}
onClick={() => void enterReorder()}
size="sm"
startIcon={<ArrowUp aria-hidden="true" size={14} />}
variant="secondary"
>
Reorder
</Button>
)}
<Button
disabled={reordering || reorderLoading}
onClick={() => setPickerOpen(true)}
size="sm"
startIcon={<Plus aria-hidden="true" size={14} />}
>
Add items
</Button>
</div>
@@ -575,7 +707,41 @@ function ManualItemsView({
)}
<Card padded={false}>
{loading ? (
{reordering && reorderItems ? (
reorderItems.length === 0 ? (
<div className="ctv-collections-empty">No items in this collection.</div>
) : (
reorderItems.map((item, index) => (
<div
className="ctv-settings-flush-row"
key={`${item.mediaType}:${item.id}`}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<ListVideo aria-hidden="true" color="var(--text-disabled)" size={15} />
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.title}</span>
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
<IconButton
disabled={index === 0 || reorderSaving}
onClick={() => moveItem(index, -1)}
size="sm"
title={`Move ${item.title} up`}
variant="ghost"
>
<ArrowUp aria-hidden="true" size={14} />
</IconButton>
<IconButton
disabled={index === reorderItems.length - 1 || reorderSaving}
onClick={() => moveItem(index, 1)}
size="sm"
title={`Move ${item.title} down`}
variant="ghost"
>
<ArrowDown aria-hidden="true" size={14} />
</IconButton>
</div>
))
)
) : loading ? (
<div className="ctv-collections-loading">
<Spinner size={18} />
<span>Loading items…</span>
@@ -609,15 +775,30 @@ function ManualItemsView({
)}
</Card>
{!loading && items.length < total && (
{reordering ? (
<div className="ctv-collections-loadmore">
<span className="ctv-collections-count">
Showing {items.length} of {total}
</span>
<Button disabled={loadingMore} onClick={() => void loadMore()} size="sm" variant="ghost">
{loadingMore ? <Spinner size={14} /> : 'Load more'}
</Button>
<span className="ctv-collections-count">{reorderItems?.length ?? 0} items</span>
<div style={{ display: 'flex', gap: 8 }}>
<Button disabled={reorderSaving} onClick={cancelReorder} size="sm" variant="ghost">
Cancel
</Button>
<Button disabled={reorderSaving} loading={reorderSaving} onClick={() => void saveOrder()} size="sm" variant="primary">
Save order
</Button>
</div>
</div>
) : (
!loading &&
items.length < total && (
<div className="ctv-collections-loadmore">
<span className="ctv-collections-count">
Showing {items.length} of {total}
</span>
<Button disabled={loadingMore} onClick={() => void loadMore()} size="sm" variant="ghost">
{loadingMore ? <Spinner size={14} /> : 'Load more'}
</Button>
</div>
)
)}
<AddItemsDialog
@@ -219,4 +219,24 @@ describe('PlayoutTemplatesEditorScreen', () => {
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.getByText('Every row needs a template selected')).toBeInTheDocument();
});
it('toggles the preview calendar and shows the winning template per day', async () => {
mockTemplatesApi();
render(<PlayoutTemplatesEditorScreen playoutId={5} />);
await waitFor(() => expect(screen.getAllByText('Weekdays').length).toBeGreaterThan(0));
const weekdaysBefore = screen.getAllByText('Weekdays').length;
fireEvent.click(screen.getByRole('button', { name: 'Preview calendar' }));
// The month grid header reads "<Month> <Year>" for the current month.
const now = new Date();
const header = `${now.toLocaleString(undefined, { month: 'long' })} ${now.getFullYear()}`;
expect(screen.getByText(header)).toBeInTheDocument();
// The single seeded template matches every day, so the calendar adds many more 'Weekdays' cells.
expect(screen.getAllByText('Weekdays').length).toBeGreaterThan(weekdaysBefore);
fireEvent.click(screen.getByRole('button', { name: 'Hide preview calendar' }));
expect(screen.queryByText(header)).not.toBeInTheDocument();
});
});
+96 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowDown, ArrowLeft, ArrowUp, Check, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, CalendarDays, Check, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, Checkbox, IconButton, Select, Spinner } from '../components';
import { daysInMonth, firstMatchingIndex } from './playoutTemplateCalendar';
import {
DAYS_OF_WEEK,
getAlternateSchedules,
@@ -682,6 +683,7 @@ export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const activeRef = useRef(true);
useEffect(() => {
@@ -870,6 +872,19 @@ export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number
)}
</Card>
<div style={{ display: 'flex' }}>
<Button
onClick={() => setShowPreview((value) => !value)}
size="sm"
startIcon={<CalendarDays aria-hidden="true" size={14} />}
variant="ghost"
>
{showPreview ? 'Hide preview calendar' : 'Preview calendar'}
</Button>
</div>
{showPreview && <PreviewCalendar items={items} templateNameOf={templateNameOf} />}
{selected && (
<Card padded={false} title="Edit template">
<div className="ctv-settings-row" style={{ borderTop: 'none' }}>
@@ -905,6 +920,86 @@ export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number
);
}
const PREVIEW_WEEKDAY_HEADERS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function PreviewCalendar({
items,
templateNameOf
}: {
items: TemplateDraftItem[];
templateNameOf: (id: number) => string;
}) {
const now = new Date();
const [cursor, setCursor] = useState({ month: now.getMonth() + 1, year: now.getFullYear() });
const prioritized = items.filter((item) => item.templateId > 0);
const monthLength = daysInMonth(cursor.year, cursor.month);
// Monday-first grid: JS getDay() is 0=Sunday..6=Saturday; shift so Monday=0.
const leadingBlanks = (new Date(cursor.year, cursor.month - 1, 1).getDay() + 6) % 7;
const cells: Array<{ day: number; winner: string | null } | null> = [];
for (let blank = 0; blank < leadingBlanks; blank += 1) {
cells.push(null);
}
for (let day = 1; day <= monthLength; day += 1) {
const matchIndex = firstMatchingIndex(prioritized, new Date(cursor.year, cursor.month - 1, day));
cells.push({
day,
winner: matchIndex >= 0 ? templateNameOf(prioritized[matchIndex].templateId) : null
});
}
const goPrevious = () =>
setCursor((value) => (value.month === 1 ? { month: 12, year: value.year - 1 } : { month: value.month - 1, year: value.year }));
const goNext = () =>
setCursor((value) => (value.month === 12 ? { month: 1, year: value.year + 1 } : { month: value.month + 1, year: value.year }));
return (
<Card padded={false} title="Preview calendar">
<div style={{ alignItems: 'center', display: 'flex', gap: 8, justifyContent: 'center', padding: 12 }}>
<IconButton onClick={goPrevious} size="sm" title="Previous month" variant="ghost">
<ArrowLeft aria-hidden="true" size={14} />
</IconButton>
<span style={{ minWidth: 180, textAlign: 'center' }}>
{monthName(cursor.month, 'long')} {cursor.year}
</span>
<IconButton onClick={goNext} size="sm" title="Next month" variant="ghost">
<ArrowRight aria-hidden="true" size={14} />
</IconButton>
</div>
<div style={{ display: 'grid', gap: 4, gridTemplateColumns: 'repeat(7, 1fr)', padding: '0 12px 12px' }}>
{PREVIEW_WEEKDAY_HEADERS.map((label) => (
<div key={label} style={{ fontSize: 11, fontWeight: 600, opacity: 0.7, textAlign: 'center' }}>
{label}
</div>
))}
{cells.map((cell, index) =>
cell === null ? (
<div key={`blank-${index}`} />
) : (
<div
key={`day-${cell.day}`}
style={{
border: '1px solid var(--ctv-border, rgba(148, 163, 184, 0.25))',
borderRadius: 4,
minHeight: 54,
padding: 4
}}
>
<div style={{ fontSize: 11, opacity: 0.6 }}>{cell.day}</div>
{cell.winner && (
<div style={{ fontSize: 11, marginTop: 2, wordBreak: 'break-word' }} title={cell.winner}>
{cell.winner}
</div>
)}
</div>
)
)}
</div>
</Card>
);
}
function templateToDraft(template: PlayoutTemplate): TemplateDraftItem {
return {
key: nextKey(),
+1 -1
View File
@@ -494,7 +494,7 @@ export function TraktListsScreen() {
<div className="ctv-settings-callout">
<Search aria-hidden="true" size={14} />
<span>
"View matched items" opens the Classic UI search page — the SPA doesn't have a search screen yet.
"View matched items" opens the SPA search screen, filtered to this list's matched items.
</span>
</div>
+132
View File
@@ -0,0 +1,132 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TrashScreen } from './TrashScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
function browseItem(id: number, mediaType: string, mediaItemId: number) {
return {
artwork: '',
collectionId: null,
collectionKind: null,
collectionType: 'Collection',
duration: null,
id,
itemCount: null,
libraryId: null,
libraryName: null,
mediaItemId,
mediaType,
multiCollectionId: null,
playlistId: null,
rerunCollectionId: null,
smartCollectionId: null,
title: `Item ${id}`
};
}
function emptyGroup() {
return { items: [], totalCount: 0 };
}
const searchResults = {
artists: emptyGroup(),
episodes: emptyGroup(),
images: emptyGroup(),
movies: { items: [browseItem(1, 'Movie', 101), browseItem(2, 'Movie', 102)], totalCount: 2 },
musicVideos: emptyGroup(),
otherVideos: emptyGroup(),
remoteStreams: emptyGroup(),
seasons: emptyGroup(),
shows: { items: [browseItem(3, 'TelevisionShow', 103)], totalCount: 1 },
songs: emptyGroup()
};
interface MockOptions {
onRequest?: (url: string, method: string, body: unknown) => Response | null;
}
function mockApi(options: MockOptions = {}) {
return 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;
if (options.onRequest) {
const override = options.onRequest(url, method, body);
if (override) {
return Promise.resolve(override);
}
}
if (url.startsWith('/api/search') && method === 'GET') {
expect(url).toContain('pageSize=100');
return Promise.resolve(jsonResponse(searchResults));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
describe('TrashScreen', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
window.localStorage.clear();
});
it('requests a pageSize of 100', async () => {
mockApi();
render(<TrashScreen />);
await screen.findByText('Item 1');
});
it('selects all items across kinds via Select all', async () => {
mockApi();
render(<TrashScreen />);
await screen.findByText('Item 1');
fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
expect(await screen.findByText('Delete 3 selected')).toBeInTheDocument();
});
it('clears the selection via Clear selection', async () => {
mockApi();
render(<TrashScreen />);
await screen.findByText('Item 1');
fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
await screen.findByText('Delete 3 selected');
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }));
await waitFor(() => {
expect(screen.queryByText(/Delete \d+ selected/)).not.toBeInTheDocument();
});
});
it('deletes all selected media item ids on confirm', async () => {
const fetchMock = mockApi();
render(<TrashScreen />);
await screen.findByText('Item 1');
fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
fireEvent.click(await screen.findByText('Delete 3 selected'));
const dialogDeleteButton = await screen.findByRole('button', { name: 'Delete' });
fireEvent.click(dialogDeleteButton);
await waitFor(() => {
const call = fetchMock.mock.calls.find(([u, init]) => u === '/api/media-items' && init?.method === 'DELETE');
expect(call).toBeTruthy();
const sentBody = JSON.parse(String(call?.[1]?.body)) as { ids: number[] };
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]);
});
});
});
+29 -1
View File
@@ -11,7 +11,9 @@ import {
} from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard';
const PAGE_SIZE = 50;
// The search API's SearchController clamps pageSize to MaxPageSize=100 and has no page-number
// param, so this is the largest single request possible — see docs/decisions.md ("Trash see all").
const PAGE_SIZE = 100;
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
const TRASH_QUERY = 'state:FileNotFound';
@@ -105,6 +107,24 @@ export function TrashScreen() {
? GROUPS.reduce((sum, group) => sum + state.results![group.key].totalCount, 0)
: 0;
const selectAll = () => {
if (state.status !== 'success' || !state.results) {
return;
}
const ids = new Set<number>();
for (const group of GROUPS) {
for (const item of state.results[group.key].items) {
const mediaItemId = mediaItemIdOf(item);
if (mediaItemId != null) {
ids.add(mediaItemId);
}
}
}
setSelected(ids);
};
const clearSelection = () => setSelected(new Set());
const runDelete = async () => {
setBusy(true);
setActionError(null);
@@ -133,6 +153,14 @@ export function TrashScreen() {
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
<Button disabled={totalMatches === 0} onClick={selectAll} size="sm" variant="secondary">
Select all
</Button>
{selected.size > 0 && (
<Button onClick={clearSelection} size="sm" variant="secondary">
Clear selection
</Button>
)}
{selected.size > 0 && (
<Button
onClick={() => setConfirm('selected')}
+98
View File
@@ -0,0 +1,98 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WatermarksScreen } from './WatermarksScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const watermarks = [{ id: 3, name: 'Logo' }];
const watermarkDetail = {
durationSeconds: 15,
frequencyMinutes: 15,
horizontalMargin: 5,
id: 3,
image: 'logo.png',
imageContentType: 'image/png',
imageSource: 'Custom',
location: 'BottomRight',
mode: 'Permanent',
name: 'Logo',
opacity: 100,
opacityExpression: null,
placeWithinSourceContent: false,
size: 'Scaled',
verticalMargin: 5,
width: 15,
zIndex: 0
};
interface MockOptions {
onRequest?: (url: string, method: string, body: unknown) => Response | null;
}
function mockApi(options: MockOptions = {}) {
return 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;
if (options.onRequest) {
const override = options.onRequest(url, method, body);
if (override) {
return Promise.resolve(override);
}
}
if (url === '/api/watermarks' && method === 'GET') {
return Promise.resolve(jsonResponse(watermarks));
}
if (url === '/api/watermarks/3' && method === 'GET') {
return Promise.resolve(jsonResponse(watermarkDetail));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
describe('WatermarksScreen', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.pushState({}, '', '/app/watermarks');
});
beforeEach(() => {
window.localStorage.clear();
window.history.pushState({}, '', '/app/watermarks');
});
it('renders watermarks', async () => {
mockApi();
render(<WatermarksScreen />);
expect(await screen.findByText('Logo')).toBeInTheDocument();
});
it('navigates to /add?from={id} when Copy is clicked', async () => {
mockApi();
render(<WatermarksScreen />);
await screen.findByText('Logo');
fireEvent.click(screen.getByRole('button', { name: /Copy Logo/ }));
await waitFor(() => {
expect(window.location.pathname + window.location.search).toBe('/app/watermarks/add?from=3');
});
});
it('prefills the editor from the source watermark with a "(copy)" name suffix', async () => {
window.history.pushState({}, '', '/app/watermarks/add?from=3');
mockApi();
render(<WatermarksScreen />);
await waitFor(() => expect(screen.getByDisplayValue('Logo (copy)')).toBeInTheDocument());
expect(screen.getByText('New watermark')).toBeInTheDocument();
});
});
+29 -13
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react';
import { ArrowLeft, Check, Copy, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
@@ -156,10 +156,13 @@ function Row({
);
}
type Mode = { id: number; kind: 'edit' } | { kind: 'add' } | { kind: 'list' };
type Mode = { id: number; kind: 'edit' } | { kind: 'add'; from: null | number } | { kind: 'list' };
function modeFromPath(pathname: string): Mode {
const normalized = pathname.replace(/\/+$/, '');
// `path` is the screen's pathname state (pathname + search), so mode derives
// from the same string React re-renders on — not the mutable window.location.
function modeFromPath(path: string): Mode {
const [pathPart, searchPart = ''] = path.split('?');
const normalized = pathPart.replace(/\/+$/, '');
if (normalized === BASE_PATH || !normalized.startsWith(`${BASE_PATH}/`)) {
return { kind: 'list' };
@@ -168,7 +171,8 @@ function modeFromPath(pathname: string): Mode {
const suffix = normalized.slice(BASE_PATH.length + 1).split('/')[0];
if (suffix === 'add') {
return { kind: 'add' };
const from = new URLSearchParams(searchPart).get('from');
return { from: from ? Number(from) : null, kind: 'add' };
}
const id = Number(suffix);
@@ -176,10 +180,10 @@ function modeFromPath(pathname: string): Mode {
}
export function WatermarksScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
const [pathname, setPathname] = useState(() => window.location.pathname + window.location.search);
useEffect(() => {
const onPop = () => setPathname(window.location.pathname);
const onPop = () => setPathname(window.location.pathname + window.location.search);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
@@ -306,6 +310,14 @@ function WatermarkList() {
>
{watermark.name}
</button>
<IconButton
onClick={() => navigateToPath(`${BASE_PATH}/add?from=${watermark.id}`)}
size="sm"
title={`Copy ${watermark.name}`}
variant="ghost"
>
<Copy aria-hidden="true" size={14} />
</IconButton>
<IconButton onClick={() => setDeleteTarget(watermark)} size="sm" title="Delete" variant="ghost">
<Trash2 aria-hidden="true" size={14} />
</IconButton>
@@ -328,8 +340,8 @@ function WatermarkList() {
);
}
function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { kind: 'add' } }) {
const [draft, setDraft] = useState<Draft | null>(mode.kind === 'add' ? defaultDraft : null);
function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { from: null | number; kind: 'add' } }) {
const [draft, setDraft] = useState<Draft | null>(mode.kind === 'add' && mode.from == null ? defaultDraft : null);
const [loadError, setLoadError] = useState<null | string>(null);
const [saveError, setSaveError] = useState<null | string>(null);
const [uploadError, setUploadError] = useState<null | string>(null);
@@ -341,18 +353,22 @@ function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { kind
// Captured once at mount; the screen keys this editor by pathname (see ChannelEditScreen).
const [loadEditId] = useState(() => (mode.kind === 'edit' ? mode.id : null));
const [loadCopyFrom] = useState(() => (mode.kind === 'add' ? mode.from : null));
useEffect(() => {
if (loadEditId == null) {
if (loadEditId == null && loadCopyFrom == null) {
return;
}
let active = true;
getWatermark(loadEditId)
const sourceId = loadEditId ?? loadCopyFrom;
getWatermark(sourceId as number)
.then((watermark) => {
if (active) {
setDraft(draftFromWatermark(watermark as Watermark & Draft));
const source = draftFromWatermark(watermark as Watermark & Draft);
setDraft(loadEditId != null ? source : { ...source, name: `${source.name} (copy)` });
}
})
.catch((error: unknown) => {
@@ -364,7 +380,7 @@ function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { kind
return () => {
active = false;
};
}, [loadEditId]);
}, [loadEditId, loadCopyFrom]);
if (loadError) {
return (
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { appliesToDate, daysInMonth, firstMatchingIndex, type RecurrenceLimits } from './playoutTemplateCalendar';
import type { DayOfWeek } from '../api';
const ALL_DAYS: DayOfWeek[] = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const ALL_DOM = Array.from({ length: 31 }, (_, index) => index + 1);
const ALL_MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
function rec(overrides: Partial<RecurrenceLimits> = {}): RecurrenceLimits {
return {
daysOfWeek: [...ALL_DAYS],
daysOfMonth: [...ALL_DOM],
monthsOfYear: [...ALL_MONTHS],
limitToDateRange: false,
startMonth: 1,
startDay: 1,
startYear: null,
endMonth: 12,
endDay: 31,
endYear: null,
...overrides
};
}
describe('daysInMonth', () => {
it('returns the length of each month, respecting leap years', () => {
expect(daysInMonth(2026, 1)).toBe(31);
expect(daysInMonth(2026, 2)).toBe(28);
expect(daysInMonth(2024, 2)).toBe(29); // leap year
expect(daysInMonth(2026, 4)).toBe(30);
});
});
describe('appliesToDate', () => {
it('applies to any date with defaults and no date-range limit', () => {
// 2026-01-06 is a Tuesday.
expect(appliesToDate(rec(), new Date(2026, 0, 6))).toBe(true);
});
it('filters by day of the week', () => {
const mondayOnly = rec({ daysOfWeek: ['Monday'] });
expect(appliesToDate(mondayOnly, new Date(2026, 0, 5))).toBe(true); // Monday
expect(appliesToDate(mondayOnly, new Date(2026, 0, 6))).toBe(false); // Tuesday
});
it('filters by day of the month', () => {
const firstOnly = rec({ daysOfMonth: [1] });
expect(appliesToDate(firstOnly, new Date(2026, 5, 1))).toBe(true);
expect(appliesToDate(firstOnly, new Date(2026, 5, 2))).toBe(false);
});
it('filters by month of the year', () => {
const juneOnly = rec({ monthsOfYear: [6] });
expect(appliesToDate(juneOnly, new Date(2026, 5, 15))).toBe(true);
expect(appliesToDate(juneOnly, new Date(2026, 6, 15))).toBe(false);
});
it('limits to an inclusive date range on both ends', () => {
const march = rec({ limitToDateRange: true, startMonth: 3, startDay: 1, endMonth: 3, endDay: 31 });
expect(appliesToDate(march, new Date(2026, 2, 1))).toBe(true); // Mar 1 (start boundary)
expect(appliesToDate(march, new Date(2026, 2, 31))).toBe(true); // Mar 31 (end boundary)
expect(appliesToDate(march, new Date(2026, 1, 28))).toBe(false); // Feb 28
expect(appliesToDate(march, new Date(2026, 3, 1))).toBe(false); // Apr 1
});
it('wraps the year boundary when the range is reversed', () => {
const winter = rec({ limitToDateRange: true, startMonth: 11, startDay: 1, endMonth: 2, endDay: 1 });
expect(appliesToDate(winter, new Date(2026, 11, 15))).toBe(true); // Dec 15
expect(appliesToDate(winter, new Date(2026, 0, 10))).toBe(true); // Jan 10
expect(appliesToDate(winter, new Date(2026, 10, 1))).toBe(true); // Nov 1 (boundary, inclusive)
expect(appliesToDate(winter, new Date(2026, 1, 1))).toBe(true); // Feb 1 (boundary, inclusive)
expect(appliesToDate(winter, new Date(2026, 5, 1))).toBe(false); // Jun 1 (excluded middle)
});
it('uses explicit years and disables wrap-around when both years are set', () => {
// start month/day (Nov 1) is after end month/day (Feb 1), which would normally reverse — but with
// explicit years reverse is forced off, so this is a straight 2025-11-01 .. 2026-02-01 window.
const withYears = rec({
limitToDateRange: true,
startMonth: 11,
startDay: 1,
startYear: 2025,
endMonth: 2,
endDay: 1,
endYear: 2026
});
expect(appliesToDate(withYears, new Date(2025, 11, 15))).toBe(true); // Dec 15 2025 (in window)
expect(appliesToDate(withYears, new Date(2026, 5, 1))).toBe(false); // Jun 1 2026 (after end)
expect(appliesToDate(withYears, new Date(2025, 5, 1))).toBe(false); // Jun 1 2025 (before start)
});
it('rolls a too-large start day forward to the 1st of the next month', () => {
// April has 30 days; startDay 31 rolls the window start forward to May 1.
const draft = rec({ limitToDateRange: true, startMonth: 4, startDay: 31, endMonth: 12, endDay: 31 });
expect(appliesToDate(draft, new Date(2026, 3, 30))).toBe(false); // Apr 30 (before rolled start)
expect(appliesToDate(draft, new Date(2026, 4, 1))).toBe(true); // May 1 (rolled start)
});
it('clamps a too-large end day to the last day of the month (incl. leap years)', () => {
const nonLeap = rec({ limitToDateRange: true, startMonth: 1, startDay: 1, endMonth: 2, endDay: 31 });
expect(appliesToDate(nonLeap, new Date(2026, 1, 28))).toBe(true); // Feb 28 2026 (clamped end)
expect(appliesToDate(nonLeap, new Date(2026, 2, 1))).toBe(false); // Mar 1 2026
expect(appliesToDate(nonLeap, new Date(2024, 1, 29))).toBe(true); // Feb 29 2024 (leap year)
});
});
describe('firstMatchingIndex', () => {
it('returns the first matching template in priority order', () => {
const items = [rec({ daysOfWeek: ['Monday'] }), rec()];
expect(firstMatchingIndex(items, new Date(2026, 0, 5))).toBe(0); // Monday → first row wins
expect(firstMatchingIndex(items, new Date(2026, 0, 6))).toBe(1); // Tuesday → falls through to row 1
});
it('returns -1 when no template applies', () => {
const items = [rec({ daysOfWeek: ['Monday'] }), rec({ daysOfWeek: ['Wednesday'] })];
expect(firstMatchingIndex(items, new Date(2026, 0, 6))).toBe(-1); // Tuesday matches neither
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { DayOfWeek } from '../api';
// The recurrence limits shared by playout templates and alternate schedules — the subset of fields the
// preview-calendar matcher reads.
export interface RecurrenceLimits {
daysOfWeek: DayOfWeek[];
daysOfMonth: number[];
monthsOfYear: number[];
limitToDateRange: boolean;
startMonth: number;
startDay: number;
startYear: number | null;
endMonth: number;
endDay: number;
endYear: number | null;
}
// Sunday-first, indexed to match JS Date.getDay() (0 = Sunday). Mirrors System.DayOfWeek used by the
// C# AppliesToDate check.
const WEEKDAY_NAMES: DayOfWeek[] = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
];
export function daysInMonth(year: number, month: number): number {
// month is 1-based; day 0 of month+1 is the last day of `month`.
return new Date(year, month, 0).getDate();
}
// A date compressed to an ordered, comparable integer (yyyymmdd), mirroring C#'s `.Date` comparisons.
function dateKey(year: number, month: number, day: number): number {
return year * 10000 + month * 100 + day;
}
// Exact TypeScript port of ErsatzTV's C# scheduling logic used by the Blazor "Preview Calendar":
// ErsatzTV/ViewModels/PlayoutTemplateEditViewModel.AppliesToDate → applied to a single template via
// ErsatzTV.Core/Scheduling/AlternateScheduleSelector.GetScheduleForDate.
// - When limitToDateRange is set, the [start, end] window is inclusive on both ends.
// - reverse = (startMonth*100+startDay) > (endMonth*100+endDay) wraps the year boundary, UNLESS
// both startYear and endYear are set (then reverse is forced off and explicit years are used).
// - A start day past the month length rolls over to the 1st of the next month; an end day past the
// month length clamps to the last day of that month (matching the C# try/catch fallbacks).
// - After the range gate, the date's weekday, day-of-month and month must all be in the sets.
export function appliesToDate(rec: RecurrenceLimits, date: Date): boolean {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
if (rec.limitToDateRange) {
let reverse = rec.startMonth * 100 + rec.startDay > rec.endMonth * 100 + rec.endDay;
let startYear = year;
let endYear = year;
if (rec.startYear != null && rec.endYear != null) {
startYear = rec.startYear;
endYear = rec.endYear;
reverse = false;
}
// start = new DateTime(startYear, startMonth, startDay); on an out-of-range day roll to the 1st
// of the next month.
let startY = startYear;
let startM = rec.startMonth;
let startD = rec.startDay;
if (rec.startDay > daysInMonth(startYear, rec.startMonth)) {
const rolled = new Date(startYear, rec.startMonth, 1); // month index rec.startMonth == next month
startY = rolled.getFullYear();
startM = rolled.getMonth() + 1;
startD = 1;
}
// end = new DateTime(endYear, endMonth, endDay); on an out-of-range day clamp to the month length.
const endY = endYear;
const endM = rec.endMonth;
let endD = rec.endDay;
const endMonthLength = daysInMonth(endYear, rec.endMonth);
if (rec.endDay > endMonthLength) {
endD = endMonthLength;
}
let startKey = dateKey(startY, startM, startD);
let endKey = dateKey(endY, endM, endD);
const dKey = dateKey(year, month, day);
if (reverse) {
[startKey, endKey] = [endKey, startKey];
if (dKey > startKey && dKey < endKey) {
return false;
}
} else if (dKey < startKey || dKey > endKey) {
return false;
}
}
if (!rec.daysOfWeek.includes(WEEKDAY_NAMES[date.getDay()])) {
return false;
}
if (!rec.daysOfMonth.includes(day)) {
return false;
}
if (!rec.monthsOfYear.includes(month)) {
return false;
}
return true;
}
// The index of the first template (in priority order) that applies to `date`, or -1 if none —
// mirrors the Blazor DateRangeChanged loop (ordered by Index, first AppliesToDate match wins).
export function firstMatchingIndex(items: RecurrenceLimits[], date: Date): number {
for (let i = 0; i < items.length; i += 1) {
if (appliesToDate(items[i], date)) {
return i;
}
}
return -1;
}