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
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:
@@ -274,7 +274,8 @@ internal static class LibraryBrowseItemMapper
|
||||
null,
|
||||
em.EpisodeId,
|
||||
null,
|
||||
EpisodeSubtitle(em))).ToList());
|
||||
EpisodeSubtitle(em),
|
||||
em.Episode.SeasonId)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
|
||||
|
||||
@@ -46,7 +46,8 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
{ CollectionType.MusicVideo, request.MusicVideoIds },
|
||||
{ CollectionType.OtherVideo, request.OtherVideoIds },
|
||||
{ CollectionType.Song, request.SongIds },
|
||||
{ CollectionType.Image, request.ImageIds }
|
||||
{ CollectionType.Image, request.ImageIds },
|
||||
{ CollectionType.RemoteStream, request.RemoteStreamIds }
|
||||
};
|
||||
|
||||
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
|
||||
@@ -81,8 +82,9 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
await ValidateEpisodes(request),
|
||||
await ValidateMediaItems(dbContext, request, cancellationToken))
|
||||
.Apply((collection, _, _, _, _, _) => collection);
|
||||
|
||||
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -126,4 +128,30 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
|
||||
TvContext dbContext,
|
||||
AddItemsToPlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
|
||||
int existingCount = await dbContext.MediaItems
|
||||
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
|
||||
|
||||
return existingCount == ids.Count
|
||||
? Unit.Default
|
||||
: BaseError.New("Media item does not exist");
|
||||
}
|
||||
|
||||
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToPlaylist request) =>
|
||||
request.MovieIds
|
||||
.Append(request.ShowIds)
|
||||
.Append(request.SeasonIds)
|
||||
.Append(request.EpisodeIds)
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.Append(request.SongIds)
|
||||
.Append(request.ImageIds)
|
||||
.Append(request.RemoteStreamIds);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<Option<int>> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException();
|
||||
public Task<Option<string>> GetShowTitle(int libraryId, int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
public Task<List<int>> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException();
|
||||
|
||||
@@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel(
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId,
|
||||
string? Subtitle = null);
|
||||
string? Subtitle = null,
|
||||
int? SeasonId = null);
|
||||
|
||||
@@ -11,4 +11,5 @@ public record PlayoutListItemResponseModel(
|
||||
string ScheduleName,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
ChannelPlayoutMode PlayoutMode);
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
bool IsLocked);
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface ITelevisionRepository
|
||||
Task<bool> AllEpisodesExist(List<int> episodeIds);
|
||||
Task<List<Show>> GetAllShows();
|
||||
Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken);
|
||||
Task<Option<int>> GetShowIdByTitle(int libraryId, string title);
|
||||
Task<Option<string>> GetShowTitle(int libraryId, int showId);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<int>> GetEpisodeIdsForShow(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
|
||||
@@ -80,16 +80,16 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
.SelectOneAsync(s => s.Id, s => s.Id == showId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Option<int>> GetShowIdByTitle(int libraryId, string title)
|
||||
public async Task<Option<string>> GetShowTitle(int libraryId, int showId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => sm.ShowId == showId)
|
||||
.Where(sm => sm.Show.LibraryPath.LibraryId == libraryId)
|
||||
.Where(sm => EF.Functions.Like(sm.Title, $"%{title}%"))
|
||||
.Map(sm => sm.ShowId)
|
||||
.Map(sm => sm.Title)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(showId => showId > 0 ? Option<int>.Some(showId) : Option<int>.None);
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetEpisodeIdsForShow(int showId)
|
||||
|
||||
@@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
result.Page[1].MediaItemId.ShouldBe(711);
|
||||
result.Page[2].MediaItemId.ShouldBe(713);
|
||||
|
||||
// #220: every episode carries its parent season id so the SPA can route to the season
|
||||
// detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`).
|
||||
result.Page.ShouldAllBe(p => p.SeasonId == 701);
|
||||
|
||||
await _searchIndex.DidNotReceive().Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
@@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds()
|
||||
{
|
||||
await SeedLibraryGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.ShowType, 20),
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 10)
|
||||
],
|
||||
2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Page.ShouldAllBe(p => p.SeasonId == null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaylistHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[TestCase(CollectionType.Artist)]
|
||||
[TestCase(CollectionType.MusicVideo)]
|
||||
[TestCase(CollectionType.OtherVideo)]
|
||||
[TestCase(CollectionType.Song)]
|
||||
[TestCase(CollectionType.Image)]
|
||||
[TestCase(CollectionType.RemoteStream)]
|
||||
public async Task AddItems_Should_Return_ValidationError_When_Previously_Unvalidated_Kind_Missing(
|
||||
CollectionType collectionType)
|
||||
{
|
||||
await SeedPlaylist(1);
|
||||
var handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Media item does not exist");
|
||||
}
|
||||
|
||||
[TestCase(CollectionType.Movie)]
|
||||
[TestCase(CollectionType.TelevisionShow)]
|
||||
[TestCase(CollectionType.TelevisionSeason)]
|
||||
[TestCase(CollectionType.Episode)]
|
||||
public async Task AddItems_Should_Return_ValidationError_When_Existing_Validated_Kind_Missing(
|
||||
CollectionType collectionType)
|
||||
{
|
||||
await SeedPlaylist(1);
|
||||
var handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Add_Valid_RemoteStream_Item_To_Playlist()
|
||||
{
|
||||
await SeedPlaylist(1);
|
||||
await SeedRemoteStream(50);
|
||||
var handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(1, CollectionType.RemoteStream, [50]), CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Playlist playlist = await context.Playlists.FindAsync(1);
|
||||
List<PlaylistItem> items = context.Entry(playlist!).Collection(p => p.Items).Query().ToList();
|
||||
|
||||
items.ShouldHaveSingleItem();
|
||||
items[0].CollectionType.ShouldBe(CollectionType.RemoteStream);
|
||||
items[0].MediaItemId.ShouldBe(50);
|
||||
}
|
||||
|
||||
private AddItemsToPlaylistHandler MakeHandler()
|
||||
{
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
return new AddItemsToPlaylistHandler(Db.Factory, movieRepository, televisionRepository);
|
||||
}
|
||||
|
||||
private async Task SeedPlaylist(int id, string name = "Playlist")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Playlists.Add(new Playlist { Id = id, Name = name, Items = [] });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedRemoteStream(int id)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.RemoteStreams.Add(new RemoteStream
|
||||
{
|
||||
Id = id,
|
||||
RemoteStreamMetadata = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AddItemsToPlaylist MakeAddItems(int playlistId, CollectionType collectionType, List<int> ids)
|
||||
{
|
||||
List<int> movieIds = collectionType == CollectionType.Movie ? ids : [];
|
||||
List<int> showIds = collectionType == CollectionType.TelevisionShow ? ids : [];
|
||||
List<int> seasonIds = collectionType == CollectionType.TelevisionSeason ? ids : [];
|
||||
List<int> episodeIds = collectionType == CollectionType.Episode ? ids : [];
|
||||
List<int> artistIds = collectionType == CollectionType.Artist ? ids : [];
|
||||
List<int> musicVideoIds = collectionType == CollectionType.MusicVideo ? ids : [];
|
||||
List<int> otherVideoIds = collectionType == CollectionType.OtherVideo ? ids : [];
|
||||
List<int> songIds = collectionType == CollectionType.Song ? ids : [];
|
||||
List<int> imageIds = collectionType == CollectionType.Image ? ids : [];
|
||||
List<int> remoteStreamIds = collectionType == CollectionType.RemoteStream ? ids : [];
|
||||
|
||||
return new AddItemsToPlaylist(
|
||||
playlistId,
|
||||
movieIds,
|
||||
showIds,
|
||||
seasonIds,
|
||||
episodeIds,
|
||||
artistIds,
|
||||
musicVideoIds,
|
||||
otherVideoIds,
|
||||
songIds,
|
||||
imageIds,
|
||||
remoteStreamIds);
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -113,22 +114,33 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
|
||||
@@ -139,6 +151,7 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(LibrariesController), nameof(LibrariesController.ScanShow), StatusCodes.Status404NotFound)]
|
||||
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
|
||||
Type controllerType,
|
||||
string actionName,
|
||||
|
||||
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -30,6 +31,7 @@ public class ChannelControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private ChannelController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
@@ -37,7 +39,8 @@ public class ChannelControllerTests
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator);
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator, _entityLocker);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -411,6 +414,20 @@ public class ChannelControllerTests
|
||||
buildPlayout.Mode.ShouldBe(expectedMode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
||||
{
|
||||
|
||||
@@ -3,7 +3,10 @@ using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
@@ -16,12 +19,14 @@ public class LibrariesControllerTests
|
||||
{
|
||||
private LibrariesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private ITelevisionRepository _televisionRepository = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
|
||||
_televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
_controller = new LibrariesController(_televisionRepository, _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -52,4 +57,42 @@ public class LibrariesControllerTests
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_NotFoundProblem_When_Show_Not_In_Library()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 999).Returns(Option<string>.None);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(999));
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueShowScanByLibraryId>(r =>
|
||||
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class LogsControllerTests
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(-1, 500, "boom", CancellationToken.None);
|
||||
await _controller.GetLogs(-1, 500, "boom", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
@@ -52,6 +52,74 @@ public class LogsControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Default_To_Timestamp_Descending()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
q.SortDescending == true &&
|
||||
SelectsTimestamp(q.SortExpression)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
q.SortDescending == false &&
|
||||
SelectsLevel(q.SortExpression)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q => SelectsTimestamp(q.SortExpression)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q => q.SortDescending == true),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static bool SelectsTimestamp(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
|
||||
{
|
||||
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
|
||||
return Equals(expr.Compile()(sample), sample.Timestamp);
|
||||
}
|
||||
|
||||
private static bool SelectsLevel(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
|
||||
{
|
||||
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
|
||||
return Equals(expr.Compile()(sample), sample.Level);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
|
||||
{
|
||||
|
||||
@@ -115,6 +115,7 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "409")]
|
||||
[TestCase("/api/channel-templates/default", "get", "404")]
|
||||
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
|
||||
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
|
||||
@@ -188,12 +189,21 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/playouts/{id}", "get", "404")]
|
||||
[TestCase("/api/playouts", "post", "404")]
|
||||
[TestCase("/api/playouts", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}", "put", "404")]
|
||||
[TestCase("/api/playouts/{id}", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}", "put", "422")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "404")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "409")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "422")]
|
||||
[TestCase("/api/playouts/{id}/deco", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/alternate-schedules", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/templates", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/items", "get", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "409")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "409")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")]
|
||||
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
|
||||
[TestCase("/api/collections/{id}/custom-order", "put", "404")]
|
||||
@@ -260,6 +270,7 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
|
||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||
string path,
|
||||
string method,
|
||||
|
||||
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
|
||||
{
|
||||
private PlayoutController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new PlayoutController(_mediator);
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_controller = new PlayoutController(_mediator, _entityLocker);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -68,6 +71,82 @@ public class PlayoutControllerTests
|
||||
"/api/playouts/items/{id:int}/scheduling-context");
|
||||
}
|
||||
|
||||
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EraseItems_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
9,
|
||||
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.UpdateDefaultDeco(
|
||||
9,
|
||||
new UpdateDefaultDecoRequest(null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Stamp_IsLocked_From_Locker()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)]));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page.Single().IsLocked.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ----- Erase items / history -----
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
@@ -17,7 +18,10 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
|
||||
public class ChannelController(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
[HttpGet("/api/channels")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -209,6 +213,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
string channelNumber,
|
||||
[FromQuery] PlayoutBuildMode? mode,
|
||||
@@ -218,6 +223,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
|
||||
if (entityLocker.IsPlayoutLocked(playoutId))
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Playout build in progress",
|
||||
"A build for this playout is currently in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
return new OkResult();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -29,27 +30,23 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan show")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ShowTitle))
|
||||
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
|
||||
foreach (string title in maybeTitle)
|
||||
{
|
||||
return new BadRequestObjectResult(new { error = "ShowTitle is required" });
|
||||
}
|
||||
|
||||
string trimmedTitle = request.ShowTitle.Trim();
|
||||
Option<int> maybeShowId = await televisionRepository.GetShowIdByTitle(id, trimmedTitle);
|
||||
foreach (int showId in maybeShowId)
|
||||
{
|
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan));
|
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
|
||||
|
||||
return result
|
||||
? new OkResult()
|
||||
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
|
||||
}
|
||||
|
||||
return new BadRequestObjectResult(
|
||||
new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" });
|
||||
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
|
||||
}
|
||||
}
|
||||
|
||||
public record ScanShowRequest(string ShowTitle, bool DeepScan = false);
|
||||
public record ScanShowRequest(int ShowId, bool DeepScan = false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Application.Logs;
|
||||
using ErsatzTV.Core.Api.Logs;
|
||||
using MediatR;
|
||||
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
|
||||
// Timestamp/Level; Message was never sortable there either).
|
||||
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
|
||||
|
||||
[HttpGet("/api/logs", Name = "GetLogs")]
|
||||
[Tags("Logs")]
|
||||
[EndpointSummary("Get recent log entries")]
|
||||
[EndpointDescription(
|
||||
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
|
||||
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
|
||||
"pre-existing default, newest first).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedLogEntriesResponseModel> GetLogs(
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] string filter = "",
|
||||
[FromQuery] string sortField = "timestamp",
|
||||
[FromQuery] string sortDirection = "desc",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
|
||||
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
|
||||
? sortField!.ToLowerInvariant()
|
||||
: "timestamp";
|
||||
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
|
||||
{
|
||||
"level" => le => le.Level,
|
||||
_ => le => le.Timestamp
|
||||
};
|
||||
|
||||
PagedLogEntriesViewModel result = await mediator.Send(
|
||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty),
|
||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
|
||||
{
|
||||
SortExpression = sortExpression,
|
||||
SortDescending = descending
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new PagedLogEntriesResponseModel(
|
||||
|
||||
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
// Blazor disables per-playout Reset/Erase/Delete/Edit while a build is in flight
|
||||
// (EntityLocker.IsPlayoutLocked); the API mirrors that invariant by rejecting any
|
||||
// id-keyed mutation with 409 while the build lock is held. See docs/decisions.md 2026-07-10.
|
||||
private const string BuildInProgressTitle = "Playout build in progress";
|
||||
|
||||
private const string BuildInProgressDetail =
|
||||
"A build for this playout is currently in progress; try again once it completes.";
|
||||
|
||||
private static IActionResult PlayoutLockedProblem() =>
|
||||
ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail);
|
||||
|
||||
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("List playouts")]
|
||||
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
||||
return new PagedPlayoutsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToListItemResponse).ToList());
|
||||
result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateDefaultDeco(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateDefaultDecoRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceAlternateSchedules(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceTemplates(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -515,6 +551,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
|
||||
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
|
||||
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
@@ -531,9 +570,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -563,9 +608,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -608,9 +659,15 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
@@ -722,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.EndDay,
|
||||
vm.EndYear);
|
||||
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
|
||||
new(
|
||||
vm.PlayoutId,
|
||||
vm.ChannelNumber,
|
||||
@@ -731,7 +788,8 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.ScheduleName,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.PlayoutMode);
|
||||
vm.PlayoutMode,
|
||||
isLocked);
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
|
||||
@@ -50,6 +50,13 @@ public static class ApiResults
|
||||
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
|
||||
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
|
||||
|
||||
/// <summary>
|
||||
/// 409 <see cref="ProblemDetails" /> directly — for a mutation that races a background operation
|
||||
/// holding a lock (e.g. a playout build in flight). Mirrors <see cref="NotFoundProblem" />.
|
||||
/// </summary>
|
||||
public static IActionResult ConflictProblem(string title, string detail) =>
|
||||
new ConflictObjectResult(CreateProblemDetails(409, title, detail));
|
||||
|
||||
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -1844,6 +1844,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5447,6 +5467,46 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5540,6 +5600,7 @@
|
||||
"Logs"
|
||||
],
|
||||
"summary": "Get recent log entries",
|
||||
"description": "sortField is validated against an allow-list (timestamp, level); an unrecognized value falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the pre-existing default, newest first).",
|
||||
"operationId": "GetLogs",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5567,6 +5628,22 @@
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortField",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "timestamp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortDirection",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "desc"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -7489,6 +7566,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -7551,6 +7648,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -7746,6 +7863,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -7952,6 +8089,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -8158,6 +8315,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -8483,6 +8660,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -8549,6 +8746,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -18036,6 +18253,13 @@
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"seasonId": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19575,7 +19799,8 @@
|
||||
"scheduleName",
|
||||
"dailyRebuildTime",
|
||||
"buildStatus",
|
||||
"playoutMode"
|
||||
"playoutMode",
|
||||
"isLocked"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19614,6 +19839,9 @@
|
||||
},
|
||||
"playoutMode": {
|
||||
"$ref": "#/components/schemas/ChannelPlayoutMode"
|
||||
},
|
||||
"isLocked": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -20452,15 +20680,13 @@
|
||||
},
|
||||
"ScanShowRequest": {
|
||||
"required": [
|
||||
"showTitle"
|
||||
"showId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"showTitle": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
"showId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"deepScan": {
|
||||
"type": "boolean",
|
||||
|
||||
+3
-2
@@ -36,6 +36,7 @@ Also present in `docs/`:
|
||||
- **`docs/endpoint-index.md`** — generated REST endpoint index (method/path/operationId/summary per
|
||||
OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
|
||||
`scripts/update-openapi.sh`.
|
||||
- **`docs/handoffs/chicorytv-issue-queue.md`** — living session-to-session handoff: current queue
|
||||
state, what's next. Check this for what's actively in flight before starting new work.
|
||||
- **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore.
|
||||
Queue state (goal, arc, what's in flight/next) lives in the pinned Gitea tracker ersatztv#237
|
||||
— read that, not this file, for current state (protocol: decisions.md 2026-07-11).
|
||||
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
|
||||
|
||||
@@ -32,6 +32,12 @@ Exemplars:
|
||||
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
|
||||
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
|
||||
input for page math.
|
||||
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
|
||||
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
|
||||
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
|
||||
`sortDirection` falls back to the default direction. Copy this pattern (normalize, don't 422) for
|
||||
any new sortable endpoint — it matches the pageNum/pageSize clamp precedent above and keeps a bad
|
||||
query string from ever producing an error response for a read-only listing.
|
||||
|
||||
## 2. DTOs: where they live and their nullable context
|
||||
|
||||
@@ -84,6 +90,34 @@ hand-rolling `IActionResult` status codes:
|
||||
| `ToDeletedResult()` | `Either<BaseError, Unit>` | `Left` → `ToErrorResult()`; `Right` → 204 |
|
||||
| `ToGetResult()` | `Option<T>` | `Some` → 200 + body; `None` → 404 |
|
||||
| `ApiResults.NotFoundProblem(detail?)` | — | 404 `ProblemDetails` directly (e.g. when a controller has to pre-check existence itself, see `TemplateController.DeleteGroup`) |
|
||||
| `ApiResults.ConflictProblem(title, detail)` | — | 409 `ProblemDetails` directly — for a mutation that races a background operation holding a lock (see §3a) |
|
||||
|
||||
### 3a. 409 when a mutation races a background lock
|
||||
|
||||
When an endpoint mutates an entity that a background operation may be actively rebuilding under an
|
||||
`IEntityLocker` lock, guard the mutation and return **409 Conflict** (`ApiResults.ConflictProblem`)
|
||||
while the lock is held. This mirrors the Blazor UI, which disables the same actions while the lock
|
||||
event is live.
|
||||
|
||||
**This guard is advisory check-then-act, not mutual exclusion.** It narrows the race but does not
|
||||
eliminate it: a build already queued can acquire the lock a moment *after* the check passes, and the
|
||||
mutation then interleaves with the build anyway. That residual window is accepted where the
|
||||
consequences are self-healing (a playout half-mutated during a build is corrected by the next
|
||||
rebuild). If an entity's consequences were NOT self-healing, this pattern would be insufficient —
|
||||
the mutation would need to actually acquire the lock for its duration instead.
|
||||
|
||||
Established by issue #215 (`PlayoutController` + `ChannelController.ResetPlayout`): inject
|
||||
`IEntityLocker`, and at the top of every id-keyed mutation (`PUT`/`POST`/`DELETE`) check
|
||||
`IsPlayoutLocked(id)` → `ConflictProblem("Playout build in progress", ...)`; add
|
||||
`[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]` to each guarded
|
||||
action. Precedent for the 409 shape: `TraktController` (its private `ConflictProblem()`). Two nuances:
|
||||
- **Fire-and-forget bulk operations don't 409** — `POST /api/playouts/reset-all` stays 202; its
|
||||
handler (`ResetAllPlayoutsHandler`) already *skips* locked playouts, matching Blazor + the handler
|
||||
semantics. Only per-id mutations 409.
|
||||
- **Surface the lock state to clients** so they can pre-disable the buttons: stamp an `IsLocked`
|
||||
boolean onto the list DTO (`PlayoutListItemResponseModel`, set from `IsPlayoutLocked` in the
|
||||
controller's list projection) rather than adding a push channel. The SPA reads it and, on a 409,
|
||||
refreshes the list to pick up the flag.
|
||||
|
||||
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
|
||||
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
|
||||
|
||||
+38
-14
@@ -54,23 +54,23 @@ redirect).
|
||||
>
|
||||
> | Cluster | Verdict | Gap issue |
|
||||
> |---|---|---|
|
||||
> | Troubleshooting (4 routes) | PARITY-OK (2 minors) | #213 |
|
||||
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK (block-copy UI added 2026-07-09) | — |
|
||||
> | Troubleshooting (4 routes) | PARITY-OK (block-history page-size persistence + Id>=0 History gating added 2026-07-11) | — |
|
||||
> | Blocks/Templates/Decos/Deco-templates | PARITY-OK (block-copy UI added 2026-07-09; blocks/templates list search/filter added 2026-07-11) | — |
|
||||
> | Filler presets / Trakt / FFmpeg profiles | PARITY-OK | — |
|
||||
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
||||
> | Playout creation + alternate-schedules | PARITY-OK | — |
|
||||
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09) | — |
|
||||
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear added; 100/kind cap → decisions.md) | — |
|
||||
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
|
||||
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
|
||||
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
|
||||
> | Channel editor | PARITY-OK (external logo URL mutual-exclusion, bare-create defaults, enumerated pickers — 2026-07-11) | #212 |
|
||||
> | Channels-numbers / Logs | PARITY-OK / minors | #213 |
|
||||
> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10) | — |
|
||||
> | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
|
||||
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
|
||||
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
|
||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10) | — |
|
||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
|
||||
> | Schedules editors | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** |
|
||||
> | Media sources | **disproven — moved to Section 3** | **#202** |
|
||||
>
|
||||
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212/#213 are SHOULD-FIX.
|
||||
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11.
|
||||
|
||||
Confirmed as of this doc: the SPA screen exists (verified against `web/src/App.tsx`'s route table
|
||||
and `web/src/screens/`) but `LegacyUiRedirects.cs` has **no entry** for the Blazor route yet.
|
||||
@@ -92,8 +92,8 @@ been added to the redirect map yet.
|
||||
| `/system/troubleshooting/block-playout` | `Troubleshooting/BlockPlayoutTroubleshooting.razor` (+`BlockPlayoutHistory.razor`) | `/app/troubleshooting/blocks` | **covered by PR #182 / #145** |
|
||||
| `/system/troubleshooting/sequential-schedule` | `Troubleshooting/YamlValidator.razor` | `/app/troubleshooting/yaml` | **covered by PR #182 / #145** |
|
||||
| `/system/troubleshooting/playback` | `Troubleshooting/PlaybackTroubleshooting.razor` | `/app/troubleshooting/playback` | **covered by #145** — no nav entry; entry points are the Channels table Troubleshoot action (`?channel={id}`) the movie detail page, and per-episode Troubleshoot actions on season detail pages (`?mediaItem={id}`, #209) — remaining kinds (music videos, songs, …) still need a hand-built `?mediaItem={id}` URL |
|
||||
| `/blocks`, `/blocks/{Id:int}` | `Blocks.razor`, `BlockEditor.razor` | `/app/blocks`(`/{id}`) | allowSubPaths; #144 S1 |
|
||||
| `/templates`, `/templates/{Id:int}` | `Templates.razor`, `TemplateEditor.razor` | `/app/templates`(`/{id}`) | allowSubPaths; #144 S2 |
|
||||
| `/blocks`, `/blocks/{Id:int}` | `Blocks.razor`, `BlockEditor.razor` | `/app/blocks`(`/{id}`) | allowSubPaths; #144 S1; list search/filter (#213) added 2026-07-11 |
|
||||
| `/templates`, `/templates/{Id:int}` | `Templates.razor`, `TemplateEditor.razor` | `/app/templates`(`/{id}`) | allowSubPaths; #144 S2; list search/filter (#213) added 2026-07-11 |
|
||||
| `/decos`, `/decos/{Id:int}` | `Decos.razor`, `DecoEditor.razor` | `/app/decos`(`/{id}`) | allowSubPaths; #144 S3 |
|
||||
| `/deco-templates`, `/deco-templates/{Id:int}` | `DecoTemplates.razor`, `DecoTemplateEditor.razor` | `/app/deco-templates`(`/{id}`) | allowSubPaths; #144 S4 |
|
||||
| `/playouts/add`(`/{kind}`) | `PlayoutEditor.razor` variants | `/app/playouts` | merged into playouts screen creation flow; #144 S5 |
|
||||
@@ -115,7 +115,7 @@ been added to the redirect map yet.
|
||||
| `/media/tv/shows/{ShowId:int}` | `TelevisionSeasonList.razor` | `/app/media/shows/{id}` | show + season list (`ShowDetailScreen`); PR #183 / #141 |
|
||||
| `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=seasons` | seasons browsable as a top-level kind (`MediaBrowseScreen`; also reachable via show drill-in); #209 review fix |
|
||||
| `/media/tv/seasons/{SeasonId:int}` | `TelevisionEpisodeList.razor` | `/app/media/seasons/{id}` | season + episode list (`SeasonDetailScreen`); PR #183 / #141 |
|
||||
| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media/seasons/{id}` | no standalone SPA episode browse; covered via season detail drill-in; PR #183 / #141 |
|
||||
| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media?kind=episodes` | standalone SPA episode browse EXISTS (`MediaBrowseScreen`, generic grid, top-level `episodes` kind); episode cards there and on the Search screen now navigate to the season detail page and anchor/highlight the episode (`/app/media/seasons/{seasonId}#episode-{id}`), matching `Search.razor:241`'s `media/tv/seasons/{SeasonId}#episode-{EpisodeId}` link (`LibraryBrowseItemResponseModel.SeasonId`, `mediaDetailPath`); #220 |
|
||||
| `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 |
|
||||
| `/media/music/artists/{ArtistId:int}` | `Artist.razor` | `/app/media/artists/{id}` | detail page (`ArtistDetailScreen`); PR #183 / #141 |
|
||||
| `/media/music/videos`(`/page/{n}`) | `MusicVideoList.razor` | `/app/media?kind=music-videos` | generic browse; PR #183 / #141 |
|
||||
@@ -185,9 +185,12 @@ maps each Blazor field → SPA control → request key → test.
|
||||
|
||||
### Remaining mutation-depth gaps inside Section 2 rows
|
||||
|
||||
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor),
|
||||
#213 (remaining nits: logs sort + page-size persistence, block-history page-size/gating,
|
||||
blocks/templates list filter — all read-only conveniences).
|
||||
Tracked as #91 phase (b) gates without moving whole rows — #212 DONE 2026-07-11 (channel editor),
|
||||
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
|
||||
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
|
||||
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
|
||||
the Blocks and Templates lists (`fix/213-spa-nits`); logs column sorting + page-size persistence
|
||||
and trash per-kind "see all" paging (`fix/213-logs-trash`).
|
||||
CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar)
|
||||
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
|
||||
select-all, and Trakt-note items of #213 landed in the same PR.
|
||||
@@ -197,6 +200,27 @@ query-wide Add All via `GET /api/search/all-items`, Save As Smart Collection) an
|
||||
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
|
||||
Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
|
||||
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
|
||||
CLOSED 2026-07-10: **#221** (adversarial-reviewer#18 follow-up to #208/#209) — those PRs added
|
||||
mutation actions to two screens whose fetch model keeps the previous result set rendered during a
|
||||
refetch. On Search and Media browse the per-card Add-to menu, Select/select-mode, selection action
|
||||
bar, Add-all, and Save-as-smart-collection are now **gated while a refetch is in flight** (query on
|
||||
Search; kind/query/page on Media browse), with a visible "Refreshing…" cue and dimmed grid; card
|
||||
navigation stays live. `SearchScreen.addAll` also binds its completion to the requesting query so a
|
||||
late `GET /api/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
|
||||
See `docs/spa-conventions.md` §3a for the pattern.
|
||||
|
||||
CLOSED 2026-07-10: **#215** (adversarial-reviewer#18 removal gate) — Blazor's `EntityLocker`
|
||||
build-lock gating of per-playout Reset/Erase/Delete/Edit is now enforced server-side: every
|
||||
id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns **409** while
|
||||
`IsPlayoutLocked(id)`, and the SPA mirrors the lock via an `IsLocked` flag on the playout list
|
||||
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
|
||||
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
|
||||
|
||||
2026-07-11 (#213, remaining scope): logs sort (`GET /api/logs` `sortField`/`sortDirection`,
|
||||
clickable column headers) and page-size persistence (client-local `localStorage`, not a server
|
||||
`ConfigElement`) landed; trash "see all" now pages past the 100/kind cap via
|
||||
`GET /api/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
|
||||
(block-history page-size/gating, blocks/templates list filters) — #213 fully closed.
|
||||
|
||||
## Section 4 — Blazor home / escape hatch
|
||||
|
||||
|
||||
+89
-1
@@ -181,6 +181,42 @@ that per-kind cap); this mirrors the legacy Blazor Trash page, which had the sam
|
||||
behavior and cap. True paging is deferred until the search API grows a page param — not attempted
|
||||
here, since it would mean adding a paging contract server-side, out of scope for this pass.
|
||||
|
||||
**Superseded 2026-07-11 (#213)**: the cap is lifted via a per-kind "See all N …" button, without
|
||||
adding any new API surface. `GET /api/library/browse` (`LibraryBrowseController` /
|
||||
`GetLibraryBrowseItems`) already accepts `mediaType` + `pageNum` + `pageSize` and runs the same
|
||||
underlying query as `GetSearchResults` (which itself fans out to `GetLibraryBrowseItems` per kind,
|
||||
just always at `pageNum=0`) — so `TrashScreen.tsx` pages `pageNum=1, 2, …` through
|
||||
`/api/library/browse?query=state:FileNotFound&mediaType={kind}&pageSize=100` for a kind once the
|
||||
user asks to see past the first 100, and appends the results client-side. The 100/kind **first
|
||||
page** still comes from `/api/search` (unchanged, cheapest for the common case where a kind has
|
||||
few matches); only kinds that exceed the cap ever issue the follow-up `/api/library/browse` calls.
|
||||
|
||||
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
|
||||
|
||||
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
|
||||
`ConfigElementKey.LogsPageSize` (`SaveConfigElementByKey`/`GetConfigElementByKey`), a
|
||||
per-server-instance setting stored in the DB. `LogsScreen.tsx` instead persists it to
|
||||
`window.localStorage` under `ctv-logs-page-size` (same wrapped-`Storage` pattern as
|
||||
`designSystem.ts`'s theme preference: try/catch getter, validated against the known option set,
|
||||
falls back to a default) and restores it on mount. Deliberate deviation: this is a per-browser UI
|
||||
preference, not server/business state — no other client should see or be affected by it, so there
|
||||
is no reason to round-trip it through the API and grow a new `/api/*` surface (or reuse the
|
||||
generic config-element endpoints) just to store a page-size number. Follows the existing SPA
|
||||
localStorage convention (`designSystem.ts` theme, `auth.ts` token) rather than introducing a new
|
||||
persistence mechanism.
|
||||
|
||||
## 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`
|
||||
|
||||
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
|
||||
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
|
||||
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
|
||||
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
|
||||
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
|
||||
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
|
||||
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
|
||||
indicating the active field/direction; clicking the active column toggles direction, clicking the
|
||||
other column switches to it ascending.
|
||||
|
||||
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
|
||||
|
||||
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
|
||||
@@ -246,6 +282,43 @@ Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that l
|
||||
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
|
||||
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
|
||||
|
||||
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
|
||||
|
||||
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
|
||||
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
|
||||
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
|
||||
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
|
||||
Blazor is deleted the invariant would vanish entirely.
|
||||
|
||||
Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
|
||||
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
|
||||
— `PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
|
||||
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
|
||||
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
|
||||
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
|
||||
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
|
||||
is "no mutation during a build", matching Blazor's edit-disable.
|
||||
|
||||
- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
|
||||
buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
|
||||
after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
|
||||
self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
|
||||
mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
|
||||
publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
|
||||
builds, a semantics change out of scope for restoring Blazor parity.
|
||||
|
||||
- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
|
||||
*silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
|
||||
bulk enqueue always accepts.
|
||||
- **SPA mirrors the lock via data, not a push channel** — `PlayoutListItemResponseModel` gains an
|
||||
`IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
|
||||
screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
|
||||
Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
|
||||
picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
|
||||
|
||||
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
|
||||
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
|
||||
|
||||
## 2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
|
||||
|
||||
The ChicoryTV schedules editor (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`,
|
||||
@@ -310,4 +383,19 @@ Also landed with #212: `preferredAudioLanguageCode`/`preferredSubtitleLanguageCo
|
||||
`/api/channels/stream-selectors`. Each keeps the channel's currently-stored value selectable even if
|
||||
it's absent from the reference list (`optionsKeepingCurrent` in `ChannelEditScreen.tsx`) so loading
|
||||
an existing channel never silently changes the value out from under an unmanaged language code or a
|
||||
template/selector file removed from disk since save.
|
||||
template/selector file removed from disk since save.## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
|
||||
|
||||
With multiple sessions/agents working the repo in parallel, the old protocol — every session
|
||||
wholesale-rewrites `docs/handoffs/chicorytv-issue-queue.md` on main (session state + queue +
|
||||
next-session prompt) — became a last-writer-wins race. New protocol: **volatile queue state
|
||||
moved to Gitea**, which is concurrency-safe by construction. Pinned tracker issue **#237**
|
||||
holds the goal + ordered arc in its body (edited rarely, only on arc changes, re-read before
|
||||
edit) and an append-only session-comment log (fixed template: Closed / Filed / Triage /
|
||||
Arc change / Recommended next). Milestone `Blazor removal (#91 phase b)` + the `review` and
|
||||
`in-progress` labels are the machine-queryable view. Sessions **claim** an issue before working
|
||||
it (`in-progress` label + claim comment; the tiny read→claim race window is accepted, later
|
||||
claimant backs off; stale claims — no commits/comments ~48h — may be taken over with a comment).
|
||||
Every new issue gets an explicit end-of-session triage verdict — gate-blocker (milestone + arc
|
||||
slot) or backlog (label only) — so review findings adjust the queue only through that step and
|
||||
the arc doesn't drift. The handoff file keeps only the **static kickoff prompt** and the
|
||||
**append-only Lessons lore** (per-session prompts are gone; task context lives in issue bodies).
|
||||
|
||||
@@ -99,3 +99,66 @@ scripts/e2e-local.sh [CONFIG_DIR]
|
||||
- Waits (up to 120s) for the `Done migrating search index` log line.
|
||||
- Prints the PID and port, then **exits leaving the server running** — the caller is responsible
|
||||
for killing the PID when done (`kill <PID>`).
|
||||
|
||||
## Seeding a local TV library for E2E
|
||||
|
||||
Channels/playouts are API-seedable (step 5 above), but a **local media library is not** — there
|
||||
is no `/api/*` endpoint to add a local library folder. To exercise media-browse / search / detail
|
||||
screens you need real scanned items. Recipe (used to verify the #220 episode-nav PR):
|
||||
|
||||
1. **Generate tiny media files on disk** — one show (one season, ~3 episodes), plus a second show
|
||||
whose title *contains the first as a substring* (good substring-search sanity data), plus a
|
||||
movie if you need a non-episode kind. Keep TV and movies under **separate roots** so each
|
||||
library scans cleanly (a Shows library pointed at a folder that also contains movies will try
|
||||
to parse the movies as shows). Each file is a 2-second `testsrc` clip:
|
||||
```bash
|
||||
MEDIA=/tmp/etv-media # any scratch path
|
||||
mkdir -p "$MEDIA/tv/Show Alpha/Season 01" \
|
||||
"$MEDIA/tv/Show Alpha Returns/Season 01" \
|
||||
"$MEDIA/movies/Test Movie (2020)"
|
||||
for n in 01 02 03; do
|
||||
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
|
||||
"$MEDIA/tv/Show Alpha/Season 01/Show Alpha - s01e$n.mkv"
|
||||
done
|
||||
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
|
||||
"$MEDIA/tv/Show Alpha Returns/Season 01/Show Alpha Returns - s01e01.mkv"
|
||||
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \
|
||||
"$MEDIA/movies/Test Movie (2020)/Test Movie (2020).mkv"
|
||||
```
|
||||
|
||||
2. **Attach the folders to the built-in local libraries via SQLite.** A fresh config DB already
|
||||
has the seven default local libraries (`Library` rows for a single `LocalMediaSource`): `Movies`
|
||||
is `Id=1`, `Shows` is `Id=2`. `LibraryPath` is just `(Path TEXT, LibraryId INT)` — insert one
|
||||
row per root, pointing each at the matching library:
|
||||
```bash
|
||||
DB="$CONFIG_DIR/ersatztv.sqlite3" # CONFIG_DIR from the run above; server may be running
|
||||
sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/tv', 2);" # Shows
|
||||
sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/movies', 1);" # Movies
|
||||
```
|
||||
|
||||
3. **Trigger a scan and wait for items to appear.** The scan endpoint takes an empty body:
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8409/api/libraries/2/scan -H 'Content-Type: application/json' -d '{}'
|
||||
curl -s -X POST http://localhost:8409/api/libraries/1/scan -H 'Content-Type: application/json' -d '{}'
|
||||
# poll until episodes show up (scanner runs as a background subprocess):
|
||||
curl -s "http://localhost:8409/api/library/browse?mediaType=Episode&pageSize=50"
|
||||
```
|
||||
The scan runs even though `LibraryPath` was inserted after startup — the scan handler re-reads
|
||||
the library from the DB. Browse (`/api/library/browse`) reads straight from the DB, so items
|
||||
appear there within a few seconds.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- **Do NOT delete the `search-index/` folder to "reset" search.** On startup the app *recreates the
|
||||
index empty* (`Search index failed to initialize; will delete and recreate` → `Migrating search
|
||||
index to version N`) and that migration does **not** re-index from the DB — only a **scan**
|
||||
writes documents into the Lucene index. The scanner subprocess writes the index while running; a
|
||||
restart never rebuilds it from existing DB rows. If you wipe `search-index/`, a *rescan of
|
||||
unchanged files won't repopulate it* (the scanner skips unchanged items), so search stays empty.
|
||||
The clean recovery is a fresh `CONFIG_DIR`: launch → insert `LibraryPath` → scan **once** → leave
|
||||
the index alone.
|
||||
- **Search query relevance is field-scoped, not free-text.** The `/api/search` default field does
|
||||
**not** match bare title words: `Alpha` and `Show` return nothing for a "Show Alpha" title, while
|
||||
`title:Alpha`, `Show*`, or `*Alpha*` all match. The SPA search box forwards the query verbatim, so
|
||||
when driving search-result screens in E2E use a field/wildcard query (e.g. `title:Alpha`) to get
|
||||
deterministic hits. (This is pre-existing ErsatzTV search behavior, independent of any SPA change.)
|
||||
|
||||
@@ -1,59 +1,55 @@
|
||||
# ChicoryTV issue-queue handoff (living document)
|
||||
# ChicoryTV issue-queue handoff (static kickoff + workflow lore)
|
||||
|
||||
Paste the prompt below into a fresh session to work the next item. Each session ends by
|
||||
UPDATING THIS FILE in place (rewrite the state section and the queue for the next item) so it
|
||||
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic);
|
||||
all backend gap issues (#100–#111), all SPA screens (#84–#89, #93, #109), the rebrand (#90),
|
||||
the cutover root-flip (#91 phase a), full scheduling parity (#144/#162), media/troubleshooting
|
||||
parity (#141/#161/#158/#180), onboarding docs (#185), the collections/API-gap batch
|
||||
(#155/#151/#152-backend/#184), the multi/rerun/playlist SPA editors (#151/#152 PR #194, #153
|
||||
PR #195), CI speedups (#190 PR #192), #145 playback troubleshooting (PR #199), #198 OpenAPI
|
||||
casing (PR #201) + #193 rerun existence-check (PR #200), the first #91-gate batch
|
||||
#210/#211/#213-partial (PR #214), and the search/media mutation batch #208/#209 (PR #216, shared
|
||||
Add-to layer) are MERGED. **v26.5.0 in prod; v26.6.0 tagged** (awaiting Komodo pin bump in
|
||||
server-management).
|
||||
> **PROTOCOL CHANGED 2026-07-11** (decisions.md entry of same date). Queue state lives in the
|
||||
> **pinned Gitea tracker [ersatztv#237](http://192.168.1.95:3000/timothy/ersatztv/issues/237)**,
|
||||
> not in this file. Do **NOT** write session state, queue order, or next-session prompts here —
|
||||
> sessions ending under the old protocol should append their session comment to #237 instead.
|
||||
> This file holds only the two stable things: the standing kickoff prompt and the workflow lore.
|
||||
> Historical per-session state: `git log` of this file (last state-bearing revision: 8b77d5e7).
|
||||
|
||||
**Session state (2026-07-10, #208/#209 session)**: **#208 + #209 CLOSED via PR #216** (merged
|
||||
c63cadb8). New API surface: `POST /api/playlists/{id}/items` (wraps existing `AddItemsToPlaylist`;
|
||||
handler hardened to reject system playlists — latent Blazor-path gap) and `GET
|
||||
/api/search/all-items` (wraps `QuerySearchIndexAllItems`; Add-All = materialize-then-add, Blazor
|
||||
two-step parity — no query-based add command exists). SPA: shared Add-to layer
|
||||
`web/src/media/addTo/` (Collection dialog w/ inline create, Playlist dialog filtering `isSystem`,
|
||||
Schedule dialog replicating `AddProgramScheduleItem.ForMediaItem` defaults —
|
||||
`addTo/scheduleItem.ts`, SaveAsSmartCollectionDialog, AddToMenu popover) + `MediaPosterCard`
|
||||
`actions` slot; wired into search (card drill-in, per-card add, Select toggle + selection bar,
|
||||
Add All, Save As Smart Collection) and browse/detail (multi-select + select-all-on-page, all four
|
||||
detail screens, per-show Quick/Deep scan gated Plex/Jellyfin/Emby, per-episode Media Info +
|
||||
Troubleshoot, seasons browse kind). **Add-to-schedule is gated to shows/seasons/artists** — the
|
||||
server validator (`ProgramScheduleItemCommandBase.CollectionTypeMustBeValid`) only accepts those
|
||||
per-media-item kinds, matching Blazor's ForMediaItem call sites (found by live E2E: movie adds
|
||||
422'd). Also fixed: portal font bug (nothing set a `body` font; portaled dialogs fell back to
|
||||
Times — `body` rule added), a mount-debounce race (the 300ms query-debounce commit wiped
|
||||
in-progress selections; `lastQueryRef` no-change guard in Search+Browse screens), and the
|
||||
e2e-local.sh stale-asset gotcha (`cp -R` into an existing dir nests + serves the previous run's
|
||||
assets — script now rm-first). New issues: **#217** (add-items handlers validate only 4/10 kinds
|
||||
— pre-existing), **#218** (fresh-DB "Playouts 3" badge + "1 failing" chip). Retro/pre-review
|
||||
issues filed in adversarial-reviewer: #18 (this milestone + #214), #19 (upcoming #207/#212, #202,
|
||||
#91b, #197). #213 remains OPEN (reduced read-only-convenience scope).
|
||||
---
|
||||
|
||||
**Blazor removal (#91 phase b) remaining MUST-FIX gates, in order:**
|
||||
1. **#207 (+#212)** — schedules editor full mutation depth (endpoints exist; needs languages
|
||||
enumeration endpoint shared with #212 channel-editor pickers) ← NEXT (prompt below)
|
||||
2. **#202** — media-source management (write REST API + SPA screens; largest, riskiest)
|
||||
3. **#91 phase (b) removal PR** — pattern redirects + catch-all (#204 plan), blazor-final tag
|
||||
(#205), auth posture note (#206), delete Pages/Shared/ViewModels/Validators + 6 packages
|
||||
(MudBlazor, MediatR.Courier.DependencyInjection, Blazored.FluentValidation, BlazorSortable,
|
||||
Heron.MudCalendar, Chronic.Core). Removal recon is DONE (2026-07-09 gate session): Courier
|
||||
consumers are 100% Blazor (`AddCourier` Startup.cs:394 dies too); the Startup `MapWhen`
|
||||
"blazor" branch (736–795) CO-HOSTS MapControllers/OpenAPI/Scalar — surgical removal only
|
||||
(MapBlazorHub:775, MapFallbackToPage:776, services 379–398); `wwwroot/lib/*`+`css/site.css`
|
||||
are `_Host`-only; check `Locals/*.resx` consumers before deleting; keep `Extensions/`
|
||||
except `NavigationManagerExtensions.cs`.
|
||||
# STANDING KICKOFF PROMPT (paste into a fresh session, unchanged every time)
|
||||
|
||||
You are Fable, the ORCHESTRATOR in the main Claude Code session. Fable is EXPENSIVE: delegate
|
||||
(recon → Explore/haiku; mechanical → sonnet; judgment-heavy → opus; fable forks for review).
|
||||
FIRST read CLAUDE.md, docs/README.md + the convention docs it indexes, and the Lessons below.
|
||||
|
||||
Then work the queue:
|
||||
1. Read the pinned tracker **ersatztv#237** — body = goal + ordered arc + session protocol —
|
||||
and its most recent session comments; list open issues in the `Blazor removal (#91 phase b)`
|
||||
milestone and with the `review` label.
|
||||
2. Pick the top arc item that is open and NOT labeled `in-progress` (or the item the user names).
|
||||
3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s).
|
||||
4. Read the issue bodies (they carry the task context/evidence) and work the item under the
|
||||
HARD CONSTRAINTS below.
|
||||
5. Finish by following the session-end protocol in #237: ONE session comment on the tracker
|
||||
(template in the tracker body, incl. triage verdicts for any new issues), remove your
|
||||
`in-progress` labels, and complete the per-issue Task Completion Protocol from CLAUDE.md.
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Work in worktrees off origin/main. Copy web/node_modules from the main checkout.
|
||||
- PARALLELIZE BY DEFAULT: split the task into disjoint slices up front and run 3–4 implementer
|
||||
agents concurrently (recon agents are free — always fan those out). 3–4 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 8–9-way fan-out). NEVER set ETV_UPDATE_GOLDENS.
|
||||
- Never two committing agents on ONE worktree — give each parallel slice its own worktree
|
||||
branched off the feature branch and merge back. Sequence only where a slice genuinely depends
|
||||
on another's output (backend-first narrow, SPA-wide after worked well for #216).
|
||||
- Merge consent in-conversation per session (prior pre-approvals do NOT carry over).
|
||||
- Arm a CI monitor on the PR head sha AT PR-OPEN (commit-status endpoint), not at the end.
|
||||
- Live-E2E via scripts/e2e-local.sh; NEVER exercise download endpoints via browser tabs (curl
|
||||
them). Adversarial review fork per PR diff, SCOPED "review only".
|
||||
|
||||
---
|
||||
|
||||
# Lessons / workflow lore (append-only; conventions live in docs/, this is workflow lore)
|
||||
|
||||
**Lessons for all remaining prompts** (conventions live in docs/; this is workflow lore):
|
||||
- READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
|
||||
doc sections. Only recon the task-specific delta.
|
||||
- **blazor-route-parity.md now carries mutation-depth verdicts** (2026-07-09 sweep table at the
|
||||
- **blazor-route-parity.md carries mutation-depth verdicts** (2026-07-09 sweep table at the
|
||||
top of Section 2). A row is only trustworthy if its cluster verdict is PARITY-OK; the sweep
|
||||
evidence lives in the issues #207–#213. Keep the verdict table updated as gates close.
|
||||
- "Screen exists" ≠ parity: the root cause of the false SPA-READY rows was same-session
|
||||
@@ -95,65 +91,22 @@ issues filed in adversarial-reviewer: #18 (this milestone + #214), #19 (upcoming
|
||||
- Live E2E seeding: the local library isn't API-seedable; the #216 E2E agent generated tiny
|
||||
ffmpeg testsrc MKVs + inserted LibraryPath rows via SQL then scanned. Recipe not yet in
|
||||
docs/e2e-local.md — worth adding next time it's needed.
|
||||
|
||||
---
|
||||
|
||||
# PROMPT — #207 + #212: schedules editor mutation depth + channel-editor gaps
|
||||
|
||||
You are Fable, the ORCHESTRATOR in the main Claude Code session. Fable is EXPENSIVE: delegate
|
||||
(recon → Explore/haiku; mechanical → sonnet; judgment-heavy → opus; fable forks for review).
|
||||
FIRST read CLAUDE.md, docs/README.md + the convention docs it indexes, and the Lessons above.
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Work in worktrees off origin/main. Copy web/node_modules from the main checkout.
|
||||
- PARALLELIZE BY DEFAULT: split the task into disjoint slices up front and run 3–4 implementer
|
||||
agents concurrently (recon agents are free — always fan those out). 3–4 concurrent
|
||||
dotnet/npm builds are fine on this Mac (M4, 10 cores, 16 GB); check `uptime` before a big
|
||||
fan-out — if 1-min load > ~6, drop to 2–3. Never 5+ builds (the historic crash was an
|
||||
8–9-way fan-out). NEVER set ETV_UPDATE_GOLDENS.
|
||||
- Never two committing agents on ONE worktree — give each parallel slice its own worktree
|
||||
branched off the feature branch and merge back. Sequence only where a slice genuinely depends
|
||||
on another's output (backend-first narrow, SPA-wide after worked well for #216).
|
||||
- Merge consent in-conversation per session (prior pre-approvals do NOT carry over).
|
||||
- Arm a CI monitor on the PR head sha AT PR-OPEN (commit-status endpoint), not at the end.
|
||||
- Live-E2E via scripts/e2e-local.sh; NEVER exercise download endpoints via browser tabs (curl
|
||||
them). Adversarial review fork per PR diff, SCOPED "review only".
|
||||
|
||||
## Task — close the #207 + #212 gates
|
||||
Read issues #207 and #212 (bodies carry the #203-sweep evidence) and adversarial-reviewer #19
|
||||
(pre-review asks). #207 is the screen the false-parity review made an example of: the SPA
|
||||
schedules editor is a near read-only viewer — schedule create/edit/delete missing, every
|
||||
per-item inspector control hard-coded disabled (~35 Blazor fields), add-item is defaults-only —
|
||||
while the REST endpoints already exist and are unused (`PUT /api/schedules/{id}/items` replace,
|
||||
`POST .../items` single-add with the FULL ScheduleItemRequest — see `addTo/scheduleItem.ts` for
|
||||
a working payload builder). Enumerate EVERY Blazor `ScheduleItemsEditor.razor` field and wire it
|
||||
(per-capability diff is the parity standard). #212 channel editor gaps: external logo URL, bare
|
||||
create, and pickers — needs a languages enumeration endpoint shared by both editors (preferred
|
||||
audio/subtitle language pickers); design that endpoint once. Record conventions in
|
||||
decisions.md; full PR routine (OpenAPI regen for any new endpoint; blazor-route-parity verdict
|
||||
rows for the schedules + channel-editor clusters). Comment + close #207/#212 per protocol.
|
||||
|
||||
## On completion — REQUIRED last step
|
||||
Update THIS handoff (pop the done items, promote #202 to next with a fresh prompt), commit to
|
||||
main, print the next prompt in a fenced code block.
|
||||
|
||||
---
|
||||
|
||||
## Issue queue (work top-down)
|
||||
1. **#207 (+#212)** schedules editor depth + channel-editor gaps ← PROMPT above.
|
||||
2. **#202** media-source management (write API + SPA) — largest; pre-review the API design
|
||||
(adversarial-reviewer #19) before building.
|
||||
3. **#91 phase (b)** Blazor removal PR (gate cleared once 1–2 close; #204/#205/#206 fold in;
|
||||
removal recon in session state of the 2026-07-09 entry; adversarial pass mandatory).
|
||||
4. **#197** cold API contract+security review — HARD GATE on #58 close/go-live and MANDATORY
|
||||
before any remote exposure. Inputs: #215, #217, backlog nits.
|
||||
5. Backlog: #215 (API playout mutations skip EntityLocker gating), #217 (add-items validates
|
||||
only 4/10 kinds), #218 (fresh-DB Playouts badge / "1 failing" chip), #213 remainder (logs
|
||||
sort/page-size persistence, block-history page-size/gating, list filters), #99 (TS/HLS-Direct
|
||||
session tracking), #66 (artwork magic-byte sniffing), CI: unpin MySQL service host port 3306
|
||||
(concurrent-run collision), nits (unclamped pageSize, 30 MB bare 413, PlayoutController route
|
||||
Name=/lightweight exists-check, guide 21-include eager-load + fillerKind notes — #85/#102
|
||||
comments), review nits from #216 (last-used-collection memory, detailBrowseItem cast).
|
||||
Cross-refs: v26.6.0 deploy = Komodo pin bump (server-management). Real-transcode E2E of the
|
||||
playback screen: once on the test container (`ersatztv-test` runs `:latest`) — local ffmpeg
|
||||
8.1.2 lacks subtitles/zscale filters.
|
||||
- **Parallel sessions (2026-07-11 protocol)**: claim before working (`in-progress` label — the
|
||||
tiny read→claim race window is accepted; later claimant backs off). Claiming prevents
|
||||
duplicate pickup, NOT overlapping code changes — check the tracker's dependency notes
|
||||
("#234 after #231", "coordinate with #215") before touching shared surfaces. Lessons edits
|
||||
to THIS file: append bullets only, `git pull --rebase` before commit.
|
||||
- **Fan-out health = RAM, not CPU load** (2026-07-11, user calibration): the 8–9-way crash was
|
||||
RAM starvation. `uptime` load of 10–24 during parallel Roslyn/vitest bursts is benign with
|
||||
memory healthy. Watchdog pattern: background monitor emitting only when `memory_pressure -Q`
|
||||
free % < 20 (silence = healthy); pause/stagger agents below 10%.
|
||||
- **CI MySQL host-port collision FIXED on main** (`ef8915f1`, issue #236): the migrations
|
||||
service no longer publishes host 3306. Branches created before that commit still collide —
|
||||
merge main in. There are now TWO runners (ci-runner VM 127 + bumblebee-runner), 4 slots:
|
||||
faster drains, and superseded-run results are ignorable. Neither the cancel-run API route
|
||||
nor the web cancel route exists on this Gitea version — stale runs just drain.
|
||||
- **Two sessions touching one machine**: a branch may be checked out in ANOTHER session's
|
||||
worktree — never commit/merge inside a worktree you didn't create. To land a merge on such a
|
||||
branch without touching their checkout: plumbing merge (`git read-tree -m base ours theirs`
|
||||
into a temp GIT_INDEX_FILE → `write-tree` → `commit-tree -p ours -p theirs` → push the
|
||||
commit to the branch ref); the owning session then `git pull`s.
|
||||
|
||||
@@ -60,6 +60,47 @@ fetches from the API:
|
||||
`eslint-plugin-react-hooks` in `web/eslint.config.js` — a synchronous `setState` in an effect body
|
||||
will fail `npm run lint`.
|
||||
|
||||
## 3a. "Keep results visible during refetch" ⇒ gate mutations + show a refreshing cue
|
||||
|
||||
Some grid screens deliberately keep the **previous** successful result set rendered while a refetch
|
||||
is in flight (no full-screen loading state on a query/kind/page change), so the grid doesn't flash
|
||||
empty. `SearchScreen.tsx` and `MediaBrowseScreen.tsx` do this. If such a screen also carries
|
||||
**mutation surfaces** (per-card Add-to menu, Select/select-mode, a selection action bar, "Add all",
|
||||
"Save as smart collection"), those surfaces would otherwise stay live over a **stale** result set —
|
||||
an add/select action then targets the about-to-be-replaced items, or (worse) a query-wide "Add all"
|
||||
bulk request resolves against the previous query. This was issue #221 (adversarial-reviewer#18).
|
||||
|
||||
Convention — when a screen keeps stale results visible during a refetch:
|
||||
|
||||
- **Key the success state to the request params that produced it.** Store the identifying params on
|
||||
the `status: 'success'` variant (`SearchScreen`: the `query`; `MediaBrowseScreen`: a
|
||||
`kind|query|page` `key`), set in the seq-guarded `.then`. Derive
|
||||
`const refreshing = state.status === 'success' && state.<key> !== <current params>;` in render.
|
||||
Prefer this over a synchronously-set `refreshing` flag: setting state synchronously from the load
|
||||
path trips the `react-hooks` "no set-state-in-effect" rule (§3).
|
||||
**Invariant, not a guarantee**: the derivation is only self-correcting when *every* value the
|
||||
current params can take will actually trigger a fetch. If `load()` early-returns for some param
|
||||
value (e.g. `SearchScreen`'s blank-query guard), `state` never updates for that value and a stale
|
||||
`status: 'success'` variant lingers — so the comparison must exclude params that suppress
|
||||
fetching, or gate the whole flag on the same condition that gates the fetch (`SearchScreen`:
|
||||
`const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();` —
|
||||
fixed post-review in #222 after the naive derivation got stuck `true` once the query was cleared
|
||||
to empty, see PR discussion for #221).
|
||||
- **While `refreshing`:** show a visible cue (a `role="status"` "Refreshing…" row with `<Spinner>`
|
||||
plus the `.ctv-media-grid-dim` opacity class on the grid) and **disable every mutation surface** —
|
||||
per-card Add-to menu (withhold the `actions` node), select toggle + in-grid selection
|
||||
(`const canSelect = selectMode && !refreshing;` gates `onToggleSelect`), the selection action bar,
|
||||
"Add all", "Save as smart collection". Card navigation (`onOpen`) **may** stay live — but only
|
||||
outside select mode: while `selectMode && refreshing`, `MediaPosterCard` falls back to `onOpen`
|
||||
whenever `onToggleSelect` is undefined, so both props must be withheld together or a mid-select
|
||||
click navigates away instead of no-op'ing. The select-mode toggle itself should only be disabled
|
||||
while refreshing when *entering* select mode (`refreshing && !selectMode`) — exiting only clears
|
||||
selection, not a mutation, so it must stay available.
|
||||
- **Bind async bulk completions to their request params, not just mount.** A whole-query/whole-set
|
||||
request (e.g. `getSearchAllItems`) must, on resolve, check that its snapshotted params are still
|
||||
current (compare against a ref that always holds the committed value — `SearchScreen` reuses
|
||||
`lastQueryRef`) and **discard** otherwise. Checking only `activeRef` (mounted) is insufficient.
|
||||
|
||||
## 4. API client modules
|
||||
|
||||
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
|
||||
@@ -118,6 +159,20 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
|
||||
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
|
||||
and the accepted deviations from Blazor).
|
||||
|
||||
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
|
||||
|
||||
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
|
||||
`window.localStorage` under a namespaced `ctv-` key, **not** a round-trip through the API — the
|
||||
established pattern is `designSystem.ts`'s `getStoredDesignSystemTheme`/`applyDesignSystemTheme`
|
||||
(`ctv-theme`): a small `getStorage()` helper that returns `window.localStorage` wrapped in a
|
||||
try/catch (so a disabled/unavailable storage API degrades to the default instead of throwing), a
|
||||
getter that validates the stored value against the known option set before trusting it, and a
|
||||
setter that writes straight through. `LogsScreen.tsx`'s page-size persistence (`ctv-logs-page-size`,
|
||||
#213) follows the same shape. Reserve this for state that's genuinely local to the browser/user
|
||||
session — if a preference needs to be shared across devices or is really server/business state
|
||||
(e.g. Blazor's `ConfigElement`-backed settings), it belongs behind an API endpoint instead; see
|
||||
`docs/decisions.md` 2026-07-11 for the specific reasoning on logs page-size.
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
|
||||
@@ -164,3 +219,15 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
|
||||
|
||||
Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
|
||||
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.
|
||||
## 9. Review checklist — temporal semantics
|
||||
|
||||
- **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change,
|
||||
unmount, StrictMode double-invoke) and *which* render/request does it still own?** A debounce timer
|
||||
fires on mount too (§3, the `lastQueryRef` no-change guard exists precisely for that); a `.then`
|
||||
can resolve after the params it was launched for have moved on (§3a, the `refreshing` gate and the
|
||||
Add-all query binding exist for that). A guard that only checks "still mounted" (`activeRef`) does
|
||||
not answer "still current".
|
||||
- **For any "make X consistent with Y" change, re-validate the exemplar Y's temporal behavior before
|
||||
copying it.** #221 came from copying a fetch model that keeps stale results visible onto screens
|
||||
that had gained mutation surfaces — the exemplar was safe read-only, the copy was not. Copying a
|
||||
pattern copies its *assumptions*; confirm they still hold in the new context.
|
||||
|
||||
@@ -1844,6 +1844,25 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables mutation buttons and shows a Building cue for a locked (building) playout', async () => {
|
||||
mockDashboardApi({
|
||||
playoutItems: [playoutItem()],
|
||||
playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
|
||||
playouts: { page: [listPlayout({ id: 20, isLocked: true, scheduleKind: 'Block' })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Building…')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Erase items' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Alternate schedules for an on-demand Classic playout', async () => {
|
||||
mockDashboardApi({
|
||||
playoutItems: [playoutItem()],
|
||||
@@ -3360,6 +3379,7 @@ function listPlayout(overrides: Record<string, unknown> = {}): Record<string, un
|
||||
channelNumber: '5.1',
|
||||
dailyRebuildTime: '04:00:00',
|
||||
id: 20,
|
||||
isLocked: false,
|
||||
scheduleKind: 'Classic',
|
||||
scheduleName: 'Prime Time Cartoons',
|
||||
...overrides
|
||||
|
||||
+18
-6
@@ -131,6 +131,7 @@ import {
|
||||
deleteChannel,
|
||||
getDecos,
|
||||
getFfmpegSettings,
|
||||
ApiError,
|
||||
messageFromError,
|
||||
getSchedules,
|
||||
resetAllPlayouts,
|
||||
@@ -352,7 +353,6 @@ const routes: ScreenRoute[] = [
|
||||
icon: <ListVideo aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Reset All',
|
||||
placeholder: 'Playouts workspace',
|
||||
badge: 3,
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
@@ -2542,6 +2542,11 @@ function PlayoutsScreen() {
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setMutationError(messageFromError(error));
|
||||
// A 409 means a build lock is now held for this playout; refresh so the row
|
||||
// picks up its IsLocked state and the mutation buttons disable themselves.
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
query.refresh();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setMutatingState(false);
|
||||
@@ -2643,6 +2648,9 @@ function PlayoutsScreen() {
|
||||
}
|
||||
|
||||
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
|
||||
// The playout's build lock is held (a build is in flight). Server rejects destructive
|
||||
// mutations with 409 while locked; mirror that by disabling the buttons here.
|
||||
const selectedLocked = selectedSummary.isLocked;
|
||||
const nowPlaying = selectedState?.nowPlaying ?? null;
|
||||
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
|
||||
const nextItem = nextPlayoutItem(items, nowItem);
|
||||
@@ -2707,7 +2715,7 @@ function PlayoutsScreen() {
|
||||
<div className="ctv-playouts-title">
|
||||
<ChannelLogo name={selectedSummary.channelName} size={38} />
|
||||
<div>
|
||||
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>}</span>
|
||||
<span><code>{selectedSummary.channelNumber}</code> {selectedState?.onAir && <Badge tone="accent" dot>On air</Badge>} {selectedLocked && <Badge tone="warn" dot>Building…</Badge>}</span>
|
||||
<h2>{selectedSummary.channelName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2795,20 +2803,22 @@ function PlayoutsScreen() {
|
||||
)}
|
||||
<div className="ctv-playout-detail-actions">
|
||||
<Button
|
||||
disabled={mutating}
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={resetSelectedChannel}
|
||||
size="sm"
|
||||
startIcon={<RefreshCw aria-hidden="true" size={13} />}
|
||||
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||
variant="secondary"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{selectedSummary.scheduleKind === 'Block' && (
|
||||
<Button
|
||||
disabled={mutating}
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={eraseSelectedItems}
|
||||
size="sm"
|
||||
startIcon={<Eraser aria-hidden="true" size={13} />}
|
||||
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
Erase items
|
||||
@@ -2819,20 +2829,22 @@ function PlayoutsScreen() {
|
||||
selectedSummary.scheduleKind === 'Sequential' ||
|
||||
selectedSummary.scheduleKind === 'Scripted') && (
|
||||
<Button
|
||||
disabled={mutating}
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={eraseSelectedItemsAndHistory}
|
||||
size="sm"
|
||||
startIcon={<Eraser aria-hidden="true" size={13} />}
|
||||
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
Erase items and history
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={mutating}
|
||||
disabled={mutating || selectedLocked}
|
||||
onClick={deleteSelectedPlayout}
|
||||
size="sm"
|
||||
startIcon={<Trash2 aria-hidden="true" size={13} />}
|
||||
title={selectedLocked ? 'A build is in progress for this playout' : undefined}
|
||||
variant="danger"
|
||||
>
|
||||
Delete
|
||||
|
||||
Vendored
+3
-1
@@ -743,6 +743,7 @@ export interface components {
|
||||
"mediaItemId": null | number;
|
||||
"playlistId": null | number;
|
||||
"subtitle"?: null | string;
|
||||
"seasonId"?: null | number;
|
||||
};
|
||||
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream";
|
||||
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
|
||||
@@ -1034,6 +1035,7 @@ export interface components {
|
||||
"dailyRebuildTime": null | string;
|
||||
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"isLocked": boolean;
|
||||
};
|
||||
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
|
||||
"PlayoutResponseModel": {
|
||||
@@ -1185,7 +1187,7 @@ export interface components {
|
||||
"libraryRefreshInterval": number;
|
||||
};
|
||||
"ScanShowRequest": {
|
||||
"showTitle": null | string;
|
||||
"showId": number;
|
||||
"deepScan"?: boolean;
|
||||
};
|
||||
"ScheduleItemRequest": {
|
||||
|
||||
@@ -22,19 +22,19 @@ describe('libraries api client', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
it('scanShow POSTs the show title and deepScan flag', async () => {
|
||||
it('scanShow POSTs the show id and deepScan flag', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await scanShow(4, { deepScan: true, showTitle: 'The Office' });
|
||||
await scanShow(4, { deepScan: true, showId: 42 });
|
||||
const { init, url } = lastCall(fetchMock);
|
||||
expect(url).toBe('/api/libraries/4/scan-show');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showTitle: 'The Office' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: true, showId: 42 });
|
||||
});
|
||||
|
||||
it('scanShow defaults deepScan to false when omitted', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await scanShow(9, { showTitle: 'Firefly' });
|
||||
await scanShow(9, { showId: 17 });
|
||||
const { init } = lastCall(fetchMock);
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showTitle: 'Firefly' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showId: 17 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,16 +52,16 @@ export function scanLibrary(libraryId: number): Promise<void> {
|
||||
}
|
||||
|
||||
export interface ScanShowParams {
|
||||
showTitle: string;
|
||||
showId: number;
|
||||
deepScan?: boolean;
|
||||
}
|
||||
|
||||
// Queues a scan of a single show (by title) within a library. Returns 200 on success, 400 when
|
||||
// the title can't be resolved / the library doesn't support single-show scanning. Body keys are
|
||||
// `showTitle` and `deepScan` (see LibrariesController.ScanShowRequest).
|
||||
// Queues a scan of a single show (by id) within a library. Returns 200 on success, 404 when the
|
||||
// show id doesn't exist in the library, 400 when the library doesn't support single-show
|
||||
// scanning. Body keys are `showId` and `deepScan` (see LibrariesController.ScanShowRequest).
|
||||
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
|
||||
return request<void>(`/api/libraries/${libraryId}/scan-show`, {
|
||||
body: { deepScan: params.deepScan ?? false, showTitle: params.showTitle },
|
||||
body: { deepScan: params.deepScan ?? false, showId: params.showId },
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,6 +44,20 @@ describe('getLogs', () => {
|
||||
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50');
|
||||
});
|
||||
|
||||
it('builds the query string from sortField and sortDirection', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(samplePage), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await getLogs({ sortDirection: 'asc', sortField: 'level' });
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/logs?sortField=level&sortDirection=asc');
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on failure', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
|
||||
|
||||
@@ -4,10 +4,15 @@ import type { components } from './generated/v1';
|
||||
export type LogEntry = components['schemas']['LogEntryResponseModel'];
|
||||
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
|
||||
|
||||
export type LogsSortField = 'timestamp' | 'level';
|
||||
export type LogsSortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface GetLogsParams {
|
||||
filter?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
sortDirection?: LogsSortDirection;
|
||||
sortField?: LogsSortField;
|
||||
}
|
||||
|
||||
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
||||
@@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
||||
searchParams.set('pageSize', String(params.pageSize));
|
||||
}
|
||||
|
||||
if (params.sortField) {
|
||||
searchParams.set('sortField', params.sortField);
|
||||
}
|
||||
|
||||
if (params.sortDirection) {
|
||||
searchParams.set('sortDirection', params.sortDirection);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
|
||||
|
||||
@@ -13,7 +13,9 @@ export function MediaPosterCard({
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
height = 150,
|
||||
actions
|
||||
actions,
|
||||
id,
|
||||
highlighted
|
||||
}: {
|
||||
item: LibraryBrowseItem;
|
||||
selected?: boolean;
|
||||
@@ -21,6 +23,13 @@ export function MediaPosterCard({
|
||||
onOpen?: (item: LibraryBrowseItem) => void;
|
||||
height?: number;
|
||||
actions?: ReactNode;
|
||||
// Stable DOM id (e.g. `episode-{id}`) so callers can deep-link/scroll to this card.
|
||||
id?: string;
|
||||
// Applies a ring highlight while this card is the deep-link target (e.g. the current
|
||||
// `#episode-{id}` hash) — persists for as long as the anchor matches this card, with only the
|
||||
// ring's outer glow pulse fading shortly after mount (see .ctv-media-card-highlighted in
|
||||
// shell.css).
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const hue = hueOf(item.title);
|
||||
const Icon = TYPE_ICON[item.mediaType] ?? Film;
|
||||
@@ -45,7 +54,8 @@ export function MediaPosterCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ctv-media-card${selected ? ' ctv-media-card-selected' : ''}${interactive ? ' ctv-press' : ''}`}
|
||||
className={`ctv-media-card${selected ? ' ctv-media-card-selected' : ''}${interactive ? ' ctv-press' : ''}${highlighted ? ' ctv-media-card-highlighted' : ''}`}
|
||||
id={id}
|
||||
onClick={interactive ? activate : undefined}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,8 @@ export function mediaDetailPath(item: LibraryBrowseItem): string | null {
|
||||
return `/app/media/seasons/${item.id}`;
|
||||
case 'Artist':
|
||||
return `/app/media/artists/${item.id}`;
|
||||
case 'Episode':
|
||||
return item.seasonId != null ? `/app/media/seasons/${item.seasonId}#episode-${item.id}` : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ const playoutsPage = {
|
||||
};
|
||||
|
||||
const blocks = [
|
||||
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' }
|
||||
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' },
|
||||
{ id: -1, groupId: 1, groupName: 'Morning', name: '(none)', minutes: 0, stopScheduling: 'AfterDurationEnd' }
|
||||
];
|
||||
|
||||
const historyPage = {
|
||||
@@ -87,4 +88,36 @@ describe('BlockPlayoutTroubleshootingScreen', () => {
|
||||
expect(await screen.findByText('Cartoons Collection')).toBeTruthy();
|
||||
expect(screen.getByText('S1E1 - Pilot')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the History action for a block with a negative (unpersisted) id', async () => {
|
||||
mockApi();
|
||||
render(<BlockPlayoutTroubleshootingScreen />);
|
||||
|
||||
await screen.findByRole('option', { name: '1 - Cartoons' });
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
|
||||
|
||||
await screen.findByText('Toons');
|
||||
expect(await screen.findByText('(none)')).toBeTruthy();
|
||||
|
||||
// Only the persisted block (id 10) gets a History button.
|
||||
expect(screen.getAllByRole('button', { name: 'History' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restores the page size from localStorage on mount and persists a change', async () => {
|
||||
window.localStorage.setItem('ctv-block-history-page-size', '50');
|
||||
mockApi();
|
||||
render(<BlockPlayoutTroubleshootingScreen />);
|
||||
|
||||
await screen.findByRole('option', { name: '1 - Cartoons' });
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
|
||||
await screen.findByText('Toons');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'History' }));
|
||||
|
||||
await screen.findByText('{"BlockId":10}');
|
||||
const pageSizeSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
|
||||
expect(pageSizeSelect.value).toBe('50');
|
||||
|
||||
fireEvent.change(pageSizeSelect, { target: { value: '25' } });
|
||||
expect(window.localStorage.getItem('ctv-block-history-page-size')).toBe('25');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,31 @@ import {
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ['10', '25', '50', '100'];
|
||||
|
||||
// Mirrors Blazor's TroubleshootingBlockPlayoutHistoryPageSize ConfigElement — persisted
|
||||
// client-side here since this screen has no server-side config surface. Namespaced with the
|
||||
// same `ctv-` prefix as other SPA-persisted preferences (e.g. `ctv-theme`).
|
||||
const PAGE_SIZE_STORAGE_KEY = 'ctv-block-history-page-size';
|
||||
|
||||
function readStoredPageSize(): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PAGE_SIZE_STORAGE_KEY);
|
||||
if (raw != null && PAGE_SIZE_OPTIONS.includes(raw)) {
|
||||
return Number(raw);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. private browsing) - fall back to the default.
|
||||
}
|
||||
return 10;
|
||||
}
|
||||
|
||||
function writeStoredPageSize(pageSize: number): void {
|
||||
try {
|
||||
window.localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(pageSize));
|
||||
} catch {
|
||||
// ignore - persistence is a convenience, not a requirement.
|
||||
}
|
||||
}
|
||||
|
||||
type PlayoutsState =
|
||||
| { status: 'loading'; playouts: []; error: null }
|
||||
| { status: 'success'; playouts: PlayoutSummary[]; error: null }
|
||||
@@ -58,7 +83,7 @@ export function BlockPlayoutTroubleshootingScreen() {
|
||||
const [blockFilter, setBlockFilter] = useState('');
|
||||
const [selectedBlock, setSelectedBlock] = useState<SelectedBlock | null>(null);
|
||||
const [pageNum, setPageNum] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(() => readStoredPageSize());
|
||||
const [historyState, setHistoryState] = useState<HistoryState>({
|
||||
status: 'loading',
|
||||
entries: [],
|
||||
@@ -343,7 +368,9 @@ export function BlockPlayoutTroubleshootingScreen() {
|
||||
<Select
|
||||
onChange={(event) => {
|
||||
setPageNum(0);
|
||||
setPageSize(Number(event.target.value));
|
||||
const nextPageSize = Number(event.target.value);
|
||||
setPageSize(nextPageSize);
|
||||
writeStoredPageSize(nextPageSize);
|
||||
}}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
style={{ width: 96 }}
|
||||
@@ -464,14 +491,18 @@ function BlockGroupRows({
|
||||
<td>{block.name}</td>
|
||||
<td>{block.minutes}</td>
|
||||
<td>
|
||||
<Button
|
||||
onClick={() => onOpenHistory(block)}
|
||||
size="sm"
|
||||
startIcon={<History aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
{/* Mirrors Blazor BlockPlayoutTroubleshooting.razor: the History action only exists
|
||||
for persisted blocks (Id >= 0) — synthesized/virtual blocks have no history to show. */}
|
||||
{block.id >= 0 && (
|
||||
<Button
|
||||
onClick={() => onOpenHistory(block)}
|
||||
size="sm"
|
||||
startIcon={<History aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BlocksScreen } from './BlocksScreen';
|
||||
|
||||
@@ -6,8 +6,15 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const groups = [{ id: 2, name: 'Prime' }];
|
||||
const blocks = [{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' }];
|
||||
const groups = [
|
||||
{ id: 2, name: 'Prime' },
|
||||
{ id: 3, name: 'Late Night' }
|
||||
];
|
||||
const blocks = [
|
||||
{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' },
|
||||
{ id: 7, groupId: 2, groupName: 'Prime', name: 'Evening News', minutes: 30, stopScheduling: 'AfterDurationEnd' },
|
||||
{ id: 8, groupId: 3, groupName: 'Late Night', name: 'Talk Show', minutes: 60, stopScheduling: 'AfterDurationEnd' }
|
||||
];
|
||||
|
||||
function blockItem(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
@@ -107,12 +114,42 @@ describe('BlocksScreen', () => {
|
||||
expect(screen.getByText('Morning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters blocks by name, case-insensitive', async () => {
|
||||
mockApi();
|
||||
render(<BlocksScreen />);
|
||||
|
||||
await screen.findByText('Morning');
|
||||
expect(screen.getByText('Evening News')).toBeInTheDocument();
|
||||
expect(screen.getByText('Talk Show')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'EVENING' } });
|
||||
|
||||
expect(screen.getByText('Evening News')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Morning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Talk Show')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters blocks by group name and shows an empty state for no matches', async () => {
|
||||
mockApi();
|
||||
render(<BlocksScreen />);
|
||||
|
||||
await screen.findByText('Morning');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'late night' } });
|
||||
expect(screen.getByText('Talk Show')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Morning')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'zzz-no-match' } });
|
||||
expect(await screen.findByText('No blocks match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the copy dialog and POSTs to the copy route, then refreshes the list', async () => {
|
||||
mockApi();
|
||||
render(<BlocksScreen />);
|
||||
|
||||
await screen.findByText('Morning');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy block/ }));
|
||||
const row = screen.getByText('Morning').closest('.ctv-settings-flush-row') as HTMLElement;
|
||||
fireEvent.click(within(row).getByRole('button', { name: /Copy block/ }));
|
||||
|
||||
const dialog = await screen.findByText('Copy "Morning"');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Eye,
|
||||
FolderPlus,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
TriangleAlert
|
||||
} from 'lucide-react';
|
||||
@@ -320,6 +321,7 @@ function BlockList() {
|
||||
const [deleteGroupTarget, setDeleteGroupTarget] = useState<BlockGroup | null>(null);
|
||||
const [deleteBlockTarget, setDeleteBlockTarget] = useState<Block | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const activeRef = useRef(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -455,6 +457,26 @@ function BlockList() {
|
||||
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) }));
|
||||
|
||||
// Client-side filter by block name or group name, case-insensitive (parity with Blazor
|
||||
// Blocks.razor's search box). A group whose own name matches keeps all of its blocks; otherwise
|
||||
// only its matching blocks are kept, and the group is hidden entirely if none match.
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const filteredGroups = sortedGroups
|
||||
.map((group) => {
|
||||
const groupBlocks = blocks
|
||||
.filter((b) => b.groupId === group.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (needle === '') {
|
||||
return { group, groupBlocks };
|
||||
}
|
||||
const groupNameMatches = group.name.toLowerCase().includes(needle);
|
||||
const matchingBlocks = groupNameMatches
|
||||
? groupBlocks
|
||||
: groupBlocks.filter((b) => b.name.toLowerCase().includes(needle));
|
||||
return groupNameMatches || matchingBlocks.length > 0 ? { group, groupBlocks: matchingBlocks } : null;
|
||||
})
|
||||
.filter((entry): entry is { group: BlockGroup; groupBlocks: Block[] } => entry !== null);
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -472,6 +494,16 @@ function BlockList() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Input
|
||||
leadingIcon={<Search aria-hidden="true" size={14} />}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Search for blocks…"
|
||||
value={filter}
|
||||
/>
|
||||
<span className="ctv-channels-spacer" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
@@ -483,11 +515,12 @@ function BlockList() {
|
||||
<Card>
|
||||
<div className="ctv-collections-empty">No block groups yet. Create one to get started.</div>
|
||||
</Card>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Card>
|
||||
<div className="ctv-collections-empty">No blocks match this filter.</div>
|
||||
</Card>
|
||||
) : (
|
||||
sortedGroups.map((group) => {
|
||||
const groupBlocks = blocks
|
||||
.filter((b) => b.groupId === group.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
filteredGroups.map(({ group, groupBlocks }) => {
|
||||
return (
|
||||
<Card
|
||||
key={group.id}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Info,
|
||||
RefreshCw,
|
||||
Search,
|
||||
TriangleAlert
|
||||
} from 'lucide-react';
|
||||
import { Badge, Button, Card, IconButton, Input, Select, Spinner } from '../components';
|
||||
import { getLogs, messageFromLogsError, type LogEntry } from '../api';
|
||||
import { getLogs, messageFromLogsError, type LogEntry, type LogsSortDirection, type LogsSortField } from '../api';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ['25', '50', '100'];
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
// Client-local UI preference (not a Blazor-style server ConfigElement) — see docs/decisions.md
|
||||
// 2026-07-11 "Logs page-size is a client-local preference".
|
||||
const LOGS_PAGE_SIZE_STORAGE_KEY = 'ctv-logs-page-size';
|
||||
|
||||
const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = {
|
||||
Debug: 'neutral',
|
||||
@@ -24,11 +38,44 @@ function formatTimestamp(value: string): string {
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function getStorage(): Storage | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredPageSize(): number {
|
||||
const stored = getStorage()?.getItem(LOGS_PAGE_SIZE_STORAGE_KEY) ?? null;
|
||||
return stored != null && PAGE_SIZE_OPTIONS.includes(stored) ? Number(stored) : DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
function storePageSize(pageSize: number): void {
|
||||
getStorage()?.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize));
|
||||
}
|
||||
|
||||
interface SortableColumn {
|
||||
field: LogsSortField;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const COLUMNS: SortableColumn[] = [
|
||||
{ field: 'timestamp', label: 'Timestamp' },
|
||||
{ field: 'level', label: 'Level' }
|
||||
];
|
||||
|
||||
export function LogsScreen() {
|
||||
const [filterInput, setFilterInput] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [pageNum, setPageNum] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageSize, setPageSize] = useState(() => getStoredPageSize());
|
||||
const [sortField, setSortField] = useState<LogsSortField>('timestamp');
|
||||
const [sortDirection, setSortDirection] = useState<LogsSortDirection>('desc');
|
||||
const [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 });
|
||||
const activeRef = useRef(true);
|
||||
const seqRef = useRef(0);
|
||||
@@ -56,7 +103,7 @@ export function LogsScreen() {
|
||||
// a loading spinner on every filter keystroke or page change.
|
||||
const load = useCallback(() => {
|
||||
const id = ++seqRef.current;
|
||||
getLogs({ filter, pageNum, pageSize })
|
||||
getLogs({ filter, pageNum, pageSize, sortDirection, sortField })
|
||||
.then((paged) => {
|
||||
if (activeRef.current && id === seqRef.current) {
|
||||
setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
|
||||
@@ -67,7 +114,7 @@ export function LogsScreen() {
|
||||
setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 });
|
||||
}
|
||||
});
|
||||
}, [filter, pageNum, pageSize]);
|
||||
}, [filter, pageNum, pageSize, sortDirection, sortField]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -78,6 +125,22 @@ export function LogsScreen() {
|
||||
load();
|
||||
};
|
||||
|
||||
const changePageSize = (nextPageSize: number) => {
|
||||
setPageNum(0);
|
||||
setPageSize(nextPageSize);
|
||||
storePageSize(nextPageSize);
|
||||
};
|
||||
|
||||
const toggleSort = (field: LogsSortField) => {
|
||||
setPageNum(0);
|
||||
if (field === sortField) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1;
|
||||
|
||||
return (
|
||||
@@ -91,10 +154,7 @@ export function LogsScreen() {
|
||||
/>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Select
|
||||
onChange={(event) => {
|
||||
setPageNum(0);
|
||||
setPageSize(Number(event.target.value));
|
||||
}}
|
||||
onChange={(event) => changePageSize(Number(event.target.value))}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
style={{ width: 96 }}
|
||||
value={String(pageSize)}
|
||||
@@ -136,8 +196,26 @@ export function LogsScreen() {
|
||||
<table aria-label="Recent log entries" className="ctv-channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Level</th>
|
||||
{COLUMNS.map((column) => {
|
||||
const active = column.field === sortField;
|
||||
return (
|
||||
<th aria-sort={active ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} key={column.field}>
|
||||
<button
|
||||
className="ctv-logs-sort-button"
|
||||
onClick={() => toggleSort(column.field)}
|
||||
type="button"
|
||||
>
|
||||
{column.label}
|
||||
{active &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp aria-hidden="true" size={13} />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" size={13} />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -130,6 +130,149 @@ describe('MediaBrowseScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('gates old-kind cards and shows a refreshing cue during a kind-change refetch (issue #221)', async () => {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const d = deferred<Response>();
|
||||
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
render(<MediaBrowseScreen />);
|
||||
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
|
||||
|
||||
// Change kind: the refetch for the new kind is held pending while old-kind cards stay rendered.
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(2));
|
||||
|
||||
// Refreshing window: cue visible, mutation surfaces gated, old-kind cards still visible but inert.
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
|
||||
|
||||
// New-kind results resolve: controls re-enable, cue gone.
|
||||
browseDeferreds[1].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).not.toBeInTheDocument());
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const d = deferred<Response>();
|
||||
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
render(<MediaBrowseScreen />);
|
||||
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
|
||||
const pushState = vi.spyOn(window.history, 'pushState');
|
||||
fireEvent.click(screen.getByText('Blade Runner'));
|
||||
|
||||
// Neither selection nor navigation fires — the card is fully inert during select+refresh.
|
||||
expect(screen.queryByText(/selected/)).toBeNull();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select toggle: entering select mode is blocked while refreshing, exiting (Done) is allowed (#222 review)', async () => {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const d = deferred<Response>();
|
||||
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
render(<MediaBrowseScreen />);
|
||||
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
|
||||
// Not in select mode; trigger a refetch — entering select mode must be blocked.
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
|
||||
|
||||
// Settle, enter select mode, then trigger another refetch — exiting (Done) must stay enabled.
|
||||
browseDeferreds[1].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
expect(screen.getByRole('button', { name: 'Done' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'movies' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(3));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
|
||||
const doneToggle = screen.getByRole('button', { name: 'Done' });
|
||||
expect(doneToggle).not.toBeDisabled();
|
||||
fireEvent.click(doneToggle);
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('selects every loaded item with Select all on page', async () => {
|
||||
mockFetch();
|
||||
render(<MediaBrowseScreen />);
|
||||
|
||||
@@ -55,10 +55,17 @@ function kindFromSlug(slug: string | null): MediaKind {
|
||||
}
|
||||
|
||||
type BrowseState =
|
||||
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number }
|
||||
// `key` records the request params (kind + query + page) that produced this result set. When it
|
||||
// no longer matches the current params, the visible items are stale (a refetch is in flight) —
|
||||
// see `refreshing` below.
|
||||
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number; key: string }
|
||||
| { items: []; error: string; status: 'error'; totalCount: 0 }
|
||||
| { items: []; error: null; status: 'loading'; totalCount: 0 };
|
||||
|
||||
function browseKeyOf(mediaType: LibraryBrowseMediaType, query: string, pageNum: number): string {
|
||||
return `${mediaType}\u0000${query}\u0000${pageNum}`;
|
||||
}
|
||||
|
||||
type Notice = { tone: 'ok' | 'error'; message: string };
|
||||
|
||||
export function MediaBrowseScreen() {
|
||||
@@ -108,10 +115,11 @@ export function MediaBrowseScreen() {
|
||||
|
||||
const load = useCallback(() => {
|
||||
const id = ++seqRef.current;
|
||||
const key = browseKeyOf(kind.mediaType, query, pageNum);
|
||||
getLibraryBrowseItems({ mediaType: kind.mediaType, query: query || undefined, pageNum, pageSize: PAGE_SIZE })
|
||||
.then((paged) => {
|
||||
if (activeRef.current && id === seqRef.current) {
|
||||
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
|
||||
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0, key });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -185,6 +193,13 @@ export function MediaBrowseScreen() {
|
||||
const selectedItems = Array.from(selected.values());
|
||||
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1;
|
||||
|
||||
// A refetch is in flight when the currently-rendered items were produced by different params than
|
||||
// the current kind/query/page (items stay visible during refetch — see `load`). While refreshing we
|
||||
// keep items visible but gate every mutation surface and show a refreshing cue, so no add/select
|
||||
// action is scoped to the stale, about-to-be-replaced result set (issue #221).
|
||||
const refreshing = state.status === 'success' && state.key !== browseKeyOf(kind.mediaType, query, pageNum);
|
||||
const canSelect = selectMode && !refreshing;
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -213,6 +228,9 @@ export function MediaBrowseScreen() {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
// Entering select mode while refreshing is blocked (it would target a stale result set),
|
||||
// but exiting is always allowed — it only clears selection, not a mutation.
|
||||
disabled={refreshing && !selectMode}
|
||||
onClick={toggleSelectMode}
|
||||
size="sm"
|
||||
startIcon={<ListChecks aria-hidden="true" size={14} />}
|
||||
@@ -233,11 +251,12 @@ export function MediaBrowseScreen() {
|
||||
{selected.size > 0 && (
|
||||
<div className="ctv-channels-actionbar">
|
||||
<span className="ctv-channels-selected">{selected.size} selected</span>
|
||||
<Button onClick={selectAllOnPage} size="sm" variant="secondary">
|
||||
<Button disabled={refreshing} onClick={selectAllOnPage} size="sm" variant="secondary">
|
||||
Select all on page
|
||||
</Button>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => setBulkDialog('collection')}
|
||||
size="sm"
|
||||
startIcon={<FolderPlus aria-hidden="true" size={14} />}
|
||||
@@ -246,6 +265,7 @@ export function MediaBrowseScreen() {
|
||||
Add to collection
|
||||
</Button>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => setBulkDialog('playlist')}
|
||||
size="sm"
|
||||
startIcon={<ListVideo aria-hidden="true" size={14} />}
|
||||
@@ -274,6 +294,13 @@ export function MediaBrowseScreen() {
|
||||
open={bulkDialog === 'playlist'}
|
||||
/>
|
||||
|
||||
{refreshing && (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={14} />
|
||||
<span>Refreshing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
@@ -298,20 +325,27 @@ export function MediaBrowseScreen() {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="ctv-media-grid">
|
||||
<div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
|
||||
{state.items.map((item) => {
|
||||
const detailPath = mediaDetailPath(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
// While refreshing, the per-card Add-to menu is withheld (stale result set);
|
||||
// card navigation stays live.
|
||||
actions={
|
||||
selectMode ? undefined : (
|
||||
selectMode || refreshing ? undefined : (
|
||||
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
|
||||
)
|
||||
}
|
||||
item={item}
|
||||
key={`${item.mediaType}-${item.id}`}
|
||||
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={selectMode ? toggleSelect : undefined}
|
||||
// While `selectMode && refreshing`, the card must be fully inert: neither
|
||||
// `onOpen` (which MediaPosterCard falls back to when `onToggleSelect` is
|
||||
// undefined) nor `onToggleSelect` may fire, or a mid-select click would navigate
|
||||
// away instead of no-op'ing. Outside select mode, `onOpen` stays live during a
|
||||
// refresh (intended).
|
||||
onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={canSelect ? toggleSelect : undefined}
|
||||
selected={selected.has(itemKey(item))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { navigateToPath } from '../routing';
|
||||
import {
|
||||
ArtistDetailScreen,
|
||||
MovieDetailScreen,
|
||||
@@ -154,7 +155,7 @@ describe('media detail screens', () => {
|
||||
const scanCall = fetchSpy.mock.calls.find(([url]) => String(url) === '/api/libraries/3/scan-show');
|
||||
expect(scanCall).toBeTruthy();
|
||||
const body = JSON.parse(String((scanCall![1] as RequestInit).body));
|
||||
expect(body).toMatchObject({ deepScan: true, showTitle: 'The Show' });
|
||||
expect(body).toMatchObject({ deepScan: true, showId: 42 });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Scan queued')).toBeInTheDocument());
|
||||
});
|
||||
@@ -178,4 +179,134 @@ describe('media detail screens', () => {
|
||||
expect(screen.getByRole('button', { name: 'Media Info' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('anchors and highlights the episode targeted by an #episode-{id} hash (#220)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
page: [
|
||||
{ artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' },
|
||||
{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' }
|
||||
],
|
||||
totalCount: 2
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(season));
|
||||
});
|
||||
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.stubGlobal('HTMLElement', HTMLElement);
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
window.location.hash = '#episode-91';
|
||||
|
||||
render(<SeasonDetailScreen id={8} />);
|
||||
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
|
||||
|
||||
const target = document.getElementById('episode-91');
|
||||
expect(target).not.toBeNull();
|
||||
await waitFor(() => expect(scrollIntoView).toHaveBeenCalled());
|
||||
expect(target?.className).toContain('ctv-media-card-highlighted');
|
||||
|
||||
const other = document.getElementById('episode-90');
|
||||
expect(other?.className).not.toContain('ctv-media-card-highlighted');
|
||||
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('updates the anchor and scrolls on same-pathname in-app navigation (synthetic popstate, review #220)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
page: [
|
||||
{ artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' },
|
||||
{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' }
|
||||
],
|
||||
totalCount: 2
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(season));
|
||||
});
|
||||
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
window.location.hash = '';
|
||||
|
||||
render(<SeasonDetailScreen id={8} />);
|
||||
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
|
||||
// `routing.ts`'s navigateToPath is what App.tsx uses for in-app clicks (e.g. an episode card
|
||||
// inside this same season grid): pushState + a synthetic `popstate`, not a real hash change,
|
||||
// so `hashchange` alone would never fire. This exercises that same-pathname path directly.
|
||||
navigateToPath('/app/media/seasons/8#episode-91');
|
||||
|
||||
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1));
|
||||
const target = document.getElementById('episode-91');
|
||||
expect(target?.className).toContain('ctv-media-card-highlighted');
|
||||
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('does not re-scroll on refetch/pagination when the anchor is unchanged (review #220)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
const pageNum = params.get('pageNum');
|
||||
if (pageNum === '1') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
page: [{ artwork: '', collectionType: 'Episode', id: 92, mediaType: 'Episode', title: 'Third' }],
|
||||
totalCount: 61
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
page: [
|
||||
{ artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' },
|
||||
{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' }
|
||||
],
|
||||
totalCount: 61
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(season));
|
||||
});
|
||||
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
window.location.hash = '#episode-91';
|
||||
|
||||
render(<SeasonDetailScreen id={8} />);
|
||||
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
|
||||
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Page away (target leaves the DOM) and back (a fresh `items` array is fetched, but the
|
||||
// anchor value itself never changed) — must not re-trigger the scroll.
|
||||
fireEvent.click(screen.getByTitle('Next page'));
|
||||
await waitFor(() => expect(screen.getByText('Third')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTitle('Previous page'));
|
||||
await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument());
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.location.hash = '';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,8 +211,12 @@ function ChildGrid({
|
||||
const [pageNum, setPageNum] = useState(0);
|
||||
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [anchorId, setAnchorId] = useState<string | null>(() => window.location.hash.slice(1) || null);
|
||||
const activeRef = useRef(true);
|
||||
const seqRef = useRef(0);
|
||||
// The anchor value we last actually scrolled to, so a refetch/pagination that recreates
|
||||
// `items` (but leaves the hash unchanged) doesn't hijack the user's scroll position.
|
||||
const scrolledAnchorRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
@@ -221,6 +225,43 @@ function ChildGrid({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Deep-link support (#220): a season detail's episode grid can be opened with an
|
||||
// `#episode-{id}` hash (from search/browse cards, or Blazor-parity links). The SPA's own
|
||||
// in-app navigation (routing.ts navigateToPath) uses `history.pushState` + a synthetic
|
||||
// `popstate` dispatch rather than a real hash change — real browser hash navigation
|
||||
// (address bar, back/forward across a hash-only change) fires `hashchange` instead — so both
|
||||
// events must be handled to catch in-grid episode card clicks (same season pathname, new
|
||||
// fragment) as well as deep links that remount this screen.
|
||||
useEffect(() => {
|
||||
const onHashNav = () => setAnchorId(window.location.hash.slice(1) || null);
|
||||
window.addEventListener('hashchange', onHashNav);
|
||||
window.addEventListener('popstate', onHashNav);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', onHashNav);
|
||||
window.removeEventListener('popstate', onHashNav);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'success' || mediaType !== 'Episode' || !anchorId) {
|
||||
return;
|
||||
}
|
||||
// One-shot per anchor value: only scroll the first time we see this anchor resolve
|
||||
// successfully, so a later refetch/pagination (which recreates `items`) doesn't re-jump the
|
||||
// page back to it. Known limitation shared with the Blazor fragment link this replaces: the
|
||||
// anchor only resolves against the currently-loaded page (CHILD_PAGE_SIZE) — a target beyond
|
||||
// page 1 won't be found (and so won't scroll) until that page is loaded.
|
||||
if (scrolledAnchorRef.current === anchorId) {
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(anchorId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
scrolledAnchorRef.current = anchorId;
|
||||
}, [status, mediaType, anchorId, items]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
const id = ++seqRef.current;
|
||||
getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId })
|
||||
@@ -278,9 +319,12 @@ function ChildGrid({
|
||||
<div className="ctv-media-grid">
|
||||
{items.map((item) => {
|
||||
const detailPath = mediaDetailPath(item);
|
||||
const cardId = item.mediaType === 'Episode' ? `episode-${item.id}` : undefined;
|
||||
return (
|
||||
<MediaPosterCard
|
||||
actions={renderActions?.(item)}
|
||||
highlighted={cardId != null && cardId === anchorId}
|
||||
id={cardId}
|
||||
item={item}
|
||||
key={`${item.mediaType}-${item.id}`}
|
||||
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
@@ -584,7 +628,7 @@ function ShowScanControls({ show }: { show: ShowDetail }) {
|
||||
const runScan = (deepScan: boolean) => {
|
||||
setScanning(deepScan ? 'deep' : 'quick');
|
||||
setMessage(null);
|
||||
scanShow(show.libraryId, { deepScan, showTitle: show.title })
|
||||
scanShow(show.libraryId, { deepScan, showId: show.id })
|
||||
.then(() => {
|
||||
if (activeRef.current) {
|
||||
setScanning(false);
|
||||
|
||||
@@ -97,6 +97,46 @@ function renderWithQuery() {
|
||||
return render(<SearchScreen />);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// A fetch mock whose /api/search (results) and /api/search/all-items responses are held pending
|
||||
// until the test resolves them, so we can observe the in-flight "refreshing" window (issue #221).
|
||||
function mockControlledSearch() {
|
||||
const searchDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
const allItemsDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (url.startsWith('/api/search/all-items')) {
|
||||
const d = deferred<Response>();
|
||||
allItemsDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url.startsWith('/api/search')) {
|
||||
const d = deferred<Response>();
|
||||
searchDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/smart-collections') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
return { searchDeferreds, allItemsDeferreds };
|
||||
}
|
||||
|
||||
describe('SearchScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
@@ -178,6 +218,136 @@ describe('SearchScreen', () => {
|
||||
expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [7, 8] });
|
||||
});
|
||||
|
||||
it('gates mutation controls and shows a refreshing cue while a new query loads (issue #221)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// Baseline: mutation surfaces are live for the current result set.
|
||||
expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Add all to collection/ })).not.toBeDisabled();
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
|
||||
|
||||
// Change the query: the debounced commit fires a refetch, which we hold pending.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
|
||||
// Refreshing window: cue visible, every mutation surface gated, cards still visible.
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
expect(screen.getByText('Blade Runner')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /^Select/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Add all to collection/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Add all to playlist/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Save as smart collection/ })).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
|
||||
|
||||
// Replacement resolves: controls re-enable, cue gone.
|
||||
ctl.searchDeferreds[1].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
|
||||
expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('discards an Add-all result when the query changed before it resolved (issue #221)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// Kick off Add all (all-items request in flight, dialog not yet open).
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
|
||||
await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(1));
|
||||
|
||||
// Change the query before all-items resolves, then settle the new query's results.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
ctl.searchDeferreds[1].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
|
||||
|
||||
// The stale all-items result now resolves — it must NOT open a dialog for the old query.
|
||||
ctl.allItemsDeferreds[0].resolve(allItems);
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
// A fresh Add all for the current query still works.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
|
||||
await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(2));
|
||||
ctl.allItemsDeferreds[1].resolve(allItems);
|
||||
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show a stuck refreshing cue after the query is cleared to empty (#222 review)', async () => {
|
||||
mockApi();
|
||||
renderWithQuery();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// `load()` early-returns for a blank query, so `state` never updates once cleared — the
|
||||
// `refreshing` derivation must be gated on `hasQuery`, or the cue gets stuck forever over the
|
||||
// "Type a query…" empty card.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: '' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Type a query to search across every media kind.')).toBeTruthy()
|
||||
);
|
||||
expect(screen.queryByText('Refreshing…')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
|
||||
const pushState = vi.spyOn(window.history, 'pushState');
|
||||
fireEvent.click(screen.getByText('Blade Runner'));
|
||||
|
||||
// Neither selection nor navigation fires — the card is fully inert during select+refresh.
|
||||
expect(screen.queryByText('1 selected')).toBeNull();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select toggle: entering select mode is blocked while refreshing, exiting is allowed (#222 review)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// Not in select mode; trigger a refetch — entering select mode must be blocked.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
const enterToggle = screen.getByRole('button', { name: /^Select/ });
|
||||
expect(enterToggle).toBeDisabled();
|
||||
expect(enterToggle.className).toContain('ctv-button-secondary');
|
||||
|
||||
// Settle, enter select mode, then trigger another refetch — exiting must stay enabled.
|
||||
ctl.searchDeferreds[1].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
|
||||
expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-primary');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'aliens' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(3));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
|
||||
const exitToggle = screen.getByRole('button', { name: /^Select/ });
|
||||
expect(exitToggle).not.toBeDisabled();
|
||||
fireEvent.click(exitToggle);
|
||||
expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-secondary');
|
||||
});
|
||||
|
||||
it('saves the current query as a new smart collection', async () => {
|
||||
const { calls } = mockApi();
|
||||
renderWithQuery();
|
||||
|
||||
@@ -44,7 +44,9 @@ const GROUPS: GroupDef[] = [
|
||||
];
|
||||
|
||||
type SearchState =
|
||||
| { results: SearchResults; error: null; status: 'success' }
|
||||
// `query` records which query produced this result set. When it no longer matches the current
|
||||
// committed query, the visible cards are stale (a refetch is in flight) — see `refreshing` below.
|
||||
| { results: SearchResults; error: null; status: 'success'; query: string }
|
||||
| { results: null; error: string; status: 'error' }
|
||||
| { results: null; error: null; status: 'loading' };
|
||||
|
||||
@@ -116,7 +118,7 @@ export function SearchScreen() {
|
||||
getSearchResults({ query: trimmed, pageSize: PAGE_SIZE })
|
||||
.then((results) => {
|
||||
if (activeRef.current && id === seqRef.current) {
|
||||
setState({ results, error: null, status: 'success' });
|
||||
setState({ results, error: null, status: 'success', query: trimmed });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -172,6 +174,13 @@ export function SearchScreen() {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// Bind the all-items result to the query that requested it. `lastQueryRef` always holds the
|
||||
// current committed query (see the debounce effect); if it moved on, this bulk-add would be
|
||||
// scoped to the PREVIOUS query's entire result set — discard it (issue #221).
|
||||
if (lastQueryRef.current !== trimmed) {
|
||||
setPendingAll(null);
|
||||
return;
|
||||
}
|
||||
setPendingAll(null);
|
||||
setDialog({ kind, items: { requestOverride: toAddItemsRequestFromSearch(result) } });
|
||||
})
|
||||
@@ -202,6 +211,17 @@ export function SearchScreen() {
|
||||
const hasResults = hasQuery && state.status === 'success' && totalMatches > 0;
|
||||
const selectionCount = selected.size;
|
||||
|
||||
// A refetch is in flight when the currently-rendered success result set was produced by a query
|
||||
// other than the current committed one (results stay visible during refetch — see `load`). While
|
||||
// refreshing we keep cards visible but gate every mutation surface and show a refreshing cue, so
|
||||
// no add/select action is scoped to the stale, about-to-be-replaced result set (issue #221).
|
||||
// Gated on `hasQuery`: `load()` early-returns for a blank query, so `state` keeps the LAST
|
||||
// non-empty query's success variant forever once the query is cleared — without this gate,
|
||||
// `refreshing` would get stuck true over the "Type a query to search…" empty card (#222 review).
|
||||
const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();
|
||||
// Enter select mode only for the live result set.
|
||||
const canSelect = selectMode && !refreshing;
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -215,6 +235,9 @@ export function SearchScreen() {
|
||||
{hasResults && (
|
||||
<>
|
||||
<Button
|
||||
// Entering select mode while refreshing is blocked (it would target a stale result
|
||||
// set), but exiting is always allowed — it only clears selection, not a mutation.
|
||||
disabled={refreshing && !selectMode}
|
||||
onClick={toggleSelectMode}
|
||||
size="sm"
|
||||
startIcon={<CheckSquare aria-hidden="true" size={14} />}
|
||||
@@ -223,7 +246,7 @@ export function SearchScreen() {
|
||||
Select
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pendingAll !== null}
|
||||
disabled={pendingAll !== null || refreshing}
|
||||
loading={pendingAll === 'collection'}
|
||||
onClick={() => addAll('collection')}
|
||||
size="sm"
|
||||
@@ -233,7 +256,7 @@ export function SearchScreen() {
|
||||
Add all to collection
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pendingAll !== null}
|
||||
disabled={pendingAll !== null || refreshing}
|
||||
loading={pendingAll === 'playlist'}
|
||||
onClick={() => addAll('playlist')}
|
||||
size="sm"
|
||||
@@ -243,6 +266,7 @@ export function SearchScreen() {
|
||||
Add all to playlist
|
||||
</Button>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => setDialog({ kind: 'save-smart' })}
|
||||
size="sm"
|
||||
startIcon={<Save aria-hidden="true" size={14} />}
|
||||
@@ -263,6 +287,7 @@ export function SearchScreen() {
|
||||
<span className="ctv-search-selectbar-count">{selectionCount} selected</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => addSelection('collection')}
|
||||
size="sm"
|
||||
startIcon={<FolderPlus aria-hidden="true" size={14} />}
|
||||
@@ -271,6 +296,7 @@ export function SearchScreen() {
|
||||
Add to collection
|
||||
</Button>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => addSelection('playlist')}
|
||||
size="sm"
|
||||
startIcon={<ListVideo aria-hidden="true" size={14} />}
|
||||
@@ -289,6 +315,13 @@ export function SearchScreen() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refreshing && (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={14} />
|
||||
<span>Refreshing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasQuery && (
|
||||
<Card>
|
||||
<div className="ctv-collections-empty">Type a query to search across every media kind.</div>
|
||||
@@ -344,21 +377,28 @@ export function SearchScreen() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ctv-media-grid">
|
||||
<div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
|
||||
{data.items.map((item) => {
|
||||
const key = itemKey(item);
|
||||
const detailPath = mediaDetailPath(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
// While refreshing, the per-card Add-to menu is withheld (stale result set);
|
||||
// card navigation stays live.
|
||||
actions={
|
||||
selectMode ? undefined : (
|
||||
selectMode || refreshing ? undefined : (
|
||||
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
|
||||
)
|
||||
}
|
||||
item={item}
|
||||
key={key}
|
||||
// While `selectMode && refreshing`, the card must be fully inert: neither
|
||||
// `onOpen` (which MediaPosterCard falls back to when `onToggleSelect` is
|
||||
// undefined) nor `onToggleSelect` may fire, or a mid-select click would
|
||||
// navigate away instead of no-op'ing. Outside select mode, `onOpen` stays
|
||||
// live during a refresh (intended).
|
||||
onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={selectMode ? toggleSelect : undefined}
|
||||
onToggleSelect={canSelect ? toggleSelect : undefined}
|
||||
selected={selectMode ? selected.has(key) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TemplatesScreen } from './TemplatesScreen';
|
||||
|
||||
@@ -6,8 +6,15 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const groups = [{ id: 2, name: 'Prime', templateCount: 1 }];
|
||||
const templates = [{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' }];
|
||||
const groups = [
|
||||
{ id: 2, name: 'Prime', templateCount: 1 },
|
||||
{ id: 3, name: 'Overnight', templateCount: 1 }
|
||||
];
|
||||
const templates = [
|
||||
{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' },
|
||||
{ id: 6, templateGroupId: 2, groupName: 'Prime', name: 'Weekends' },
|
||||
{ id: 9, templateGroupId: 3, groupName: 'Overnight', name: 'Late Show' }
|
||||
];
|
||||
const blockGroups = [{ id: 1, name: 'Morning Blocks' }];
|
||||
const blocks = [{ id: 10, groupId: 1, groupName: 'Morning Blocks', name: 'Cartoons', minutes: 60, stopScheduling: 'AfterDurationEnd' }];
|
||||
|
||||
@@ -80,12 +87,42 @@ describe('TemplatesScreen', () => {
|
||||
expect(screen.getByText('Weekdays')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters templates by name, case-insensitive', async () => {
|
||||
mockApi();
|
||||
render(<TemplatesScreen />);
|
||||
|
||||
await screen.findByText('Weekdays');
|
||||
expect(screen.getByText('Weekends')).toBeInTheDocument();
|
||||
expect(screen.getByText('Late Show')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'weekEND' } });
|
||||
|
||||
expect(screen.getByText('Weekends')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Weekdays')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Late Show')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters templates by group name and shows an empty state for no matches', async () => {
|
||||
mockApi();
|
||||
render(<TemplatesScreen />);
|
||||
|
||||
await screen.findByText('Weekdays');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'overnight' } });
|
||||
expect(screen.getByText('Late Show')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Weekdays')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'zzz-no-match' } });
|
||||
expect(await screen.findByText('No templates match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the copy dialog and POSTs to the copy route', async () => {
|
||||
mockApi();
|
||||
render(<TemplatesScreen />);
|
||||
|
||||
await screen.findByText('Weekdays');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy template/ }));
|
||||
const row = screen.getByText('Weekdays').closest('.ctv-settings-flush-row') as HTMLElement;
|
||||
fireEvent.click(within(row).getByRole('button', { name: /Copy template/ }));
|
||||
|
||||
const dialog = await screen.findByText('Copy "Weekdays"');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, Copy, FolderPlus, Plus, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { ArrowLeft, Check, Copy, FolderPlus, Plus, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
@@ -162,6 +162,7 @@ function TemplateList() {
|
||||
const [deleteGroupTarget, setDeleteGroupTarget] = useState<TemplateGroup | null>(null);
|
||||
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const activeRef = useRef(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -300,6 +301,28 @@ function TemplateList() {
|
||||
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) }));
|
||||
|
||||
// Client-side filter by template name or group name, case-insensitive (parity with Blazor
|
||||
// Templates.razor's search box). A group whose own name matches keeps all of its templates;
|
||||
// otherwise only its matching templates are kept, and the group is hidden entirely if none match.
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const filteredGroups = sortedGroups
|
||||
.map((group) => {
|
||||
const groupTemplates = templates
|
||||
.filter((t) => t.templateGroupId === group.id && t.id > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (needle === '') {
|
||||
return { group, groupTemplates };
|
||||
}
|
||||
const groupNameMatches = group.name.toLowerCase().includes(needle);
|
||||
const matchingTemplates = groupNameMatches
|
||||
? groupTemplates
|
||||
: groupTemplates.filter((t) => t.name.toLowerCase().includes(needle));
|
||||
return groupNameMatches || matchingTemplates.length > 0
|
||||
? { group, groupTemplates: matchingTemplates }
|
||||
: null;
|
||||
})
|
||||
.filter((entry): entry is { group: TemplateGroup; groupTemplates: Template[] } => entry !== null);
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -317,6 +340,16 @@ function TemplateList() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Input
|
||||
leadingIcon={<Search aria-hidden="true" size={14} />}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Search for templates…"
|
||||
value={filter}
|
||||
/>
|
||||
<span className="ctv-channels-spacer" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
@@ -328,11 +361,12 @@ function TemplateList() {
|
||||
<Card>
|
||||
<div className="ctv-collections-empty">No template groups yet. Create one to get started.</div>
|
||||
</Card>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Card>
|
||||
<div className="ctv-collections-empty">No templates match this filter.</div>
|
||||
</Card>
|
||||
) : (
|
||||
sortedGroups.map((group) => {
|
||||
const groupTemplates = templates
|
||||
.filter((t) => t.templateGroupId === group.id && t.id > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
filteredGroups.map(({ group, groupTemplates }) => {
|
||||
return (
|
||||
<Card
|
||||
key={group.id}
|
||||
|
||||
@@ -44,6 +44,12 @@ const searchResults = {
|
||||
songs: emptyGroup()
|
||||
};
|
||||
|
||||
// Movies group has more matches than the /api/search first-page cap (100) to exercise "See all".
|
||||
function searchResultsWithMoreMovies() {
|
||||
const movieItems = Array.from({ length: 100 }, (_, i) => browseItem(i + 1, 'Movie', 200 + i));
|
||||
return { ...searchResults, movies: { items: movieItems, totalCount: 130 }, shows: emptyGroup() };
|
||||
}
|
||||
|
||||
interface MockOptions {
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
}
|
||||
@@ -129,4 +135,37 @@ describe('TrashScreen', () => {
|
||||
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]);
|
||||
});
|
||||
});
|
||||
|
||||
it('pages past the 100/kind cap via "See all" using GET /api/library/browse', async () => {
|
||||
const moreMovies = searchResultsWithMoreMovies();
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) => {
|
||||
if (url.startsWith('/api/search') && method === 'GET') {
|
||||
return jsonResponse(moreMovies);
|
||||
}
|
||||
if (url.startsWith('/api/library/browse') && method === 'GET') {
|
||||
expect(url).toContain('mediaType=Movie');
|
||||
expect(url).toContain('pageNum=1');
|
||||
expect(url).toContain('query=state%3AFileNotFound');
|
||||
const extraMovies = Array.from({ length: 30 }, (_, i) => browseItem(i + 500, 'Movie', 900 + i));
|
||||
return jsonResponse({ page: extraMovies, totalCount: 130 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<TrashScreen />);
|
||||
await screen.findByText('130 missing');
|
||||
|
||||
const seeAllButton = screen.getByRole('button', { name: 'See all 130 movies' });
|
||||
fireEvent.click(seeAllButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.some(([u]) => u.toString().startsWith('/api/library/browse'))).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /See all/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,20 @@ import { Button, Card, ConfirmDialog, Spinner } from '../components';
|
||||
import {
|
||||
deleteMediaItems,
|
||||
emptyTrash,
|
||||
getLibraryBrowseItems,
|
||||
getSearchResults,
|
||||
messageFromLibraryBrowseError,
|
||||
messageFromSearchError,
|
||||
type LibraryBrowseItem,
|
||||
type LibraryBrowseMediaType,
|
||||
type SearchResults
|
||||
} from '../api';
|
||||
import { MediaPosterCard } from '../media/MediaPosterCard';
|
||||
|
||||
// The search API's SearchController clamps pageSize to MaxPageSize=100 and has no page-number
|
||||
// param, so this is the largest single request possible — see docs/decisions.md ("Trash see all").
|
||||
// The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
|
||||
// pageSize to MaxPageSize=100 — see docs/decisions.md ("Trash see all"). "See all" beyond that
|
||||
// first page pages through the same underlying data via GET /api/library/browse (mediaType +
|
||||
// pageNum), which already supports paging — no new API surface was needed to lift the cap.
|
||||
const PAGE_SIZE = 100;
|
||||
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
|
||||
const TRASH_QUERY = 'state:FileNotFound';
|
||||
@@ -20,21 +25,30 @@ const TRASH_QUERY = 'state:FileNotFound';
|
||||
interface GroupDef {
|
||||
key: keyof SearchResults;
|
||||
label: string;
|
||||
mediaType: LibraryBrowseMediaType;
|
||||
}
|
||||
|
||||
const GROUPS: GroupDef[] = [
|
||||
{ key: 'movies', label: 'Movies' },
|
||||
{ key: 'shows', label: 'TV Shows' },
|
||||
{ key: 'seasons', label: 'Seasons' },
|
||||
{ key: 'episodes', label: 'Episodes' },
|
||||
{ key: 'artists', label: 'Artists' },
|
||||
{ key: 'musicVideos', label: 'Music Videos' },
|
||||
{ key: 'songs', label: 'Songs' },
|
||||
{ key: 'otherVideos', label: 'Other Videos' },
|
||||
{ key: 'images', label: 'Images' },
|
||||
{ key: 'remoteStreams', label: 'Remote Streams' }
|
||||
{ key: 'movies', label: 'Movies', mediaType: 'Movie' },
|
||||
{ key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
|
||||
{ key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
|
||||
{ key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
|
||||
{ key: 'artists', label: 'Artists', mediaType: 'Artist' },
|
||||
{ key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
|
||||
{ key: 'songs', label: 'Songs', mediaType: 'Song' },
|
||||
{ key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
|
||||
{ key: 'images', label: 'Images', mediaType: 'Image' },
|
||||
{ key: 'remoteStreams', label: 'Remote Streams', mediaType: 'RemoteStream' }
|
||||
];
|
||||
|
||||
interface SeeAllState {
|
||||
error: string | null;
|
||||
items: LibraryBrowseItem[];
|
||||
loading: boolean;
|
||||
// Next page to request; page 0 was already loaded by the initial /api/search call.
|
||||
nextPageNum: number;
|
||||
}
|
||||
|
||||
type TrashState =
|
||||
| { results: SearchResults; error: null; status: 'success' }
|
||||
| { results: null; error: string; status: 'error' }
|
||||
@@ -46,6 +60,7 @@ function mediaItemIdOf(item: LibraryBrowseItem): number | null {
|
||||
|
||||
export function TrashScreen() {
|
||||
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
|
||||
const [seeAll, setSeeAll] = useState<Partial<Record<keyof SearchResults, SeeAllState>>>({});
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -83,9 +98,45 @@ export function TrashScreen() {
|
||||
|
||||
const refresh = () => {
|
||||
setState({ results: null, error: null, status: 'loading' });
|
||||
setSeeAll({});
|
||||
load();
|
||||
};
|
||||
|
||||
const loadMore = (group: GroupDef) => {
|
||||
const current = seeAll[group.key];
|
||||
const nextPageNum = current?.nextPageNum ?? 1; // page 0 was already loaded by /api/search above
|
||||
setSeeAll((prev) => ({
|
||||
...prev,
|
||||
[group.key]: { error: null, items: current?.items ?? [], loading: true, nextPageNum }
|
||||
}));
|
||||
|
||||
getLibraryBrowseItems({ mediaType: group.mediaType, pageNum: nextPageNum, pageSize: PAGE_SIZE, query: TRASH_QUERY })
|
||||
.then((page) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setSeeAll((prev) => {
|
||||
const existing = prev[group.key];
|
||||
const items = [...(existing?.items ?? []), ...(page.page ?? [])];
|
||||
return { ...prev, [group.key]: { error: null, items, loading: false, nextPageNum: nextPageNum + 1 } };
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setSeeAll((prev) => ({
|
||||
...prev,
|
||||
[group.key]: {
|
||||
error: messageFromLibraryBrowseError(error, 'Unable to load more items'),
|
||||
items: current?.items ?? [],
|
||||
loading: false,
|
||||
nextPageNum
|
||||
}
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = (item: LibraryBrowseItem) => {
|
||||
const mediaItemId = mediaItemIdOf(item);
|
||||
if (mediaItemId == null) {
|
||||
@@ -113,7 +164,8 @@ export function TrashScreen() {
|
||||
}
|
||||
const ids = new Set<number>();
|
||||
for (const group of GROUPS) {
|
||||
for (const item of state.results[group.key].items) {
|
||||
const items = [...state.results[group.key].items, ...(seeAll[group.key]?.items ?? [])];
|
||||
for (const item of items) {
|
||||
const mediaItemId = mediaItemIdOf(item);
|
||||
if (mediaItemId != null) {
|
||||
ids.add(mediaItemId);
|
||||
@@ -219,9 +271,12 @@ export function TrashScreen() {
|
||||
state.results &&
|
||||
GROUPS.map((group) => {
|
||||
const data = state.results![group.key];
|
||||
if (data.totalCount === 0 || data.items.length === 0) {
|
||||
const more = seeAll[group.key];
|
||||
const items = [...data.items, ...(more?.items ?? [])];
|
||||
if (data.totalCount === 0 || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const hasMore = items.length < data.totalCount;
|
||||
|
||||
return (
|
||||
<section key={group.key}>
|
||||
@@ -232,7 +287,7 @@ export function TrashScreen() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="ctv-media-grid">
|
||||
{data.items.map((item) => {
|
||||
{items.map((item) => {
|
||||
const mediaItemId = mediaItemIdOf(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
@@ -244,6 +299,19 @@ export function TrashScreen() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{more?.error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{more.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="ctv-media-section-footer">
|
||||
<Button disabled={more?.loading === true} onClick={() => loadMore(group)} size="sm" variant="secondary">
|
||||
{more?.loading ? 'Loading…' : `See all ${data.totalCount} ${group.label.toLowerCase()}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -841,6 +841,26 @@ body {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ctv-logs-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctv-logs-sort-button:hover,
|
||||
.ctv-logs-sort-button:focus-visible {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-channel-check {
|
||||
width: 38px;
|
||||
padding-left: 14px !important;
|
||||
@@ -2691,6 +2711,14 @@ body {
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
/* Dim the stale result set while a refetch is in flight (search/media browse — issue #221). The
|
||||
per-card mutation menu is withheld in the same state; navigation stays live so no pointer-events
|
||||
change here. */
|
||||
.ctv-media-grid-dim {
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--dur-fast, 120ms) var(--ease-standard, ease);
|
||||
}
|
||||
|
||||
.ctv-media-card {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -2711,6 +2739,24 @@ body {
|
||||
box-shadow: 0 0 0 1px var(--action-primary);
|
||||
}
|
||||
|
||||
/* Deep-link target highlight (#220), e.g. `#episode-{id}` from search/browse episode cards.
|
||||
The ring itself (border + 2px box-shadow) persists for as long as the hash names this card;
|
||||
only the outer glow pulse fades out shortly after mount. */
|
||||
.ctv-media-card-highlighted {
|
||||
border-color: var(--action-primary);
|
||||
box-shadow: 0 0 0 2px var(--action-primary);
|
||||
animation: ctv-media-card-highlight-pulse 2400ms ease-out 1;
|
||||
}
|
||||
|
||||
@keyframes ctv-media-card-highlight-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 4px var(--action-primary);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 2px var(--action-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ctv-media-card-poster {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -3080,6 +3126,12 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-media-section-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: var(--space-5, 10px) 0 var(--space-7, 16px);
|
||||
}
|
||||
|
||||
/* lineup */
|
||||
.ctv-builder-lineup-list {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user