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