Integration: review-gates batch — #220/#215/#217/#219/#218 + #213 remainder (PRs #223–#228) #239

Merged
timothy merged 23 commits from integrate/review-gates into main 2026-07-11 02:03:47 +02:00
49 changed files with 1898 additions and 128 deletions
@@ -274,7 +274,8 @@ internal static class LibraryBrowseItemMapper
null,
em.EpisodeId,
null,
EpisodeSubtitle(em))).ToList());
EpisodeSubtitle(em),
em.Episode.SeasonId)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
@@ -46,7 +46,8 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
{ CollectionType.MusicVideo, request.MusicVideoIds },
{ CollectionType.OtherVideo, request.OtherVideoIds },
{ CollectionType.Song, request.SongIds },
{ CollectionType.Image, request.ImageIds }
{ CollectionType.Image, request.ImageIds },
{ CollectionType.RemoteStream, request.RemoteStreamIds }
};
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -81,8 +82,9 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
await ValidateMovies(request),
await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request))
.Apply((collection, _, _, _, _) => collection);
await ValidateEpisodes(request),
await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((collection, _, _, _, _, _) => collection);
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
@@ -126,4 +128,30 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken)
{
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
int existingCount = await dbContext.MediaItems
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
return existingCount == ids.Count
? Unit.Default
: BaseError.New("Media item does not exist");
}
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToPlaylist request) =>
request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds);
}
@@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<Option<int>> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException();
public Task<Option<string>> GetShowTitle(int libraryId, int showId) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
public Task<List<int>> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException();
@@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel(
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId,
string? Subtitle = null);
string? Subtitle = null,
int? SeasonId = null);
@@ -11,4 +11,5 @@ public record PlayoutListItemResponseModel(
string ScheduleName,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode);
ChannelPlayoutMode PlayoutMode,
bool IsLocked);
@@ -10,7 +10,7 @@ public interface ITelevisionRepository
Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken);
Task<Option<int>> GetShowIdByTitle(int libraryId, string title);
Task<Option<string>> GetShowTitle(int libraryId, int showId);
Task<List<Episode>> GetShowItems(int showId);
Task<List<int>> GetEpisodeIdsForShow(int showId);
Task<List<Season>> GetAllSeasons();
@@ -80,16 +80,16 @@ public class TelevisionRepository : ITelevisionRepository
.SelectOneAsync(s => s.Id, s => s.Id == showId, cancellationToken);
}
public async Task<Option<int>> GetShowIdByTitle(int libraryId, string title)
public async Task<Option<string>> GetShowTitle(int libraryId, int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => sm.ShowId == showId)
.Where(sm => sm.Show.LibraryPath.LibraryId == libraryId)
.Where(sm => EF.Functions.Like(sm.Title, $"%{title}%"))
.Map(sm => sm.ShowId)
.Map(sm => sm.Title)
.FirstOrDefaultAsync()
.Map(showId => showId > 0 ? Option<int>.Some(showId) : Option<int>.None);
.Map(Optional);
}
public async Task<List<int>> GetEpisodeIdsForShow(int showId)
@@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[1].MediaItemId.ShouldBe(711);
result.Page[2].MediaItemId.ShouldBe(713);
// #220: every episode carries its parent season id so the SPA can route to the season
// detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`).
result.Page.ShouldAllBe(p => p.SeasonId == 701);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
@@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds()
{
await SeedLibraryGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(
[
new SearchItem(LuceneSearchIndex.ShowType, 20),
new SearchItem(LuceneSearchIndex.MovieType, 10)
],
2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 10),
CancellationToken.None);
result.Page.ShouldAllBe(p => p.SeasonId == null);
}
[Test]
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
{
@@ -0,0 +1,139 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class PlaylistHandlerTests : MediaCollectionHandlerTestBase
{
[TestCase(CollectionType.Artist)]
[TestCase(CollectionType.MusicVideo)]
[TestCase(CollectionType.OtherVideo)]
[TestCase(CollectionType.Song)]
[TestCase(CollectionType.Image)]
[TestCase(CollectionType.RemoteStream)]
public async Task AddItems_Should_Return_ValidationError_When_Previously_Unvalidated_Kind_Missing(
CollectionType collectionType)
{
await SeedPlaylist(1);
var handler = MakeHandler();
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Media item does not exist");
}
[TestCase(CollectionType.Movie)]
[TestCase(CollectionType.TelevisionShow)]
[TestCase(CollectionType.TelevisionSeason)]
[TestCase(CollectionType.Episode)]
public async Task AddItems_Should_Return_ValidationError_When_Existing_Validated_Kind_Missing(
CollectionType collectionType)
{
await SeedPlaylist(1);
var handler = MakeHandler();
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
}
[Test]
public async Task AddItems_Should_Add_Valid_RemoteStream_Item_To_Playlist()
{
await SeedPlaylist(1);
await SeedRemoteStream(50);
var handler = MakeHandler();
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(1, CollectionType.RemoteStream, [50]), CancellationToken.None);
RightOf(result);
await using TvContext context = Db.CreateContext();
Playlist playlist = await context.Playlists.FindAsync(1);
List<PlaylistItem> items = context.Entry(playlist!).Collection(p => p.Items).Query().ToList();
items.ShouldHaveSingleItem();
items[0].CollectionType.ShouldBe(CollectionType.RemoteStream);
items[0].MediaItemId.ShouldBe(50);
}
private AddItemsToPlaylistHandler MakeHandler()
{
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
return new AddItemsToPlaylistHandler(Db.Factory, movieRepository, televisionRepository);
}
private async Task SeedPlaylist(int id, string name = "Playlist")
{
await using TvContext context = Db.CreateContext();
context.Playlists.Add(new Playlist { Id = id, Name = name, Items = [] });
await context.SaveChangesAsync();
}
private async Task SeedRemoteStream(int id)
{
await using TvContext context = Db.CreateContext();
context.RemoteStreams.Add(new RemoteStream
{
Id = id,
RemoteStreamMetadata = []
});
await context.SaveChangesAsync();
}
private static AddItemsToPlaylist MakeAddItems(int playlistId, CollectionType collectionType, List<int> ids)
{
List<int> movieIds = collectionType == CollectionType.Movie ? ids : [];
List<int> showIds = collectionType == CollectionType.TelevisionShow ? ids : [];
List<int> seasonIds = collectionType == CollectionType.TelevisionSeason ? ids : [];
List<int> episodeIds = collectionType == CollectionType.Episode ? ids : [];
List<int> artistIds = collectionType == CollectionType.Artist ? ids : [];
List<int> musicVideoIds = collectionType == CollectionType.MusicVideo ? ids : [];
List<int> otherVideoIds = collectionType == CollectionType.OtherVideo ? ids : [];
List<int> songIds = collectionType == CollectionType.Song ? ids : [];
List<int> imageIds = collectionType == CollectionType.Image ? ids : [];
List<int> remoteStreamIds = collectionType == CollectionType.RemoteStream ? ids : [];
return new AddItemsToPlaylist(
playlistId,
movieIds,
showIds,
seasonIds,
episodeIds,
artistIds,
musicVideoIds,
otherVideoIds,
songIds,
imageIds,
remoteStreamIds);
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v);
}
@@ -26,6 +26,7 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status409Conflict)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
@@ -113,22 +114,33 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
@@ -139,6 +151,7 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(LibrariesController), nameof(LibrariesController.ScanShow), StatusCodes.Status404NotFound)]
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
Type controllerType,
string actionName,
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -27,6 +28,7 @@ public class ChannelControllerTests
{
private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private IEntityLocker _entityLocker = null!;
private ChannelController _controller = null!;
[SetUp]
@@ -34,7 +36,8 @@ public class ChannelControllerTests
{
_mediator = Substitute.For<IMediator>();
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_controller = new ChannelController(_workerChannel.Writer, _mediator);
_entityLocker = Substitute.For<IEntityLocker>();
_controller = new ChannelController(_workerChannel.Writer, _mediator, _entityLocker);
}
[Test]
@@ -374,6 +377,20 @@ public class ChannelControllerTests
buildPlayout.Mode.ShouldBe(expectedMode);
}
[Test]
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
@@ -3,7 +3,10 @@ using ErsatzTV.Application.Libraries;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
@@ -16,12 +19,14 @@ public class LibrariesControllerTests
{
private LibrariesController _controller = null!;
private IMediator _mediator = null!;
private ITelevisionRepository _televisionRepository = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
_televisionRepository = Substitute.For<ITelevisionRepository>();
_controller = new LibrariesController(_televisionRepository, _mediator);
}
[Test]
@@ -52,4 +57,42 @@ public class LibrariesControllerTests
result.ShouldBe(expected);
}
[Test]
public async Task ScanShow_Should_Return_NotFoundProblem_When_Show_Not_In_Library()
{
_televisionRepository.GetShowTitle(3, 999).Returns(Option<string>.None);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(999));
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
await _mediator.DidNotReceive().Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(true);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
result.ShouldBeOfType<OkResult>();
await _mediator.Received(1).Send(
Arg.Is<QueueShowScanByLibraryId>(r =>
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(false);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
result.ShouldBeOfType<BadRequestObjectResult>();
}
}
@@ -42,7 +42,7 @@ public class LogsControllerTests
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(-1, 500, "boom", CancellationToken.None);
await _controller.GetLogs(-1, 500, "boom", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
@@ -52,6 +52,74 @@ public class LogsControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Default_To_Timestamp_Descending()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
q.SortDescending == true &&
SelectsTimestamp(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
q.SortDescending == false &&
SelectsLevel(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => SelectsTimestamp(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => q.SortDescending == true),
Arg.Any<CancellationToken>());
}
private static bool SelectsTimestamp(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
{
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
return Equals(expr.Compile()(sample), sample.Timestamp);
}
private static bool SelectsLevel(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
{
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
return Equals(expr.Compile()(sample), sample.Level);
}
[Test]
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
{
@@ -115,6 +115,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "409")]
[TestCase("/api/channel-templates/default", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
@@ -188,12 +189,21 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "get", "404")]
[TestCase("/api/playouts", "post", "404")]
[TestCase("/api/playouts", "post", "422")]
[TestCase("/api/playouts/{id}", "put", "404")]
[TestCase("/api/playouts/{id}", "put", "409")]
[TestCase("/api/playouts/{id}", "put", "422")]
[TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "409")]
[TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/deco", "put", "409")]
[TestCase("/api/playouts/{id}/alternate-schedules", "put", "409")]
[TestCase("/api/playouts/{id}/templates", "put", "409")]
[TestCase("/api/playouts/{id}/items", "get", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "409")]
[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", "409")]
[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")]
@@ -260,6 +270,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/trakt/lists/{id}", "put", "404")]
[TestCase("/api/trakt/lists/{id}", "put", "422")]
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path,
string method,
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
{
private PlayoutController _controller = null!;
private IMediator _mediator = null!;
private IEntityLocker _entityLocker = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new PlayoutController(_mediator);
_entityLocker = Substitute.For<IEntityLocker>();
_controller = new PlayoutController(_mediator, _entityLocker);
}
[Test]
@@ -68,6 +71,82 @@ public class PlayoutControllerTests
"/api/playouts/items/{id:int}/scheduling-context");
}
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
[Test]
public async Task Delete_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Delete(9, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItems_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Update(
9,
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.UpdateDefaultDeco(
9,
new UpdateDefaultDecoRequest(null),
CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
}
[Test]
public async Task GetAll_Should_Stamp_IsLocked_From_Locker()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)]));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page.Single().IsLocked.ShouldBeTrue();
}
// ----- Erase items / history -----
[Test]
+14 -1
View File
@@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
@@ -16,7 +17,10 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
public class ChannelController(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IMediator mediator,
IEntityLocker entityLocker)
{
[HttpGet("/api/channels")]
[EndpointGroupName("general")]
@@ -192,6 +196,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
string channelNumber,
[FromQuery] PlayoutBuildMode? mode,
@@ -201,6 +206,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
if (entityLocker.IsPlayoutLocked(playoutId))
{
return ApiResults.ConflictProblem(
"Playout build in progress",
"A build for this playout is currently in progress; try again once it completes.");
}
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult();
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -29,27 +30,23 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
[HttpPost("/api/libraries/{id:int}/scan-show")]
[Tags("Libraries")]
[EndpointSummary("Scan show")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
{
if (string.IsNullOrWhiteSpace(request.ShowTitle))
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
foreach (string title in maybeTitle)
{
return new BadRequestObjectResult(new { error = "ShowTitle is required" });
}
string trimmedTitle = request.ShowTitle.Trim();
Option<int> maybeShowId = await televisionRepository.GetShowIdByTitle(id, trimmedTitle);
foreach (int showId in maybeShowId)
{
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan));
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
return result
? new OkResult()
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
}
return new BadRequestObjectResult(
new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" });
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
}
}
public record ScanShowRequest(string ShowTitle, bool DeepScan = false);
public record ScanShowRequest(int ShowId, bool DeepScan = false);
+28 -1
View File
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ErsatzTV.Application.Logs;
using ErsatzTV.Core.Api.Logs;
using MediatR;
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
// Timestamp/Level; Message was never sortable there either).
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
[HttpGet("/api/logs", Name = "GetLogs")]
[Tags("Logs")]
[EndpointSummary("Get recent log entries")]
[EndpointDescription(
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
"pre-existing default, newest first).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLogEntriesResponseModel> GetLogs(
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery] string filter = "",
[FromQuery] string sortField = "timestamp",
[FromQuery] string sortDirection = "desc",
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
? sortField!.ToLowerInvariant()
: "timestamp";
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
{
"level" => le => le.Level,
_ => le => le.Timestamp
};
PagedLogEntriesViewModel result = await mediator.Send(
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty),
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
{
SortExpression = sortExpression,
SortDescending = descending
},
cancellationToken);
return new PagedLogEntriesResponseModel(
+62 -4
View File
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
{
private const int MaxPageSize = 100;
// Blazor disables per-playout Reset/Erase/Delete/Edit while a build is in flight
// (EntityLocker.IsPlayoutLocked); the API mirrors that invariant by rejecting any
// id-keyed mutation with 409 while the build lock is held. See docs/decisions.md 2026-07-10.
private const string BuildInProgressTitle = "Playout build in progress";
private const string BuildInProgressDetail =
"A build for this playout is currently in progress; try again once it completes.";
private static IActionResult PlayoutLockedProblem() =>
ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail);
[HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")]
[EndpointSummary("List playouts")]
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
return new PagedPlayoutsResponseModel(
result.TotalCount,
result.Page.Map(ToListItemResponse).ToList());
result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList());
}
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateDefaultDeco(
int id,
[Required] [FromBody] UpdateDefaultDecoRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceAlternateSchedules(
int id,
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceTemplates(
int id,
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -515,6 +551,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
@@ -531,9 +570,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -563,9 +608,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -608,9 +659,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
return result.ToDeletedResult();
}
@@ -722,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.EndDay,
vm.EndYear);
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
new(
vm.PlayoutId,
vm.ChannelNumber,
@@ -731,7 +788,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode);
vm.PlayoutMode,
isLocked);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
+7
View File
@@ -50,6 +50,13 @@ public static class ApiResults
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
/// <summary>
/// 409 <see cref="ProblemDetails" /> directly — for a mutation that races a background operation
/// holding a lock (e.g. a playout build in flight). Mirrors <see cref="NotFoundProblem" />.
/// </summary>
public static IActionResult ConflictProblem(string title, string detail) =>
new ConflictObjectResult(CreateProblemDetails(409, title, detail));
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
new()
{
+233 -7
View File
@@ -1764,6 +1764,26 @@
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
@@ -5317,6 +5337,46 @@
"responses": {
"200": {
"description": "OK"
},
"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"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
@@ -5410,6 +5470,7 @@
"Logs"
],
"summary": "Get recent log entries",
"description": "sortField is validated against an allow-list (timestamp, level); an unrecognized value falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the pre-existing default, newest first).",
"operationId": "GetLogs",
"parameters": [
{
@@ -5437,6 +5498,22 @@
"type": "string",
"default": ""
}
},
{
"name": "sortField",
"in": "query",
"schema": {
"type": "string",
"default": "timestamp"
}
},
{
"name": "sortDirection",
"in": "query",
"schema": {
"type": "string",
"default": "desc"
}
}
],
"responses": {
@@ -7359,6 +7436,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -7421,6 +7518,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -7616,6 +7733,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -7822,6 +7959,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -8028,6 +8185,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -8353,6 +8530,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -8419,6 +8616,26 @@
}
}
},
"409": {
"description": "Conflict",
"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": {
@@ -18013,6 +18230,13 @@
"null",
"string"
]
},
"seasonId": {
"type": [
"null",
"integer"
],
"format": "int32"
}
}
},
@@ -19667,7 +19891,8 @@
"scheduleName",
"dailyRebuildTime",
"buildStatus",
"playoutMode"
"playoutMode",
"isLocked"
],
"type": "object",
"properties": {
@@ -19706,6 +19931,9 @@
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
},
"isLocked": {
"type": "boolean"
}
}
},
@@ -20790,15 +21018,13 @@
},
"ScanShowRequest": {
"required": [
"showTitle"
"showId"
],
"type": "object",
"properties": {
"showTitle": {
"type": [
"null",
"string"
]
"showId": {
"type": "integer",
"format": "int32"
},
"deepScan": {
"type": "boolean",
+34
View File
@@ -32,6 +32,12 @@ Exemplars:
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
input for page math.
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
`sortDirection` falls back to the default direction. Copy this pattern (normalize, don't 422) for
any new sortable endpoint — it matches the pageNum/pageSize clamp precedent above and keeps a bad
query string from ever producing an error response for a read-only listing.
## 2. DTOs: where they live and their nullable context
@@ -67,6 +73,34 @@ hand-rolling `IActionResult` status codes:
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left` → `ToErrorResult()`; `Right` → 204 |
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 |
| `ApiResults.NotFoundProblem(detail?)` | — | 404 `ProblemDetails` directly (e.g. when a controller has to pre-check existence itself, see `TemplateController.DeleteGroup`) |
| `ApiResults.ConflictProblem(title, detail)` | — | 409 `ProblemDetails` directly — for a mutation that races a background operation holding a lock (see §3a) |
### 3a. 409 when a mutation races a background lock
When an endpoint mutates an entity that a background operation may be actively rebuilding under an
`IEntityLocker` lock, guard the mutation and return **409 Conflict** (`ApiResults.ConflictProblem`)
while the lock is held. This mirrors the Blazor UI, which disables the same actions while the lock
event is live.
**This guard is advisory check-then-act, not mutual exclusion.** It narrows the race but does not
eliminate it: a build already queued can acquire the lock a moment *after* the check passes, and the
mutation then interleaves with the build anyway. That residual window is accepted where the
consequences are self-healing (a playout half-mutated during a build is corrected by the next
rebuild). If an entity's consequences were NOT self-healing, this pattern would be insufficient —
the mutation would need to actually acquire the lock for its duration instead.
Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout`): inject
`IEntityLocker`, and at the top of every id-keyed mutation (`PUT`/`POST`/`DELETE`) check
`IsPlayoutLocked(id)` → `ConflictProblem("Playout build in progress", ...)`; add
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances:
- **Fire-and-forget bulk operations don't 409** — `POST /api/playouts/reset-all` stays 202; its
handler (`ResetAllPlayoutsHandler`) already *skips* locked playouts, matching Blazor + the handler
semantics. Only per-id mutations 409.
- **Surface the lock state to clients** so they can pre-disable the buttons: stamp an `IsLocked`
boolean onto the list DTO (`PlayoutListItemResponseModel`, set from `IsPlayoutLocked` in the
controller's list projection) rather than adding a push channel. The SPA reads it and, on a 409,
refreshes the list to pick up the flag.
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
+28 -12
View File
@@ -54,23 +54,23 @@ redirect).
>
> | Cluster | Verdict | Gap issue |
> |---|---|---|
> | Troubleshooting (4 routes) | PARITY-OK (2 minors) | #213 |
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK (block-copy UI added 2026-07-09) | — |
> | Troubleshooting (4 routes) | PARITY-OK (block-history page-size persistence + Id>=0 History gating added 2026-07-11) | — |
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK (block-copy UI added 2026-07-09; blocks/templates list search/filter added 2026-07-11) | — |
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — |
> | 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) | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
> | Channels-numbers / Logs | PARITY-OK / minors | #213 |
> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10; mutation controls + Add-all now gated during refetch — #221) | — |
> | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
> | Schedules editors | **disproven — moved to Section 3** | **#207** |
> | Media sources | **disproven — moved to Section 3** | **#202** |
>
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212/#213 are SHOULD-FIX.
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 is SHOULD-FIX (#213 closed 2026-07-11).
Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table
and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet.
@@ -92,8 +92,8 @@ been added to the redirect map yet.
| `/system/troubleshooting/block-playout` | `Troubleshooting/BlockPlayoutTroubleshooting.razor` (+`BlockPlayoutHistory.razor`) | `/app/troubleshooting/blocks` | **covered by PR #182 / #145** |
| `/system/troubleshooting/sequential-schedule` | `Troubleshooting/YamlValidator.razor` | `/app/troubleshooting/yaml` | **covered by PR #182 / #145** |
| `/system/troubleshooting/playback` | `Troubleshooting/PlaybackTroubleshooting.razor` | `/app/troubleshooting/playback` | **covered by #145** — no nav entry; entry points are the Channels table Troubleshoot action (`?channel={id}`) the movie detail page, and per-episode Troubleshoot actions on season detail pages (`?mediaItem={id}`, #209) — remaining kinds (music videos, songs, …) still need a hand-built `?mediaItem={id}` URL |
| `/blocks`, `/blocks/{Id:int}` | `Blocks.razor`, `BlockEditor.razor` | `/app/blocks`(`/{id}`) | allowSubPaths; #144 S1 |
| `/templates`, `/templates/{Id:int}` | `Templates.razor`, `TemplateEditor.razor` | `/app/templates`(`/{id}`) | allowSubPaths; #144 S2 |
| `/blocks`, `/blocks/{Id:int}` | `Blocks.razor`, `BlockEditor.razor` | `/app/blocks`(`/{id}`) | allowSubPaths; #144 S1; list search/filter (#213) added 2026-07-11 |
| `/templates`, `/templates/{Id:int}` | `Templates.razor`, `TemplateEditor.razor` | `/app/templates`(`/{id}`) | allowSubPaths; #144 S2; list search/filter (#213) added 2026-07-11 |
| `/decos`, `/decos/{Id:int}` | `Decos.razor`, `DecoEditor.razor` | `/app/decos`(`/{id}`) | allowSubPaths; #144 S3 |
| `/deco-templates`, `/deco-templates/{Id:int}` | `DecoTemplates.razor`, `DecoTemplateEditor.razor` | `/app/deco-templates`(`/{id}`) | allowSubPaths; #144 S4 |
| `/playouts/add`(`/{kind}`) | `PlayoutEditor.razor` variants | `/app/playouts` | merged into playouts screen creation flow; #144 S5 |
@@ -115,7 +115,7 @@ been added to the redirect map yet.
| `/media/tv/shows/{ShowId:int}` | `TelevisionSeasonList.razor` | `/app/media/shows/{id}` | show + season list (`ShowDetailScreen`); PR #183 / #141 |
| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=seasons` | seasons browsable as a top-level kind (`MediaBrowseScreen`; also reachable via show drill-in); #209 review fix |
| `/media/tv/seasons/{SeasonId:int}` | `TelevisionEpisodeList.razor` | `/app/media/seasons/{id}` | season + episode list (`SeasonDetailScreen`); PR #183 / #141 |
| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media/seasons/{id}` | no standalone SPA episode browse; covered via season detail drill-in; PR #183 / #141 |
| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media?kind=episodes` | standalone SPA episode browse EXISTS (`MediaBrowseScreen`, generic grid, top-level `episodes` kind); episode cards there and on the Search screen now navigate to the season detail page and anchor/highlight the episode (`/app/media/seasons/{seasonId}#episode-{id}`), matching `Search.razor:241`'s `media/tv/seasons/{SeasonId}#episode-{EpisodeId}` link (`LibraryBrowseItemResponseModel.SeasonId`, `mediaDetailPath`); #220 |
| `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 |
| `/media/music/artists/{ArtistId:int}` | `Artist.razor` | `/app/media/artists/{id}` | detail page (`ArtistDetailScreen`); PR #183 / #141 |
| `/media/music/videos`(`/page/{n}`) | `MusicVideoList.razor` | `/app/media?kind=music-videos` | generic browse; PR #183 / #141 |
@@ -181,8 +181,11 @@ 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 — 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).
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
the Blocks and Templates lists (`fix/213-spa-nits`); logs column sorting + page-size persistence
and trash per-kind "see all" paging (`fix/213-logs-trash`).
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.
@@ -201,6 +204,19 @@ navigation stays live. `SearchScreen.addAll` also binds its completion to the re
late `GET /api/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
See `docs/spa-conventions.md` §3a for the pattern.
CLOSED 2026-07-10: **#215** (adversarial-reviewer#18 removal gate) — Blazor's `EntityLocker`
build-lock gating of per-playout Reset/Erase/Delete/Edit is now enforced server-side: every
id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns **409** while
`IsPlayoutLocked(id)`, and the SPA mirrors the lock via an `IsLocked` flag on the playout list
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
2026-07-11 (#213, remaining scope): logs sort (`GET /api/logs` `sortField`/`sortDirection`,
clickable column headers) and page-size persistence (client-local `localStorage`, not a server
`ConfigElement`) landed; trash "see all" now pages past the 100/kind cap via
`GET /api/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
(block-history page-size/gating, blocks/templates list filters) — #213 fully closed.
## Section 4 — Blazor home / escape hatch
Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the
+73
View File
@@ -181,6 +181,42 @@ that per-kind cap); this mirrors the legacy Blazor Trash page, which had the sam
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.
**Superseded 2026-07-11 (#213)**: the cap is lifted via a per-kind "See all N …" button, without
adding any new API surface. `GET /api/library/browse` (`LibraryBrowseController` /
`GetLibraryBrowseItems`) already accepts `mediaType` + `pageNum` + `pageSize` and runs the same
underlying query as `GetSearchResults` (which itself fans out to `GetLibraryBrowseItems` per kind,
just always at `pageNum=0`) — so `TrashScreen.tsx` pages `pageNum=1, 2, …` through
`/api/library/browse?query=state:FileNotFound&mediaType={kind}&pageSize=100` for a kind once the
user asks to see past the first 100, and appends the results client-side. The 100/kind **first
page** still comes from `/api/search` (unchanged, cheapest for the common case where a kind has
few matches); only kinds that exceed the cap ever issue the follow-up `/api/library/browse` calls.
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
`ConfigElementKey.LogsPageSize` (`SaveConfigElementByKey`/`GetConfigElementByKey`), a
per-server-instance setting stored in the DB. `LogsScreen.tsx` instead persists it to
`window.localStorage` under `ctv-logs-page-size` (same wrapped-`Storage` pattern as
`designSystem.ts`'s theme preference: try/catch getter, validated against the known option set,
falls back to a default) and restores it on mount. Deliberate deviation: this is a per-browser UI
preference, not server/business state — no other client should see or be affected by it, so there
is no reason to round-trip it through the API and grow a new `/api/*` surface (or reuse the
generic config-element endpoints) just to store a page-size number. Follows the existing SPA
localStorage convention (`designSystem.ts` theme, `auth.ts` token) rather than introducing a new
persistence mechanism.
## 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
indicating the active field/direction; clicking the active column toggles direction, clicking the
other column switches to it ascending.
## 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
@@ -226,6 +262,43 @@ TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s t
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
command exists server-side. Issues #208/#209.
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
Blazor is deleted the invariant would vanish entirely.
Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
— `PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
is "no mutation during a build", matching Blazor's edit-disable.
- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
builds, a semantics change out of scope for restoring Blazor parity.
- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
*silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
bulk enqueue always accepts.
- **SPA mirrors the lock via data, not a push channel** — `PlayoutListItemResponseModel` gains an
`IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
With multiple sessions/agents working the repo in parallel, the old protocol — every session
+63
View File
@@ -99,3 +99,66 @@ scripts/e2e-local.sh [CONFIG_DIR]
- Waits (up to 120s) for the `Done migrating search index` log line.
- Prints the PID and port, then **exits leaving the server running** — the caller is responsible
for killing the PID when done (`kill <PID>`).
## Seeding a local TV library for E2E
Channels/playouts are API-seedable (step 5 above), but a **local media library is not** — there
is no `/api/*` endpoint to add a local library folder. To exercise media-browse / search / detail
screens you need real scanned items. Recipe (used to verify the #220 episode-nav PR):
1. **Generate tiny media files on disk** — one show (one season, ~3 episodes), plus a second show
whose title *contains the first as a substring* (good substring-search sanity data), plus a
movie if you need a non-episode kind. Keep TV and movies under **separate roots** so each
library scans cleanly (a Shows library pointed at a folder that also contains movies will try
to parse the movies as shows). Each file is a 2-second `testsrc` clip:
```bash
MEDIA=/tmp/etv-media # any scratch path
mkdir -p "$MEDIA/tv/Show Alpha/Season 01" \
"$MEDIA/tv/Show Alpha Returns/Season 01" \
"$MEDIA/movies/Test Movie (2020)"
for n in 01 02 03; do
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
"$MEDIA/tv/Show Alpha/Season 01/Show Alpha - s01e$n.mkv"
done
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
"$MEDIA/tv/Show Alpha Returns/Season 01/Show Alpha Returns - s01e01.mkv"
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
"$MEDIA/movies/Test Movie (2020)/Test Movie (2020).mkv"
```
2. **Attach the folders to the built-in local libraries via SQLite.** A fresh config DB already
has the seven default local libraries (`Library` rows for a single `LocalMediaSource`): `Movies`
is `Id=1`, `Shows` is `Id=2`. `LibraryPath` is just `(Path TEXT, LibraryId INT)` — insert one
row per root, pointing each at the matching library:
```bash
DB="$CONFIG_DIR/ersatztv.sqlite3" # CONFIG_DIR from the run above; server may be running
sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/tv', 2);" # Shows
sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/movies', 1);" # Movies
```
3. **Trigger a scan and wait for items to appear.** The scan endpoint takes an empty body:
```bash
curl -s -X POST http://localhost:8409/api/libraries/2/scan -H 'Content-Type: application/json' -d '{}'
curl -s -X POST http://localhost:8409/api/libraries/1/scan -H 'Content-Type: application/json' -d '{}'
# poll until episodes show up (scanner runs as a background subprocess):
curl -s "http://localhost:8409/api/library/browse?mediaType=Episode&pageSize=50"
```
The scan runs even though `LibraryPath` was inserted after startup — the scan handler re-reads
the library from the DB. Browse (`/api/library/browse`) reads straight from the DB, so items
appear there within a few seconds.
### Gotchas
- **Do NOT delete the `search-index/` folder to "reset" search.** On startup the app *recreates the
index empty* (`Search index failed to initialize; will delete and recreate` → `Migrating search
index to version N`) and that migration does **not** re-index from the DB — only a **scan**
writes documents into the Lucene index. The scanner subprocess writes the index while running; a
restart never rebuilds it from existing DB rows. If you wipe `search-index/`, a *rescan of
unchanged files won't repopulate it* (the scanner skips unchanged items), so search stays empty.
The clean recovery is a fresh `CONFIG_DIR`: launch → insert `LibraryPath` → scan **once** → leave
the index alone.
- **Search query relevance is field-scoped, not free-text.** The `/api/search` default field does
**not** match bare title words: `Alpha` and `Show` return nothing for a "Show Alpha" title, while
`title:Alpha`, `Show*`, or `*Alpha*` all match. The SPA search box forwards the query verbatim, so
when driving search-result screens in E2E use a field/wildcard query (e.g. `title:Alpha`) to get
deterministic hits. (This is pre-existing ErsatzTV search behavior, independent of any SPA change.)
+14
View File
@@ -159,6 +159,20 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
and the accepted deviations from Blazor).
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
`window.localStorage` under a namespaced `ctv-` key, **not** a round-trip through the API — the
established pattern is `designSystem.ts`'s `getStoredDesignSystemTheme`/`applyDesignSystemTheme`
(`ctv-theme`): a small `getStorage()` helper that returns `window.localStorage` wrapped in a
try/catch (so a disabled/unavailable storage API degrades to the default instead of throwing), a
getter that validates the stored value against the known option set before trusting it, and a
setter that writes straight through. `LogsScreen.tsx`'s page-size persistence (`ctv-logs-page-size`,
#213) follows the same shape. Reserve this for state that's genuinely local to the browser/user
session — if a preference needs to be shared across devices or is really server/business state
(e.g. Blazor's `ConfigElement`-backed settings), it belongs behind an API endpoint instead; see
`docs/decisions.md` 2026-07-11 for the specific reasoning on logs page-size.
## 6. Tests
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
+20
View File
@@ -2005,6 +2005,25 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
});
it('disables mutation buttons and shows a Building cue for a locked (building) playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
playouts: { page: [listPlayout({ id: 20, isLocked: true, scheduleKind: 'Block' })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(screen.getByText('Building…')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Erase items' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});
it('disables Alternate schedules for an on-demand Classic playout', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
@@ -3531,6 +3550,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
channelNumber: '5.1',
dailyRebuildTime: '04:00:00',
id: 20,
isLocked: false,
scheduleKind: 'Classic',
scheduleName: 'Prime Time Cartoons',
...overrides
+18 -6
View File
@@ -134,6 +134,7 @@ import {
resetChannelPlayout,
deleteChannel,
getDecos,
ApiError,
messageFromError,
addScheduleItem,
deleteScheduleItem,
@@ -362,7 +363,6 @@ const routes: ScreenRoute[] = [
icon: <ListVideo aria-hidden="true" size={16} />,
primaryAction: 'Reset All',
placeholder: 'Playouts workspace',
badge: 3,
allowSubPaths: true
},
{
@@ -3093,6 +3093,11 @@ function PlayoutsScreen() {
})
.catch((error: unknown) => {
setMutationError(messageFromError(error));
// A 409 means a build lock is now held for this playout; refresh so the row
// picks up its IsLocked state and the mutation buttons disable themselves.
if (error instanceof ApiError && error.status === 409) {
query.refresh();
}
})
.finally(() => {
setMutatingState(false);
@@ -3194,6 +3199,9 @@ function PlayoutsScreen() {
}
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
// The playout's build lock is held (a build is in flight). Server rejects destructive
// mutations with 409 while locked; mirror that by disabling the buttons here.
const selectedLocked = selectedSummary.isLocked;
const nowPlaying = selectedState?.nowPlaying ?? null;
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
const nextItem = nextPlayoutItem(items, nowItem);
@@ -3258,7 +3266,7 @@ function PlayoutsScreen() {
<div className="ctv-playouts-title">
<ChannelLogo name={selectedSummary.channelName} size={38} />
<div>
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>}</span>
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>} {selectedLocked && <Badge tone="warn" dot>Building…</Badge>}</span>
<h2>{selectedSummary.channelName}</h2>
</div>
</div>
@@ -3346,20 +3354,22 @@ function PlayoutsScreen() {
)}
<div className="ctv-playout-detail-actions">
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={resetSelectedChannel}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="secondary"
>
Reset
</Button>
{selectedSummary.scheduleKind === 'Block' && (
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={eraseSelectedItems}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost"
>
Erase items
@@ -3370,20 +3380,22 @@ function PlayoutsScreen() {
selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && (
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={eraseSelectedItemsAndHistory}
size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost"
>
Erase items and history
</Button>
)}
<Button
disabled={mutating}
disabled={mutating || selectedLocked}
onClick={deleteSelectedPlayout}
size="sm"
startIcon={<Trash2 aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="danger"
>
Delete
+3 -1
View File
@@ -761,6 +761,7 @@ export interface components {
"mediaItemId": null | number;
"playlistId": null | number;
"subtitle"?: null | string;
"seasonId"?: null | number;
};
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream";
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
@@ -1076,6 +1077,7 @@ export interface components {
"dailyRebuildTime": null | string;
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"isLocked": boolean;
};
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
"PlayoutResponseModel": {
@@ -1280,7 +1282,7 @@ export interface components {
"libraryRefreshInterval": number;
};
"ScanShowRequest": {
"showTitle": null | string;
"showId": number;
"deepScan"?: boolean;
};
"ScheduleItemRequest": {
+5 -5
View File
@@ -22,19 +22,19 @@ describe('libraries api client', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
});
it('scanShow POSTs the show title and deepScan flag', async () => {
it('scanShow POSTs the show id and deepScan flag', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(4, { deepScan: true, showTitle: 'The Office' });
await scanShow(4, { deepScan: true, showId: 42 });
const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/libraries/4/scan-show');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showTitle: 'The Office' });
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showId: 42 });
});
it('scanShow defaults deepScan to false when omitted', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(9, { showTitle: 'Firefly' });
await scanShow(9, { showId: 17 });
const { init } = lastCall(fetchMock);
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showTitle: 'Firefly' });
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showId: 17 });
});
});
+5 -5
View File
@@ -52,16 +52,16 @@ export function scanLibrary(libraryId: number): Promise<void> {
}
export interface ScanShowParams {
showTitle: string;
showId: number;
deepScan?: boolean;
}
// Queues a scan of a single show (by title) within a library. Returns 200 on success, 400 when
// the title can't be resolved / the library doesn't support single-show scanning. Body keys are
// `showTitle` and `deepScan` (see LibrariesController.ScanShowRequest).
// Queues a scan of a single show (by id) within a library. Returns 200 on success, 404 when the
// show id doesn't exist in the library, 400 when the library doesn't support single-show
// scanning. Body keys are `showId` and `deepScan` (see LibrariesController.ScanShowRequest).
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan-show`, {
body: { deepScan: params.deepScan ?? false, showTitle: params.showTitle },
body: { deepScan: params.deepScan ?? false, showId: params.showId },
method: 'POST'
});
}
+14
View File
@@ -44,6 +44,20 @@ describe('getLogs', () => {
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50');
});
it('builds the query string from sortField and sortDirection', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(samplePage), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getLogs({ sortDirection: 'asc', sortField: 'level' });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/logs?sortField=level&sortDirection=asc');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
+13
View File
@@ -4,10 +4,15 @@ import type { components } from './generated/v1';
export type LogEntry = components['schemas']['LogEntryResponseModel'];
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
export type LogsSortField = 'timestamp' | 'level';
export type LogsSortDirection = 'asc' | 'desc';
export interface GetLogsParams {
filter?: string;
pageNum?: number;
pageSize?: number;
sortDirection?: LogsSortDirection;
sortField?: LogsSortField;
}
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
@@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
searchParams.set('pageSize', String(params.pageSize));
}
if (params.sortField) {
searchParams.set('sortField', params.sortField);
}
if (params.sortDirection) {
searchParams.set('sortDirection', params.sortDirection);
}
const queryString = searchParams.toString();
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
+12 -2
View File
@@ -13,7 +13,9 @@ export function MediaPosterCard({
onToggleSelect,
onOpen,
height = 150,
actions
actions,
id,
highlighted
}: {
item: LibraryBrowseItem;
selected?: boolean;
@@ -21,6 +23,13 @@ export function MediaPosterCard({
onOpen?: (item: LibraryBrowseItem) => void;
height?: number;
actions?: ReactNode;
// Stable DOM id (e.g. `episode-{id}`) so callers can deep-link/scroll to this card.
id?: string;
// Applies a ring highlight while this card is the deep-link target (e.g. the current
// `#episode-{id}` hash) — persists for as long as the anchor matches this card, with only the
// ring's outer glow pulse fading shortly after mount (see .ctv-media-card-highlighted in
// shell.css).
highlighted?: boolean;
}) {
const hue = hueOf(item.title);
const Icon = TYPE_ICON[item.mediaType] ?? Film;
@@ -45,7 +54,8 @@ export function MediaPosterCard({
return (
<div
className={`ctv-media-card${selected ? ' ctv-media-card-selected' : ''}${interactive ? ' ctv-press' : ''}`}
className={`ctv-media-card${selected ? ' ctv-media-card-selected' : ''}${interactive ? ' ctv-press' : ''}${highlighted ? ' ctv-media-card-highlighted' : ''}`}
id={id}
onClick={interactive ? activate : undefined}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { mediaDetailPath } from './mediaKinds';
import type { LibraryBrowseItem } from '../api';
function episodeItem(overrides: Partial<LibraryBrowseItem>): LibraryBrowseItem {
return {
artwork: '',
id: 91,
mediaType: 'Episode',
title: 'Pilot',
...overrides
} as unknown as LibraryBrowseItem;
}
describe('mediaDetailPath', () => {
it('routes an episode with a seasonId to the season detail page, anchored to the episode (#220)', () => {
const item = episodeItem({ seasonId: 8 } as Partial<LibraryBrowseItem>);
expect(mediaDetailPath(item)).toBe('/app/media/seasons/8#episode-91');
});
it('returns null for an episode with no seasonId', () => {
const item = episodeItem({});
expect(mediaDetailPath(item)).toBeNull();
});
it('still routes a movie to its detail page', () => {
const item = {
artwork: '',
id: 5,
mediaType: 'Movie',
title: 'Blade Runner'
} as unknown as LibraryBrowseItem;
expect(mediaDetailPath(item)).toBe('/app/media/movies/5');
});
});
+2
View File
@@ -61,6 +61,8 @@ export function mediaDetailPath(item: LibraryBrowseItem): string | null {
return `/app/media/seasons/${item.id}`;
case 'Artist':
return `/app/media/artists/${item.id}`;
case 'Episode':
return item.seasonId != null ? `/app/media/seasons/${item.seasonId}#episode-${item.id}` : null;
default:
return null;
}
@@ -15,7 +15,8 @@ const playoutsPage = {
};
const blocks = [
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' }
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' },
{ id: -1, groupId: 1, groupName: 'Morning', name: '(none)', minutes: 0, stopScheduling: 'AfterDurationEnd' }
];
const historyPage = {
@@ -87,4 +88,36 @@ describe('BlockPlayoutTroubleshootingScreen', () => {
expect(await screen.findByText('Cartoons Collection')).toBeTruthy();
expect(screen.getByText('S1E1 - Pilot')).toBeTruthy();
});
it('hides the History action for a block with a negative (unpersisted) id', async () => {
mockApi();
render(<BlockPlayoutTroubleshootingScreen />);
await screen.findByRole('option', { name: '1 - Cartoons' });
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
await screen.findByText('Toons');
expect(await screen.findByText('(none)')).toBeTruthy();
// Only the persisted block (id 10) gets a History button.
expect(screen.getAllByRole('button', { name: 'History' })).toHaveLength(1);
});
it('restores the page size from localStorage on mount and persists a change', async () => {
window.localStorage.setItem('ctv-block-history-page-size', '50');
mockApi();
render(<BlockPlayoutTroubleshootingScreen />);
await screen.findByRole('option', { name: '1 - Cartoons' });
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
await screen.findByText('Toons');
fireEvent.click(screen.getByRole('button', { name: 'History' }));
await screen.findByText('{"BlockId":10}');
const pageSizeSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
expect(pageSizeSelect.value).toBe('50');
fireEvent.change(pageSizeSelect, { target: { value: '25' } });
expect(window.localStorage.getItem('ctv-block-history-page-size')).toBe('25');
});
});
@@ -15,6 +15,31 @@ import {
const PAGE_SIZE_OPTIONS = ['10', '25', '50', '100'];
// Mirrors Blazor's TroubleshootingBlockPlayoutHistoryPageSize ConfigElement — persisted
// client-side here since this screen has no server-side config surface. Namespaced with the
// same `ctv-` prefix as other SPA-persisted preferences (e.g. `ctv-theme`).
const PAGE_SIZE_STORAGE_KEY = 'ctv-block-history-page-size';
function readStoredPageSize(): number {
try {
const raw = window.localStorage.getItem(PAGE_SIZE_STORAGE_KEY);
if (raw != null && PAGE_SIZE_OPTIONS.includes(raw)) {
return Number(raw);
}
} catch {
// localStorage unavailable (e.g. private browsing) - fall back to the default.
}
return 10;
}
function writeStoredPageSize(pageSize: number): void {
try {
window.localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(pageSize));
} catch {
// ignore - persistence is a convenience, not a requirement.
}
}
type PlayoutsState =
| { status: 'loading'; playouts: []; error: null }
| { status: 'success'; playouts: PlayoutSummary[]; error: null }
@@ -58,7 +83,7 @@ export function BlockPlayoutTroubleshootingScreen() {
const [blockFilter, setBlockFilter] = useState('');
const [selectedBlock, setSelectedBlock] = useState<SelectedBlock | null>(null);
const [pageNum, setPageNum] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [pageSize, setPageSize] = useState(() => readStoredPageSize());
const [historyState, setHistoryState] = useState<HistoryState>({
status: 'loading',
entries: [],
@@ -343,7 +368,9 @@ export function BlockPlayoutTroubleshootingScreen() {
<Select
onChange={(event) => {
setPageNum(0);
setPageSize(Number(event.target.value));
const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
writeStoredPageSize(nextPageSize);
}}
options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }}
@@ -464,14 +491,18 @@ function BlockGroupRows({
<td>{block.name}</td>
<td>{block.minutes}</td>
<td>
<Button
onClick={() => onOpenHistory(block)}
size="sm"
startIcon={<History aria-hidden="true" size={14} />}
variant="secondary"
>
History
</Button>
{/* Mirrors Blazor BlockPlayoutTroubleshooting.razor: the History action only exists
for persisted blocks (Id >= 0) — synthesized/virtual blocks have no history to show. */}
{block.id >= 0 && (
<Button
onClick={() => onOpenHistory(block)}
size="sm"
startIcon={<History aria-hidden="true" size={14} />}
variant="secondary"
>
History
</Button>
)}
</td>
</tr>
))}
+41 -4
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BlocksScreen } from './BlocksScreen';
@@ -6,8 +6,15 @@ function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const groups = [{ id: 2, name: 'Prime' }];
const blocks = [{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' }];
const groups = [
{ id: 2, name: 'Prime' },
{ id: 3, name: 'Late Night' }
];
const blocks = [
{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' },
{ id: 7, groupId: 2, groupName: 'Prime', name: 'Evening News', minutes: 30, stopScheduling: 'AfterDurationEnd' },
{ id: 8, groupId: 3, groupName: 'Late Night', name: 'Talk Show', minutes: 60, stopScheduling: 'AfterDurationEnd' }
];
function blockItem(overrides: Record<string, unknown>) {
return {
@@ -107,12 +114,42 @@ describe('BlocksScreen', () => {
expect(screen.getByText('Morning')).toBeInTheDocument();
});
it('filters blocks by name, case-insensitive', async () => {
mockApi();
render(<BlocksScreen />);
await screen.findByText('Morning');
expect(screen.getByText('Evening News')).toBeInTheDocument();
expect(screen.getByText('Talk Show')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'EVENING' } });
expect(screen.getByText('Evening News')).toBeInTheDocument();
expect(screen.queryByText('Morning')).not.toBeInTheDocument();
expect(screen.queryByText('Talk Show')).not.toBeInTheDocument();
});
it('filters blocks by group name and shows an empty state for no matches', async () => {
mockApi();
render(<BlocksScreen />);
await screen.findByText('Morning');
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'late night' } });
expect(screen.getByText('Talk Show')).toBeInTheDocument();
expect(screen.queryByText('Morning')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'zzz-no-match' } });
expect(await screen.findByText('No blocks match this filter.')).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 row = screen.getByText('Morning').closest('.ctv-settings-flush-row') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: /Copy block/ }));
const dialog = await screen.findByText('Copy "Morning"');
expect(dialog).toBeInTheDocument();
+37 -4
View File
@@ -9,6 +9,7 @@ import {
Eye,
FolderPlus,
Plus,
Search,
Trash2,
TriangleAlert
} from 'lucide-react';
@@ -320,6 +321,7 @@ function BlockList() {
const [deleteGroupTarget, setDeleteGroupTarget] = useState<BlockGroup | null>(null);
const [deleteBlockTarget, setDeleteBlockTarget] = useState<Block | null>(null);
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('');
const activeRef = useRef(true);
const load = useCallback(() => {
@@ -455,6 +457,26 @@ 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) }));
// Client-side filter by block name or group name, case-insensitive (parity with Blazor
// Blocks.razor's search box). A group whose own name matches keeps all of its blocks; otherwise
// only its matching blocks are kept, and the group is hidden entirely if none match.
const needle = filter.trim().toLowerCase();
const filteredGroups = sortedGroups
.map((group) => {
const groupBlocks = blocks
.filter((b) => b.groupId === group.id)
.sort((a, b) => a.name.localeCompare(b.name));
if (needle === '') {
return { group, groupBlocks };
}
const groupNameMatches = group.name.toLowerCase().includes(needle);
const matchingBlocks = groupNameMatches
? groupBlocks
: groupBlocks.filter((b) => b.name.toLowerCase().includes(needle));
return groupNameMatches || matchingBlocks.length > 0 ? { group, groupBlocks: matchingBlocks } : null;
})
.filter((entry): entry is { group: BlockGroup; groupBlocks: Block[] } => entry !== null);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -472,6 +494,16 @@ function BlockList() {
</Button>
</div>
<div className="ctv-channels-actionbar">
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search for blocks…"
value={filter}
/>
<span className="ctv-channels-spacer" />
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -483,11 +515,12 @@ function BlockList() {
<Card>
<div className="ctv-collections-empty">No block groups yet. Create one to get started.</div>
</Card>
) : filteredGroups.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No blocks match this filter.</div>
</Card>
) : (
sortedGroups.map((group) => {
const groupBlocks = blocks
.filter((b) => b.groupId === group.id)
.sort((a, b) => a.name.localeCompare(b.name));
filteredGroups.map(({ group, groupBlocks }) => {
return (
<Card
key={group.id}
+94
View File
@@ -0,0 +1,94 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LogsScreen } from './LogsScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const samplePage = {
totalCount: 2,
page: [
{ timestamp: '2026-07-07T00:00:00Z', level: 'Warning', message: 'uh oh' },
{ timestamp: '2026-07-07T00:01:00Z', level: 'Information', message: 'all good' }
]
};
function mockApi() {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/logs')) {
return Promise.resolve(jsonResponse(samplePage));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
function lastLogsUrl(fetchMock: ReturnType<typeof mockApi>): string {
const call = [...fetchMock.mock.calls].reverse().find(([u]) => u.toString().startsWith('/api/logs'));
return call ? call[0].toString() : '';
}
describe('LogsScreen', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
window.localStorage.clear();
});
it('loads with the default timestamp/desc sort', async () => {
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
expect(lastLogsUrl(fetchMock)).toContain('sortField=timestamp');
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=desc');
});
it('toggles sort direction when clicking the active column', async () => {
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
fireEvent.click(screen.getByRole('button', { name: /Timestamp/ }));
await screen.findByText('uh oh');
expect(lastLogsUrl(fetchMock)).toContain('sortField=timestamp');
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=asc');
});
it('switches sort field to ascending when clicking a new column', async () => {
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
fireEvent.click(screen.getByRole('button', { name: /Level/ }));
await screen.findByText('uh oh');
expect(lastLogsUrl(fetchMock)).toContain('sortField=level');
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=asc');
});
it('persists page size to localStorage and restores it on mount', async () => {
mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
fireEvent.change(screen.getByDisplayValue('50'), { target: { value: '100' } });
expect(await screen.findByDisplayValue('100')).toBeInTheDocument();
expect(window.localStorage.getItem('ctv-logs-page-size')).toBe('100');
cleanup();
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
expect(lastLogsUrl(fetchMock)).toContain('pageSize=100');
});
});
+89 -11
View File
@@ -1,9 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Info,
RefreshCw,
Search,
TriangleAlert
} from 'lucide-react';
import { Badge, Button, Card, IconButton, Input, Select, Spinner } from '../components';
import { getLogs, messageFromLogsError, type LogEntry } from '../api';
import { getLogs, messageFromLogsError, type LogEntry, type LogsSortDirection, type LogsSortField } from '../api';
const PAGE_SIZE_OPTIONS = ['25', '50', '100'];
const DEFAULT_PAGE_SIZE = 50;
// Client-local UI preference (not a Blazor-style server ConfigElement) — see docs/decisions.md
// 2026-07-11 "Logs page-size is a client-local preference".
const LOGS_PAGE_SIZE_STORAGE_KEY = 'ctv-logs-page-size';
const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = {
Debug: 'neutral',
@@ -24,11 +38,44 @@ function formatTimestamp(value: string): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function getStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
}
function getStoredPageSize(): number {
const stored = getStorage()?.getItem(LOGS_PAGE_SIZE_STORAGE_KEY) ?? null;
return stored != null && PAGE_SIZE_OPTIONS.includes(stored) ? Number(stored) : DEFAULT_PAGE_SIZE;
}
function storePageSize(pageSize: number): void {
getStorage()?.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize));
}
interface SortableColumn {
field: LogsSortField;
label: string;
}
const COLUMNS: SortableColumn[] = [
{ field: 'timestamp', label: 'Timestamp' },
{ field: 'level', label: 'Level' }
];
export function LogsScreen() {
const [filterInput, setFilterInput] = useState('');
const [filter, setFilter] = useState('');
const [pageNum, setPageNum] = useState(0);
const [pageSize, setPageSize] = useState(50);
const [pageSize, setPageSize] = useState(() => getStoredPageSize());
const [sortField, setSortField] = useState<LogsSortField>('timestamp');
const [sortDirection, setSortDirection] = useState<LogsSortDirection>('desc');
const [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 });
const activeRef = useRef(true);
const seqRef = useRef(0);
@@ -56,7 +103,7 @@ export function LogsScreen() {
// a loading spinner on every filter keystroke or page change.
const load = useCallback(() => {
const id = ++seqRef.current;
getLogs({ filter, pageNum, pageSize })
getLogs({ filter, pageNum, pageSize, sortDirection, sortField })
.then((paged) => {
if (activeRef.current && id === seqRef.current) {
setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
@@ -67,7 +114,7 @@ export function LogsScreen() {
setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 });
}
});
}, [filter, pageNum, pageSize]);
}, [filter, pageNum, pageSize, sortDirection, sortField]);
useEffect(() => {
load();
@@ -78,6 +125,22 @@ export function LogsScreen() {
load();
};
const changePageSize = (nextPageSize: number) => {
setPageNum(0);
setPageSize(nextPageSize);
storePageSize(nextPageSize);
};
const toggleSort = (field: LogsSortField) => {
setPageNum(0);
if (field === sortField) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1;
return (
@@ -91,10 +154,7 @@ export function LogsScreen() {
/>
<span className="ctv-channels-spacer" />
<Select
onChange={(event) => {
setPageNum(0);
setPageSize(Number(event.target.value));
}}
onChange={(event) => changePageSize(Number(event.target.value))}
options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }}
value={String(pageSize)}
@@ -136,8 +196,26 @@ export function LogsScreen() {
<table aria-label="Recent log entries" className="ctv-channels-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Level</th>
{COLUMNS.map((column) => {
const active = column.field === sortField;
return (
<th aria-sort={active ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} key={column.field}>
<button
className="ctv-logs-sort-button"
onClick={() => toggleSort(column.field)}
type="button"
>
{column.label}
{active &&
(sortDirection === 'asc' ? (
<ChevronUp aria-hidden="true" size={13} />
) : (
<ChevronDown aria-hidden="true" size={13} />
))}
</button>
</th>
);
})}
<th>Message</th>
</tr>
</thead>
+132 -1
View File
@@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { navigateToPath } from '../routing';
import {
ArtistDetailScreen,
MovieDetailScreen,
@@ -154,7 +155,7 @@ describe('media detail screens', () => {
const scanCall = fetchSpy.mock.calls.find(([url]) => String(url) === '/api/libraries/3/scan-show');
expect(scanCall).toBeTruthy();
const body = JSON.parse(String((scanCall![1] as RequestInit).body));
expect(body).toMatchObject({ deepScan: true, showTitle: 'The Show' });
expect(body).toMatchObject({ deepScan: true, showId: 42 });
});
await waitFor(() => expect(screen.getByText('Scan queued')).toBeInTheDocument());
});
@@ -178,4 +179,134 @@ describe('media detail screens', () => {
expect(screen.getByRole('button', { name: 'Media Info' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).toBeInTheDocument();
});
it('anchors and highlights the episode targeted by an #episode-{id} hash (#220)', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
return Promise.resolve(
jsonResponse({
page: [
{ artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' },
{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' }
],
totalCount: 2
})
);
}
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(jsonResponse(season));
});
const scrollIntoView = vi.fn();
vi.stubGlobal('HTMLElement', HTMLElement);
Element.prototype.scrollIntoView = scrollIntoView;
window.location.hash = '#episode-91';
render(<SeasonDetailScreen id={8} />);
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
const target = document.getElementById('episode-91');
expect(target).not.toBeNull();
await waitFor(() => expect(scrollIntoView).toHaveBeenCalled());
expect(target?.className).toContain('ctv-media-card-highlighted');
const other = document.getElementById('episode-90');
expect(other?.className).not.toContain('ctv-media-card-highlighted');
window.location.hash = '';
});
it('updates the anchor and scrolls on same-pathname in-app navigation (synthetic popstate, review #220)', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
return Promise.resolve(
jsonResponse({
page: [
{ artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' },
{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' }
],
totalCount: 2
})
);
}
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(jsonResponse(season));
});
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
window.location.hash = '';
render(<SeasonDetailScreen id={8} />);
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
expect(scrollIntoView).not.toHaveBeenCalled();
// `routing.ts`'s navigateToPath is what App.tsx uses for in-app clicks (e.g. an episode card
// inside this same season grid): pushState + a synthetic `popstate`, not a real hash change,
// so `hashchange` alone would never fire. This exercises that same-pathname path directly.
navigateToPath('/app/media/seasons/8#episode-91');
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1));
const target = document.getElementById('episode-91');
expect(target?.className).toContain('ctv-media-card-highlighted');
window.location.hash = '';
});
it('does not re-scroll on refetch/pagination when the anchor is unchanged (review #220)', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
const params = new URL(url, 'http://localhost').searchParams;
const pageNum = params.get('pageNum');
if (pageNum === '1') {
return Promise.resolve(
jsonResponse({
page: [{ artwork: '', collectionType: 'Episode', id: 92, mediaType: 'Episode', title: 'Third' }],
totalCount: 61
})
);
}
return Promise.resolve(
jsonResponse({
page: [
{ artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' },
{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' }
],
totalCount: 61
})
);
}
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(jsonResponse(season));
});
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView;
window.location.hash = '#episode-91';
render(<SeasonDetailScreen id={8} />);
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1));
// Page away (target leaves the DOM) and back (a fresh `items` array is fetched, but the
// anchor value itself never changed) — must not re-trigger the scroll.
fireEvent.click(screen.getByTitle('Next page'));
await waitFor(() => expect(screen.getByText('Third')).toBeInTheDocument());
fireEvent.click(screen.getByTitle('Previous page'));
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
expect(scrollIntoView).toHaveBeenCalledTimes(1);
window.location.hash = '';
});
});
+45 -1
View File
@@ -211,8 +211,12 @@ function ChildGrid({
const [pageNum, setPageNum] = useState(0);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null);
const [anchorId, setAnchorId] = useState<string | null>(() => window.location.hash.slice(1) || null);
const activeRef = useRef(true);
const seqRef = useRef(0);
// The anchor value we last actually scrolled to, so a refetch/pagination that recreates
// `items` (but leaves the hash unchanged) doesn't hijack the user's scroll position.
const scrolledAnchorRef = useRef<string | null>(null);
useEffect(() => {
activeRef.current = true;
@@ -221,6 +225,43 @@ function ChildGrid({
};
}, []);
// Deep-link support (#220): a season detail's episode grid can be opened with an
// `#episode-{id}` hash (from search/browse cards, or Blazor-parity links). The SPA's own
// in-app navigation (routing.ts navigateToPath) uses `history.pushState` + a synthetic
// `popstate` dispatch rather than a real hash change — real browser hash navigation
// (address bar, back/forward across a hash-only change) fires `hashchange` instead — so both
// events must be handled to catch in-grid episode card clicks (same season pathname, new
// fragment) as well as deep links that remount this screen.
useEffect(() => {
const onHashNav = () => setAnchorId(window.location.hash.slice(1) || null);
window.addEventListener('hashchange', onHashNav);
window.addEventListener('popstate', onHashNav);
return () => {
window.removeEventListener('hashchange', onHashNav);
window.removeEventListener('popstate', onHashNav);
};
}, []);
useEffect(() => {
if (status !== 'success' || mediaType !== 'Episode' || !anchorId) {
return;
}
// One-shot per anchor value: only scroll the first time we see this anchor resolve
// successfully, so a later refetch/pagination (which recreates `items`) doesn't re-jump the
// page back to it. Known limitation shared with the Blazor fragment link this replaces: the
// anchor only resolves against the currently-loaded page (CHILD_PAGE_SIZE) — a target beyond
// page 1 won't be found (and so won't scroll) until that page is loaded.
if (scrolledAnchorRef.current === anchorId) {
return;
}
const target = document.getElementById(anchorId);
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
scrolledAnchorRef.current = anchorId;
}, [status, mediaType, anchorId, items]);
const load = useCallback(() => {
const id = ++seqRef.current;
getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId })
@@ -278,9 +319,12 @@ function ChildGrid({
<div className="ctv-media-grid">
{items.map((item) => {
const detailPath = mediaDetailPath(item);
const cardId = item.mediaType === 'Episode' ? `episode-${item.id}` : undefined;
return (
<MediaPosterCard
actions={renderActions?.(item)}
highlighted={cardId != null && cardId === anchorId}
id={cardId}
item={item}
key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
@@ -584,7 +628,7 @@ function ShowScanControls({ show }: { show: ShowDetail }) {
const runScan = (deepScan: boolean) => {
setScanning(deepScan ? 'deep' : 'quick');
setMessage(null);
scanShow(show.libraryId, { deepScan, showTitle: show.title })
scanShow(show.libraryId, { deepScan, showId: show.id })
.then(() => {
if (activeRef.current) {
setScanning(false);
+41 -4
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplatesScreen } from './TemplatesScreen';
@@ -6,8 +6,15 @@ function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const groups = [{ id: 2, name: 'Prime', templateCount: 1 }];
const templates = [{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' }];
const groups = [
{ id: 2, name: 'Prime', templateCount: 1 },
{ id: 3, name: 'Overnight', templateCount: 1 }
];
const templates = [
{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' },
{ id: 6, templateGroupId: 2, groupName: 'Prime', name: 'Weekends' },
{ id: 9, templateGroupId: 3, groupName: 'Overnight', name: 'Late Show' }
];
const blockGroups = [{ id: 1, name: 'Morning Blocks' }];
const blocks = [{ id: 10, groupId: 1, groupName: 'Morning Blocks', name: 'Cartoons', minutes: 60, stopScheduling: 'AfterDurationEnd' }];
@@ -80,12 +87,42 @@ describe('TemplatesScreen', () => {
expect(screen.getByText('Weekdays')).toBeInTheDocument();
});
it('filters templates by name, case-insensitive', async () => {
mockApi();
render(<TemplatesScreen />);
await screen.findByText('Weekdays');
expect(screen.getByText('Weekends')).toBeInTheDocument();
expect(screen.getByText('Late Show')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'weekEND' } });
expect(screen.getByText('Weekends')).toBeInTheDocument();
expect(screen.queryByText('Weekdays')).not.toBeInTheDocument();
expect(screen.queryByText('Late Show')).not.toBeInTheDocument();
});
it('filters templates by group name and shows an empty state for no matches', async () => {
mockApi();
render(<TemplatesScreen />);
await screen.findByText('Weekdays');
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'overnight' } });
expect(screen.getByText('Late Show')).toBeInTheDocument();
expect(screen.queryByText('Weekdays')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'zzz-no-match' } });
expect(await screen.findByText('No templates match this filter.')).toBeInTheDocument();
});
it('opens the copy dialog and POSTs to the copy route', async () => {
mockApi();
render(<TemplatesScreen />);
await screen.findByText('Weekdays');
fireEvent.click(screen.getByRole('button', { name: /Copy template/ }));
const row = screen.getByText('Weekdays').closest('.ctv-settings-flush-row') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: /Copy template/ }));
const dialog = await screen.findByText('Copy "Weekdays"');
expect(dialog).toBeInTheDocument();
+39 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Check, Copy, FolderPlus, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { ArrowLeft, Check, Copy, FolderPlus, Plus, Search, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
import {
@@ -162,6 +162,7 @@ function TemplateList() {
const [deleteGroupTarget, setDeleteGroupTarget] = useState<TemplateGroup | null>(null);
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('');
const activeRef = useRef(true);
const load = useCallback(() => {
@@ -300,6 +301,28 @@ function TemplateList() {
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) }));
// Client-side filter by template name or group name, case-insensitive (parity with Blazor
// Templates.razor's search box). A group whose own name matches keeps all of its templates;
// otherwise only its matching templates are kept, and the group is hidden entirely if none match.
const needle = filter.trim().toLowerCase();
const filteredGroups = sortedGroups
.map((group) => {
const groupTemplates = templates
.filter((t) => t.templateGroupId === group.id && t.id > 0)
.sort((a, b) => a.name.localeCompare(b.name));
if (needle === '') {
return { group, groupTemplates };
}
const groupNameMatches = group.name.toLowerCase().includes(needle);
const matchingTemplates = groupNameMatches
? groupTemplates
: groupTemplates.filter((t) => t.name.toLowerCase().includes(needle));
return groupNameMatches || matchingTemplates.length > 0
? { group, groupTemplates: matchingTemplates }
: null;
})
.filter((entry): entry is { group: TemplateGroup; groupTemplates: Template[] } => entry !== null);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -317,6 +340,16 @@ function TemplateList() {
</Button>
</div>
<div className="ctv-channels-actionbar">
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search for templates…"
value={filter}
/>
<span className="ctv-channels-spacer" />
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -328,11 +361,12 @@ function TemplateList() {
<Card>
<div className="ctv-collections-empty">No template groups yet. Create one to get started.</div>
</Card>
) : filteredGroups.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No templates match this filter.</div>
</Card>
) : (
sortedGroups.map((group) => {
const groupTemplates = templates
.filter((t) => t.templateGroupId === group.id && t.id > 0)
.sort((a, b) => a.name.localeCompare(b.name));
filteredGroups.map(({ group, groupTemplates }) => {
return (
<Card
key={group.id}
+39
View File
@@ -44,6 +44,12 @@ const searchResults = {
songs: emptyGroup()
};
// Movies group has more matches than the /api/search first-page cap (100) to exercise "See all".
function searchResultsWithMoreMovies() {
const movieItems = Array.from({ length: 100 }, (_, i) => browseItem(i + 1, 'Movie', 200 + i));
return { ...searchResults, movies: { items: movieItems, totalCount: 130 }, shows: emptyGroup() };
}
interface MockOptions {
onRequest?: (url: string, method: string, body: unknown) => Response | null;
}
@@ -129,4 +135,37 @@ describe('TrashScreen', () => {
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]);
});
});
it('pages past the 100/kind cap via "See all" using GET /api/library/browse', async () => {
const moreMovies = searchResultsWithMoreMovies();
const fetchMock = mockApi({
onRequest: (url, method) => {
if (url.startsWith('/api/search') && method === 'GET') {
return jsonResponse(moreMovies);
}
if (url.startsWith('/api/library/browse') && method === 'GET') {
expect(url).toContain('mediaType=Movie');
expect(url).toContain('pageNum=1');
expect(url).toContain('query=state%3AFileNotFound');
const extraMovies = Array.from({ length: 30 }, (_, i) => browseItem(i + 500, 'Movie', 900 + i));
return jsonResponse({ page: extraMovies, totalCount: 130 });
}
return null;
}
});
render(<TrashScreen />);
await screen.findByText('130 missing');
const seeAllButton = screen.getByRole('button', { name: 'See all 130 movies' });
fireEvent.click(seeAllButton);
await waitFor(() => {
expect(fetchMock.mock.calls.some(([u]) => u.toString().startsWith('/api/library/browse'))).toBe(true);
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: /See all/ })).not.toBeInTheDocument();
});
});
});
+83 -15
View File
@@ -4,15 +4,20 @@ import { Button, Card, ConfirmDialog, Spinner } from '../components';
import {
deleteMediaItems,
emptyTrash,
getLibraryBrowseItems,
getSearchResults,
messageFromLibraryBrowseError,
messageFromSearchError,
type LibraryBrowseItem,
type LibraryBrowseMediaType,
type SearchResults
} from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard';
// 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").
// The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
// pageSize to MaxPageSize=100 — see docs/decisions.md ("Trash see all"). "See all" beyond that
// first page pages through the same underlying data via GET /api/library/browse (mediaType +
// pageNum), which already supports paging — no new API surface was needed to lift the cap.
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';
@@ -20,21 +25,30 @@ const TRASH_QUERY = 'state:FileNotFound';
interface GroupDef {
key: keyof SearchResults;
label: string;
mediaType: LibraryBrowseMediaType;
}
const GROUPS: GroupDef[] = [
{ key: 'movies', label: 'Movies' },
{ key: 'shows', label: 'TV Shows' },
{ key: 'seasons', label: 'Seasons' },
{ key: 'episodes', label: 'Episodes' },
{ key: 'artists', label: 'Artists' },
{ key: 'musicVideos', label: 'Music Videos' },
{ key: 'songs', label: 'Songs' },
{ key: 'otherVideos', label: 'Other Videos' },
{ key: 'images', label: 'Images' },
{ key: 'remoteStreams', label: 'Remote Streams' }
{ key: 'movies', label: 'Movies', mediaType: 'Movie' },
{ key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
{ key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
{ key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
{ key: 'artists', label: 'Artists', mediaType: 'Artist' },
{ key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
{ key: 'songs', label: 'Songs', mediaType: 'Song' },
{ key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
{ key: 'images', label: 'Images', mediaType: 'Image' },
{ key: 'remoteStreams', label: 'Remote Streams', mediaType: 'RemoteStream' }
];
interface SeeAllState {
error: string | null;
items: LibraryBrowseItem[];
loading: boolean;
// Next page to request; page 0 was already loaded by the initial /api/search call.
nextPageNum: number;
}
type TrashState =
| { results: SearchResults; error: null; status: 'success' }
| { results: null; error: string; status: 'error' }
@@ -46,6 +60,7 @@ function mediaItemIdOf(item: LibraryBrowseItem): number | null {
export function TrashScreen() {
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
const [seeAll, setSeeAll] = useState<Partial<Record<keyof SearchResults, SeeAllState>>>({});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
const [busy, setBusy] = useState(false);
@@ -83,9 +98,45 @@ export function TrashScreen() {
const refresh = () => {
setState({ results: null, error: null, status: 'loading' });
setSeeAll({});
load();
};
const loadMore = (group: GroupDef) => {
const current = seeAll[group.key];
const nextPageNum = current?.nextPageNum ?? 1; // page 0 was already loaded by /api/search above
setSeeAll((prev) => ({
...prev,
[group.key]: { error: null, items: current?.items ?? [], loading: true, nextPageNum }
}));
getLibraryBrowseItems({ mediaType: group.mediaType, pageNum: nextPageNum, pageSize: PAGE_SIZE, query: TRASH_QUERY })
.then((page) => {
if (!activeRef.current) {
return;
}
setSeeAll((prev) => {
const existing = prev[group.key];
const items = [...(existing?.items ?? []), ...(page.page ?? [])];
return { ...prev, [group.key]: { error: null, items, loading: false, nextPageNum: nextPageNum + 1 } };
});
})
.catch((error: unknown) => {
if (!activeRef.current) {
return;
}
setSeeAll((prev) => ({
...prev,
[group.key]: {
error: messageFromLibraryBrowseError(error, 'Unable to load more items'),
items: current?.items ?? [],
loading: false,
nextPageNum
}
}));
});
};
const toggle = (item: LibraryBrowseItem) => {
const mediaItemId = mediaItemIdOf(item);
if (mediaItemId == null) {
@@ -113,7 +164,8 @@ export function TrashScreen() {
}
const ids = new Set<number>();
for (const group of GROUPS) {
for (const item of state.results[group.key].items) {
const items = [...state.results[group.key].items, ...(seeAll[group.key]?.items ?? [])];
for (const item of items) {
const mediaItemId = mediaItemIdOf(item);
if (mediaItemId != null) {
ids.add(mediaItemId);
@@ -219,9 +271,12 @@ export function TrashScreen() {
state.results &&
GROUPS.map((group) => {
const data = state.results![group.key];
if (data.totalCount === 0 || data.items.length === 0) {
const more = seeAll[group.key];
const items = [...data.items, ...(more?.items ?? [])];
if (data.totalCount === 0 || items.length === 0) {
return null;
}
const hasMore = items.length < data.totalCount;
return (
<section key={group.key}>
@@ -232,7 +287,7 @@ export function TrashScreen() {
</span>
</div>
<div className="ctv-media-grid">
{data.items.map((item) => {
{items.map((item) => {
const mediaItemId = mediaItemIdOf(item);
return (
<MediaPosterCard
@@ -244,6 +299,19 @@ export function TrashScreen() {
);
})}
</div>
{more?.error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{more.error}</span>
</div>
)}
{hasMore && (
<div className="ctv-media-section-footer">
<Button disabled={more?.loading === true} onClick={() => loadMore(group)} size="sm" variant="secondary">
{more?.loading ? 'Loading…' : `See all ${data.totalCount} ${group.label.toLowerCase()}`}
</Button>
</div>
)}
</section>
);
})}
+44
View File
@@ -841,6 +841,26 @@ body {
vertical-align: middle;
}
.ctv-logs-sort-button {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
margin: 0;
padding: 0;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.ctv-logs-sort-button:hover,
.ctv-logs-sort-button:focus-visible {
color: var(--text-primary);
}
.ctv-channel-check {
width: 38px;
padding-left: 14px !important;
@@ -2537,6 +2557,24 @@ body {
box-shadow: 0 0 0 1px var(--action-primary);
}
/* Deep-link target highlight (#220), e.g. `#episode-{id}` from search/browse episode cards.
The ring itself (border + 2px box-shadow) persists for as long as the hash names this card;
only the outer glow pulse fades out shortly after mount. */
.ctv-media-card-highlighted {
border-color: var(--action-primary);
box-shadow: 0 0 0 2px var(--action-primary);
animation: ctv-media-card-highlight-pulse 2400ms ease-out 1;
}
@keyframes ctv-media-card-highlight-pulse {
0% {
box-shadow: 0 0 0 4px var(--action-primary);
}
100% {
box-shadow: 0 0 0 2px var(--action-primary);
}
}
.ctv-media-card-poster {
position: relative;
overflow: hidden;
@@ -2906,6 +2944,12 @@ body {
color: var(--text-secondary);
}
.ctv-media-section-footer {
display: flex;
justify-content: center;
margin: var(--space-5, 10px) 0 var(--space-7, 16px);
}
/* lineup */
.ctv-builder-lineup-list {
display: flex;