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
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:
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+11
-2
@@ -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;
|
||||
|
||||
@@ -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([]));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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' })] });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. “All” searches movies, shows and artists; pick
|
||||
“Seasons” to find a specific season. Episodes, music, images and other item kinds can’t be
|
||||
added from here yet.
|
||||
“All” 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user