diff --git a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs index 245101502..874f1c9a2 100644 --- a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs +++ b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs @@ -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> GetMusicVideos( diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs index 98251b91a..fd8586c21 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs @@ -46,7 +46,8 @@ public class AddItemsToPlaylistHandler : IRequestHandler 0 ? playlist.Items.Max(i => i.Index) + 1 : 0; @@ -81,8 +82,9 @@ public class AddItemsToPlaylistHandler : IRequestHandler collection); + await ValidateEpisodes(request), + await ValidateMediaItems(dbContext, request, cancellationToken)) + .Apply((collection, _, _, _, _, _) => collection); private static async Task> PlaylistMustExist( TvContext dbContext, @@ -126,4 +128,30 @@ public class AddItemsToPlaylistHandler : IRequestHandler v == true) .MapT(_ => Unit.Default) .Map(v => v.ToValidation("Episode does not exist")); + + private static async Task> ValidateMediaItems( + TvContext dbContext, + AddItemsToPlaylist request, + CancellationToken cancellationToken) + { + List 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 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); } diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index e69ac7432..eb4087d8a 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository public Task> GetAllShows() => throw new NotSupportedException(); public Task> GetShow(int showId, CancellationToken cancellationToken) => throw new NotSupportedException(); - public Task> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException(); + public Task> GetShowTitle(int libraryId, int showId) => throw new NotSupportedException(); public Task> GetShowItems(int showId) => throw new NotSupportedException(); public Task> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException(); diff --git a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs index 67f6cffd2..c7a84a5f1 100644 --- a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs +++ b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs @@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel( int? RerunCollectionId, int? MediaItemId, int? PlaylistId, - string? Subtitle = null); + string? Subtitle = null, + int? SeasonId = null); diff --git a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs index 33aa15d5b..a5da52036 100644 --- a/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs +++ b/ErsatzTV.Core/Api/Playouts/PlayoutListItemResponseModel.cs @@ -11,4 +11,5 @@ public record PlayoutListItemResponseModel( string ScheduleName, TimeSpan? DailyRebuildTime, PlayoutBuildStatusResponseModel? BuildStatus, - ChannelPlayoutMode PlayoutMode); + ChannelPlayoutMode PlayoutMode, + bool IsLocked); diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 947802782..3518743c2 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -10,7 +10,7 @@ public interface ITelevisionRepository Task AllEpisodesExist(List episodeIds); Task> GetAllShows(); Task> GetShow(int showId, CancellationToken cancellationToken); - Task> GetShowIdByTitle(int libraryId, string title); + Task> GetShowTitle(int libraryId, int showId); Task> GetShowItems(int showId); Task> GetEpisodeIdsForShow(int showId); Task> GetAllSeasons(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index b788851bc..4909612a7 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -80,16 +80,16 @@ public class TelevisionRepository : ITelevisionRepository .SelectOneAsync(s => s.Id, s => s.Id == showId, cancellationToken); } - public async Task> GetShowIdByTitle(int libraryId, string title) + public async Task> 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.Some(showId) : Option.None); + .Map(Optional); } public async Task> GetEpisodeIdsForShow(int showId) diff --git a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs index b04b5c3b8..5afc03187 100644 --- a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs @@ -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(), Arg.Any(), @@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests Arg.Any()); } + [Test] + public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds() + { + await SeedLibraryGraph(); + _searchIndex.Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .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() { diff --git a/ErsatzTV.Tests/Application/MediaCollections/PlaylistHandlerTests.cs b/ErsatzTV.Tests/Application/MediaCollections/PlaylistHandlerTests.cs new file mode 100644 index 000000000..b0082fcbd --- /dev/null +++ b/ErsatzTV.Tests/Application/MediaCollections/PlaylistHandlerTests.cs @@ -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 result = + await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + 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 result = + await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + } + + [Test] + public async Task AddItems_Should_Add_Valid_RemoteStream_Item_To_Playlist() + { + await SeedPlaylist(1); + await SeedRemoteStream(50); + var handler = MakeHandler(); + + Either 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 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(); + movieRepository.AllMoviesExist(Arg.Any>()).Returns(true); + ITelevisionRepository televisionRepository = Substitute.For(); + televisionRepository.AllShowsExist(Arg.Any>()).Returns(true); + televisionRepository.AllSeasonsExist(Arg.Any>()).Returns(true); + televisionRepository.AllEpisodesExist(Arg.Any>()).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 ids) + { + List movieIds = collectionType == CollectionType.Movie ? ids : []; + List showIds = collectionType == CollectionType.TelevisionShow ? ids : []; + List seasonIds = collectionType == CollectionType.TelevisionSeason ? ids : []; + List episodeIds = collectionType == CollectionType.Episode ? ids : []; + List artistIds = collectionType == CollectionType.Artist ? ids : []; + List musicVideoIds = collectionType == CollectionType.MusicVideo ? ids : []; + List otherVideoIds = collectionType == CollectionType.OtherVideo ? ids : []; + List songIds = collectionType == CollectionType.Song ? ids : []; + List imageIds = collectionType == CollectionType.Image ? ids : []; + List remoteStreamIds = collectionType == CollectionType.RemoteStream ? ids : []; + + return new AddItemsToPlaylist( + playlistId, + movieIds, + showIds, + seasonIds, + episodeIds, + artistIds, + musicVideoIds, + otherVideoIds, + songIds, + imageIds, + remoteStreamIds); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); + + private static TR RightOf(Either either) => + either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v); +} diff --git a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs index 7ab30a490..9b42119e5 100644 --- a/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiErrorResponseMetadataTests.cs @@ -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, diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index 9377d2f7c..cd25e7760 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -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 _workerChannel = null!; + private IEntityLocker _entityLocker = null!; private ChannelController _controller = null!; [SetUp] @@ -34,7 +36,8 @@ public class ChannelControllerTests { _mediator = Substitute.For(); _workerChannel = System.Threading.Channels.Channel.CreateUnbounded(); - _controller = new ChannelController(_workerChannel.Writer, _mediator); + _entityLocker = Substitute.For(); + _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(), Arg.Any()) + .Returns(Option.Some(9)); + _entityLocker.IsPlayoutLocked(9).Returns(true); + + IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None); + + var conflict = result.ShouldBeOfType(); + conflict.Value.ShouldBeOfType().Status.ShouldBe(409); + _workerChannel.Reader.TryRead(out _).ShouldBeFalse(); + } + [Test] public async Task ResetPlayout_Should_Honor_Explicit_Mode() { diff --git a/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs index 95d67c9a0..0971c747c 100644 --- a/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LibrariesControllerTests.cs @@ -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(); - _controller = new LibrariesController(Substitute.For(), _mediator); + _televisionRepository = Substitute.For(); + _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.None); + + IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(999)); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library() + { + _televisionRepository.GetShowTitle(3, 42).Returns(Option.Some("The Office")); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(true); + + IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true)); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(r => + r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan), + Arg.Any()); + } + + [Test] + public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue() + { + _televisionRepository.GetShowTitle(3, 42).Returns(Option.Some("The Office")); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(false); + + IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42)); + + result.ShouldBeOfType(); + } } diff --git a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs index 467b82f30..e3cd79d12 100644 --- a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs @@ -42,7 +42,7 @@ public class LogsControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .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(q => @@ -52,6 +52,74 @@ public class LogsControllerTests Arg.Any()); } + [Test] + public async Task GetLogs_Should_Default_To_Timestamp_Descending() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.SortDescending == true && + SelectsTimestamp(q.SortExpression)), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.SortDescending == false && + SelectsLevel(q.SortExpression)), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => SelectsTimestamp(q.SortExpression)), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.SortDescending == true), + Arg.Any()); + } + + private static bool SelectsTimestamp(System.Linq.Expressions.Expression> 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> 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() { diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index a24049ea0..58b7358e1 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -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, diff --git a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs index cd285aad6..2f8a85930 100644 --- a/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/PlayoutControllerTests.cs @@ -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(); - _controller = new PlayoutController(_mediator); + _entityLocker = Substitute.For(); + _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(); + conflict.Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [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().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [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().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [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().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [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().Value.ShouldBeOfType().Status.ShouldBe(409); + } + + [Test] + public async Task GetAll_Should_Stamp_IsLocked_From_Locker() + { + _entityLocker.IsPlayoutLocked(9).Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)])); + + PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None); + + result.Page.Single().IsLocked.ShouldBeTrue(); + } + // ----- Erase items / history ----- [Test] diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index 9082d7f3b..e675e1ec2 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -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 workerChannel, IMediator mediator) +public class ChannelController( + ChannelWriter workerChannel, + IMediator mediator, + IEntityLocker entityLocker) { [HttpGet("/api/channels")] [EndpointGroupName("general")] @@ -192,6 +196,7 @@ public class ChannelController(ChannelWriter workerCh [EndpointGroupName("general")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task ResetPlayout( string channelNumber, [FromQuery] PlayoutBuildMode? mode, @@ -201,6 +206,14 @@ public class ChannelController(ChannelWriter 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(); diff --git a/ErsatzTV/Controllers/Api/LibrariesController.cs b/ErsatzTV/Controllers/Api/LibrariesController.cs index 15878394b..844a73859 100644 --- a/ErsatzTV/Controllers/Api/LibrariesController.cs +++ b/ErsatzTV/Controllers/Api/LibrariesController.cs @@ -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 ScanShow(int id, [FromBody] ScanShowRequest request) { - if (string.IsNullOrWhiteSpace(request.ShowTitle)) + Option 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 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); diff --git a/ErsatzTV/Controllers/Api/LogsController.cs b/ErsatzTV/Controllers/Api/LogsController.cs index 46c6a1620..19e4aadf9 100644 --- a/ErsatzTV/Controllers/Api/LogsController.cs +++ b/ErsatzTV/Controllers/Api/LogsController.cs @@ -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 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 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> 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( diff --git a/ErsatzTV/Controllers/Api/PlayoutController.cs b/ErsatzTV/Controllers/Api/PlayoutController.cs index d7da83203..a0bff515f 100644 --- a/ErsatzTV/Controllers/Api/PlayoutController.cs +++ b/ErsatzTV/Controllers/Api/PlayoutController.cs @@ -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 Update( int id, [Required] [FromBody] UpdatePlayoutDetailsRequest request, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Option 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 UpdateDefaultDeco( int id, [Required] [FromBody] UpdateDefaultDecoRequest request, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Option 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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceAlternateSchedules( int id, [Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Option 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), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task ReplaceTemplates( int id, [Required] [FromBody] ReplacePlayoutTemplatesRequest request, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Option 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 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 EraseItems(int id, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Option 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 EraseItemsAndHistory(int id, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Option 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 Delete(int id, CancellationToken cancellationToken) { + if (entityLocker.IsPlayoutLocked(id)) + { + return PlayoutLockedProblem(); + } + Either 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 diff --git a/ErsatzTV/Extensions/ApiResults.cs b/ErsatzTV/Extensions/ApiResults.cs index 8ec9a331f..ced802a41 100644 --- a/ErsatzTV/Extensions/ApiResults.cs +++ b/ErsatzTV/Extensions/ApiResults.cs @@ -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)); + /// + /// 409 directly — for a mutation that races a background operation + /// holding a lock (e.g. a playout build in flight). Mirrors . + /// + 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() { diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 847e90c5a..164f12521 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -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", diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 2a89fe5b0..ee373a97f 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -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` | `Left` → `ToErrorResult()`; `Right` → 204 | | `ToGetResult()` | `Option` | `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. diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index e45d238ef..d05500748 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index f46d5d465..0c8779c60 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/docs/e2e-local.md b/docs/e2e-local.md index de54c04f0..cb6d62a1f 100644 --- a/docs/e2e-local.md +++ b/docs/e2e-local.md @@ -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 `). + +## 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.) diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index d3346cc22..7fff75c26 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -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. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 9d6bb692e..fd6beb59d 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + + 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 = {}): Record