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, null,
em.EpisodeId, em.EpisodeId,
null, null,
EpisodeSubtitle(em))).ToList()); EpisodeSubtitle(em),
em.Episode.SeasonId)).ToList());
} }
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos( public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
@@ -46,7 +46,8 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
{ CollectionType.MusicVideo, request.MusicVideoIds }, { CollectionType.MusicVideo, request.MusicVideoIds },
{ CollectionType.OtherVideo, request.OtherVideoIds }, { CollectionType.OtherVideo, request.OtherVideoIds },
{ CollectionType.Song, request.SongIds }, { 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; 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 ValidateMovies(request),
await ValidateShows(request), await ValidateShows(request),
await ValidateSeasons(request), await ValidateSeasons(request),
await ValidateEpisodes(request)) await ValidateEpisodes(request),
.Apply((collection, _, _, _, _) => collection); await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((collection, _, _, _, _, _) => collection);
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist( private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext, TvContext dbContext,
@@ -126,4 +128,30 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
.Filter(v => v == true) .Filter(v => v == true)
.MapT(_ => Unit.Default) .MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist")); .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<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken) => 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<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
public Task<List<int>> GetEpisodeIdsForShow(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? RerunCollectionId,
int? MediaItemId, int? MediaItemId,
int? PlaylistId, int? PlaylistId,
string? Subtitle = null); string? Subtitle = null,
int? SeasonId = null);
@@ -11,4 +11,5 @@ public record PlayoutListItemResponseModel(
string ScheduleName, string ScheduleName,
TimeSpan? DailyRebuildTime, TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus, PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode); ChannelPlayoutMode PlayoutMode,
bool IsLocked);
@@ -10,7 +10,7 @@ public interface ITelevisionRepository
Task<bool> AllEpisodesExist(List<int> episodeIds); Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows(); Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken); 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<Episode>> GetShowItems(int showId);
Task<List<int>> GetEpisodeIdsForShow(int showId); Task<List<int>> GetEpisodeIdsForShow(int showId);
Task<List<Season>> GetAllSeasons(); Task<List<Season>> GetAllSeasons();
@@ -80,16 +80,16 @@ public class TelevisionRepository : ITelevisionRepository
.SelectOneAsync(s => s.Id, s => s.Id == showId, cancellationToken); .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(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ShowMetadata return await dbContext.ShowMetadata
.AsNoTracking() .AsNoTracking()
.Where(sm => sm.ShowId == showId)
.Where(sm => sm.Show.LibraryPath.LibraryId == libraryId) .Where(sm => sm.Show.LibraryPath.LibraryId == libraryId)
.Where(sm => EF.Functions.Like(sm.Title, $"%{title}%")) .Map(sm => sm.Title)
.Map(sm => sm.ShowId)
.FirstOrDefaultAsync() .FirstOrDefaultAsync()
.Map(showId => showId > 0 ? Option<int>.Some(showId) : Option<int>.None); .Map(Optional);
} }
public async Task<List<int>> GetEpisodeIdsForShow(int showId) public async Task<List<int>> GetEpisodeIdsForShow(int showId)
@@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[1].MediaItemId.ShouldBe(711); result.Page[1].MediaItemId.ShouldBe(711);
result.Page[2].MediaItemId.ShouldBe(713); 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( await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<string>(), Arg.Any<string>(),
@@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests
Arg.Any<CancellationToken>()); 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] [Test]
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId() 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.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)] [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.GetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)] [TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)] [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.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)] [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.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)] [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.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)] [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.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)] [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.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)] [TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)] [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.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.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)] [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)] [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.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)] [TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)] [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( public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
Type controllerType, Type controllerType,
string actionName, string actionName,
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using LanguageExt; using LanguageExt;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@@ -30,6 +31,7 @@ public class ChannelControllerTests
{ {
private IMediator _mediator = null!; private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!; private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private IEntityLocker _entityLocker = null!;
private ChannelController _controller = null!; private ChannelController _controller = null!;
[SetUp] [SetUp]
@@ -37,7 +39,8 @@ public class ChannelControllerTests
{ {
_mediator = Substitute.For<IMediator>(); _mediator = Substitute.For<IMediator>();
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>(); _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] [Test]
@@ -411,6 +414,20 @@ public class ChannelControllerTests
buildPlayout.Mode.ShouldBe(expectedMode); 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] [Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode() public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{ {
@@ -3,7 +3,10 @@ using ErsatzTV.Application.Libraries;
using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Libraries; using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
@@ -16,12 +19,14 @@ public class LibrariesControllerTests
{ {
private LibrariesController _controller = null!; private LibrariesController _controller = null!;
private IMediator _mediator = null!; private IMediator _mediator = null!;
private ITelevisionRepository _televisionRepository = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
_mediator = Substitute.For<IMediator>(); _mediator = Substitute.For<IMediator>();
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator); _televisionRepository = Substitute.For<ITelevisionRepository>();
_controller = new LibrariesController(_televisionRepository, _mediator);
} }
[Test] [Test]
@@ -52,4 +57,42 @@ public class LibrariesControllerTests
result.ShouldBe(expected); 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>()) _mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, [])); .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( await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => Arg.Is<GetRecentLogEntries>(q =>
@@ -52,6 +52,74 @@ public class LogsControllerTests
Arg.Any<CancellationToken>()); 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] [Test]
public async Task GetLogs_Should_Map_Entries_To_Response_Model() 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", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")] [TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")] [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", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")] [TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")] [TestCase("/api/channel-templates/default/{id}", "put", "422")]
@@ -188,12 +189,21 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "get", "404")] [TestCase("/api/playouts/{id}", "get", "404")]
[TestCase("/api/playouts", "post", "404")] [TestCase("/api/playouts", "post", "404")]
[TestCase("/api/playouts", "post", "422")] [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", "404")]
[TestCase("/api/playouts/{id}", "delete", "409")]
[TestCase("/api/playouts/{id}", "delete", "422")] [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}/items", "get", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "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", "post", "422")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")] [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/{id}/erase-items-and-history", "post", "422")]
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")] [TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
[TestCase("/api/collections/{id}/custom-order", "put", "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", "404")]
[TestCase("/api/trakt/lists/{id}", "put", "422")] [TestCase("/api/trakt/lists/{id}", "put", "422")]
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")] [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( public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path, string path,
string method, string method,
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
{ {
private PlayoutController _controller = null!; private PlayoutController _controller = null!;
private IMediator _mediator = null!; private IMediator _mediator = null!;
private IEntityLocker _entityLocker = null!;
[SetUp] [SetUp]
public void SetUp() public void SetUp()
{ {
_mediator = Substitute.For<IMediator>(); _mediator = Substitute.For<IMediator>();
_controller = new PlayoutController(_mediator); _entityLocker = Substitute.For<IEntityLocker>();
_controller = new PlayoutController(_mediator, _entityLocker);
} }
[Test] [Test]
@@ -68,6 +71,82 @@ public class PlayoutControllerTests
"/api/playouts/items/{id:int}/scheduling-context"); "/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 ----- // ----- Erase items / history -----
[Test] [Test]
+14 -1
View File
@@ -8,6 +8,7 @@ using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using MediatR; using MediatR;
@@ -17,7 +18,10 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api; namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator) public class ChannelController(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IMediator mediator,
IEntityLocker entityLocker)
{ {
[HttpGet("/api/channels")] [HttpGet("/api/channels")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
@@ -209,6 +213,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout( public async Task<IActionResult> ResetPlayout(
string channelNumber, string channelNumber,
[FromQuery] PlayoutBuildMode? mode, [FromQuery] PlayoutBuildMode? mode,
@@ -218,6 +223,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken); await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
foreach (int playoutId in maybePlayoutId) 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); PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken); await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult(); return new OkResult();
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Libraries; using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries; using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Extensions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -29,27 +30,23 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
[HttpPost("/api/libraries/{id:int}/scan-show")] [HttpPost("/api/libraries/{id:int}/scan-show")]
[Tags("Libraries")] [Tags("Libraries")]
[EndpointSummary("Scan show")] [EndpointSummary("Scan show")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request) 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" }); bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
}
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));
return result return result
? new OkResult() ? 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." }); : 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( return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
new { error = $"Unable to locate show with title {request.ShowTitle} 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.Application.Logs;
using ErsatzTV.Core.Api.Logs; using ErsatzTV.Core.Api.Logs;
using MediatR; using MediatR;
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
{ {
private const int MaxPageSize = 100; 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")] [HttpGet("/api/logs", Name = "GetLogs")]
[Tags("Logs")] [Tags("Logs")]
[EndpointSummary("Get recent log entries")] [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")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLogEntriesResponseModel> GetLogs( public async Task<PagedLogEntriesResponseModel> GetLogs(
[FromQuery] int pageNum = 0, [FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100, [FromQuery] int pageSize = 100,
[FromQuery] string filter = "", [FromQuery] string filter = "",
[FromQuery] string sortField = "timestamp",
[FromQuery] string sortDirection = "desc",
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
int clampedPageNum = Math.Max(0, pageNum); int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); 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( PagedLogEntriesViewModel result = await mediator.Send(
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty), new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
{
SortExpression = sortExpression,
SortDescending = descending
},
cancellationToken); cancellationToken);
return new PagedLogEntriesResponseModel( return new PagedLogEntriesResponseModel(
+62 -4
View File
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api; namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
{ {
private const int MaxPageSize = 100; 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")] [HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")] [Tags("Playouts")]
[EndpointSummary("List playouts")] [EndpointSummary("List playouts")]
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken); await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
return new PagedPlayoutsResponseModel( return new PagedPlayoutsResponseModel(
result.TotalCount, 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")] [HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update( public async Task<IActionResult> Update(
int id, int id,
[Required] [FromBody] UpdatePlayoutDetailsRequest request, [Required] [FromBody] UpdatePlayoutDetailsRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone) if (maybePlayout.IsNone)
{ {
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateDefaultDeco( public async Task<IActionResult> UpdateDefaultDeco(
int id, int id,
[Required] [FromBody] UpdateDefaultDecoRequest request, [Required] [FromBody] UpdateDefaultDecoRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone) if (maybePlayout.IsNone)
{ {
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceAlternateSchedules( public async Task<IActionResult> ReplaceAlternateSchedules(
int id, int id,
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request, [Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone) if (maybePlayout.IsNone)
{ {
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceTemplates( public async Task<IActionResult> ReplaceTemplates(
int id, int id,
[Required] [FromBody] ReplacePlayoutTemplatesRequest request, [Required] [FromBody] ReplacePlayoutTemplatesRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken); Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone) if (maybePlayout.IsNone)
{ {
@@ -515,6 +551,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointSummary("Reset all playouts")] [EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)] [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) public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{ {
await mediator.Send(new ResetAllPlayouts(), cancellationToken); await mediator.Send(new ResetAllPlayouts(), cancellationToken);
@@ -531,9 +570,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken) 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); Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone) if (maybePlayout.IsNone)
{ {
@@ -563,9 +608,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken) 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); Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone) if (maybePlayout.IsNone)
{ {
@@ -608,9 +659,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken) 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); Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
return result.ToDeletedResult(); return result.ToDeletedResult();
} }
@@ -722,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.EndDay, vm.EndDay,
vm.EndYear); vm.EndYear);
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) => private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
new( new(
vm.PlayoutId, vm.PlayoutId,
vm.ChannelNumber, vm.ChannelNumber,
@@ -731,7 +788,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.ScheduleName, vm.ScheduleName,
vm.DbDailyRebuildTime, vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus), ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode); vm.PlayoutMode,
isLocked);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) => private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null buildStatus is null
+7
View File
@@ -50,6 +50,13 @@ public static class ApiResults
public static IActionResult NotFoundProblem(string detail = "Resource not found") => public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail)); 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) => private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
new() 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": { "responses": {
"200": { "200": {
"description": "OK" "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" "Logs"
], ],
"summary": "Get recent log entries", "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", "operationId": "GetLogs",
"parameters": [ "parameters": [
{ {
@@ -5567,6 +5628,22 @@
"type": "string", "type": "string",
"default": "" "default": ""
} }
},
{
"name": "sortField",
"in": "query",
"schema": {
"type": "string",
"default": "timestamp"
}
},
{
"name": "sortDirection",
"in": "query",
"schema": {
"type": "string",
"default": "desc"
}
} }
], ],
"responses": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "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": { "422": {
"description": "Unprocessable Entity", "description": "Unprocessable Entity",
"content": { "content": {
@@ -18036,6 +18253,13 @@
"null", "null",
"string" "string"
] ]
},
"seasonId": {
"type": [
"null",
"integer"
],
"format": "int32"
} }
} }
}, },
@@ -19575,7 +19799,8 @@
"scheduleName", "scheduleName",
"dailyRebuildTime", "dailyRebuildTime",
"buildStatus", "buildStatus",
"playoutMode" "playoutMode",
"isLocked"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -19614,6 +19839,9 @@
}, },
"playoutMode": { "playoutMode": {
"$ref": "#/components/schemas/ChannelPlayoutMode" "$ref": "#/components/schemas/ChannelPlayoutMode"
},
"isLocked": {
"type": "boolean"
} }
} }
}, },
@@ -20452,15 +20680,13 @@
}, },
"ScanShowRequest": { "ScanShowRequest": {
"required": [ "required": [
"showTitle" "showId"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
"showTitle": { "showId": {
"type": [ "type": "integer",
"null", "format": "int32"
"string"
]
}, },
"deepScan": { "deepScan": {
"type": "boolean", "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 - **`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` / OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
`scripts/update-openapi.sh`. `scripts/update-openapi.sh`.
- **`docs/handoffs/chicorytv-issue-queue.md`** — living session-to-session handoff: current queue - **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore.
state, what's next. Check this for what's actively in flight before starting new work. 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). - **`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)` `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 (`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
input for page math. 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 ## 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 | | `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left``ToErrorResult()`; `Right` → 204 |
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 | | `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.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 `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. 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 | > | Cluster | Verdict | Gap issue |
> |---|---|---| > |---|---|---|
> | Troubleshooting (4 routes) | PARITY-OK (2 minors) | #213 | > | 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/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 | — | > | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — | > | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — | > | 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) | — | > | 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 added; 100/kind cap → decisions.md) | — | > | 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) | — | > | 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 | > | 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 | > | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10) | — | > | 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 (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** | > | 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** | > | 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 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. 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/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/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 | | `/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 | | `/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 | | `/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 | | `/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 | | `/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 | | `/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/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`(`/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/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`(`/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/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 | | `/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 ### Remaining mutation-depth gaps inside Section 2 rows
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor), Tracked as #91 phase (b) gates without moving whole rows — #212 DONE 2026-07-11 (channel editor),
#213 (remaining nits: logs sort + page-size persistence, block-history page-size/gating, #213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
blocks/templates list filter — all read-only conveniences). 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) 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 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. 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 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 Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`. (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 ## 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 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. 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 ## 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 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` 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. (`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 ## 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/`, 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 `/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 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 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. - 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 - Prints the PID and port, then **exits leaving the server running** — the caller is responsible
for killing the PID when done (`kill <PID>`). 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 > **PROTOCOL CHANGED 2026-07-11** (decisions.md entry of same date). Queue state lives in the
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it > **pinned Gitea tracker [ersatztv#237](http://192.168.1.95:3000/timothy/ersatztv/issues/237)**,
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic); > not in this file. Do **NOT** write session state, queue order, or next-session prompts here —
all backend gap issues (#100#111), all SPA screens (#84#89, #93, #109), the rebrand (#90), > sessions ending under the old protocol should append their session comment to #237 instead.
the cutover root-flip (#91 phase a), full scheduling parity (#144/#162), media/troubleshooting > This file holds only the two stable things: the standing kickoff prompt and the workflow lore.
parity (#141/#161/#158/#180), onboarding docs (#185), the collections/API-gap batch > Historical per-session state: `git log` of this file (last state-bearing revision: 8b77d5e7).
(#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).
**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:** # STANDING KICKOFF PROMPT (paste into a fresh session, unchanged every time)
1. **#207 (+#212)** — schedules editor full mutation depth (endpoints exist; needs languages
enumeration endpoint shared with #212 channel-editor pickers) ← NEXT (prompt below) You are Fable, the ORCHESTRATOR in the main Claude Code session. Fable is EXPENSIVE: delegate
2. **#202** — media-source management (write REST API + SPA screens; largest, riskiest) (recon → Explore/haiku; mechanical → sonnet; judgment-heavy → opus; fable forks for review).
3. **#91 phase (b) removal PR** — pattern redirects + catch-all (#204 plan), blazor-final tag FIRST read CLAUDE.md, docs/README.md + the convention docs it indexes, and the Lessons below.
(#205), auth posture note (#206), delete Pages/Shared/ViewModels/Validators + 6 packages
(MudBlazor, MediatR.Courier.DependencyInjection, Blazored.FluentValidation, BlazorSortable, Then work the queue:
Heron.MudCalendar, Chronic.Core). Removal recon is DONE (2026-07-09 gate session): Courier 1. Read the pinned tracker **ersatztv#237** — body = goal + ordered arc + session protocol —
consumers are 100% Blazor (`AddCourier` Startup.cs:394 dies too); the Startup `MapWhen` and its most recent session comments; list open issues in the `Blazor removal (#91 phase b)`
"blazor" branch (736795) CO-HOSTS MapControllers/OpenAPI/Scalar — surgical removal only milestone and with the `review` label.
(MapBlazorHub:775, MapFallbackToPage:776, services 379398); `wwwroot/lib/*`+`css/site.css` 2. Pick the top arc item that is open and NOT labeled `in-progress` (or the item the user names).
are `_Host`-only; check `Locals/*.resx` consumers before deleting; keep `Extensions/` 3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s).
except `NavigationManagerExtensions.cs`. 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 - READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
doc sections. Only recon the task-specific delta. 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 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. 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 - "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 - 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 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. docs/e2e-local.md — worth adding next time it's needed.
- **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
# PROMPT — #207 + #212: schedules editor mutation depth + channel-editor gaps ("#234 after #231", "coordinate with #215") before touching shared surfaces. Lessons edits
to THIS file: append bullets only, `git pull --rebase` before commit.
You are Fable, the ORCHESTRATOR in the main Claude Code session. Fable is EXPENSIVE: delegate - **Fan-out health = RAM, not CPU load** (2026-07-11, user calibration): the 89-way crash was
(recon → Explore/haiku; mechanical → sonnet; judgment-heavy → opus; fable forks for review). RAM starvation. `uptime` load of 1024 during parallel Roslyn/vitest bursts is benign with
FIRST read CLAUDE.md, docs/README.md + the convention docs it indexes, and the Lessons above. memory healthy. Watchdog pattern: background monitor emitting only when `memory_pressure -Q`
free % < 20 (silence = healthy); pause/stagger agents below 10%.
HARD CONSTRAINTS: - **CI MySQL host-port collision FIXED on main** (`ef8915f1`, issue #236): the migrations
- Work in worktrees off origin/main. Copy web/node_modules from the main checkout. service no longer publishes host 3306. Branches created before that commit still collide —
- PARALLELIZE BY DEFAULT: split the task into disjoint slices up front and run 34 implementer merge main in. There are now TWO runners (ci-runner VM 127 + bumblebee-runner), 4 slots:
agents concurrently (recon agents are free — always fan those out). 34 concurrent faster drains, and superseded-run results are ignorable. Neither the cancel-run API route
dotnet/npm builds are fine on this Mac (M4, 10 cores, 16 GB); check `uptime` before a big nor the web cancel route exists on this Gitea version — stale runs just drain.
fan-out — if 1-min load > ~6, drop to 23. Never 5+ builds (the historic crash was an - **Two sessions touching one machine**: a branch may be checked out in ANOTHER session's
89-way fan-out). NEVER set ETV_UPDATE_GOLDENS. worktree — never commit/merge inside a worktree you didn't create. To land a merge on such a
- Never two committing agents on ONE worktree — give each parallel slice its own worktree branch without touching their checkout: plumbing merge (`git read-tree -m base ours theirs`
branched off the feature branch and merge back. Sequence only where a slice genuinely depends into a temp GIT_INDEX_FILE → `write-tree``commit-tree -p ours -p theirs` → push the
on another's output (backend-first narrow, SPA-wide after worked well for #216). commit to the branch ref); the owning session then `git pull`s.
- 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.
+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 `eslint-plugin-react-hooks` in `web/eslint.config.js` — a synchronous `setState` in an effect body
will fail `npm run lint`. 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 ## 4. API client modules
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see 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 on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
and the accepted deviations from Blazor). 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 ## 6. Tests
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file. - **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 Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time. `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(); 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 () => { it('disables Alternate schedules for an on-demand Classic playout', async () => {
mockDashboardApi({ mockDashboardApi({
playoutItems: [playoutItem()], playoutItems: [playoutItem()],
@@ -3360,6 +3379,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
channelNumber: '5.1', channelNumber: '5.1',
dailyRebuildTime: '04:00:00', dailyRebuildTime: '04:00:00',
id: 20, id: 20,
isLocked: false,
scheduleKind: 'Classic', scheduleKind: 'Classic',
scheduleName: 'Prime Time Cartoons', scheduleName: 'Prime Time Cartoons',
...overrides ...overrides
+18 -6
View File
@@ -131,6 +131,7 @@ import {
deleteChannel, deleteChannel,
getDecos, getDecos,
getFfmpegSettings, getFfmpegSettings,
ApiError,
messageFromError, messageFromError,
getSchedules, getSchedules,
resetAllPlayouts, resetAllPlayouts,
@@ -352,7 +353,6 @@ const routes: ScreenRoute[] = [
icon: <ListVideo aria-hidden="true" size={16} />, icon: <ListVideo aria-hidden="true" size={16} />,
primaryAction: 'Reset All', primaryAction: 'Reset All',
placeholder: 'Playouts workspace', placeholder: 'Playouts workspace',
badge: 3,
allowSubPaths: true allowSubPaths: true
}, },
{ {
@@ -2542,6 +2542,11 @@ function PlayoutsScreen() {
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
setMutationError(messageFromError(error)); 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(() => { .finally(() => {
setMutatingState(false); setMutatingState(false);
@@ -2643,6 +2648,9 @@ function PlayoutsScreen() {
} }
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber); 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 nowPlaying = selectedState?.nowPlaying ?? null;
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null; const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
const nextItem = nextPlayoutItem(items, nowItem); const nextItem = nextPlayoutItem(items, nowItem);
@@ -2707,7 +2715,7 @@ function PlayoutsScreen() {
<div className="ctv-playouts-title"> <div className="ctv-playouts-title">
<ChannelLogo name={selectedSummary.channelName} size={38} /> <ChannelLogo name={selectedSummary.channelName} size={38} />
<div> <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> <h2>{selectedSummary.channelName}</h2>
</div> </div>
</div> </div>
@@ -2795,20 +2803,22 @@ function PlayoutsScreen() {
)} )}
<div className="ctv-playout-detail-actions"> <div className="ctv-playout-detail-actions">
<Button <Button
disabled={mutating} disabled={mutating || selectedLocked}
onClick={resetSelectedChannel} onClick={resetSelectedChannel}
size="sm" size="sm"
startIcon={<RefreshCw aria-hidden="true" size={13} />} startIcon={<RefreshCw aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="secondary" variant="secondary"
> >
Reset Reset
</Button> </Button>
{selectedSummary.scheduleKind === 'Block' && ( {selectedSummary.scheduleKind === 'Block' && (
<Button <Button
disabled={mutating} disabled={mutating || selectedLocked}
onClick={eraseSelectedItems} onClick={eraseSelectedItems}
size="sm" size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />} startIcon={<Eraser aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost" variant="ghost"
> >
Erase items Erase items
@@ -2819,20 +2829,22 @@ function PlayoutsScreen() {
selectedSummary.scheduleKind === 'Sequential' || selectedSummary.scheduleKind === 'Sequential' ||
selectedSummary.scheduleKind === 'Scripted') && ( selectedSummary.scheduleKind === 'Scripted') && (
<Button <Button
disabled={mutating} disabled={mutating || selectedLocked}
onClick={eraseSelectedItemsAndHistory} onClick={eraseSelectedItemsAndHistory}
size="sm" size="sm"
startIcon={<Eraser aria-hidden="true" size={13} />} startIcon={<Eraser aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="ghost" variant="ghost"
> >
Erase items and history Erase items and history
</Button> </Button>
)} )}
<Button <Button
disabled={mutating} disabled={mutating || selectedLocked}
onClick={deleteSelectedPlayout} onClick={deleteSelectedPlayout}
size="sm" size="sm"
startIcon={<Trash2 aria-hidden="true" size={13} />} startIcon={<Trash2 aria-hidden="true" size={13} />}
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
variant="danger" variant="danger"
> >
Delete Delete
+3 -1
View File
@@ -743,6 +743,7 @@ export interface components {
"mediaItemId": null | number; "mediaItemId": null | number;
"playlistId": null | number; "playlistId": null | number;
"subtitle"?: null | string; "subtitle"?: null | string;
"seasonId"?: null | number;
}; };
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream"; "LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream";
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams"; "LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
@@ -1034,6 +1035,7 @@ export interface components {
"dailyRebuildTime": null | string; "dailyRebuildTime": null | string;
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"]; "buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"]; "playoutMode": components["schemas"]["ChannelPlayoutMode"];
"isLocked": boolean;
}; };
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration"; "PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
"PlayoutResponseModel": { "PlayoutResponseModel": {
@@ -1185,7 +1187,7 @@ export interface components {
"libraryRefreshInterval": number; "libraryRefreshInterval": number;
}; };
"ScanShowRequest": { "ScanShowRequest": {
"showTitle": null | string; "showId": number;
"deepScan"?: boolean; "deepScan"?: boolean;
}; };
"ScheduleItemRequest": { "ScheduleItemRequest": {
+5 -5
View File
@@ -22,19 +22,19 @@ describe('libraries api client', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' })); 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()); 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); const { init, url } = lastCall(fetchMock);
expect(url).toBe('/api/libraries/4/scan-show'); expect(url).toBe('/api/libraries/4/scan-show');
expect(init?.method).toBe('POST'); 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 () => { it('scanShow defaults deepScan to false when omitted', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent()); const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(9, { showTitle: 'Firefly' }); await scanShow(9, { showId: 17 });
const { init } = lastCall(fetchMock); 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 { export interface ScanShowParams {
showTitle: string; showId: number;
deepScan?: boolean; deepScan?: boolean;
} }
// Queues a scan of a single show (by title) within a library. Returns 200 on success, 400 when // Queues a scan of a single show (by id) within a library. Returns 200 on success, 404 when the
// the title can't be resolved / the library doesn't support single-show scanning. Body keys are // show id doesn't exist in the library, 400 when the library doesn't support single-show
// `showTitle` and `deepScan` (see LibrariesController.ScanShowRequest). // scanning. Body keys are `showId` and `deepScan` (see LibrariesController.ScanShowRequest).
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> { export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan-show`, { 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' method: 'POST'
}); });
} }
+14
View File
@@ -44,6 +44,20 @@ describe('getLogs', () => {
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50'); 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 () => { it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue( vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), { 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 LogEntry = components['schemas']['LogEntryResponseModel'];
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel']; export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
export type LogsSortField = 'timestamp' | 'level';
export type LogsSortDirection = 'asc' | 'desc';
export interface GetLogsParams { export interface GetLogsParams {
filter?: string; filter?: string;
pageNum?: number; pageNum?: number;
pageSize?: number; pageSize?: number;
sortDirection?: LogsSortDirection;
sortField?: LogsSortField;
} }
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> { export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
@@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
searchParams.set('pageSize', String(params.pageSize)); 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(); const queryString = searchParams.toString();
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`); return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
+12 -2
View File
@@ -13,7 +13,9 @@ export function MediaPosterCard({
onToggleSelect, onToggleSelect,
onOpen, onOpen,
height = 150, height = 150,
actions actions,
id,
highlighted
}: { }: {
item: LibraryBrowseItem; item: LibraryBrowseItem;
selected?: boolean; selected?: boolean;
@@ -21,6 +23,13 @@ export function MediaPosterCard({
onOpen?: (item: LibraryBrowseItem) => void; onOpen?: (item: LibraryBrowseItem) => void;
height?: number; height?: number;
actions?: ReactNode; 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 hue = hueOf(item.title);
const Icon = TYPE_ICON[item.mediaType] ?? Film; const Icon = TYPE_ICON[item.mediaType] ?? Film;
@@ -45,7 +54,8 @@ export function MediaPosterCard({
return ( return (
<div <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} onClick={interactive ? activate : undefined}
role={interactive ? 'button' : undefined} role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : 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}`; return `/app/media/seasons/${item.id}`;
case 'Artist': case 'Artist':
return `/app/media/artists/${item.id}`; return `/app/media/artists/${item.id}`;
case 'Episode':
return item.seasonId != null ? `/app/media/seasons/${item.seasonId}#episode-${item.id}` : null;
default: default:
return null; return null;
} }
@@ -15,7 +15,8 @@ const playoutsPage = {
}; };
const blocks = [ 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 = { const historyPage = {
@@ -87,4 +88,36 @@ describe('BlockPlayoutTroubleshootingScreen', () => {
expect(await screen.findByText('Cartoons Collection')).toBeTruthy(); expect(await screen.findByText('Cartoons Collection')).toBeTruthy();
expect(screen.getByText('S1E1 - Pilot')).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']; 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 = type PlayoutsState =
| { status: 'loading'; playouts: []; error: null } | { status: 'loading'; playouts: []; error: null }
| { status: 'success'; playouts: PlayoutSummary[]; error: null } | { status: 'success'; playouts: PlayoutSummary[]; error: null }
@@ -58,7 +83,7 @@ export function BlockPlayoutTroubleshootingScreen() {
const [blockFilter, setBlockFilter] = useState(''); const [blockFilter, setBlockFilter] = useState('');
const [selectedBlock, setSelectedBlock] = useState<SelectedBlock | null>(null); const [selectedBlock, setSelectedBlock] = useState<SelectedBlock | null>(null);
const [pageNum, setPageNum] = useState(0); const [pageNum, setPageNum] = useState(0);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(() => readStoredPageSize());
const [historyState, setHistoryState] = useState<HistoryState>({ const [historyState, setHistoryState] = useState<HistoryState>({
status: 'loading', status: 'loading',
entries: [], entries: [],
@@ -343,7 +368,9 @@ export function BlockPlayoutTroubleshootingScreen() {
<Select <Select
onChange={(event) => { onChange={(event) => {
setPageNum(0); setPageNum(0);
setPageSize(Number(event.target.value)); const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
writeStoredPageSize(nextPageSize);
}} }}
options={PAGE_SIZE_OPTIONS} options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }} style={{ width: 96 }}
@@ -464,14 +491,18 @@ function BlockGroupRows({
<td>{block.name}</td> <td>{block.name}</td>
<td>{block.minutes}</td> <td>{block.minutes}</td>
<td> <td>
<Button {/* Mirrors Blazor BlockPlayoutTroubleshooting.razor: the History action only exists
onClick={() => onOpenHistory(block)} for persisted blocks (Id >= 0) synthesized/virtual blocks have no history to show. */}
size="sm" {block.id >= 0 && (
startIcon={<History aria-hidden="true" size={14} />} <Button
variant="secondary" onClick={() => onOpenHistory(block)}
> size="sm"
History startIcon={<History aria-hidden="true" size={14} />}
</Button> variant="secondary"
>
History
</Button>
)}
</td> </td>
</tr> </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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BlocksScreen } from './BlocksScreen'; 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 }); return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
} }
const groups = [{ id: 2, name: 'Prime' }]; const groups = [
const blocks = [{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' }]; { 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>) { function blockItem(overrides: Record<string, unknown>) {
return { return {
@@ -107,12 +114,42 @@ describe('BlocksScreen', () => {
expect(screen.getByText('Morning')).toBeInTheDocument(); 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 () => { it('opens the copy dialog and POSTs to the copy route, then refreshes the list', async () => {
mockApi(); mockApi();
render(<BlocksScreen />); render(<BlocksScreen />);
await screen.findByText('Morning'); 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"'); const dialog = await screen.findByText('Copy "Morning"');
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
+37 -4
View File
@@ -9,6 +9,7 @@ import {
Eye, Eye,
FolderPlus, FolderPlus,
Plus, Plus,
Search,
Trash2, Trash2,
TriangleAlert TriangleAlert
} from 'lucide-react'; } from 'lucide-react';
@@ -320,6 +321,7 @@ function BlockList() {
const [deleteGroupTarget, setDeleteGroupTarget] = useState<BlockGroup | null>(null); const [deleteGroupTarget, setDeleteGroupTarget] = useState<BlockGroup | null>(null);
const [deleteBlockTarget, setDeleteBlockTarget] = useState<Block | null>(null); const [deleteBlockTarget, setDeleteBlockTarget] = useState<Block | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('');
const activeRef = useRef(true); const activeRef = useRef(true);
const load = useCallback(() => { const load = useCallback(() => {
@@ -455,6 +457,26 @@ function BlockList() {
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) })); 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 ( return (
<div className="ctv-collections"> <div className="ctv-collections">
<div className="ctv-channels-actionbar"> <div className="ctv-channels-actionbar">
@@ -472,6 +494,16 @@ function BlockList() {
</Button> </Button>
</div> </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 && ( {error && (
<div className="ctv-channels-error" role="alert"> <div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} /> <TriangleAlert aria-hidden="true" size={15} />
@@ -483,11 +515,12 @@ function BlockList() {
<Card> <Card>
<div className="ctv-collections-empty">No block groups yet. Create one to get started.</div> <div className="ctv-collections-empty">No block groups yet. Create one to get started.</div>
</Card> </Card>
) : filteredGroups.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No blocks match this filter.</div>
</Card>
) : ( ) : (
sortedGroups.map((group) => { filteredGroups.map(({ group, groupBlocks }) => {
const groupBlocks = blocks
.filter((b) => b.groupId === group.id)
.sort((a, b) => a.name.localeCompare(b.name));
return ( return (
<Card <Card
key={group.id} 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 { 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 { 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 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'> = { const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = {
Debug: 'neutral', Debug: 'neutral',
@@ -24,11 +38,44 @@ function formatTimestamp(value: string): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); 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() { export function LogsScreen() {
const [filterInput, setFilterInput] = useState(''); const [filterInput, setFilterInput] = useState('');
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [pageNum, setPageNum] = useState(0); 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 [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 });
const activeRef = useRef(true); const activeRef = useRef(true);
const seqRef = useRef(0); const seqRef = useRef(0);
@@ -56,7 +103,7 @@ export function LogsScreen() {
// a loading spinner on every filter keystroke or page change. // a loading spinner on every filter keystroke or page change.
const load = useCallback(() => { const load = useCallback(() => {
const id = ++seqRef.current; const id = ++seqRef.current;
getLogs({ filter, pageNum, pageSize }) getLogs({ filter, pageNum, pageSize, sortDirection, sortField })
.then((paged) => { .then((paged) => {
if (activeRef.current && id === seqRef.current) { if (activeRef.current && id === seqRef.current) {
setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 }); 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 }); setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 });
} }
}); });
}, [filter, pageNum, pageSize]); }, [filter, pageNum, pageSize, sortDirection, sortField]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -78,6 +125,22 @@ export function LogsScreen() {
load(); 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; const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1;
return ( return (
@@ -91,10 +154,7 @@ export function LogsScreen() {
/> />
<span className="ctv-channels-spacer" /> <span className="ctv-channels-spacer" />
<Select <Select
onChange={(event) => { onChange={(event) => changePageSize(Number(event.target.value))}
setPageNum(0);
setPageSize(Number(event.target.value));
}}
options={PAGE_SIZE_OPTIONS} options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }} style={{ width: 96 }}
value={String(pageSize)} value={String(pageSize)}
@@ -136,8 +196,26 @@ export function LogsScreen() {
<table aria-label="Recent log entries" className="ctv-channels-table"> <table aria-label="Recent log entries" className="ctv-channels-table">
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> {COLUMNS.map((column) => {
<th>Level</th> 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> <th>Message</th>
</tr> </tr>
</thead> </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 () => { it('selects every loaded item with Select all on page', async () => {
mockFetch(); mockFetch();
render(<MediaBrowseScreen />); render(<MediaBrowseScreen />);
+41 -7
View File
@@ -55,10 +55,17 @@ function kindFromSlug(slug: string | null): MediaKind {
} }
type BrowseState = 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: string; status: 'error'; totalCount: 0 }
| { items: []; error: null; status: 'loading'; 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 }; type Notice = { tone: 'ok' | 'error'; message: string };
export function MediaBrowseScreen() { export function MediaBrowseScreen() {
@@ -108,10 +115,11 @@ export function MediaBrowseScreen() {
const load = useCallback(() => { const load = useCallback(() => {
const id = ++seqRef.current; const id = ++seqRef.current;
const key = browseKeyOf(kind.mediaType, query, pageNum);
getLibraryBrowseItems({ mediaType: kind.mediaType, query: query || undefined, pageNum, pageSize: PAGE_SIZE }) getLibraryBrowseItems({ mediaType: kind.mediaType, query: query || undefined, pageNum, pageSize: PAGE_SIZE })
.then((paged) => { .then((paged) => {
if (activeRef.current && id === seqRef.current) { 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) => { .catch((error: unknown) => {
@@ -185,6 +193,13 @@ export function MediaBrowseScreen() {
const selectedItems = Array.from(selected.values()); const selectedItems = Array.from(selected.values());
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1; 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 ( return (
<div className="ctv-collections"> <div className="ctv-collections">
<div className="ctv-channels-actionbar"> <div className="ctv-channels-actionbar">
@@ -213,6 +228,9 @@ export function MediaBrowseScreen() {
</Button> </Button>
)} )}
<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} onClick={toggleSelectMode}
size="sm" size="sm"
startIcon={<ListChecks aria-hidden="true" size={14} />} startIcon={<ListChecks aria-hidden="true" size={14} />}
@@ -233,11 +251,12 @@ export function MediaBrowseScreen() {
{selected.size > 0 && ( {selected.size > 0 && (
<div className="ctv-channels-actionbar"> <div className="ctv-channels-actionbar">
<span className="ctv-channels-selected">{selected.size} selected</span> <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 Select all on page
</Button> </Button>
<span className="ctv-channels-spacer" /> <span className="ctv-channels-spacer" />
<Button <Button
disabled={refreshing}
onClick={() => setBulkDialog('collection')} onClick={() => setBulkDialog('collection')}
size="sm" size="sm"
startIcon={<FolderPlus aria-hidden="true" size={14} />} startIcon={<FolderPlus aria-hidden="true" size={14} />}
@@ -246,6 +265,7 @@ export function MediaBrowseScreen() {
Add to collection Add to collection
</Button> </Button>
<Button <Button
disabled={refreshing}
onClick={() => setBulkDialog('playlist')} onClick={() => setBulkDialog('playlist')}
size="sm" size="sm"
startIcon={<ListVideo aria-hidden="true" size={14} />} startIcon={<ListVideo aria-hidden="true" size={14} />}
@@ -274,6 +294,13 @@ export function MediaBrowseScreen() {
open={bulkDialog === 'playlist'} open={bulkDialog === 'playlist'}
/> />
{refreshing && (
<div className="ctv-collections-loading" role="status">
<Spinner size={14} />
<span>Refreshing</span>
</div>
)}
{state.status === 'error' && ( {state.status === 'error' && (
<div className="ctv-channels-error" role="alert"> <div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} /> <TriangleAlert aria-hidden="true" size={15} />
@@ -298,20 +325,27 @@ export function MediaBrowseScreen() {
</Card> </Card>
) : ( ) : (
<> <>
<div className="ctv-media-grid"> <div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
{state.items.map((item) => { {state.items.map((item) => {
const detailPath = mediaDetailPath(item); const detailPath = mediaDetailPath(item);
return ( return (
<MediaPosterCard <MediaPosterCard
// While refreshing, the per-card Add-to menu is withheld (stale result set);
// card navigation stays live.
actions={ actions={
selectMode ? undefined : ( selectMode || refreshing ? undefined : (
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} /> <AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
) )
} }
item={item} item={item}
key={`${item.mediaType}-${item.id}`} key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined} // While `selectMode && refreshing`, the card must be fully inert: neither
onToggleSelect={selectMode ? toggleSelect : undefined} // `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))} selected={selected.has(itemKey(item))}
/> />
); );
+132 -1
View File
@@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { navigateToPath } from '../routing';
import { import {
ArtistDetailScreen, ArtistDetailScreen,
MovieDetailScreen, MovieDetailScreen,
@@ -154,7 +155,7 @@ describe('media detail screens', () => {
const scanCall = fetchSpy.mock.calls.find(([url]) => String(url) === '/api/libraries/3/scan-show'); const scanCall = fetchSpy.mock.calls.find(([url]) => String(url) === '/api/libraries/3/scan-show');
expect(scanCall).toBeTruthy(); expect(scanCall).toBeTruthy();
const body = JSON.parse(String((scanCall![1] as RequestInit).body)); 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()); 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: 'Media Info' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).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 [pageNum, setPageNum] = useState(0);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading'); const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [anchorId, setAnchorId] = useState<string | null>(() => window.location.hash.slice(1) || null);
const activeRef = useRef(true); const activeRef = useRef(true);
const seqRef = useRef(0); 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(() => { useEffect(() => {
activeRef.current = true; 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 load = useCallback(() => {
const id = ++seqRef.current; const id = ++seqRef.current;
getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId }) getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId })
@@ -278,9 +319,12 @@ function ChildGrid({
<div className="ctv-media-grid"> <div className="ctv-media-grid">
{items.map((item) => { {items.map((item) => {
const detailPath = mediaDetailPath(item); const detailPath = mediaDetailPath(item);
const cardId = item.mediaType === 'Episode' ? `episode-${item.id}` : undefined;
return ( return (
<MediaPosterCard <MediaPosterCard
actions={renderActions?.(item)} actions={renderActions?.(item)}
highlighted={cardId != null && cardId === anchorId}
id={cardId}
item={item} item={item}
key={`${item.mediaType}-${item.id}`} key={`${item.mediaType}-${item.id}`}
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined} onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
@@ -584,7 +628,7 @@ function ShowScanControls({ show }: { show: ShowDetail }) {
const runScan = (deepScan: boolean) => { const runScan = (deepScan: boolean) => {
setScanning(deepScan ? 'deep' : 'quick'); setScanning(deepScan ? 'deep' : 'quick');
setMessage(null); setMessage(null);
scanShow(show.libraryId, { deepScan, showTitle: show.title }) scanShow(show.libraryId, { deepScan, showId: show.id })
.then(() => { .then(() => {
if (activeRef.current) { if (activeRef.current) {
setScanning(false); setScanning(false);
+170
View File
@@ -97,6 +97,46 @@ function renderWithQuery() {
return render(<SearchScreen />); 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', () => { describe('SearchScreen', () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
@@ -178,6 +218,136 @@ describe('SearchScreen', () => {
expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [7, 8] }); 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 () => { it('saves the current query as a new smart collection', async () => {
const { calls } = mockApi(); const { calls } = mockApi();
renderWithQuery(); renderWithQuery();
+47 -7
View File
@@ -44,7 +44,9 @@ const GROUPS: GroupDef[] = [
]; ];
type SearchState = 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: string; status: 'error' }
| { results: null; error: null; status: 'loading' }; | { results: null; error: null; status: 'loading' };
@@ -116,7 +118,7 @@ export function SearchScreen() {
getSearchResults({ query: trimmed, pageSize: PAGE_SIZE }) getSearchResults({ query: trimmed, pageSize: PAGE_SIZE })
.then((results) => { .then((results) => {
if (activeRef.current && id === seqRef.current) { if (activeRef.current && id === seqRef.current) {
setState({ results, error: null, status: 'success' }); setState({ results, error: null, status: 'success', query: trimmed });
} }
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -172,6 +174,13 @@ export function SearchScreen() {
if (!activeRef.current) { if (!activeRef.current) {
return; 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); setPendingAll(null);
setDialog({ kind, items: { requestOverride: toAddItemsRequestFromSearch(result) } }); setDialog({ kind, items: { requestOverride: toAddItemsRequestFromSearch(result) } });
}) })
@@ -202,6 +211,17 @@ export function SearchScreen() {
const hasResults = hasQuery && state.status === 'success' && totalMatches > 0; const hasResults = hasQuery && state.status === 'success' && totalMatches > 0;
const selectionCount = selected.size; 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 ( return (
<div className="ctv-collections"> <div className="ctv-collections">
<div className="ctv-channels-actionbar"> <div className="ctv-channels-actionbar">
@@ -215,6 +235,9 @@ export function SearchScreen() {
{hasResults && ( {hasResults && (
<> <>
<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} onClick={toggleSelectMode}
size="sm" size="sm"
startIcon={<CheckSquare aria-hidden="true" size={14} />} startIcon={<CheckSquare aria-hidden="true" size={14} />}
@@ -223,7 +246,7 @@ export function SearchScreen() {
Select Select
</Button> </Button>
<Button <Button
disabled={pendingAll !== null} disabled={pendingAll !== null || refreshing}
loading={pendingAll === 'collection'} loading={pendingAll === 'collection'}
onClick={() => addAll('collection')} onClick={() => addAll('collection')}
size="sm" size="sm"
@@ -233,7 +256,7 @@ export function SearchScreen() {
Add all to collection Add all to collection
</Button> </Button>
<Button <Button
disabled={pendingAll !== null} disabled={pendingAll !== null || refreshing}
loading={pendingAll === 'playlist'} loading={pendingAll === 'playlist'}
onClick={() => addAll('playlist')} onClick={() => addAll('playlist')}
size="sm" size="sm"
@@ -243,6 +266,7 @@ export function SearchScreen() {
Add all to playlist Add all to playlist
</Button> </Button>
<Button <Button
disabled={refreshing}
onClick={() => setDialog({ kind: 'save-smart' })} onClick={() => setDialog({ kind: 'save-smart' })}
size="sm" size="sm"
startIcon={<Save aria-hidden="true" size={14} />} 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-search-selectbar-count">{selectionCount} selected</span>
<span className="ctv-channels-spacer" /> <span className="ctv-channels-spacer" />
<Button <Button
disabled={refreshing}
onClick={() => addSelection('collection')} onClick={() => addSelection('collection')}
size="sm" size="sm"
startIcon={<FolderPlus aria-hidden="true" size={14} />} startIcon={<FolderPlus aria-hidden="true" size={14} />}
@@ -271,6 +296,7 @@ export function SearchScreen() {
Add to collection Add to collection
</Button> </Button>
<Button <Button
disabled={refreshing}
onClick={() => addSelection('playlist')} onClick={() => addSelection('playlist')}
size="sm" size="sm"
startIcon={<ListVideo aria-hidden="true" size={14} />} startIcon={<ListVideo aria-hidden="true" size={14} />}
@@ -289,6 +315,13 @@ export function SearchScreen() {
</div> </div>
)} )}
{refreshing && (
<div className="ctv-collections-loading" role="status">
<Spinner size={14} />
<span>Refreshing</span>
</div>
)}
{!hasQuery && ( {!hasQuery && (
<Card> <Card>
<div className="ctv-collections-empty">Type a query to search across every media kind.</div> <div className="ctv-collections-empty">Type a query to search across every media kind.</div>
@@ -344,21 +377,28 @@ export function SearchScreen() {
</Button> </Button>
)} )}
</div> </div>
<div className="ctv-media-grid"> <div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
{data.items.map((item) => { {data.items.map((item) => {
const key = itemKey(item); const key = itemKey(item);
const detailPath = mediaDetailPath(item); const detailPath = mediaDetailPath(item);
return ( return (
<MediaPosterCard <MediaPosterCard
// While refreshing, the per-card Add-to menu is withheld (stale result set);
// card navigation stays live.
actions={ actions={
selectMode ? undefined : ( selectMode || refreshing ? undefined : (
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} /> <AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
) )
} }
item={item} item={item}
key={key} 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} onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
onToggleSelect={selectMode ? toggleSelect : undefined} onToggleSelect={canSelect ? toggleSelect : undefined}
selected={selectMode ? selected.has(key) : 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplatesScreen } from './TemplatesScreen'; 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 }); return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
} }
const groups = [{ id: 2, name: 'Prime', templateCount: 1 }]; const groups = [
const templates = [{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' }]; { 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 blockGroups = [{ id: 1, name: 'Morning Blocks' }];
const blocks = [{ id: 10, groupId: 1, groupName: 'Morning Blocks', name: 'Cartoons', minutes: 60, stopScheduling: 'AfterDurationEnd' }]; 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(); 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 () => { it('opens the copy dialog and POSTs to the copy route', async () => {
mockApi(); mockApi();
render(<TemplatesScreen />); render(<TemplatesScreen />);
await screen.findByText('Weekdays'); 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"'); const dialog = await screen.findByText('Copy "Weekdays"');
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
+39 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components'; import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
import { import {
@@ -162,6 +162,7 @@ function TemplateList() {
const [deleteGroupTarget, setDeleteGroupTarget] = useState<TemplateGroup | null>(null); const [deleteGroupTarget, setDeleteGroupTarget] = useState<TemplateGroup | null>(null);
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null); const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('');
const activeRef = useRef(true); const activeRef = useRef(true);
const load = useCallback(() => { const load = useCallback(() => {
@@ -300,6 +301,28 @@ function TemplateList() {
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) })); 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 ( return (
<div className="ctv-collections"> <div className="ctv-collections">
<div className="ctv-channels-actionbar"> <div className="ctv-channels-actionbar">
@@ -317,6 +340,16 @@ function TemplateList() {
</Button> </Button>
</div> </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 && ( {error && (
<div className="ctv-channels-error" role="alert"> <div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} /> <TriangleAlert aria-hidden="true" size={15} />
@@ -328,11 +361,12 @@ function TemplateList() {
<Card> <Card>
<div className="ctv-collections-empty">No template groups yet. Create one to get started.</div> <div className="ctv-collections-empty">No template groups yet. Create one to get started.</div>
</Card> </Card>
) : filteredGroups.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No templates match this filter.</div>
</Card>
) : ( ) : (
sortedGroups.map((group) => { filteredGroups.map(({ group, groupTemplates }) => {
const groupTemplates = templates
.filter((t) => t.templateGroupId === group.id && t.id > 0)
.sort((a, b) => a.name.localeCompare(b.name));
return ( return (
<Card <Card
key={group.id} key={group.id}
+39
View File
@@ -44,6 +44,12 @@ const searchResults = {
songs: emptyGroup() 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 { interface MockOptions {
onRequest?: (url: string, method: string, body: unknown) => Response | null; onRequest?: (url: string, method: string, body: unknown) => Response | null;
} }
@@ -129,4 +135,37 @@ describe('TrashScreen', () => {
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]); 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 { import {
deleteMediaItems, deleteMediaItems,
emptyTrash, emptyTrash,
getLibraryBrowseItems,
getSearchResults, getSearchResults,
messageFromLibraryBrowseError,
messageFromSearchError, messageFromSearchError,
type LibraryBrowseItem, type LibraryBrowseItem,
type LibraryBrowseMediaType,
type SearchResults type SearchResults
} from '../api'; } from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard'; import { MediaPosterCard } from '../media/MediaPosterCard';
// The search API's SearchController clamps pageSize to MaxPageSize=100 and has no page-number // The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
// param, so this is the largest single request possible — see docs/decisions.md ("Trash see all"). // 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; const PAGE_SIZE = 100;
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page). // Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
const TRASH_QUERY = 'state:FileNotFound'; const TRASH_QUERY = 'state:FileNotFound';
@@ -20,21 +25,30 @@ const TRASH_QUERY = 'state:FileNotFound';
interface GroupDef { interface GroupDef {
key: keyof SearchResults; key: keyof SearchResults;
label: string; label: string;
mediaType: LibraryBrowseMediaType;
} }
const GROUPS: GroupDef[] = [ const GROUPS: GroupDef[] = [
{ key: 'movies', label: 'Movies' }, { key: 'movies', label: 'Movies', mediaType: 'Movie' },
{ key: 'shows', label: 'TV Shows' }, { key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
{ key: 'seasons', label: 'Seasons' }, { key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
{ key: 'episodes', label: 'Episodes' }, { key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
{ key: 'artists', label: 'Artists' }, { key: 'artists', label: 'Artists', mediaType: 'Artist' },
{ key: 'musicVideos', label: 'Music Videos' }, { key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
{ key: 'songs', label: 'Songs' }, { key: 'songs', label: 'Songs', mediaType: 'Song' },
{ key: 'otherVideos', label: 'Other Videos' }, { key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
{ key: 'images', label: 'Images' }, { key: 'images', label: 'Images', mediaType: 'Image' },
{ key: 'remoteStreams', label: 'Remote Streams' } { 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 = type TrashState =
| { results: SearchResults; error: null; status: 'success' } | { results: SearchResults; error: null; status: 'success' }
| { results: null; error: string; status: 'error' } | { results: null; error: string; status: 'error' }
@@ -46,6 +60,7 @@ function mediaItemIdOf(item: LibraryBrowseItem): number | null {
export function TrashScreen() { export function TrashScreen() {
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' }); 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 [selected, setSelected] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null); const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -83,9 +98,45 @@ export function TrashScreen() {
const refresh = () => { const refresh = () => {
setState({ results: null, error: null, status: 'loading' }); setState({ results: null, error: null, status: 'loading' });
setSeeAll({});
load(); 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 toggle = (item: LibraryBrowseItem) => {
const mediaItemId = mediaItemIdOf(item); const mediaItemId = mediaItemIdOf(item);
if (mediaItemId == null) { if (mediaItemId == null) {
@@ -113,7 +164,8 @@ export function TrashScreen() {
} }
const ids = new Set<number>(); const ids = new Set<number>();
for (const group of GROUPS) { 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); const mediaItemId = mediaItemIdOf(item);
if (mediaItemId != null) { if (mediaItemId != null) {
ids.add(mediaItemId); ids.add(mediaItemId);
@@ -219,9 +271,12 @@ export function TrashScreen() {
state.results && state.results &&
GROUPS.map((group) => { GROUPS.map((group) => {
const data = state.results![group.key]; 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; return null;
} }
const hasMore = items.length < data.totalCount;
return ( return (
<section key={group.key}> <section key={group.key}>
@@ -232,7 +287,7 @@ export function TrashScreen() {
</span> </span>
</div> </div>
<div className="ctv-media-grid"> <div className="ctv-media-grid">
{data.items.map((item) => { {items.map((item) => {
const mediaItemId = mediaItemIdOf(item); const mediaItemId = mediaItemIdOf(item);
return ( return (
<MediaPosterCard <MediaPosterCard
@@ -244,6 +299,19 @@ export function TrashScreen() {
); );
})} })}
</div> </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> </section>
); );
})} })}
+52
View File
@@ -841,6 +841,26 @@ body {
vertical-align: middle; 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 { .ctv-channel-check {
width: 38px; width: 38px;
padding-left: 14px !important; padding-left: 14px !important;
@@ -2691,6 +2711,14 @@ body {
gap: var(--space-6, 12px); 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 { .ctv-media-card {
position: relative; position: relative;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -2711,6 +2739,24 @@ body {
box-shadow: 0 0 0 1px var(--action-primary); 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 { .ctv-media-card-poster {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -3080,6 +3126,12 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.ctv-media-section-footer {
display: flex;
justify-content: center;
margin: var(--space-5, 10px) 0 var(--space-7, 16px);
}
/* lineup */ /* lineup */
.ctv-builder-lineup-list { .ctv-builder-lineup-list {
display: flex; display: flex;