merge: origin/main (review-gates batch #222/#239) into feat/207-212
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:44:58 +02:00
co-authored by Claude Fable 5
55 changed files with 2454 additions and 259 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,
@@ -13,6 +13,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;
@@ -30,6 +31,7 @@ public class ChannelControllerTests
{
private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private IEntityLocker _entityLocker = null!;
private ChannelController _controller = null!;
[SetUp]
@@ -37,7 +39,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]
@@ -411,6 +414,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
@@ -8,6 +8,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;
@@ -17,7 +18,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")]
@@ -209,6 +213,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,
@@ -218,6 +223,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
@@ -1844,6 +1844,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"
}
}
}
}
}
}
@@ -5447,6 +5467,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"
}
}
}
}
}
}
@@ -5540,6 +5600,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": [
{
@@ -5567,6 +5628,22 @@
"type": "string",
"default": ""
}
},
{
"name": "sortField",
"in": "query",
"schema": {
"type": "string",
"default": "timestamp"
}
},
{
"name": "sortDirection",
"in": "query",
"schema": {
"type": "string",
"default": "desc"
}
}
],
"responses": {
@@ -7489,6 +7566,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": {
@@ -7551,6 +7648,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": {
@@ -7746,6 +7863,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": {
@@ -7952,6 +8089,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": {
@@ -8158,6 +8315,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": {
@@ -8483,6 +8660,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": {
@@ -8549,6 +8746,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": {
@@ -18036,6 +18253,13 @@
"null",
"string"
]
},
"seasonId": {
"type": [
"null",
"integer"
],
"format": "int32"
}
}
},
@@ -19575,7 +19799,8 @@
"scheduleName",
"dailyRebuildTime",
"buildStatus",
"playoutMode"
"playoutMode",
"isLocked"
],
"type": "object",
"properties": {
@@ -19614,6 +19839,9 @@
},
"playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode"
},
"isLocked": {
"type": "boolean"
}
}
},
@@ -20452,15 +20680,13 @@
},
"ScanShowRequest": {
"required": [
"showTitle"
"showId"
],
"type": "object",
"properties": {
"showTitle": {
"type": [
"null",
"string"
]
"showId": {
"type": "integer",
"format": "int32"
},
"deepScan": {
"type": "boolean",
+3 -2
View File
@@ -36,6 +36,7 @@ Also present in `docs/`:
- **`docs/endpoint-index.md`** — generated REST endpoint index (method/path/operationId/summary per
OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
`scripts/update-openapi.sh`.
- **`docs/handoffs/chicorytv-issue-queue.md`** — living session-to-session handoff: current queue
state, what's next. Check this for what's actively in flight before starting new work.
- **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore.
Queue state (goal, arc, what's in flight/next) lives in the pinned Gitea tracker ersatztv#237
— read that, not this file, for current state (protocol: decisions.md 2026-07-11).
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
+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
@@ -84,6 +90,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.
+38 -14
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 | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #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) | — |
> | 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) | — |
> | 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 | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** |
> | Media sources | **disproven — moved to Section 3** | **#202** |
>
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212/#213 are SHOULD-FIX.
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11.
Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table
and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet.
@@ -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 |
@@ -185,9 +185,12 @@ maps each Blazor field → SPA control → request key → test.
### Remaining mutation-depth gaps inside Section 2 rows
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor),
#213 (remaining nits: logs sort + page-size persistence, block-history page-size/gating,
blocks/templates list filter — all read-only conveniences).
Tracked as #91 phase (b) gates without moving whole rows — #212 DONE 2026-07-11 (channel editor),
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
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.
@@ -197,6 +200,27 @@ query-wide Add All via `GET /api/search/all-items`, Save As Smart Collection) an
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
CLOSED 2026-07-10: **#221** (adversarial-reviewer#18 follow-up to #208/#209) — those PRs added
mutation actions to two screens whose fetch model keeps the previous result set rendered during a
refetch. On Search and Media browse the per-card Add-to menu, Select/select-mode, selection action
bar, Add-all, and Save-as-smart-collection are now **gated while a refetch is in flight** (query on
Search; kind/query/page on Media browse), with a visible "Refreshing…" cue and dimmed grid; card
navigation stays live. `SearchScreen.addAll` also binds its completion to the requesting query so a
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
+89 -1
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
@@ -246,6 +282,43 @@ Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that l
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
(`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 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
The ChicoryTV schedules editor (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`,
@@ -310,4 +383,19 @@ Also landed with #212: `preferredAudioLanguageCode`/`preferredSubtitleLanguageCo
`/api/channels/stream-selectors`. Each keeps the channel's currently-stored value selectable even if
it's absent from the reference list (`optionsKeepingCurrent` in `ChannelEditScreen.tsx`) so loading
an existing channel never silently changes the value out from under an unmanaged language code or a
template/selector file removed from disk since save.
template/selector file removed from disk since save.## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
With multiple sessions/agents working the repo in parallel, the old protocol — every session
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
next-session prompt) — became a last-writer-wins race. New protocol: **volatile queue state
moved to Gitea**, which is concurrency-safe by construction. Pinned tracker issue **#237**
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
Arc change / Recommended next). Milestone `Blazor removal (#91 phase b)` + the `review` and
`in-progress` labels are the machine-queryable view. Sessions **claim** an issue before working
it (`in-progress` label + claim comment; the tiny read→claim race window is accepted, later
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
slot) or backlog (label only) — so review findings adjust the queue only through that step and
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
+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.)
+65 -112
View File
@@ -1,59 +1,55 @@
# ChicoryTV issue-queue handoff (living document)
# ChicoryTV issue-queue handoff (static kickoff + workflow lore)
Paste the prompt below into a fresh session to work the next item. Each session ends by
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic);
all backend gap issues (#100#111), all SPA screens (#84#89, #93, #109), the rebrand (#90),
the cutover root-flip (#91 phase a), full scheduling parity (#144/#162), media/troubleshooting
parity (#141/#161/#158/#180), onboarding docs (#185), the collections/API-gap batch
(#155/#151/#152-backend/#184), the multi/rerun/playlist SPA editors (#151/#152 PR #194, #153
PR #195), CI speedups (#190 PR #192), #145 playback troubleshooting (PR #199), #198 OpenAPI
casing (PR #201) + #193 rerun existence-check (PR #200), the first #91-gate batch
#210/#211/#213-partial (PR #214), and the search/media mutation batch #208/#209 (PR #216, shared
Add-to layer) are MERGED. **v26.5.0 in prod; v26.6.0 tagged** (awaiting Komodo pin bump in
server-management).
> **PROTOCOL CHANGED 2026-07-11** (decisions.md entry of same date). Queue state lives in the
> **pinned Gitea tracker [ersatztv#237](http://192.168.1.95:3000/timothy/ersatztv/issues/237)**,
> not in this file. Do **NOT** write session state, queue order, or next-session prompts here —
> sessions ending under the old protocol should append their session comment to #237 instead.
> This file holds only the two stable things: the standing kickoff prompt and the workflow lore.
> Historical per-session state: `git log` of this file (last state-bearing revision: 8b77d5e7).
**Session state (2026-07-10, #208/#209 session)**: **#208 + #209 CLOSED via PR #216** (merged
c63cadb8). New API surface: `POST /api/playlists/{id}/items` (wraps existing `AddItemsToPlaylist`;
handler hardened to reject system playlists — latent Blazor-path gap) and `GET
/api/search/all-items` (wraps `QuerySearchIndexAllItems`; Add-All = materialize-then-add, Blazor
two-step parity — no query-based add command exists). SPA: shared Add-to layer
`web/src/media/addTo/` (Collection dialog w/ inline create, Playlist dialog filtering `isSystem`,
Schedule dialog replicating `AddProgramScheduleItem.ForMediaItem` defaults —
`addTo/scheduleItem.ts`, SaveAsSmartCollectionDialog, AddToMenu popover) + `MediaPosterCard`
`actions` slot; wired into search (card drill-in, per-card add, Select toggle + selection bar,
Add All, Save As Smart Collection) and browse/detail (multi-select + select-all-on-page, all four
detail screens, per-show Quick/Deep scan gated Plex/Jellyfin/Emby, per-episode Media Info +
Troubleshoot, seasons browse kind). **Add-to-schedule is gated to shows/seasons/artists** — the
server validator (`ProgramScheduleItemCommandBase.CollectionTypeMustBeValid`) only accepts those
per-media-item kinds, matching Blazor's ForMediaItem call sites (found by live E2E: movie adds
422'd). Also fixed: portal font bug (nothing set a `body` font; portaled dialogs fell back to
Times — `body` rule added), a mount-debounce race (the 300ms query-debounce commit wiped
in-progress selections; `lastQueryRef` no-change guard in Search+Browse screens), and the
e2e-local.sh stale-asset gotcha (`cp -R` into an existing dir nests + serves the previous run's
assets — script now rm-first). New issues: **#217** (add-items handlers validate only 4/10 kinds
— pre-existing), **#218** (fresh-DB "Playouts 3" badge + "1 failing" chip). Retro/pre-review
issues filed in adversarial-reviewer: #18 (this milestone + #214), #19 (upcoming #207/#212, #202,
#91b, #197). #213 remains OPEN (reduced read-only-convenience scope).
---
**Blazor removal (#91 phase b) remaining MUST-FIX gates, in order:**
1. **#207 (+#212)** — schedules editor full mutation depth (endpoints exist; needs languages
enumeration endpoint shared with #212 channel-editor pickers) ← NEXT (prompt below)
2. **#202** — media-source management (write REST API + SPA screens; largest, riskiest)
3. **#91 phase (b) removal PR** — pattern redirects + catch-all (#204 plan), blazor-final tag
(#205), auth posture note (#206), delete Pages/Shared/ViewModels/Validators + 6 packages
(MudBlazor, MediatR.Courier.DependencyInjection, Blazored.FluentValidation, BlazorSortable,
Heron.MudCalendar, Chronic.Core). Removal recon is DONE (2026-07-09 gate session): Courier
consumers are 100% Blazor (`AddCourier` Startup.cs:394 dies too); the Startup `MapWhen`
"blazor" branch (736795) CO-HOSTS MapControllers/OpenAPI/Scalar — surgical removal only
(MapBlazorHub:775, MapFallbackToPage:776, services 379398); `wwwroot/lib/*`+`css/site.css`
are `_Host`-only; check `Locals/*.resx` consumers before deleting; keep `Extensions/`
except `NavigationManagerExtensions.cs`.
# STANDING KICKOFF PROMPT (paste into a fresh session, unchanged every time)
You are Fable, the ORCHESTRATOR in the main Claude Code session. Fable is EXPENSIVE: delegate
(recon → Explore/haiku; mechanical → sonnet; judgment-heavy → opus; fable forks for review).
FIRST read CLAUDE.md, docs/README.md + the convention docs it indexes, and the Lessons below.
Then work the queue:
1. Read the pinned tracker **ersatztv#237** — body = goal + ordered arc + session protocol —
and its most recent session comments; list open issues in the `Blazor removal (#91 phase b)`
milestone and with the `review` label.
2. Pick the top arc item that is open and NOT labeled `in-progress` (or the item the user names).
3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s).
4. Read the issue bodies (they carry the task context/evidence) and work the item under the
HARD CONSTRAINTS below.
5. Finish by following the session-end protocol in #237: ONE session comment on the tracker
(template in the tracker body, incl. triage verdicts for any new issues), remove your
`in-progress` labels, and complete the per-issue Task Completion Protocol from CLAUDE.md.
HARD CONSTRAINTS:
- Work in worktrees off origin/main. Copy web/node_modules from the main checkout.
- PARALLELIZE BY DEFAULT: split the task into disjoint slices up front and run 34 implementer
agents concurrently (recon agents are free — always fan those out). 34 concurrent
dotnet/npm builds are fine on this Mac (M4, 10 cores, 16 GB); the go/no-go signal is FREE
RAM, not CPU load (`memory_pressure -Q`: <20% free → don't launch more build agents; <10% →
pause/stagger). CPU load spikes during builds are benign. Never 5+ builds (the historic
crash was RAM starvation from an 89-way fan-out). NEVER set ETV_UPDATE_GOLDENS.
- Never two committing agents on ONE worktree — give each parallel slice its own worktree
branched off the feature branch and merge back. Sequence only where a slice genuinely depends
on another's output (backend-first narrow, SPA-wide after worked well for #216).
- Merge consent in-conversation per session (prior pre-approvals do NOT carry over).
- Arm a CI monitor on the PR head sha AT PR-OPEN (commit-status endpoint), not at the end.
- Live-E2E via scripts/e2e-local.sh; NEVER exercise download endpoints via browser tabs (curl
them). Adversarial review fork per PR diff, SCOPED "review only".
---
# Lessons / workflow lore (append-only; conventions live in docs/, this is workflow lore)
**Lessons for all remaining prompts** (conventions live in docs/; this is workflow lore):
- READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
doc sections. Only recon the task-specific delta.
- **blazor-route-parity.md now carries mutation-depth verdicts** (2026-07-09 sweep table at the
- **blazor-route-parity.md carries mutation-depth verdicts** (2026-07-09 sweep table at the
top of Section 2). A row is only trustworthy if its cluster verdict is PARITY-OK; the sweep
evidence lives in the issues #207#213. Keep the verdict table updated as gates close.
- "Screen exists" ≠ parity: the root cause of the false SPA-READY rows was same-session
@@ -95,65 +91,22 @@ issues filed in adversarial-reviewer: #18 (this milestone + #214), #19 (upcoming
- Live E2E seeding: the local library isn't API-seedable; the #216 E2E agent generated tiny
ffmpeg testsrc MKVs + inserted LibraryPath rows via SQL then scanned. Recipe not yet in
docs/e2e-local.md — worth adding next time it's needed.
---
# PROMPT — #207 + #212: schedules editor mutation depth + channel-editor gaps
You are Fable, the ORCHESTRATOR in the main Claude Code session. Fable is EXPENSIVE: delegate
(recon → Explore/haiku; mechanical → sonnet; judgment-heavy → opus; fable forks for review).
FIRST read CLAUDE.md, docs/README.md + the convention docs it indexes, and the Lessons above.
HARD CONSTRAINTS:
- Work in worktrees off origin/main. Copy web/node_modules from the main checkout.
- PARALLELIZE BY DEFAULT: split the task into disjoint slices up front and run 34 implementer
agents concurrently (recon agents are free — always fan those out). 34 concurrent
dotnet/npm builds are fine on this Mac (M4, 10 cores, 16 GB); check `uptime` before a big
fan-out — if 1-min load > ~6, drop to 23. Never 5+ builds (the historic crash was an
89-way fan-out). NEVER set ETV_UPDATE_GOLDENS.
- Never two committing agents on ONE worktree — give each parallel slice its own worktree
branched off the feature branch and merge back. Sequence only where a slice genuinely depends
on another's output (backend-first narrow, SPA-wide after worked well for #216).
- Merge consent in-conversation per session (prior pre-approvals do NOT carry over).
- Arm a CI monitor on the PR head sha AT PR-OPEN (commit-status endpoint), not at the end.
- Live-E2E via scripts/e2e-local.sh; NEVER exercise download endpoints via browser tabs (curl
them). Adversarial review fork per PR diff, SCOPED "review only".
## Task — close the #207 + #212 gates
Read issues #207 and #212 (bodies carry the #203-sweep evidence) and adversarial-reviewer #19
(pre-review asks). #207 is the screen the false-parity review made an example of: the SPA
schedules editor is a near read-only viewer — schedule create/edit/delete missing, every
per-item inspector control hard-coded disabled (~35 Blazor fields), add-item is defaults-only —
while the REST endpoints already exist and are unused (`PUT /api/schedules/{id}/items` replace,
`POST .../items` single-add with the FULL ScheduleItemRequest — see `addTo/scheduleItem.ts` for
a working payload builder). Enumerate EVERY Blazor `ScheduleItemsEditor.razor` field and wire it
(per-capability diff is the parity standard). #212 channel editor gaps: external logo URL, bare
create, and pickers — needs a languages enumeration endpoint shared by both editors (preferred
audio/subtitle language pickers); design that endpoint once. Record conventions in
decisions.md; full PR routine (OpenAPI regen for any new endpoint; blazor-route-parity verdict
rows for the schedules + channel-editor clusters). Comment + close #207/#212 per protocol.
## On completion — REQUIRED last step
Update THIS handoff (pop the done items, promote #202 to next with a fresh prompt), commit to
main, print the next prompt in a fenced code block.
---
## Issue queue (work top-down)
1. **#207 (+#212)** schedules editor depth + channel-editor gaps ← PROMPT above.
2. **#202** media-source management (write API + SPA) — largest; pre-review the API design
(adversarial-reviewer #19) before building.
3. **#91 phase (b)** Blazor removal PR (gate cleared once 12 close; #204/#205/#206 fold in;
removal recon in session state of the 2026-07-09 entry; adversarial pass mandatory).
4. **#197** cold API contract+security review — HARD GATE on #58 close/go-live and MANDATORY
before any remote exposure. Inputs: #215, #217, backlog nits.
5. Backlog: #215 (API playout mutations skip EntityLocker gating), #217 (add-items validates
only 4/10 kinds), #218 (fresh-DB Playouts badge / "1 failing" chip), #213 remainder (logs
sort/page-size persistence, block-history page-size/gating, list filters), #99 (TS/HLS-Direct
session tracking), #66 (artwork magic-byte sniffing), CI: unpin MySQL service host port 3306
(concurrent-run collision), nits (unclamped pageSize, 30 MB bare 413, PlayoutController route
Name=/lightweight exists-check, guide 21-include eager-load + fillerKind notes — #85/#102
comments), review nits from #216 (last-used-collection memory, detailBrowseItem cast).
Cross-refs: v26.6.0 deploy = Komodo pin bump (server-management). Real-transcode E2E of the
playback screen: once on the test container (`ersatztv-test` runs `:latest`) — local ffmpeg
8.1.2 lacks subtitles/zscale filters.
- **Parallel sessions (2026-07-11 protocol)**: claim before working (`in-progress` label — the
tiny read→claim race window is accepted; later claimant backs off). Claiming prevents
duplicate pickup, NOT overlapping code changes — check the tracker's dependency notes
("#234 after #231", "coordinate with #215") before touching shared surfaces. Lessons edits
to THIS file: append bullets only, `git pull --rebase` before commit.
- **Fan-out health = RAM, not CPU load** (2026-07-11, user calibration): the 89-way crash was
RAM starvation. `uptime` load of 1024 during parallel Roslyn/vitest bursts is benign with
memory healthy. Watchdog pattern: background monitor emitting only when `memory_pressure -Q`
free % < 20 (silence = healthy); pause/stagger agents below 10%.
- **CI MySQL host-port collision FIXED on main** (`ef8915f1`, issue #236): the migrations
service no longer publishes host 3306. Branches created before that commit still collide —
merge main in. There are now TWO runners (ci-runner VM 127 + bumblebee-runner), 4 slots:
faster drains, and superseded-run results are ignorable. Neither the cancel-run API route
nor the web cancel route exists on this Gitea version — stale runs just drain.
- **Two sessions touching one machine**: a branch may be checked out in ANOTHER session's
worktree — never commit/merge inside a worktree you didn't create. To land a merge on such a
branch without touching their checkout: plumbing merge (`git read-tree -m base ours theirs`
into a temp GIT_INDEX_FILE → `write-tree``commit-tree -p ours -p theirs` → push the
commit to the branch ref); the owning session then `git pull`s.
+67
View File
@@ -60,6 +60,47 @@ fetches from the API:
`eslint-plugin-react-hooks` in `web/eslint.config.js` — a synchronous `setState` in an effect body
will fail `npm run lint`.
## 3a. "Keep results visible during refetch" ⇒ gate mutations + show a refreshing cue
Some grid screens deliberately keep the **previous** successful result set rendered while a refetch
is in flight (no full-screen loading state on a query/kind/page change), so the grid doesn't flash
empty. `SearchScreen.tsx` and `MediaBrowseScreen.tsx` do this. If such a screen also carries
**mutation surfaces** (per-card Add-to menu, Select/select-mode, a selection action bar, "Add all",
"Save as smart collection"), those surfaces would otherwise stay live over a **stale** result set —
an add/select action then targets the about-to-be-replaced items, or (worse) a query-wide "Add all"
bulk request resolves against the previous query. This was issue #221 (adversarial-reviewer#18).
Convention — when a screen keeps stale results visible during a refetch:
- **Key the success state to the request params that produced it.** Store the identifying params on
the `status: 'success'` variant (`SearchScreen`: the `query`; `MediaBrowseScreen`: a
`kind|query|page` `key`), set in the seq-guarded `.then`. Derive
`const refreshing = state.status === 'success' && state.<key> !== <current params>;` in render.
Prefer this over a synchronously-set `refreshing` flag: setting state synchronously from the load
path trips the `react-hooks` "no set-state-in-effect" rule (§3).
**Invariant, not a guarantee**: the derivation is only self-correcting when *every* value the
current params can take will actually trigger a fetch. If `load()` early-returns for some param
value (e.g. `SearchScreen`'s blank-query guard), `state` never updates for that value and a stale
`status: 'success'` variant lingers — so the comparison must exclude params that suppress
fetching, or gate the whole flag on the same condition that gates the fetch (`SearchScreen`:
`const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();`
fixed post-review in #222 after the naive derivation got stuck `true` once the query was cleared
to empty, see PR discussion for #221).
- **While `refreshing`:** show a visible cue (a `role="status"` "Refreshing…" row with `<Spinner>`
plus the `.ctv-media-grid-dim` opacity class on the grid) and **disable every mutation surface**
per-card Add-to menu (withhold the `actions` node), select toggle + in-grid selection
(`const canSelect = selectMode && !refreshing;` gates `onToggleSelect`), the selection action bar,
"Add all", "Save as smart collection". Card navigation (`onOpen`) **may** stay live — but only
outside select mode: while `selectMode && refreshing`, `MediaPosterCard` falls back to `onOpen`
whenever `onToggleSelect` is undefined, so both props must be withheld together or a mid-select
click navigates away instead of no-op'ing. The select-mode toggle itself should only be disabled
while refreshing when *entering* select mode (`refreshing && !selectMode`) — exiting only clears
selection, not a mutation, so it must stay available.
- **Bind async bulk completions to their request params, not just mount.** A whole-query/whole-set
request (e.g. `getSearchAllItems`) must, on resolve, check that its snapshotted params are still
current (compare against a ref that always holds the committed value — `SearchScreen` reuses
`lastQueryRef`) and **discard** otherwise. Checking only `activeRef` (mounted) is insufficient.
## 4. API client modules
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
@@ -118,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.
@@ -164,3 +219,15 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.
## 9. Review checklist — temporal semantics
- **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change,
unmount, StrictMode double-invoke) and *which* render/request does it still own?** A debounce timer
fires on mount too (§3, the `lastQueryRef` no-change guard exists precisely for that); a `.then`
can resolve after the params it was launched for have moved on (§3a, the `refreshing` gate and the
Add-all query binding exist for that). A guard that only checks "still mounted" (`activeRef`) does
not answer "still current".
- **For any "make X consistent with Y" change, re-validate the exemplar Y's temporal behavior before
copying it.** #221 came from copying a fetch model that keeps stale results visible onto screens
that had gained mutation surfaces — the exemplar was safe read-only, the copy was not. Copying a
pattern copies its *assumptions*; confirm they still hold in the new context.
+20
View File
@@ -1844,6 +1844,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()],
@@ -3360,6 +3379,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
@@ -131,6 +131,7 @@ import {
deleteChannel,
getDecos,
getFfmpegSettings,
ApiError,
messageFromError,
getSchedules,
resetAllPlayouts,
@@ -352,7 +353,6 @@ const routes: ScreenRoute[] = [
icon: <ListVideo aria-hidden="true" size={16} />,
primaryAction: 'Reset All',
placeholder: 'Playouts workspace',
badge: 3,
allowSubPaths: true
},
{
@@ -2542,6 +2542,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);
@@ -2643,6 +2648,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);
@@ -2707,7 +2715,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>
@@ -2795,20 +2803,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
@@ -2819,20 +2829,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
@@ -743,6 +743,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";
@@ -1034,6 +1035,7 @@ export interface components {
"dailyRebuildTime": null | string;
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"isLocked": boolean;
};
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
"PlayoutResponseModel": {
@@ -1185,7 +1187,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,6 +491,9 @@ function BlockGroupRows({
<td>{block.name}</td>
<td>{block.minutes}</td>
<td>
{/* 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"
@@ -472,6 +502,7 @@ function BlockGroupRows({
>
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>
+143
View File
@@ -130,6 +130,149 @@ describe('MediaBrowseScreen', () => {
});
});
it('gates old-kind cards and shows a refreshing cue during a kind-change refetch (issue #221)', async () => {
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
const d = deferred<Response>();
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
return d.promise;
}
if (url === '/api/collections') {
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
}
if (url === '/api/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
render(<MediaBrowseScreen />);
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
// Change kind: the refetch for the new kind is held pending while old-kind cards stay rendered.
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
await waitFor(() => expect(browseDeferreds.length).toBe(2));
// Refreshing window: cue visible, mutation surfaces gated, old-kind cards still visible but inert.
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
// New-kind results resolve: controls re-enable, cue gone.
browseDeferreds[1].resolve({ page: items, totalCount: items.length });
await waitFor(() => expect(screen.queryByText('Refreshing…')).not.toBeInTheDocument());
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
});
it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
const d = deferred<Response>();
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
return d.promise;
}
if (url === '/api/collections') {
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
}
if (url === '/api/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
render(<MediaBrowseScreen />);
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
await waitFor(() => expect(browseDeferreds.length).toBe(2));
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
const pushState = vi.spyOn(window.history, 'pushState');
fireEvent.click(screen.getByText('Blade Runner'));
// Neither selection nor navigation fires — the card is fully inert during select+refresh.
expect(screen.queryByText(/selected/)).toBeNull();
expect(pushState).not.toHaveBeenCalled();
});
it('select toggle: entering select mode is blocked while refreshing, exiting (Done) is allowed (#222 review)', async () => {
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/library/browse')) {
const d = deferred<Response>();
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
return d.promise;
}
if (url === '/api/collections') {
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
}
if (url === '/api/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
render(<MediaBrowseScreen />);
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
// Not in select mode; trigger a refetch — entering select mode must be blocked.
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
await waitFor(() => expect(browseDeferreds.length).toBe(2));
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
// Settle, enter select mode, then trigger another refetch — exiting (Done) must stay enabled.
browseDeferreds[1].resolve({ page: items, totalCount: items.length });
await waitFor(() => expect(screen.queryByText('Refreshing…')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
expect(screen.getByRole('button', { name: 'Done' })).toBeTruthy();
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'movies' } });
await waitFor(() => expect(browseDeferreds.length).toBe(3));
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
const doneToggle = screen.getByRole('button', { name: 'Done' });
expect(doneToggle).not.toBeDisabled();
fireEvent.click(doneToggle);
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
});
it('selects every loaded item with Select all on page', async () => {
mockFetch();
render(<MediaBrowseScreen />);
+41 -7
View File
@@ -55,10 +55,17 @@ function kindFromSlug(slug: string | null): MediaKind {
}
type BrowseState =
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number }
// `key` records the request params (kind + query + page) that produced this result set. When it
// no longer matches the current params, the visible items are stale (a refetch is in flight) —
// see `refreshing` below.
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number; key: string }
| { items: []; error: string; status: 'error'; totalCount: 0 }
| { items: []; error: null; status: 'loading'; totalCount: 0 };
function browseKeyOf(mediaType: LibraryBrowseMediaType, query: string, pageNum: number): string {
return `${mediaType}\u0000${query}\u0000${pageNum}`;
}
type Notice = { tone: 'ok' | 'error'; message: string };
export function MediaBrowseScreen() {
@@ -108,10 +115,11 @@ export function MediaBrowseScreen() {
const load = useCallback(() => {
const id = ++seqRef.current;
const key = browseKeyOf(kind.mediaType, query, pageNum);
getLibraryBrowseItems({ mediaType: kind.mediaType, query: query || undefined, pageNum, pageSize: PAGE_SIZE })
.then((paged) => {
if (activeRef.current && id === seqRef.current) {
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0, key });
}
})
.catch((error: unknown) => {
@@ -185,6 +193,13 @@ export function MediaBrowseScreen() {
const selectedItems = Array.from(selected.values());
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1;
// A refetch is in flight when the currently-rendered items were produced by different params than
// the current kind/query/page (items stay visible during refetch — see `load`). While refreshing we
// keep items visible but gate every mutation surface and show a refreshing cue, so no add/select
// action is scoped to the stale, about-to-be-replaced result set (issue #221).
const refreshing = state.status === 'success' && state.key !== browseKeyOf(kind.mediaType, query, pageNum);
const canSelect = selectMode && !refreshing;
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -213,6 +228,9 @@ export function MediaBrowseScreen() {
</Button>
)}
<Button
// Entering select mode while refreshing is blocked (it would target a stale result set),
// but exiting is always allowed — it only clears selection, not a mutation.
disabled={refreshing && !selectMode}
onClick={toggleSelectMode}
size="sm"
startIcon={<ListChecks aria-hidden="true" size={14} />}
@@ -233,11 +251,12 @@ export function MediaBrowseScreen() {
{selected.size > 0 && (
<div className="ctv-channels-actionbar">
<span className="ctv-channels-selected">{selected.size} selected</span>
<Button onClick={selectAllOnPage} size="sm" variant="secondary">
<Button disabled={refreshing} onClick={selectAllOnPage} size="sm" variant="secondary">
Select all on page
</Button>
<span className="ctv-channels-spacer" />
<Button
disabled={refreshing}
onClick={() => setBulkDialog('collection')}
size="sm"
startIcon={<FolderPlus aria-hidden="true" size={14} />}
@@ -246,6 +265,7 @@ export function MediaBrowseScreen() {
Add to collection
</Button>
<Button
disabled={refreshing}
onClick={() => setBulkDialog('playlist')}
size="sm"
startIcon={<ListVideo aria-hidden="true" size={14} />}
@@ -274,6 +294,13 @@ export function MediaBrowseScreen() {
open={bulkDialog === 'playlist'}
/>
{refreshing && (
<div className="ctv-collections-loading" role="status">
<Spinner size={14} />
<span>Refreshing</span>
</div>
)}
{state.status === 'error' && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -298,20 +325,27 @@ export function MediaBrowseScreen() {
</Card>
) : (
<>
<div className="ctv-media-grid">
<div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
{state.items.map((item) => {
const detailPath = mediaDetailPath(item);
return (
<MediaPosterCard
// While refreshing, the per-card Add-to menu is withheld (stale result set);
// card navigation stays live.
actions={
selectMode ? undefined : (
selectMode || refreshing ? undefined : (
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
)
}
item={item}
key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
onToggleSelect={selectMode ? toggleSelect : undefined}
// While `selectMode && refreshing`, the card must be fully inert: neither
// `onOpen` (which MediaPosterCard falls back to when `onToggleSelect` is
// undefined) nor `onToggleSelect` may fire, or a mid-select click would navigate
// away instead of no-op'ing. Outside select mode, `onOpen` stays live during a
// refresh (intended).
onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
onToggleSelect={canSelect ? toggleSelect : undefined}
selected={selected.has(itemKey(item))}
/>
);
+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);
+170
View File
@@ -97,6 +97,46 @@ function renderWithQuery() {
return render(<SearchScreen />);
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
// A fetch mock whose /api/search (results) and /api/search/all-items responses are held pending
// until the test resolves them, so we can observe the in-flight "refreshing" window (issue #221).
function mockControlledSearch() {
const searchDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
const allItemsDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
if (url.startsWith('/api/search/all-items')) {
const d = deferred<Response>();
allItemsDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
return d.promise;
}
if (url.startsWith('/api/search')) {
const d = deferred<Response>();
searchDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
return d.promise;
}
if (url === '/api/collections' && method === 'GET') {
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }]));
}
if (url === '/api/smart-collections') {
return Promise.resolve(jsonResponse([]));
}
if (url === '/api/playlists/groups') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
return { searchDeferreds, allItemsDeferreds };
}
describe('SearchScreen', () => {
beforeEach(() => {
window.localStorage.clear();
@@ -178,6 +218,136 @@ describe('SearchScreen', () => {
expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [7, 8] });
});
it('gates mutation controls and shows a refreshing cue while a new query loads (issue #221)', async () => {
const ctl = mockControlledSearch();
renderWithQuery();
ctl.searchDeferreds[0].resolve(searchResults());
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
// Baseline: mutation surfaces are live for the current result set.
expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
expect(screen.getByRole('button', { name: /Add all to collection/ })).not.toBeDisabled();
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
// Change the query: the debounced commit fires a refetch, which we hold pending.
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
// Refreshing window: cue visible, every mutation surface gated, cards still visible.
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
expect(screen.getByText('Blade Runner')).toBeTruthy();
expect(screen.getByRole('button', { name: /^Select/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Add all to collection/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Add all to playlist/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Save as smart collection/ })).toBeDisabled();
expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
// Replacement resolves: controls re-enable, cue gone.
ctl.searchDeferreds[1].resolve(searchResults());
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
});
it('discards an Add-all result when the query changed before it resolved (issue #221)', async () => {
const ctl = mockControlledSearch();
renderWithQuery();
ctl.searchDeferreds[0].resolve(searchResults());
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
// Kick off Add all (all-items request in flight, dialog not yet open).
fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(1));
// Change the query before all-items resolves, then settle the new query's results.
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
ctl.searchDeferreds[1].resolve(searchResults());
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
// The stale all-items result now resolves — it must NOT open a dialog for the old query.
ctl.allItemsDeferreds[0].resolve(allItems);
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
expect(screen.queryByRole('dialog')).toBeNull();
// A fresh Add all for the current query still works.
fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(2));
ctl.allItemsDeferreds[1].resolve(allItems);
expect(await screen.findByRole('dialog')).toBeTruthy();
});
it('does not show a stuck refreshing cue after the query is cleared to empty (#222 review)', async () => {
mockApi();
renderWithQuery();
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
// `load()` early-returns for a blank query, so `state` never updates once cleared — the
// `refreshing` derivation must be gated on `hasQuery`, or the cue gets stuck forever over the
// "Type a query…" empty card.
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: '' } });
await waitFor(() =>
expect(screen.getByText('Type a query to search across every media kind.')).toBeTruthy()
);
expect(screen.queryByText('Refreshing…')).toBeNull();
});
it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
const ctl = mockControlledSearch();
renderWithQuery();
ctl.searchDeferreds[0].resolve(searchResults());
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
const pushState = vi.spyOn(window.history, 'pushState');
fireEvent.click(screen.getByText('Blade Runner'));
// Neither selection nor navigation fires — the card is fully inert during select+refresh.
expect(screen.queryByText('1 selected')).toBeNull();
expect(pushState).not.toHaveBeenCalled();
});
it('select toggle: entering select mode is blocked while refreshing, exiting is allowed (#222 review)', async () => {
const ctl = mockControlledSearch();
renderWithQuery();
ctl.searchDeferreds[0].resolve(searchResults());
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
// Not in select mode; trigger a refetch — entering select mode must be blocked.
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
const enterToggle = screen.getByRole('button', { name: /^Select/ });
expect(enterToggle).toBeDisabled();
expect(enterToggle.className).toContain('ctv-button-secondary');
// Settle, enter select mode, then trigger another refetch — exiting must stay enabled.
ctl.searchDeferreds[1].resolve(searchResults());
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-primary');
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'aliens' } });
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(3));
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
const exitToggle = screen.getByRole('button', { name: /^Select/ });
expect(exitToggle).not.toBeDisabled();
fireEvent.click(exitToggle);
expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-secondary');
});
it('saves the current query as a new smart collection', async () => {
const { calls } = mockApi();
renderWithQuery();
+47 -7
View File
@@ -44,7 +44,9 @@ const GROUPS: GroupDef[] = [
];
type SearchState =
| { results: SearchResults; error: null; status: 'success' }
// `query` records which query produced this result set. When it no longer matches the current
// committed query, the visible cards are stale (a refetch is in flight) — see `refreshing` below.
| { results: SearchResults; error: null; status: 'success'; query: string }
| { results: null; error: string; status: 'error' }
| { results: null; error: null; status: 'loading' };
@@ -116,7 +118,7 @@ export function SearchScreen() {
getSearchResults({ query: trimmed, pageSize: PAGE_SIZE })
.then((results) => {
if (activeRef.current && id === seqRef.current) {
setState({ results, error: null, status: 'success' });
setState({ results, error: null, status: 'success', query: trimmed });
}
})
.catch((error: unknown) => {
@@ -172,6 +174,13 @@ export function SearchScreen() {
if (!activeRef.current) {
return;
}
// Bind the all-items result to the query that requested it. `lastQueryRef` always holds the
// current committed query (see the debounce effect); if it moved on, this bulk-add would be
// scoped to the PREVIOUS query's entire result set — discard it (issue #221).
if (lastQueryRef.current !== trimmed) {
setPendingAll(null);
return;
}
setPendingAll(null);
setDialog({ kind, items: { requestOverride: toAddItemsRequestFromSearch(result) } });
})
@@ -202,6 +211,17 @@ export function SearchScreen() {
const hasResults = hasQuery && state.status === 'success' && totalMatches > 0;
const selectionCount = selected.size;
// A refetch is in flight when the currently-rendered success result set was produced by a query
// other than the current committed one (results stay visible during refetch — see `load`). While
// refreshing we keep cards visible but gate every mutation surface and show a refreshing cue, so
// no add/select action is scoped to the stale, about-to-be-replaced result set (issue #221).
// Gated on `hasQuery`: `load()` early-returns for a blank query, so `state` keeps the LAST
// non-empty query's success variant forever once the query is cleared — without this gate,
// `refreshing` would get stuck true over the "Type a query to search…" empty card (#222 review).
const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();
// Enter select mode only for the live result set.
const canSelect = selectMode && !refreshing;
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -215,6 +235,9 @@ export function SearchScreen() {
{hasResults && (
<>
<Button
// Entering select mode while refreshing is blocked (it would target a stale result
// set), but exiting is always allowed — it only clears selection, not a mutation.
disabled={refreshing && !selectMode}
onClick={toggleSelectMode}
size="sm"
startIcon={<CheckSquare aria-hidden="true" size={14} />}
@@ -223,7 +246,7 @@ export function SearchScreen() {
Select
</Button>
<Button
disabled={pendingAll !== null}
disabled={pendingAll !== null || refreshing}
loading={pendingAll === 'collection'}
onClick={() => addAll('collection')}
size="sm"
@@ -233,7 +256,7 @@ export function SearchScreen() {
Add all to collection
</Button>
<Button
disabled={pendingAll !== null}
disabled={pendingAll !== null || refreshing}
loading={pendingAll === 'playlist'}
onClick={() => addAll('playlist')}
size="sm"
@@ -243,6 +266,7 @@ export function SearchScreen() {
Add all to playlist
</Button>
<Button
disabled={refreshing}
onClick={() => setDialog({ kind: 'save-smart' })}
size="sm"
startIcon={<Save aria-hidden="true" size={14} />}
@@ -263,6 +287,7 @@ export function SearchScreen() {
<span className="ctv-search-selectbar-count">{selectionCount} selected</span>
<span className="ctv-channels-spacer" />
<Button
disabled={refreshing}
onClick={() => addSelection('collection')}
size="sm"
startIcon={<FolderPlus aria-hidden="true" size={14} />}
@@ -271,6 +296,7 @@ export function SearchScreen() {
Add to collection
</Button>
<Button
disabled={refreshing}
onClick={() => addSelection('playlist')}
size="sm"
startIcon={<ListVideo aria-hidden="true" size={14} />}
@@ -289,6 +315,13 @@ export function SearchScreen() {
</div>
)}
{refreshing && (
<div className="ctv-collections-loading" role="status">
<Spinner size={14} />
<span>Refreshing</span>
</div>
)}
{!hasQuery && (
<Card>
<div className="ctv-collections-empty">Type a query to search across every media kind.</div>
@@ -344,21 +377,28 @@ export function SearchScreen() {
</Button>
)}
</div>
<div className="ctv-media-grid">
<div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
{data.items.map((item) => {
const key = itemKey(item);
const detailPath = mediaDetailPath(item);
return (
<MediaPosterCard
// While refreshing, the per-card Add-to menu is withheld (stale result set);
// card navigation stays live.
actions={
selectMode ? undefined : (
selectMode || refreshing ? undefined : (
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
)
}
item={item}
key={key}
// While `selectMode && refreshing`, the card must be fully inert: neither
// `onOpen` (which MediaPosterCard falls back to when `onToggleSelect` is
// undefined) nor `onToggleSelect` may fire, or a mid-select click would
// navigate away instead of no-op'ing. Outside select mode, `onOpen` stays
// live during a refresh (intended).
onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
onToggleSelect={selectMode ? toggleSelect : undefined}
onToggleSelect={canSelect ? toggleSelect : undefined}
selected={selectMode ? selected.has(key) : undefined}
/>
);
+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>
);
})}
+52
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;
@@ -2691,6 +2711,14 @@ body {
gap: var(--space-6, 12px);
}
/* Dim the stale result set while a refetch is in flight (search/media browse issue #221). The
per-card mutation menu is withheld in the same state; navigation stays live so no pointer-events
change here. */
.ctv-media-grid-dim {
opacity: 0.5;
transition: opacity var(--dur-fast, 120ms) var(--ease-standard, ease);
}
.ctv-media-card {
position: relative;
border-radius: var(--radius-sm);
@@ -2711,6 +2739,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;
@@ -3080,6 +3126,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;