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