diff --git a/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs b/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs index 9f75026c6..0fc4a26b4 100644 --- a/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs +++ b/ErsatzTV.Application/Emby/Commands/DisconnectEmbyHandler.cs @@ -30,12 +30,21 @@ public class DisconnectEmbyHandler : IRequestHandler ids = await _mediaSourceRepository.DeleteAllEmby(); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _embySecretStore.DeleteAll(); - _entityLocker.UnlockRemoteMediaSource(); + // This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) — + // release unconditionally so a throw from any awaited dependency (repo delete, search-index + // commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7). + try + { + List ids = await _mediaSourceRepository.DeleteAllEmby(); + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + await _embySecretStore.DeleteAll(); - return Unit.Default; + return Unit.Default; + } + finally + { + _entityLocker.UnlockRemoteMediaSource(); + } } } diff --git a/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs index 66661fbed..33c3964c5 100644 --- a/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs +++ b/ErsatzTV.Application/Emby/Commands/UpdateEmbyPathReplacementsHandler.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -12,12 +12,29 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler _mediaSourceRepository = mediaSourceRepository; - public Task> Handle( + public async Task> Handle( UpdateEmbyPathReplacements request, - CancellationToken cancellationToken) => - Validate(request, cancellationToken) - .MapT(pms => MergePathReplacements(request, pms)) - .Bind(v => v.ToEitherAsync()); + CancellationToken cancellationToken) + { + Option maybeSource = + await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken); + + return await maybeSource.Match( + Some: async embyMediaSource => + { + Option maybeError = ValidateItems(request, embyMediaSource); + return await maybeError.Match( + Some: error => Task.FromResult(Left(error)), + None: async () => + { + await MergePathReplacements(request, embyMediaSource); + return Right(Unit.Default); + }); + }, + None: () => Task.FromResult( + Left( + BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist.")))); + } private Task MergePathReplacements( UpdateEmbyPathReplacements request, @@ -37,12 +54,38 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath }; - private Task> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) => - EmbyMediaSourceMustExist(request, cancellationToken); + // Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by + // EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the + // mutation is all-or-nothing. + private static Option ValidateItems( + UpdateEmbyPathReplacements request, + EmbyMediaSource embyMediaSource) + { + List items = request.PathReplacements ?? []; - private Task> EmbyMediaSourceMustExist( - UpdateEmbyPathReplacements request, CancellationToken cancellationToken) => - _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken) - .Map(v => v.ToValidation( - $"Emby media source {request.EmbyMediaSourceId} does not exist.")); + if (items.Any(item => item is null)) + { + return BaseError.New("Path replacement items must not be null."); + } + + if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath))) + { + return BaseError.New("Each path replacement requires a non-blank Emby path and local path."); + } + + var existingIds = (embyMediaSource.PathReplacements ?? new List()) + .Map(pr => pr.Id) + .ToList(); + var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id)) + .Map(item => item.Id) + .ToList(); + if (foreignIds.Count > 0) + { + return BaseError.New( + $"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " + + $"{request.EmbyMediaSourceId}."); + } + + return Option.None; + } } diff --git a/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs index babacadcc..b533cc3f7 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs @@ -30,12 +30,21 @@ public class DisconnectJellyfinHandler : IRequestHandler ids = await _mediaSourceRepository.DeleteAllJellyfin(); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _jellyfinSecretStore.DeleteAll(); - _entityLocker.UnlockRemoteMediaSource(); + // This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) — + // release unconditionally so a throw from any awaited dependency (repo delete, search-index + // commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7). + try + { + List ids = await _mediaSourceRepository.DeleteAllJellyfin(); + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + await _jellyfinSecretStore.DeleteAll(); - return Unit.Default; + return Unit.Default; + } + finally + { + _entityLocker.UnlockRemoteMediaSource(); + } } } diff --git a/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs b/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs index 9ac74f1eb..c74408f09 100644 --- a/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs +++ b/ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -12,12 +12,29 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler _mediaSourceRepository = mediaSourceRepository; - public Task> Handle( + public async Task> Handle( UpdateJellyfinPathReplacements request, - CancellationToken cancellationToken) => - Validate(request) - .MapT(pms => MergePathReplacements(request, pms)) - .Bind(v => v.ToEitherAsync()); + CancellationToken cancellationToken) + { + Option maybeSource = + await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId); + + return await maybeSource.Match( + Some: async jellyfinMediaSource => + { + Option maybeError = ValidateItems(request, jellyfinMediaSource); + return await maybeError.Match( + Some: error => Task.FromResult(Left(error)), + None: async () => + { + await MergePathReplacements(request, jellyfinMediaSource); + return Right(Unit.Default); + }); + }, + None: () => Task.FromResult( + Left( + BaseError.New($"Jellyfin media source {request.JellyfinMediaSourceId} does not exist.")))); + } private Task MergePathReplacements( UpdateJellyfinPathReplacements request, @@ -37,12 +54,38 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath }; - private Task> Validate(UpdateJellyfinPathReplacements request) => - JellyfinMediaSourceMustExist(request); + // Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by + // JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the + // mutation is all-or-nothing. + private static Option ValidateItems( + UpdateJellyfinPathReplacements request, + JellyfinMediaSource jellyfinMediaSource) + { + List items = request.PathReplacements ?? []; - private Task> JellyfinMediaSourceMustExist( - UpdateJellyfinPathReplacements request) => - _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId) - .Map(v => v.ToValidation( - $"Jellyfin media source {request.JellyfinMediaSourceId} does not exist.")); + if (items.Any(item => item is null)) + { + return BaseError.New("Path replacement items must not be null."); + } + + if (items.Any(item => string.IsNullOrWhiteSpace(item.JellyfinPath) || string.IsNullOrWhiteSpace(item.LocalPath))) + { + return BaseError.New("Each path replacement requires a non-blank Jellyfin path and local path."); + } + + var existingIds = (jellyfinMediaSource.PathReplacements ?? new List()) + .Map(pr => pr.Id) + .ToList(); + var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id)) + .Map(item => item.Id) + .ToList(); + if (foreignIds.Count > 0) + { + return BaseError.New( + $"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Jellyfin media source " + + $"{request.JellyfinMediaSourceId}."); + } + + return Option.None; + } } diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs index 7dc41a2c1..0f5976a34 100644 --- a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs @@ -1,4 +1,5 @@ -using System.Threading.Channels; +using System.IO.Abstractions; +using System.Threading.Channels; using ErsatzTV.Application.MediaSources; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -14,15 +15,18 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase, { private readonly IDbContextFactory _dbContextFactory; private readonly IEntityLocker _entityLocker; + private readonly IFileSystem _fileSystem; private readonly ChannelWriter _scannerWorkerChannel; public CreateLocalLibraryHandler( ChannelWriter scannerWorkerChannel, IEntityLocker entityLocker, + IFileSystem fileSystem, IDbContextFactory dbContextFactory) { _scannerWorkerChannel = scannerWorkerChannel; _entityLocker = entityLocker; + _fileSystem = fileSystem; _dbContextFactory = dbContextFactory; } @@ -31,7 +35,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request); + Validation validation = await Validate(_fileSystem, dbContext, request); return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary)); } @@ -61,11 +65,13 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase, } private static Task> Validate( + IFileSystem fileSystem, TvContext dbContext, CreateLocalLibrary request) => MediaSourceMustExist(dbContext, request) .BindT(localLibrary => NameMustBeValid(request, localLibrary)) - .BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary)); + .BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary)) + .BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary)); private static Task> MediaSourceMustExist( TvContext dbContext, diff --git a/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs b/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs index 795d430fd..2fef7469f 100644 --- a/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs +++ b/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs @@ -1,4 +1,5 @@ -using ErsatzTV.Core; +using System.IO.Abstractions; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -14,6 +15,28 @@ public abstract class LocalLibraryHandlerBase .Bind(_ => request.NotLongerThan(50)(c => c.Name)) .Map(_ => localLibrary).AsTask(); + /// + /// Validates that every NEW path (Id < 1 — see design #202 §C2) exists on the + /// filesystem. Existing rows are exempt: an unmounted share must not block saving a rename, + /// matching Blazor's behavior of only checking existence when a path is added. + /// + protected static Task> NewPathsMustExist( + IFileSystem fileSystem, + LocalLibrary localLibrary) + { + List missing = localLibrary.Paths + .Filter(p => p.Id < 1) + .Filter(p => !fileSystem.Directory.Exists(p.Path)) + .Map(p => p.Path) + .ToList(); + + Validation result = missing.Count == 0 + ? Success(localLibrary) + : Fail($"Path(s) do not exist on the filesystem: {string.Join(", ", missing)}"); + + return result.AsTask(); + } + protected static async Task> PathsMustBeValid( TvContext dbContext, LocalLibrary localLibrary, diff --git a/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs b/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs index 0a85786d7..b9f05843c 100644 --- a/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs @@ -79,10 +79,31 @@ public class MoveLocalLibraryPathHandler : IRequestHandler> Validate( TvContext dbContext, MoveLocalLibraryPath request, - CancellationToken cancellationToken) => - (await LibraryPathMustExist(dbContext, request, cancellationToken), - await LocalLibraryMustExist(dbContext, request, cancellationToken)) - .Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary)); + CancellationToken cancellationToken) + { + Validation parameters = + (await LibraryPathMustExist(dbContext, request, cancellationToken), + await LocalLibraryMustExist(dbContext, request, cancellationToken)) + .Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary)); + + return parameters + .Bind(TargetLibraryMustDiffer) + .Bind(TargetLibraryMustMatchMediaKind); + } + + // Blazor's move dialog filters the target-library picker to same-kind, source-excluded + // libraries only (MoveLocalLibraryPathDialog.razor:82); an API/MCP client bypasses that + // client-side filter today, so #202 moves both invariants into the handler (design #202 §C5, + // finding 3). + private static Validation TargetLibraryMustDiffer(Parameters parameters) => + parameters.LibraryPath.LibraryId == parameters.Library.Id + ? Fail("Target library must be different from the source path's current library") + : Success(parameters); + + private static Validation TargetLibraryMustMatchMediaKind(Parameters parameters) => + parameters.LibraryPath.Library.MediaKind != parameters.Library.MediaKind + ? Fail("Target library must have the same media kind as the source path's library") + : Success(parameters); private static Task> LibraryPathMustExist( TvContext dbContext, diff --git a/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs index b20728c12..8e4cac975 100644 --- a/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs @@ -1,4 +1,5 @@ -using System.Threading.Channels; +using System.IO.Abstractions; +using System.Threading.Channels; using Dapper; using ErsatzTV.Application.MediaSources; using ErsatzTV.Core; @@ -17,17 +18,20 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase, { private readonly IDbContextFactory _dbContextFactory; private readonly IEntityLocker _entityLocker; + private readonly IFileSystem _fileSystem; private readonly ChannelWriter _scannerWorkerChannel; private readonly ISearchIndex _searchIndex; public UpdateLocalLibraryHandler( ChannelWriter scannerWorkerChannel, IEntityLocker entityLocker, + IFileSystem fileSystem, ISearchIndex searchIndex, IDbContextFactory dbContextFactory) { _scannerWorkerChannel = scannerWorkerChannel; _entityLocker = entityLocker; + _fileSystem = fileSystem; _searchIndex = searchIndex; _dbContextFactory = dbContextFactory; } @@ -37,7 +41,8 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); + Validation validation = + await Validate(_fileSystem, dbContext, request, cancellationToken); return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters)); } @@ -114,13 +119,15 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase, } private static Task> Validate( + IFileSystem fileSystem, TvContext dbContext, UpdateLocalLibrary request, CancellationToken cancellationToken) => LocalLibraryMustExist(dbContext, request, cancellationToken) .BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters)) .BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id) - .MapT(_ => parameters)); + .MapT(_ => parameters)) + .BindT(parameters => NewPathsMustExist(fileSystem, parameters.Incoming).MapT(_ => parameters)); private static Task> LocalLibraryMustExist( TvContext dbContext, diff --git a/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs b/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs index c20d4231c..4d1986bfb 100644 --- a/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/SignOutOfPlexHandler.cs @@ -27,12 +27,22 @@ public class SignOutOfPlexHandler : IRequestHandler> Handle(SignOutOfPlex request, CancellationToken cancellationToken) { - List ids = await _mediaSourceRepository.DeleteAllPlex(); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _plexSecretStore.DeleteAll(); - _entityLocker.UnlockPlex(); + // Terminal handler (no lock handoff): release the Plex lock on EVERY exit via finally so a throw + // from any awaited dependency (repo delete, search-index commit, secret store) cannot wedge Plex + // locked at 409 until restart (#202 §C6 / finding 7). This is the UNCONDITIONAL-finally case — + // contrast the pin-flow handlers, which hand the lock off and must NOT use a blanket finally. + try + { + List ids = await _mediaSourceRepository.DeleteAllPlex(); + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + await _plexSecretStore.DeleteAll(); - return Unit.Default; + return Unit.Default; + } + finally + { + _entityLocker.UnlockPlex(); + } } } diff --git a/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs b/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs index bc4fbfd48..2a8e0c5a4 100644 --- a/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs @@ -1,5 +1,6 @@ -using System.Threading.Channels; +using System.Threading.Channels; using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Plex; namespace ErsatzTV.Application.Plex; @@ -7,34 +8,59 @@ namespace ErsatzTV.Application.Plex; public class TryCompletePlexPinFlowHandler : IRequestHandler> { private readonly ChannelWriter _channel; + private readonly IEntityLocker _entityLocker; private readonly IPlexTvApiClient _plexTvApiClient; public TryCompletePlexPinFlowHandler( IPlexTvApiClient plexTvApiClient, - ChannelWriter channel) + ChannelWriter channel, + IEntityLocker entityLocker) { _plexTvApiClient = plexTvApiClient; _channel = channel; + _entityLocker = entityLocker; } public async Task> Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken) { + // Lock-release discipline (#202 §C1.6 / §C6): the Plex lock this pin flow holds is released + // ONLY on non-handoff exits — the 2-minute timeout (Task.Delay throws + // OperationCanceledException), a poll exception, a failed enqueue, or the (effectively dead) + // return-false at loop entry. On SUCCESS the lock is HANDED OFF to SynchronizePlexMediaSources, + // whose handler is the sole releaser after server discovery (SynchronizePlexMediaSourcesHandler). + // This is deliberately NOT an unconditional finally: a blanket release here would double-release + // AND release before discovery, re-opening the finding-5 race (an empty server list reading as + // success). Contrast the terminal SignOutOfPlexHandler, which DOES use finally (it has no handoff). using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); CancellationToken token = linkedTokenSource.Token; - while (!token.IsCancellationRequested) + try { - bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin); - if (result) + while (!token.IsCancellationRequested) { - await _channel.WriteAsync(new SynchronizePlexMediaSources(), token); - return true; + bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin); + if (result) + { + // hand the lock off to the sync handler — do NOT release on this success path + await _channel.WriteAsync(new SynchronizePlexMediaSources(), token); + return true; + } + + await Task.Delay(TimeSpan.FromSeconds(1), token); } - await Task.Delay(TimeSpan.FromSeconds(1), token); + // effectively unreachable (Task.Delay throws on cancellation before the loop condition is + // re-evaluated) but if the flow ever ends here it abandoned without auth → release + _entityLocker.UnlockPlex(); + return false; + } + catch (Exception) + { + // non-handoff exit: timeout-throw, poll exception, or failed enqueue — release the lock so an + // abandoned flow does not wedge Plex locked, then rethrow (PlexService logs it as before) + _entityLocker.UnlockPlex(); + throw; } - - return false; } } diff --git a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs index 6192f90dd..70a8b02c0 100644 --- a/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs +++ b/ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs @@ -35,14 +35,56 @@ public class private static PlexPathReplacement Project(PlexPathReplacementItem vm) => new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; - private Task> Validate( + private async Task> Validate( UpdatePlexPathReplacements request, - CancellationToken cancellationToken) => - PlexMediaSourceMustExist(request, cancellationToken); + CancellationToken cancellationToken) + { + Option maybeSource = + await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken); - private Task> PlexMediaSourceMustExist( + foreach (PlexMediaSource plexMediaSource in maybeSource) + { + return ValidatePathReplacements(request, plexMediaSource); + } + + return Fail( + BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist.")); + } + + // Programmatic clients now reach this handler directly (#202), so validate what the Blazor form + // enforced plus the cross-source ownership hole (finding 2c/8): reject a null list, null items, + // blank RemotePath/LocalPath, and any positive Id NOT owned by this source — all before any write, + // so there is no partial mutation. + private static Validation ValidatePathReplacements( UpdatePlexPathReplacements request, - CancellationToken cancellationToken) => - _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken) - .Map(v => v.ToValidation($"Plex media source {request.PlexMediaSourceId} does not exist.")); + PlexMediaSource plexMediaSource) + { + List items = request.PathReplacements; + if (items is null) + { + return Fail(BaseError.New("[PathReplacements] is required")); + } + + if (items.Any(i => i is null)) + { + return Fail(BaseError.New("[PathReplacements] must not contain null items")); + } + + if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath))) + { + return Fail( + BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath")); + } + + var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet(); + List foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList(); + if (foreignIds.Count > 0) + { + return Fail( + BaseError.New( + $"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}")); + } + + return Success(plexMediaSource); + } } diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs new file mode 100644 index 000000000..2f824207a --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalLibraryDetailResponseModel( + int Id, + string Name, + LibraryMediaKind MediaKind, + bool IsLocked, + int MediaItemCount, + List Paths); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs new file mode 100644 index 000000000..9fd9084c0 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs new file mode 100644 index 000000000..7bb4bb8d2 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool IsLocked); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs new file mode 100644 index 000000000..d8e192c96 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalPathCheckResponseModel(bool Exists); diff --git a/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs new file mode 100644 index 000000000..40883f44a --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record PathReplacementResponseModel(int Id, string RemotePath, string LocalPath); diff --git a/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs new file mode 100644 index 000000000..141db29f3 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record PlexPinFlowResponseModel(string AuthUrl); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs new file mode 100644 index 000000000..c514a1af6 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +// NEVER include the raw API key in this DTO — the secure connection contract (design #202 finding 1) +// requires the key never leave the server; callers only learn whether one is configured. +public record RemoteConnectionResponseModel(string Address, bool HasApiKey); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs new file mode 100644 index 000000000..307f0b853 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record RemoteLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs new file mode 100644 index 000000000..3e18cc656 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record RemoteMediaSourceItemResponseModel(int Id, string Name, string Address); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs new file mode 100644 index 000000000..0654d7bf7 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record RemoteMediaSourceStateResponseModel( + bool IsAuthorized, + bool IsLocked, + List Servers); diff --git a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs index 7cdba2aa7..afa8038de 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs @@ -7,6 +7,14 @@ public interface ILibraryRepository Task Add(LibraryPath libraryPath); Task> GetLibrary(int libraryId); Task> GetLocal(int libraryId); + + /// + /// Resolves the owning library id for a library path, without loading the full entity graph. + /// Used by the local-libraries API to pre-check a path's existence/ownership before a move + /// (see design #202 §C6 — the 404/lock-check for POST .../paths/{pathId}/move). + /// + Task> GetLibraryIdForPath(int libraryPathId); + Task> GetAll(); Task UpdateLastScan(Library library); Task UpdateLastScan(LibraryPath libraryPath); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs index cdf5aa9ed..64eab6b38 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs @@ -39,6 +39,15 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory> GetLibraryIdForPath(int libraryPathId) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); + int? libraryId = await dbContext.Connection.QuerySingleOrDefaultAsync( + "SELECT LibraryId FROM LibraryPath WHERE Id = @LibraryPathId", + new { LibraryPathId = libraryPathId }); + return Optional(libraryId); + } + public async Task> GetAll() { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index 7541b221f..a66b0d7cf 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -396,11 +396,14 @@ public class MediaSourceRepository(IDbContextFactory dbContextFactory foreach (PlexPathReplacement update in toUpdate) { + // Scope the UPDATE to the owning source (#202 finding 2c): without the PlexMediaSourceId + // predicate a PUT to source A could overwrite source B's row by id (defense-in-depth behind + // the handler's ownership guard). await dbContext.Connection.ExecuteAsync( @"UPDATE PlexPathReplacement SET PlexPath = @PlexPath, LocalPath = @LocalPath - WHERE Id = @Id", - new { update.PlexPath, update.LocalPath, update.Id }); + WHERE Id = @Id AND PlexMediaSourceId = @PlexMediaSourceId", + new { update.PlexPath, update.LocalPath, update.Id, PlexMediaSourceId = plexMediaSourceId }); } foreach (PlexPathReplacement delete in toDelete) @@ -692,8 +695,14 @@ public class MediaSourceRepository(IDbContextFactory dbContextFactory await dbContext.Connection.ExecuteAsync( @"UPDATE JellyfinPathReplacement SET JellyfinPath = @JellyfinPath, LocalPath = @LocalPath - WHERE Id = @Id", - new { update.JellyfinPath, update.LocalPath, update.Id }); + WHERE Id = @Id AND JellyfinMediaSourceId = @JellyfinMediaSourceId", + new + { + update.JellyfinPath, + update.LocalPath, + update.Id, + JellyfinMediaSourceId = jellyfinMediaSourceId + }); } foreach (JellyfinPathReplacement delete in toDelete) @@ -890,8 +899,14 @@ public class MediaSourceRepository(IDbContextFactory dbContextFactory await dbContext.Connection.ExecuteAsync( @"UPDATE EmbyPathReplacement SET EmbyPath = @EmbyPath, LocalPath = @LocalPath - WHERE Id = @Id", - new { update.EmbyPath, update.LocalPath, update.Id }); + WHERE Id = @Id AND EmbyMediaSourceId = @EmbyMediaSourceId", + new + { + update.EmbyPath, + update.LocalPath, + update.Id, + EmbyMediaSourceId = embyMediaSourceId + }); } foreach (EmbyPathReplacement delete in toDelete) diff --git a/ErsatzTV.Tests/Application/Emby/DisconnectEmbyHandlerTests.cs b/ErsatzTV.Tests/Application/Emby/DisconnectEmbyHandlerTests.cs new file mode 100644 index 000000000..4efd4eab6 --- /dev/null +++ b/ErsatzTV.Tests/Application/Emby/DisconnectEmbyHandlerTests.cs @@ -0,0 +1,85 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using LanguageExt; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Emby; + +[TestFixture] +public class DisconnectEmbyHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private IEmbySecretStore _embySecretStore = null!; + private IEntityLocker _entityLocker = null!; + private ISearchIndex _searchIndex = null!; + private DisconnectEmbyHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _embySecretStore = Substitute.For(); + _entityLocker = Substitute.For(); + _searchIndex = Substitute.For(); + _handler = new DisconnectEmbyHandler( + _mediaSourceRepository, + _embySecretStore, + _entityLocker, + _searchIndex); + } + + [Test] + public async Task Handle_Should_Release_Lock_On_Success() + { + _mediaSourceRepository.DeleteAllEmby().Returns(new List()); + + Either result = await _handler.Handle(new DisconnectEmby(), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_Repository_Delete_Throws() + { + _mediaSourceRepository.DeleteAllEmby().ThrowsAsync(new InvalidOperationException("db exploded")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SearchIndex_RemoveItems_Throws() + { + _mediaSourceRepository.DeleteAllEmby().Returns([1, 2]); + _searchIndex.RemoveItems(Arg.Any>()).ThrowsAsync(new InvalidOperationException("index down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SecretStore_DeleteAll_Throws() + { + _mediaSourceRepository.DeleteAllEmby().Returns(new List()); + _embySecretStore.DeleteAll().ThrowsAsync(new InvalidOperationException("secret store down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } +} diff --git a/ErsatzTV.Tests/Application/Emby/UpdateEmbyPathReplacementsHandlerTests.cs b/ErsatzTV.Tests/Application/Emby/UpdateEmbyPathReplacementsHandlerTests.cs new file mode 100644 index 000000000..35781b8a7 --- /dev/null +++ b/ErsatzTV.Tests/Application/Emby/UpdateEmbyPathReplacementsHandlerTests.cs @@ -0,0 +1,136 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Emby; + +[TestFixture] +public class UpdateEmbyPathReplacementsHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private UpdateEmbyPathReplacementsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _handler = new UpdateEmbyPathReplacementsHandler(_mediaSourceRepository); + } + + [Test] + public async Task Handle_Should_Return_422_When_Source_Does_Not_Exist() + { + _mediaSourceRepository.GetEmby(99, Arg.Any()).Returns(Option.None); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(99, [new EmbyPathReplacementItem(0, "/emby", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Cross_Source_Id_And_Perform_No_Mutation() + { + var source = new EmbyMediaSource + { + Id = 1, + PathReplacements = [new EmbyPathReplacement { Id = 1, EmbyPath = "/emby1", LocalPath = "/l1" }] + }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + + // id 999 belongs to some other source, not this one + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(1, [new EmbyPathReplacementItem(999, "/emby", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [TestCase("", "/local")] + [TestCase("/emby", "")] + [TestCase(" ", " ")] + public async Task Handle_Should_Return_422_For_Blank_Paths(string embyPath, string localPath) + { + var source = new EmbyMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(1, [new EmbyPathReplacementItem(0, embyPath, localPath)]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Null_Items_List_Defense() + { + var source = new EmbyMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements(1, [null!]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + } + + [Test] + public async Task Handle_Should_Merge_Add_Update_Delete_On_Valid_Request() + { + var source = new EmbyMediaSource + { + Id = 1, + PathReplacements = + [ + new EmbyPathReplacement { Id = 1, EmbyPath = "/old", LocalPath = "/old-local" }, + new EmbyPathReplacement { Id = 2, EmbyPath = "/gone", LocalPath = "/gone-local" } + ] + }; + _mediaSourceRepository.GetEmby(1, Arg.Any()).Returns(Option.Some(source)); + _mediaSourceRepository.UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()) + .Returns(Unit.Default); + + Either result = await _handler.Handle( + new UpdateEmbyPathReplacements( + 1, + [ + new EmbyPathReplacementItem(1, "/updated", "/updated-local"), // update + new EmbyPathReplacementItem(0, "/new", "/new-local") // add + // id 2 is absent -> delete + ]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await _mediaSourceRepository.Received(1).UpdatePathReplacements( + 1, + Arg.Is>(l => l.Count == 1 && l[0].EmbyPath == "/new"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 1 && l[0].EmbyPath == "/updated"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 2)); + } +} diff --git a/ErsatzTV.Tests/Application/Jellyfin/DisconnectJellyfinHandlerTests.cs b/ErsatzTV.Tests/Application/Jellyfin/DisconnectJellyfinHandlerTests.cs new file mode 100644 index 000000000..a9367962e --- /dev/null +++ b/ErsatzTV.Tests/Application/Jellyfin/DisconnectJellyfinHandlerTests.cs @@ -0,0 +1,85 @@ +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using LanguageExt; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Jellyfin; + +[TestFixture] +public class DisconnectJellyfinHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private IJellyfinSecretStore _jellyfinSecretStore = null!; + private IEntityLocker _entityLocker = null!; + private ISearchIndex _searchIndex = null!; + private DisconnectJellyfinHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _jellyfinSecretStore = Substitute.For(); + _entityLocker = Substitute.For(); + _searchIndex = Substitute.For(); + _handler = new DisconnectJellyfinHandler( + _mediaSourceRepository, + _jellyfinSecretStore, + _entityLocker, + _searchIndex); + } + + [Test] + public async Task Handle_Should_Release_Lock_On_Success() + { + _mediaSourceRepository.DeleteAllJellyfin().Returns(new List()); + + Either result = await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_Repository_Delete_Throws() + { + _mediaSourceRepository.DeleteAllJellyfin().ThrowsAsync(new InvalidOperationException("db exploded")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SearchIndex_RemoveItems_Throws() + { + _mediaSourceRepository.DeleteAllJellyfin().Returns([1, 2]); + _searchIndex.RemoveItems(Arg.Any>()).ThrowsAsync(new InvalidOperationException("index down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } + + [Test] + public async Task Handle_Should_Release_Lock_When_SecretStore_DeleteAll_Throws() + { + _mediaSourceRepository.DeleteAllJellyfin().Returns(new List()); + _jellyfinSecretStore.DeleteAll().ThrowsAsync(new InvalidOperationException("secret store down")); + + await Should.ThrowAsync( + async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None)); + + _entityLocker.Received(1).UnlockRemoteMediaSource(); + } +} diff --git a/ErsatzTV.Tests/Application/Jellyfin/UpdateJellyfinPathReplacementsHandlerTests.cs b/ErsatzTV.Tests/Application/Jellyfin/UpdateJellyfinPathReplacementsHandlerTests.cs new file mode 100644 index 000000000..ebf10c34d --- /dev/null +++ b/ErsatzTV.Tests/Application/Jellyfin/UpdateJellyfinPathReplacementsHandlerTests.cs @@ -0,0 +1,136 @@ +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Jellyfin; + +[TestFixture] +public class UpdateJellyfinPathReplacementsHandlerTests +{ + private IMediaSourceRepository _mediaSourceRepository = null!; + private UpdateJellyfinPathReplacementsHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _mediaSourceRepository = Substitute.For(); + _handler = new UpdateJellyfinPathReplacementsHandler(_mediaSourceRepository); + } + + [Test] + public async Task Handle_Should_Return_422_When_Source_Does_Not_Exist() + { + _mediaSourceRepository.GetJellyfin(99).Returns(Option.None); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(99, [new JellyfinPathReplacementItem(0, "/jf", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Cross_Source_Id_And_Perform_No_Mutation() + { + var source = new JellyfinMediaSource + { + Id = 1, + PathReplacements = [new JellyfinPathReplacement { Id = 1, JellyfinPath = "/jf1", LocalPath = "/l1" }] + }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + + // id 999 belongs to some other source, not this one + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(1, [new JellyfinPathReplacementItem(999, "/jf", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [TestCase("", "/local")] + [TestCase("/jf", "")] + [TestCase(" ", " ")] + public async Task Handle_Should_Return_422_For_Blank_Paths(string jellyfinPath, string localPath) + { + var source = new JellyfinMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(1, [new JellyfinPathReplacementItem(0, jellyfinPath, localPath)]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task Handle_Should_Return_422_For_Null_Items_List_Defense() + { + var source = new JellyfinMediaSource { Id = 1, PathReplacements = [] }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements(1, [null!]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + } + + [Test] + public async Task Handle_Should_Merge_Add_Update_Delete_On_Valid_Request() + { + var source = new JellyfinMediaSource + { + Id = 1, + PathReplacements = + [ + new JellyfinPathReplacement { Id = 1, JellyfinPath = "/old", LocalPath = "/old-local" }, + new JellyfinPathReplacement { Id = 2, JellyfinPath = "/gone", LocalPath = "/gone-local" } + ] + }; + _mediaSourceRepository.GetJellyfin(1).Returns(Option.Some(source)); + _mediaSourceRepository.UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()) + .Returns(Unit.Default); + + Either result = await _handler.Handle( + new UpdateJellyfinPathReplacements( + 1, + [ + new JellyfinPathReplacementItem(1, "/updated", "/updated-local"), // update + new JellyfinPathReplacementItem(0, "/new", "/new-local") // add + // id 2 is absent -> delete + ]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await _mediaSourceRepository.Received(1).UpdatePathReplacements( + 1, + Arg.Is>(l => l.Count == 1 && l[0].JellyfinPath == "/new"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 1 && l[0].JellyfinPath == "/updated"), + Arg.Is>(l => l.Count == 1 && l[0].Id == 2)); + } +} diff --git a/ErsatzTV.Tests/Application/Libraries/CreateLocalLibraryHandlerTests.cs b/ErsatzTV.Tests/Application/Libraries/CreateLocalLibraryHandlerTests.cs new file mode 100644 index 000000000..47d2b4a58 --- /dev/null +++ b/ErsatzTV.Tests/Application/Libraries/CreateLocalLibraryHandlerTests.cs @@ -0,0 +1,102 @@ +using System.IO.Abstractions; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using ThreadingChannel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Libraries; + +[TestFixture] +public class CreateLocalLibraryHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Return_422_When_New_Path_Does_Not_Exist() + { + await SeedLocalMediaSource(); + + CreateLocalLibraryHandler handler = CreateHandler(new MockFileSystem()); + + Either result = await handler.Handle( + new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + result.IfLeft(error => error.Value.ShouldContain("/media/movies")); + } + + [Test] + public async Task Handle_Should_List_Only_The_Missing_Paths_When_Mixed() + { + await SeedLocalMediaSource(); + + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory("/media/movies"); + + CreateLocalLibraryHandler handler = CreateHandler(fileSystem); + + Either result = await handler.Handle( + new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies", "/media/missing"]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + result.IfLeft(error => + { + error.Value.ShouldContain("/media/missing"); + error.Value.ShouldNotContain("/media/movies"); + }); + } + + [Test] + public async Task Handle_Should_Create_When_All_New_Paths_Exist() + { + await SeedLocalMediaSource(); + + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory("/media/movies"); + + CreateLocalLibraryHandler handler = CreateHandler(fileSystem); + + Either result = await handler.Handle( + new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + result.IfRight(vm => + { + vm.Name.ShouldBe("Movies"); + vm.MediaKind.ShouldBe(LibraryMediaKind.Movies); + }); + } + + private CreateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) => + new( + ThreadingChannel.CreateUnbounded().Writer, + Substitute.For(), + fileSystem, + _db.Factory); + + private async Task SeedLocalMediaSource() + { + await using TvContext context = _db.CreateContext(); + await context.LocalMediaSources.AddAsync(new LocalMediaSource()); + await context.SaveChangesAsync(); + } +} diff --git a/ErsatzTV.Tests/Application/Libraries/MoveLocalLibraryPathHandlerTests.cs b/ErsatzTV.Tests/Application/Libraries/MoveLocalLibraryPathHandlerTests.cs new file mode 100644 index 000000000..2b3dde670 --- /dev/null +++ b/ErsatzTV.Tests/Application/Libraries/MoveLocalLibraryPathHandlerTests.cs @@ -0,0 +1,138 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Libraries; + +[TestFixture] +public class MoveLocalLibraryPathHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Return_422_When_Target_Library_Is_Same_As_Source() + { + (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", LibraryMediaKind.Movies); + + MoveLocalLibraryPathHandler handler = CreateHandler(); + + Either result = + await handler.Handle(new MoveLocalLibraryPath(pathId, libraryId), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + result.IfLeft(error => error.Value.ShouldContain("different")); + + await using TvContext dbContext = _db.CreateContext(); + (await dbContext.LibraryPaths.FindAsync(pathId))!.LibraryId.ShouldBe(libraryId); + } + + [Test] + public async Task Handle_Should_Return_422_When_Target_Library_Has_Different_MediaKind() + { + (int sourceLibraryId, int pathId) = await SeedLibraryWithPath("Movies", LibraryMediaKind.Movies); + int targetLibraryId = await SeedLibrary("TV Shows", LibraryMediaKind.Shows); + + MoveLocalLibraryPathHandler handler = CreateHandler(); + + Either result = + await handler.Handle(new MoveLocalLibraryPath(pathId, targetLibraryId), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + result.IfLeft(error => error.Value.ShouldContain("media kind")); + + await using TvContext dbContext = _db.CreateContext(); + (await dbContext.LibraryPaths.FindAsync(pathId))!.LibraryId.ShouldBe(sourceLibraryId); + } + + [Test] + public async Task Handle_Should_Move_Path_When_Target_Library_Is_Different_And_Same_MediaKind() + { + (_, int pathId) = await SeedLibraryWithPath("Movies", LibraryMediaKind.Movies); + int targetLibraryId = await SeedLibrary("More Movies", LibraryMediaKind.Movies); + + MoveLocalLibraryPathHandler handler = CreateHandler(); + + Either result = + await handler.Handle(new MoveLocalLibraryPath(pathId, targetLibraryId), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext dbContext = _db.CreateContext(); + (await dbContext.LibraryPaths.FindAsync(pathId))!.LibraryId.ShouldBe(targetLibraryId); + } + + [Test] + public async Task Handle_Should_Return_422_When_Path_Does_Not_Exist() + { + int targetLibraryId = await SeedLibrary("Movies", LibraryMediaKind.Movies); + + MoveLocalLibraryPathHandler handler = CreateHandler(); + + Either result = + await handler.Handle(new MoveLocalLibraryPath(9999, targetLibraryId), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + } + + private MoveLocalLibraryPathHandler CreateHandler() => + new( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _db.Factory, + NullLogger.Instance); + + private async Task SeedLibrary(string name, LibraryMediaKind mediaKind) + { + await using TvContext context = _db.CreateContext(); + var source = new LocalMediaSource + { + Libraries = + [ + new LocalLibrary { Name = name, MediaKind = mediaKind, Paths = [] } + ] + }; + await context.LocalMediaSources.AddAsync(source); + await context.SaveChangesAsync(); + return source.Libraries[0].Id; + } + + private async Task<(int LibraryId, int PathId)> SeedLibraryWithPath(string name, LibraryMediaKind mediaKind) + { + await using TvContext context = _db.CreateContext(); + var source = new LocalMediaSource + { + Libraries = + [ + new LocalLibrary + { + Name = name, + MediaKind = mediaKind, + Paths = [new LibraryPath { Path = "/media/one" }] + } + ] + }; + await context.LocalMediaSources.AddAsync(source); + await context.SaveChangesAsync(); + return (source.Libraries[0].Id, source.Libraries[0].Paths[0].Id); + } +} diff --git a/ErsatzTV.Tests/Application/Libraries/UpdateLocalLibraryHandlerTests.cs b/ErsatzTV.Tests/Application/Libraries/UpdateLocalLibraryHandlerTests.cs new file mode 100644 index 000000000..a78883f1f --- /dev/null +++ b/ErsatzTV.Tests/Application/Libraries/UpdateLocalLibraryHandlerTests.cs @@ -0,0 +1,161 @@ +using System.IO.Abstractions; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; +using ThreadingChannel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Libraries; + +[TestFixture] +public class UpdateLocalLibraryHandlerTests +{ + private InMemoryTvContext _db = null!; + + [SetUp] + public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task Handle_Should_Return_422_When_New_Path_Does_Not_Exist() + { + (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/movies"); + + UpdateLocalLibraryHandler handler = CreateHandler(new MockFileSystem()); + + Either result = await handler.Handle( + new UpdateLocalLibrary( + libraryId, + "Movies", + [ + new UpdateLocalLibraryPath(pathId, "/media/movies"), + new UpdateLocalLibraryPath(0, "/media/missing") + ]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + result.IfLeft(error => error.Value.ShouldContain("/media/missing")); + } + + [Test] + public async Task Handle_Should_Exempt_Existing_Paths_From_The_Existence_Check() + { + // the existing row's directory is NOT in the mock filesystem (simulates an unmounted + // share) -- design #202 §C2: existing rows are exempt so a rename doesn't fail on a + // temporarily-missing mount. + (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/movies"); + + UpdateLocalLibraryHandler handler = CreateHandler(new MockFileSystem()); + + Either result = await handler.Handle( + new UpdateLocalLibrary( + libraryId, + "Movies Renamed", + [new UpdateLocalLibraryPath(pathId, "/media/movies")]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + } + + [Test] + public async Task Handle_Should_Identify_Paths_By_Normalized_String_Not_Id() + { + (int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/Movies/"); + + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory("/media/movies"); + + UpdateLocalLibraryHandler handler = CreateHandler(fileSystem); + + // same path, different case + no trailing slash, Id omitted (0): the handler's merge is by + // normalized path string (design #202 §C4c), so this must NOT be treated as delete-old + + // add-new -- the existing LibraryPath row (and its id) is preserved. + Either result = await handler.Handle( + new UpdateLocalLibrary(libraryId, "Movies", [new UpdateLocalLibraryPath(0, "/media/movies")]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext dbContext = _db.CreateContext(); + List paths = await dbContext.LibraryPaths + .Where(lp => lp.LibraryId == libraryId) + .ToListAsync(); + + paths.Count.ShouldBe(1); + paths[0].Id.ShouldBe(pathId); + } + + [Test] + public async Task Handle_Should_Add_And_Remove_Paths_By_Normalized_String_Diff() + { + (int libraryId, int keptPathId) = await SeedLibraryWithPath("Movies", "/media/keep"); + + var fileSystem = new MockFileSystem(); + fileSystem.Directory.CreateDirectory("/media/new"); + + UpdateLocalLibraryHandler handler = CreateHandler(fileSystem); + + Either result = await handler.Handle( + new UpdateLocalLibrary( + libraryId, + "Movies", + [ + new UpdateLocalLibraryPath(keptPathId, "/media/keep"), + new UpdateLocalLibraryPath(0, "/media/new") + ]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext dbContext = _db.CreateContext(); + List paths = await dbContext.LibraryPaths + .Where(lp => lp.LibraryId == libraryId) + .Select(lp => lp.Path) + .ToListAsync(); + + paths.Count.ShouldBe(2); + paths.ShouldContain("/media/keep"); + paths.ShouldContain("/media/new"); + } + + private UpdateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) => + new( + ThreadingChannel.CreateUnbounded().Writer, + Substitute.For(), + fileSystem, + Substitute.For(), + _db.Factory); + + private async Task<(int LibraryId, int PathId)> SeedLibraryWithPath(string name, string path) + { + await using TvContext context = _db.CreateContext(); + var source = new LocalMediaSource + { + Libraries = + [ + new LocalLibrary + { + Name = name, + MediaKind = LibraryMediaKind.Movies, + Paths = [new LibraryPath { Path = path }] + } + ] + }; + await context.LocalMediaSources.AddAsync(source); + await context.SaveChangesAsync(); + return (source.Libraries[0].Id, source.Libraries[0].Paths[0].Id); + } +} diff --git a/ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs b/ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs new file mode 100644 index 000000000..3ed9cb877 --- /dev/null +++ b/ErsatzTV.Tests/Application/Plex/PlexHandlerTests.cs @@ -0,0 +1,257 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Plex; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Plex; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Plex; + +[TestFixture] +public class PlexHandlerTests +{ + // ----- TryCompletePlexPinFlowHandler: release ONLY on non-handoff exits (§C1.6 / finding 5) ----- + + [Test] + public async Task PinFlow_Should_Release_Lock_On_Thrown_Cancellation() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + using var externalCts = new CancellationTokenSource(); + // poll returns false, then cancels the flow's token so Task.Delay THROWS (the real timeout path) + plexTvApiClient.TryCompletePinFlow(Arg.Any()) + .Returns(_ => + { + externalCts.Cancel(); + return false; + }); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + await Should.ThrowAsync(() => + handler.Handle(new TryCompletePlexPinFlow(Pin()), externalCts.Token)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task PinFlow_Should_Release_Lock_On_Poll_Exception() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + plexTvApiClient.TryCompletePinFlow(Arg.Any()) + .Returns(_ => throw new InvalidOperationException("poll boom")); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + await Should.ThrowAsync(() => + handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task PinFlow_Should_Release_Lock_On_Enqueue_Exception() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + plexTvApiClient.TryCompletePinFlow(Arg.Any()).Returns(true); + channel.WriteAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("enqueue boom")); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + await Should.ThrowAsync(() => + handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task PinFlow_Success_Should_Not_Release_Lock_In_Handler() + { + IPlexTvApiClient plexTvApiClient = Substitute.For(); + var channel = Substitute.For>(); + IEntityLocker entityLocker = Substitute.For(); + + plexTvApiClient.TryCompletePinFlow(Arg.Any()).Returns(true); + + var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker); + + Either result = + await handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + // the lock is handed off to SynchronizePlexMediaSources — the handler must NOT release it + entityLocker.DidNotReceive().UnlockPlex(); + await channel.Received(1).WriteAsync( + Arg.Any(), + Arg.Any()); + } + + // ----- SignOutOfPlexHandler: unconditional finally-release (§C6 / finding 7) ----- + + [Test] + public async Task SignOut_Should_Release_Lock_When_Dependency_Throws() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + IPlexSecretStore plexSecretStore = Substitute.For(); + IEntityLocker entityLocker = Substitute.For(); + ISearchIndex searchIndex = Substitute.For(); + + mediaSourceRepository.DeleteAllPlex() + .Returns>(_ => throw new InvalidOperationException("delete boom")); + + var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex); + + await Should.ThrowAsync(() => + handler.Handle(new SignOutOfPlex(), CancellationToken.None)); + + entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task SignOut_Should_Release_Lock_On_Success() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + IPlexSecretStore plexSecretStore = Substitute.For(); + IEntityLocker entityLocker = Substitute.For(); + ISearchIndex searchIndex = Substitute.For(); + + mediaSourceRepository.DeleteAllPlex().Returns(new List { 1, 2 }); + + var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex); + + Either result = await handler.Handle(new SignOutOfPlex(), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + entityLocker.Received(1).UnlockPlex(); + } + + // ----- UpdatePlexPathReplacementsHandler: ownership + nonblank + null validation (§C4b / findings 2c/8) ----- + + [Test] + public async Task PathReplacements_Should_Reject_CrossSource_Id_Without_Mutating() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + // id 99 is not owned by source 1 (its only replacement is id 5) → must not touch the repo + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(99, "/remote", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task PathReplacements_Should_Reject_Blank_Rows_Without_Mutating() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(0, " ", "/local")]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task PathReplacements_Should_Reject_Null_Items_Without_Mutating() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [null]), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await mediaSourceRepository.DidNotReceive().UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + [Test] + public async Task PathReplacements_Should_Accept_Owned_Rows_And_Mutate() + { + IMediaSourceRepository mediaSourceRepository = Substitute.For(); + mediaSourceRepository.GetPlex(1, Arg.Any()) + .Returns(Option.Some(SourceWithReplacement(1, replacementId: 5))); + mediaSourceRepository.UpdatePathReplacements( + Arg.Any(), + Arg.Any>(), + Arg.Any>(), + Arg.Any>()) + .Returns(Unit.Default); + + var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository); + + Either result = await handler.Handle( + new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(5, "/remote", "/local")]), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await mediaSourceRepository.Received(1).UpdatePathReplacements( + 1, + Arg.Any>(), + Arg.Any>(), + Arg.Any>()); + } + + private static PlexAuthPin Pin() => new(1, "code", "client-id"); + + private static PlexMediaSource SourceWithReplacement(int sourceId, int replacementId) => + new() + { + Id = sourceId, + PathReplacements = + [ + new PlexPathReplacement + { + Id = replacementId, + PlexPath = "/existing-remote", + LocalPath = "/existing-local", + PlexMediaSourceId = sourceId + } + ] + }; +} diff --git a/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs new file mode 100644 index 000000000..d5a672677 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/EmbyMediaSourcesControllerTests.cs @@ -0,0 +1,426 @@ +using System.Reflection; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Emby; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Locking; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class EmbyMediaSourcesControllerTests +{ + private IMediator _mediator = null!; + private IEntityLocker _entityLocker = null!; + private Channel _scannerChannel = null!; + private EmbyMediaSourcesController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _scannerChannel = System.Threading.Channels.Channel.CreateUnbounded(); + _controller = new EmbyMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(EmbyMediaSourcesController.GetState), "GET", "/api/media-sources/emby"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.GetConnection), + "GET", + "/api/media-sources/emby/connection"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.SaveConnection), + "PUT", + "/api/media-sources/emby/connection"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.Disconnect), + "POST", + "/api/media-sources/emby/disconnect"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.GetLibraries), + "GET", + "/api/media-sources/emby/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.ReplaceLibraryPreferences), + "PUT", + "/api/media-sources/emby/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.GetPathReplacements), + "GET", + "/api/media-sources/emby/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.ReplacePathReplacements), + "PUT", + "/api/media-sources/emby/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(EmbyMediaSourcesController.RefreshLibraries), + "POST", + "/api/media-sources/emby/{id:int}/refresh-libraries"); + } + + [Test] + public async Task GetState_Should_Report_Authorized_And_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new EmbyMediaSourceViewModel(1, "My Server", "http://emby.local")]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "secret" }); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeTrue(); + result.IsLocked.ShouldBeTrue(); + result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://emby.local")]); + } + + [Test] + public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "" }); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeFalse(); + } + + [Test] + public async Task GetConnection_Should_Never_Return_The_Api_Key() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "super-secret" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.Address.ShouldBe("http://emby.local"); + result.HasApiKey.ShouldBeTrue(); + result.ToString().ShouldNotContain("super-secret"); + } + + [Test] + public async Task GetConnection_Should_Report_No_Key_When_Not_Configured() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "", ApiKey = "" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.HasApiKey.ShouldBeFalse(); + } + + [Test] + public async Task SaveConnection_Should_Return_409_When_Locked() + { + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address() + { + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("not-a-uri", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new EmbySecrets { Address = "", ApiKey = "" }); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", ""), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new EmbySecrets { Address = "http://emby.local", ApiKey = "existing-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", " "), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType() + .HasApiKey.ShouldBeTrue(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://emby.local"), + Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Set_New_Key_When_NonBlank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new EmbySecrets { Address = "http://emby.local", ApiKey = "new-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://emby.local", "new-key"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "new-key"), + Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_409_When_Lock_Fails() + { + _entityLocker.LockRemoteMediaSource().Returns(false); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_204_On_Success() + { + _entityLocker.LockRemoteMediaSource().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [ + new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1), + new EmbyLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1) + ]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(true); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType>() + .Single().Id.ShouldBe(1); + + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue(); + first.ShouldBeOfType().EmbyMediaSourceId.ShouldBe(1); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue(); + second.ShouldBeOfType().EmbyLibraryId.ShouldBe(1); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(false); + + await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + _scannerChannel.Reader.TryRead(out _).ShouldBeFalse(); + } + + [Test] + public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new EmbyPathReplacementViewModel(1, "/emby", "/local")]); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/emby", "/local")]), + CancellationToken.None); + + List body = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + body.Single().ShouldBe(new PathReplacementResponseModel(1, "/emby", "/local")); + } + + [Test] + public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad row"))); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task RefreshLibraries_Should_Return_409_When_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + } + + [Test] + public async Task RefreshLibraries_Should_Enqueue_And_Return_202() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new EmbyMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(false); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType(); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().EmbyMediaSourceId.ShouldBe(1); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs new file mode 100644 index 000000000..5623b5dfa --- /dev/null +++ b/ErsatzTV.Tests/Controllers/JellyfinMediaSourcesControllerTests.cs @@ -0,0 +1,426 @@ +using System.Reflection; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Jellyfin; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class JellyfinMediaSourcesControllerTests +{ + private IMediator _mediator = null!; + private IEntityLocker _entityLocker = null!; + private Channel _scannerChannel = null!; + private JellyfinMediaSourcesController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _scannerChannel = System.Threading.Channels.Channel.CreateUnbounded(); + _controller = new JellyfinMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(JellyfinMediaSourcesController.GetState), "GET", "/api/media-sources/jellyfin"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.GetConnection), + "GET", + "/api/media-sources/jellyfin/connection"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.SaveConnection), + "PUT", + "/api/media-sources/jellyfin/connection"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.Disconnect), + "POST", + "/api/media-sources/jellyfin/disconnect"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.GetLibraries), + "GET", + "/api/media-sources/jellyfin/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.ReplaceLibraryPreferences), + "PUT", + "/api/media-sources/jellyfin/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.GetPathReplacements), + "GET", + "/api/media-sources/jellyfin/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.ReplacePathReplacements), + "PUT", + "/api/media-sources/jellyfin/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(JellyfinMediaSourcesController.RefreshLibraries), + "POST", + "/api/media-sources/jellyfin/{id:int}/refresh-libraries"); + } + + [Test] + public async Task GetState_Should_Report_Authorized_And_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new JellyfinMediaSourceViewModel(1, "My Server", "http://jf.local")]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "secret" }); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeTrue(); + result.IsLocked.ShouldBeTrue(); + result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://jf.local")]); + } + + [Test] + public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "" }); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeFalse(); + } + + [Test] + public async Task GetConnection_Should_Never_Return_The_Api_Key() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "super-secret" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.Address.ShouldBe("http://jf.local"); + result.HasApiKey.ShouldBeTrue(); + result.ToString().ShouldNotContain("super-secret"); + } + + [Test] + public async Task GetConnection_Should_Report_No_Key_When_Not_Configured() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "", ApiKey = "" }); + + RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None); + + result.HasApiKey.ShouldBeFalse(); + } + + [Test] + public async Task SaveConnection_Should_Return_409_When_Locked() + { + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address() + { + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("not-a-uri", "key"), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new JellyfinSecrets { Address = "", ApiKey = "" }); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", ""), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new JellyfinSecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new JellyfinSecrets { Address = "http://jf.local", ApiKey = "existing-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", " "), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType() + .HasApiKey.ShouldBeTrue(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://jf.local"), + Arg.Any()); + } + + [Test] + public async Task SaveConnection_Should_Set_New_Key_When_NonBlank() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + new JellyfinSecrets { Address = "http://old.local", ApiKey = "existing-key" }, + new JellyfinSecrets { Address = "http://jf.local", ApiKey = "new-key" }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SaveConnection( + new SaveRemoteConnectionRequest("http://jf.local", "new-key"), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.Secrets.ApiKey == "new-key"), + Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_409_When_Lock_Fails() + { + _entityLocker.LockRemoteMediaSource().Returns(false); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Disconnect_Should_Return_204_On_Success() + { + _entityLocker.LockRemoteMediaSource().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Disconnect(CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [ + new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1), + new JellyfinLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1) + ]); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(true); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + result.ShouldBeOfType().Value + .ShouldBeOfType>() + .Single().Id.ShouldBe(1); + + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue(); + first.ShouldBeOfType().JellyfinMediaSourceId.ShouldBe(1); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue(); + second.ShouldBeOfType().JellyfinLibraryId.ShouldBe(1); + } + + [Test] + public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)], + [new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(1).Returns(false); + + await _controller.ReplaceLibraryPreferences( + 1, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]), + CancellationToken.None); + + _scannerChannel.Reader.TryRead(out _).ShouldBeFalse(); + } + + [Test] + public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns([new JellyfinPathReplacementViewModel(1, "/jellyfin", "/local")]); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/jellyfin", "/local")]), + CancellationToken.None); + + List body = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + body.Single().ShouldBe(new PathReplacementResponseModel(1, "/jellyfin", "/local")); + } + + [Test] + public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad row"))); + + IActionResult result = await _controller.ReplacePathReplacements( + 1, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]), + CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task RefreshLibraries_Should_Return_409_When_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(true); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType().StatusCode.ShouldBe(409); + } + + [Test] + public async Task RefreshLibraries_Should_Enqueue_And_Return_202() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(new JellyfinMediaSourceViewModel(1, "s", "a"))); + _entityLocker.IsRemoteMediaSourceLocked().Returns(false); + + IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None); + + result.ShouldBeOfType(); + _scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue(); + request.ShouldBeOfType().JellyfinMediaSourceId.ShouldBe(1); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(JellyfinMediaSourcesController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs b/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs new file mode 100644 index 000000000..0cdcc7948 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs @@ -0,0 +1,363 @@ +using System.Reflection; +using ErsatzTV.Application.Libraries; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +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; +using Shouldly; +using Testably.Abstractions.Testing; +using Unit = LanguageExt.Unit; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class LocalLibrariesControllerTests +{ + private LocalLibrariesController _controller = null!; + private IEntityLocker _entityLocker = null!; + private MockFileSystem _fileSystem = null!; + private ILibraryRepository _libraryRepository = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _fileSystem = new MockFileSystem(); + _libraryRepository = Substitute.For(); + _controller = new LocalLibrariesController(_mediator, _entityLocker, _fileSystem, _libraryRepository); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(LocalLibrariesController.GetAll), "GET", "/api/libraries/local"); + ShouldHaveActionRoute(nameof(LocalLibrariesController.GetById), "GET", "/api/libraries/local/{id:int}"); + ShouldHaveActionRoute(nameof(LocalLibrariesController.Create), "POST", "/api/libraries/local"); + ShouldHaveActionRoute(nameof(LocalLibrariesController.Update), "PUT", "/api/libraries/local/{id:int}"); + ShouldHaveActionRoute(nameof(LocalLibrariesController.Delete), "DELETE", "/api/libraries/local/{id:int}"); + ShouldHaveActionRoute( + nameof(LocalLibrariesController.MovePath), + "POST", + "/api/libraries/local/paths/{pathId:int}/move"); + ShouldHaveActionRoute( + nameof(LocalLibrariesController.CheckPathExists), + "POST", + "/api/libraries/local/path-exists"); + } + + [Test] + public async Task GetAll_Should_Project_Libraries_And_Lock_State() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + new(1, "Movies", LibraryMediaKind.Movies, 0), + new(2, "TV Shows", LibraryMediaKind.Shows, 0) + }); + _entityLocker.IsLibraryLocked(1).Returns(true); + _entityLocker.IsLibraryLocked(2).Returns(false); + + List result = await _controller.GetAll(CancellationToken.None); + + result.Count.ShouldBe(2); + result[0].ShouldBe(new LocalLibraryResponseModel(1, "Movies", LibraryMediaKind.Movies, true)); + result[1].ShouldBe(new LocalLibraryResponseModel(2, "TV Shows", LibraryMediaKind.Shows, false)); + } + + [Test] + public async Task GetById_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(99, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); + } + + [Test] + public async Task GetById_Should_Return_Detail_With_Paths_And_Counts() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(10, 3, "/media/movies") }); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(5); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(5); + _entityLocker.IsLibraryLocked(3).Returns(false); + + IActionResult result = await _controller.GetById(3, CancellationToken.None); + + var ok = result.ShouldBeOfType(); + var detail = ok.Value.ShouldBeOfType(); + detail.Id.ShouldBe(3); + detail.MediaItemCount.ShouldBe(5); + detail.Paths.Count.ShouldBe(1); + detail.Paths[0].ShouldBe(new LocalLibraryPathResponseModel(10, "/media/movies", 5)); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right( + new LocalLibraryViewModel(5, "Movies", LibraryMediaKind.Movies, 0))); + + IActionResult result = await _controller.Create( + new CreateLocalLibraryRequest("Movies", LibraryMediaKind.Movies, ["/media/movies"]), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.Location.ShouldBe("/api/libraries/local/5"); + created.Value.ShouldBeOfType().Name.ShouldBe("Movies"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Movies" && c.MediaKind == LibraryMediaKind.Movies), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create( + new CreateLocalLibraryRequest(string.Empty, LibraryMediaKind.Movies, []), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Update( + 99, + new UpdateLocalLibraryRequest("New Name", []), + CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_409_When_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); + _entityLocker.IsLibraryLocked(3).Returns(true); + + IActionResult result = await _controller.Update( + 3, + new UpdateLocalLibraryRequest("New Name", []), + CancellationToken.None); + + var conflict = result.ShouldBeOfType(); + conflict.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status409Conflict); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); + _entityLocker.IsLibraryLocked(3).Returns(false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Update( + 3, + new UpdateLocalLibraryRequest("New Name", []), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Reload_And_Return_Detail_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns( + Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)), + Some(new LocalLibraryViewModel(3, "New Name", LibraryMediaKind.Movies, 0))); + _entityLocker.IsLibraryLocked(3).Returns(false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right( + new LocalLibraryViewModel(3, "New Name", LibraryMediaKind.Movies, 0))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _mediator.Send(Arg.Any(), Arg.Any()).Returns(0); + + IActionResult result = await _controller.Update( + 3, + new UpdateLocalLibraryRequest("New Name", []), + CancellationToken.None); + + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType().Name.ShouldBe("New Name"); + await _mediator.Received(1).Send( + Arg.Is(c => c.Id == 3 && c.Name == "New Name"), + Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_404_When_Missing() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.Delete(99, CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_409_When_Locked() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); + _entityLocker.IsLibraryLocked(3).Returns(true); + + IActionResult result = await _controller.Delete(3, CancellationToken.None); + + var conflict = result.ShouldBeOfType(); + conflict.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status409Conflict); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0))); + _entityLocker.IsLibraryLocked(3).Returns(false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(3, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.LocalLibraryId == 3), + Arg.Any()); + } + + [Test] + public async Task MovePath_Should_Return_404_When_Path_Unknown() + { + _libraryRepository.GetLibraryIdForPath(999).Returns(Option.None); + + IActionResult result = await _controller.MovePath( + 999, + new MoveLocalLibraryPathRequest(2), + CancellationToken.None); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status404NotFound); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task MovePath_Should_Return_409_When_Source_Library_Locked() + { + _libraryRepository.GetLibraryIdForPath(10).Returns(Some(1)); + _entityLocker.IsLibraryLocked(1).Returns(true); + + IActionResult result = await _controller.MovePath( + 10, + new MoveLocalLibraryPathRequest(2), + CancellationToken.None); + + var conflict = result.ShouldBeOfType(); + conflict.Value.ShouldBeOfType().Status.ShouldBe(StatusCodes.Status409Conflict); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task MovePath_Should_Return_204_On_Success() + { + _libraryRepository.GetLibraryIdForPath(10).Returns(Some(1)); + _entityLocker.IsLibraryLocked(1).Returns(false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.MovePath( + 10, + new MoveLocalLibraryPathRequest(2), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.LibraryPathId == 10 && c.TargetLibraryId == 2), + Arg.Any()); + } + + [Test] + public async Task MovePath_Should_Return_422_When_Handler_Rejects_Move_After_PreCheck_Passes() + { + // documents the residual check-then-act race (design #202 §C5): the pre-check passed, but + // the handler's own validation (e.g. same-kind/different-library) still fails 422, not 404. + _libraryRepository.GetLibraryIdForPath(10).Returns(Some(1)); + _entityLocker.IsLibraryLocked(1).Returns(false); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("Target library must be different from the source path's current library"))); + + IActionResult result = await _controller.MovePath( + 10, + new MoveLocalLibraryPathRequest(1), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public void CheckPathExists_Should_Return_True_When_Directory_Exists() + { + _fileSystem.Directory.CreateDirectory("/media/movies"); + + IActionResult result = _controller.CheckPathExists(new LocalPathCheckRequest("/media/movies")); + + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType().Exists.ShouldBeTrue(); + } + + [Test] + public void CheckPathExists_Should_Return_False_When_Directory_Missing() + { + IActionResult result = _controller.CheckPathExists(new LocalPathCheckRequest("/media/does-not-exist")); + + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType().Exists.ShouldBeFalse(); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(LocalLibrariesController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs b/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs new file mode 100644 index 000000000..4c66dacd1 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/PlexMediaSourcesControllerTests.cs @@ -0,0 +1,470 @@ +using System.Reflection; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Plex; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Plex; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class PlexMediaSourcesControllerTests +{ + private ChannelWriter _channel = null!; + private PlexMediaSourcesController _controller = null!; + private IEntityLocker _entityLocker = null!; + private IMediator _mediator = null!; + private IPlexSecretStore _plexSecretStore = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _entityLocker = Substitute.For(); + _plexSecretStore = Substitute.For(); + _channel = Substitute.For>(); + _controller = new PlexMediaSourcesController(_mediator, _entityLocker, _plexSecretStore, _channel); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(PlexMediaSourcesController.GetState), "GET", "/api/media-sources/plex"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.StartPinFlow), + "POST", + "/api/media-sources/plex/pin-flow"); + ShouldHaveActionRoute(nameof(PlexMediaSourcesController.SignOutOfPlex), "POST", "/api/media-sources/plex/sign-out"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.GetLibraries), + "GET", + "/api/media-sources/plex/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.ReplaceLibraryPreferences), + "PUT", + "/api/media-sources/plex/{id:int}/libraries"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.GetPathReplacements), + "GET", + "/api/media-sources/plex/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.ReplacePathReplacements), + "PUT", + "/api/media-sources/plex/{id:int}/path-replacements"); + ShouldHaveActionRoute( + nameof(PlexMediaSourcesController.RefreshLibraries), + "POST", + "/api/media-sources/plex/{id:int}/refresh-libraries"); + } + + // ----- P1 GetState ----- + + [Test] + public async Task GetState_Should_Stamp_Authorized_Locked_And_Servers() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(3, "Server", "http://plex:32400") }); + _plexSecretStore.GetUserAuthTokens() + .Returns(new List { new("me@example.com", "token") }); + _entityLocker.IsPlexLocked().Returns(true); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeTrue(); + result.IsLocked.ShouldBeTrue(); + result.Servers.Single().Id.ShouldBe(3); + result.Servers.Single().Address.ShouldBe("http://plex:32400"); + } + + [Test] + public async Task GetState_Should_Report_Unauthorized_With_No_Tokens() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List()); + _plexSecretStore.GetUserAuthTokens().Returns(new List()); + + RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None); + + result.IsAuthorized.ShouldBeFalse(); + } + + // ----- P2 StartPinFlow ----- + + [Test] + public async Task StartPinFlow_Should_Return_409_When_Lock_Held() + { + _entityLocker.LockPlex().Returns(false); + + IActionResult result = await _controller.StartPinFlow(CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task StartPinFlow_Should_Return_200_And_Hold_Lock_On_Success() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right("https://app.plex.tv/auth#?code=abc")); + + IActionResult result = await _controller.StartPinFlow(CancellationToken.None); + + var model = result.ShouldBeOfType().Value.ShouldBeOfType(); + model.AuthUrl.ShouldBe("https://app.plex.tv/auth#?code=abc"); + // the Right/200 path keeps the lock held for the background flow + _entityLocker.DidNotReceive().UnlockPlex(); + } + + [Test] + public async Task StartPinFlow_Should_Compensate_Unlock_On_Left() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("plex.tv error"))); + + IActionResult result = await _controller.StartPinFlow(CancellationToken.None); + + result.ShouldBeOfType(); + _entityLocker.Received(1).UnlockPlex(); + } + + [Test] + public async Task StartPinFlow_Should_Compensate_Unlock_On_Thrown_Dispatch() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("channel closed")); + + await Should.ThrowAsync(() => _controller.StartPinFlow(CancellationToken.None)); + + _entityLocker.Received(1).UnlockPlex(); + } + + // ----- P3 SignOut ----- + + [Test] + public async Task SignOut_Should_Return_409_When_Lock_Held() + { + _entityLocker.LockPlex().Returns(false); + + IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SignOut_Should_Return_204_And_Not_Release_In_Controller() + { + _entityLocker.LockPlex().Returns(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); + // the handler is the designated releaser (finally) — the controller must not double-release + _entityLocker.DidNotReceive().UnlockPlex(); + } + + // ----- P4 GetLibraries ----- + + [Test] + public async Task GetLibraries_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.GetLibraries(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task GetLibraries_Should_Return_200_List() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); + + IActionResult result = await _controller.GetLibraries(3, CancellationToken.None); + + var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); + list.Single().Id.ShouldBe(5); + list.Single().ShouldSyncItems.ShouldBeTrue(); + } + + // ----- P5 ReplaceLibraryPreferences ----- + + [Test] + public async Task ReplaceLibraries_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 9, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Return_422_When_Id_Not_Owned() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Return_422_For_Zero_Id_Row() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Movies", LibraryMediaKind.Movies, true) }); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(0, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Return_422_When_An_Owned_Library_Is_Missing() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List + { + new(5, "Movies", LibraryMediaKind.Movies, true), + new(6, "Shows", LibraryMediaKind.Shows, true) + }); + + // request omits owned id 6 -> 422: the PUT is a complete flag document (§C4a), matching Jellyfin/Emby + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplaceLibraries_Should_Save_Enqueue_Ordered_Pair_And_Return_Reloaded() + { + SourceExists(true); + // owned set for validation, then the reloaded set (drives the sync loop + the response) + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Shows", LibraryMediaKind.Shows, true) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(5).Returns(true); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + var list = result.ShouldBeOfType().Value.ShouldBeOfType>(); + list.Single().Id.ShouldBe(5); + + // library message carries Unlock:false; the networks message carries the single release Unlock:true + Received.InOrder(() => + { + _channel.WriteAsync( + Arg.Is(s => s.PlexLibraryId == 5 && !s.Unlock), + Arg.Any()); + _channel.WriteAsync( + Arg.Is(n => n.PlexLibraryId == 5 && n.Unlock), + Arg.Any()); + }); + } + + [Test] + public async Task ReplaceLibraries_Should_Skip_Enqueue_For_Locked_Library() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(5, "Shows", LibraryMediaKind.Shows, true) }); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _entityLocker.LockLibrary(5).Returns(false); + + IActionResult result = await _controller.ReplaceLibraryPreferences( + 3, + new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _channel.DidNotReceive().WriteAsync( + Arg.Any(), + Arg.Any()); + } + + // ----- P6 GetPathReplacements ----- + + [Test] + public async Task GetPathReplacements_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.GetPathReplacements(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetPathReplacements_Should_Map_Plex_Path_To_Remote_Path() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(7, "/plex", "/local") }); + + IActionResult result = await _controller.GetPathReplacements(3, CancellationToken.None); + + var list = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + list.Single().RemotePath.ShouldBe("/plex"); + list.Single().LocalPath.ShouldBe("/local"); + } + + // ----- P7 ReplacePathReplacements ----- + + [Test] + public async Task ReplacePathReplacements_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.ReplacePathReplacements( + 9, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(0, "/remote", "/local")]), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_422_On_Handler_Left() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("cross-source"))); + + IActionResult result = await _controller.ReplacePathReplacements( + 3, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(99, "/remote", "/local")]), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task ReplacePathReplacements_Should_Return_200_Reloaded_On_Success() + { + SourceExists(true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new List { new(7, "/plex", "/local") }); + + IActionResult result = await _controller.ReplacePathReplacements( + 3, + new ReplacePathReplacementsRequest([new PathReplacementItemRequest(7, "/plex", "/local")]), + CancellationToken.None); + + var list = result.ShouldBeOfType().Value + .ShouldBeOfType>(); + list.Single().Id.ShouldBe(7); + } + + // ----- P8 RefreshLibraries ----- + + [Test] + public async Task RefreshLibraries_Should_Return_404_When_Source_Missing() + { + SourceExists(false); + + IActionResult result = await _controller.RefreshLibraries(9, CancellationToken.None); + + result.ShouldBeOfType(); + await _channel.DidNotReceive().WriteAsync( + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task RefreshLibraries_Should_Return_409_When_Plex_Locked() + { + SourceExists(true); + _entityLocker.IsPlexLocked().Returns(true); + + IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(409); + await _channel.DidNotReceive().WriteAsync( + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task RefreshLibraries_Should_Return_202_And_Enqueue() + { + SourceExists(true); + _entityLocker.IsPlexLocked().Returns(false); + + IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None); + + result.ShouldBeOfType(); + await _channel.Received(1).WriteAsync( + Arg.Is(s => s.PlexMediaSourceId == 3), + Arg.Any()); + } + + private void SourceExists(bool exists) => + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(exists + ? Option.Some(new PlexMediaSourceViewModel(3, "Server", "http://plex:32400")) + : Option.None); + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(PlexMediaSourcesController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Infrastructure/MediaSourceRepositoryPathReplacementScopeTests.cs b/ErsatzTV.Tests/Infrastructure/MediaSourceRepositoryPathReplacementScopeTests.cs new file mode 100644 index 000000000..a15f13015 --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/MediaSourceRepositoryPathReplacementScopeTests.cs @@ -0,0 +1,112 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Tests.Support; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Infrastructure; + +// Design #202 findings 2c/8 — repo-level defense-in-depth: the UPDATE for path replacements must be +// scoped to the owning media source so a PUT against source A can never silently overwrite a row +// that belongs to source B, even if a caller bypasses the handler-level ownership guard. +[TestFixture] +public class MediaSourceRepositoryPathReplacementScopeTests +{ + private InMemoryTvContext _db = null!; + private MediaSourceRepository _repository = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _repository = new MediaSourceRepository(_db.Factory, NullLogger.Instance); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + [Test] + public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Jellyfin_Source() + { + var sourceA = new JellyfinMediaSource + { + ServerName = "A", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/a", LocalPath = "/a-local" }] + }; + var sourceB = new JellyfinMediaSource + { + ServerName = "B", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/b", LocalPath = "/b-local" }] + }; + + await using (TvContext context = _db.CreateContext()) + { + context.MediaSources.AddRange(sourceA, sourceB); + await context.SaveChangesAsync(); + } + + int bRowId = sourceB.PathReplacements.Single().Id; + + // attack: source A's PUT carries source B's row id as an "update" + var maliciousUpdate = new JellyfinPathReplacement + { + Id = bRowId, + JellyfinPath = "/hijacked", + LocalPath = "/hijacked-local" + }; + + await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []); + + await using TvContext verifyContext = _db.CreateContext(); + JellyfinPathReplacement bRowAfter = await verifyContext.JellyfinPathReplacements.FindAsync(bRowId); + bRowAfter.JellyfinPath.ShouldBe("/b"); + bRowAfter.LocalPath.ShouldBe("/b-local"); + } + + [Test] + public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Emby_Source() + { + var sourceA = new EmbyMediaSource + { + ServerName = "A", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new EmbyPathReplacement { EmbyPath = "/a", LocalPath = "/a-local" }] + }; + var sourceB = new EmbyMediaSource + { + ServerName = "B", + OperatingSystem = "Linux", + Connections = [], + PathReplacements = [new EmbyPathReplacement { EmbyPath = "/b", LocalPath = "/b-local" }] + }; + + await using (TvContext context = _db.CreateContext()) + { + context.MediaSources.AddRange(sourceA, sourceB); + await context.SaveChangesAsync(); + } + + int bRowId = sourceB.PathReplacements.Single().Id; + + var maliciousUpdate = new EmbyPathReplacement + { + Id = bRowId, + EmbyPath = "/hijacked", + LocalPath = "/hijacked-local" + }; + + await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []); + + await using TvContext verifyContext = _db.CreateContext(); + EmbyPathReplacement bRowAfter = await verifyContext.EmbyPathReplacements.FindAsync(bRowId); + bRowAfter.EmbyPath.ShouldBe("/b"); + bRowAfter.LocalPath.ShouldBe("/b-local"); + } +} diff --git a/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs b/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs new file mode 100644 index 000000000..9780e20f4 --- /dev/null +++ b/ErsatzTV/Controllers/Api/EmbyMediaSourcesController.cs @@ -0,0 +1,350 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Emby; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +// Design #202 §A.4 (E1-E9). Copy-symmetric with JellyfinMediaSourcesController. +[ApiController] +public class EmbyMediaSourcesController( + IMediator mediator, + IEntityLocker entityLocker, + ChannelWriter scannerWorkerChannel) : ControllerBase +{ + [HttpGet("/api/media-sources/emby", Name = "GetEmbyState")] + [Tags("Emby")] + [EndpointSummary("Get Emby connection state and discovered servers")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)] + public async Task GetState(CancellationToken cancellationToken) + { + List sources = + await mediator.Send(new GetAllEmbyMediaSources(), cancellationToken); + EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); + + bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey); + bool isLocked = entityLocker.IsRemoteMediaSourceLocked(); + + return new RemoteMediaSourceStateResponseModel( + isAuthorized, + isLocked, + sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList()); + } + + [HttpGet("/api/media-sources/emby/connection", Name = "GetEmbyConnection")] + [Tags("Emby")] + [EndpointSummary("Get the Emby connection address")] + [EndpointDescription( + "Never returns the API key — only whether one is currently configured (design #202 secure connection " + + "contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + public async Task GetConnection(CancellationToken cancellationToken) + { + EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); + return new RemoteConnectionResponseModel( + secrets.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(secrets.ApiKey)); + } + + [HttpPut("/api/media-sources/emby/connection", Name = "SaveEmbyConnection")] + [Tags("Emby")] + [EndpointSummary("Connect, reconnect, or edit the Emby connection")] + [EndpointDescription( + "A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " + + "on first connect (no existing secret).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task SaveConnection( + [Required] [FromBody] SaveRemoteConnectionRequest request, + CancellationToken cancellationToken) + { + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Emby operation in progress", + "An Emby sign-in or sync is already in progress."); + } + + if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _)) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails("Address must be an absolute URI.")); + } + + EmbySecrets existingSecrets = await mediator.Send(new GetEmbySecrets(), cancellationToken); + if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required.")); + } + + Either result = await mediator.Send( + request.ToEmbyCommand(existingSecrets.ApiKey), + cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + EmbySecrets saved = await mediator.Send(new GetEmbySecrets(), cancellationToken); + return new OkObjectResult( + new RemoteConnectionResponseModel( + saved.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(saved.ApiKey))); + }); + } + + [HttpPost("/api/media-sources/emby/disconnect", Name = "DisconnectEmby")] + [Tags("Emby")] + [EndpointSummary("Disconnect Emby")] + [EndpointDescription("Purges the Emby connection, discovered servers, and all synced Emby content.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Disconnect(CancellationToken cancellationToken) + { + if (!entityLocker.LockRemoteMediaSource()) + { + return ApiResults.ConflictProblem( + "Emby operation in progress", + "An Emby sign-in or sync is already in progress."); + } + + Either result = await mediator.Send(new DisconnectEmby(), cancellationToken); + return result.Match( + Left: error => error.ToErrorResult(), + Right: _ => (IActionResult)new NoContentResult()); + } + + [HttpGet("/api/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")] + [Tags("Emby")] + [EndpointSummary("Get an Emby source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = + await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/emby/{id:int}/libraries", Name = "ReplaceEmbyLibraryPreferences")] + [Tags("Emby")] + [EndpointSummary("Replace an Emby source's library sync preferences")] + [EndpointDescription( + "The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " + + "the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " + + "response rather than the request body.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceLibraryPreferences( + int id, + [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List existingLibraries = + await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); + + UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries); + if (validationError is not null) + { + return validationError; + } + + Either result = + await mediator.Send(request.ToEmbyCommand(), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems)) + { + await EnqueueLibrarySync(id, library.Id, cancellationToken); + } + + List reloaded = + await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpGet("/api/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")] + [Tags("Emby")] + [EndpointSummary("Get an Emby source's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPathReplacements(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List replacements = + await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")] + [Tags("Emby")] + [EndpointSummary("Replace an Emby source's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplacePathReplacements( + int id, + [Required] [FromBody] ReplacePathReplacementsRequest request, + CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either result = + await mediator.Send(request.ToEmbyCommand(id), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + List reloaded = + await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpPost("/api/media-sources/emby/{id:int}/refresh-libraries", Name = "RefreshEmbyLibraries")] + [Tags("Emby")] + [EndpointSummary("Refresh an Emby source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task RefreshLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Emby operation in progress", + "An Emby sign-in or sync is already in progress."); + } + + await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(id), cancellationToken); + return new AcceptedResult(); + } + + // §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking + // (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity). + private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken) + { + if (!entityLocker.LockLibrary(libraryId)) + { + return; + } + + try + { + await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(sourceId), cancellationToken); + await scannerWorkerChannel.WriteAsync( + new SynchronizeEmbyLibraryByIdIfNeeded(libraryId), + cancellationToken); + } + catch + { + entityLocker.UnlockLibrary(libraryId); + throw; + } + } + + private static UnprocessableEntityObjectResult ValidateLibraryPreferences( + ReplaceRemoteLibraryPreferencesRequest request, + List existingLibraries) + { + List libraries = request.Libraries ?? []; + + if (libraries.Any(l => l.Id < 1)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required.")); + } + + var existingIds = existingLibraries.Map(l => l.Id).ToList(); + var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList(); + if (foreignIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + $"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Emby source.")); + } + + var incomingIds = libraries.Map(l => l.Id).ToList(); + var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList(); + if (missingIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + "The request must include every library for this source " + + $"(missing id(s) {string.Join(", ", missingIds)}).")); + } + + return null; + } + + private static ProblemDetails CreateProblemDetails(string detail) => + new() + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation failed", + Detail = detail + }; + + private static RemoteLibraryResponseModel ProjectToResponseModel(EmbyLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems); + + private static PathReplacementResponseModel ProjectToResponseModel(EmbyPathReplacementViewModel vm) => + new(vm.Id, vm.EmbyPath, vm.LocalPath); +} diff --git a/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs b/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs new file mode 100644 index 000000000..aa767ed14 --- /dev/null +++ b/ErsatzTV/Controllers/Api/JellyfinMediaSourcesController.cs @@ -0,0 +1,350 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Jellyfin; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +// Design #202 §A.3 (J1-J9). Jellyfin and Emby are copy-symmetric — see EmbyMediaSourcesController. +[ApiController] +public class JellyfinMediaSourcesController( + IMediator mediator, + IEntityLocker entityLocker, + ChannelWriter scannerWorkerChannel) : ControllerBase +{ + [HttpGet("/api/media-sources/jellyfin", Name = "GetJellyfinState")] + [Tags("Jellyfin")] + [EndpointSummary("Get Jellyfin connection state and discovered servers")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)] + public async Task GetState(CancellationToken cancellationToken) + { + List sources = + await mediator.Send(new GetAllJellyfinMediaSources(), cancellationToken); + JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + + bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey); + bool isLocked = entityLocker.IsRemoteMediaSourceLocked(); + + return new RemoteMediaSourceStateResponseModel( + isAuthorized, + isLocked, + sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList()); + } + + [HttpGet("/api/media-sources/jellyfin/connection", Name = "GetJellyfinConnection")] + [Tags("Jellyfin")] + [EndpointSummary("Get the Jellyfin connection address")] + [EndpointDescription( + "Never returns the API key — only whether one is currently configured (design #202 secure connection " + + "contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + public async Task GetConnection(CancellationToken cancellationToken) + { + JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + return new RemoteConnectionResponseModel( + secrets.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(secrets.ApiKey)); + } + + [HttpPut("/api/media-sources/jellyfin/connection", Name = "SaveJellyfinConnection")] + [Tags("Jellyfin")] + [EndpointSummary("Connect, reconnect, or edit the Jellyfin connection")] + [EndpointDescription( + "A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " + + "on first connect (no existing secret).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task SaveConnection( + [Required] [FromBody] SaveRemoteConnectionRequest request, + CancellationToken cancellationToken) + { + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Jellyfin operation in progress", + "A Jellyfin sign-in or sync is already in progress."); + } + + if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _)) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails("Address must be an absolute URI.")); + } + + JellyfinSecrets existingSecrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required.")); + } + + Either result = await mediator.Send( + request.ToJellyfinCommand(existingSecrets.ApiKey), + cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + JellyfinSecrets saved = await mediator.Send(new GetJellyfinSecrets(), cancellationToken); + return new OkObjectResult( + new RemoteConnectionResponseModel( + saved.Address ?? string.Empty, + !string.IsNullOrWhiteSpace(saved.ApiKey))); + }); + } + + [HttpPost("/api/media-sources/jellyfin/disconnect", Name = "DisconnectJellyfin")] + [Tags("Jellyfin")] + [EndpointSummary("Disconnect Jellyfin")] + [EndpointDescription("Purges the Jellyfin connection, discovered servers, and all synced Jellyfin content.")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Disconnect(CancellationToken cancellationToken) + { + if (!entityLocker.LockRemoteMediaSource()) + { + return ApiResults.ConflictProblem( + "Jellyfin operation in progress", + "A Jellyfin sign-in or sync is already in progress."); + } + + Either result = await mediator.Send(new DisconnectJellyfin(), cancellationToken); + return result.Match( + Left: error => error.ToErrorResult(), + Right: _ => (IActionResult)new NoContentResult()); + } + + [HttpGet("/api/media-sources/jellyfin/{id:int}/libraries", Name = "GetJellyfinLibraries")] + [Tags("Jellyfin")] + [EndpointSummary("Get a Jellyfin source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = + await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/jellyfin/{id:int}/libraries", Name = "ReplaceJellyfinLibraryPreferences")] + [Tags("Jellyfin")] + [EndpointSummary("Replace a Jellyfin source's library sync preferences")] + [EndpointDescription( + "The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " + + "the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " + + "response rather than the request body.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceLibraryPreferences( + int id, + [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List existingLibraries = + await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); + + UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries); + if (validationError is not null) + { + return validationError; + } + + Either result = + await mediator.Send(request.ToJellyfinCommand(), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems)) + { + await EnqueueLibrarySync(id, library.Id, cancellationToken); + } + + List reloaded = + await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpGet("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")] + [Tags("Jellyfin")] + [EndpointSummary("Get a Jellyfin source's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPathReplacements(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + List replacements = + await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList()); + } + + [HttpPut("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "ReplaceJellyfinPathReplacements")] + [Tags("Jellyfin")] + [EndpointSummary("Replace a Jellyfin source's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplacePathReplacements( + int id, + [Required] [FromBody] ReplacePathReplacementsRequest request, + CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + Either result = + await mediator.Send(request.ToJellyfinCommand(id), cancellationToken); + + return await result.Match>( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + List reloaded = + await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList()); + }); + } + + [HttpPost("/api/media-sources/jellyfin/{id:int}/refresh-libraries", Name = "RefreshJellyfinLibraries")] + [Tags("Jellyfin")] + [EndpointSummary("Refresh a Jellyfin source's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task RefreshLibraries(int id, CancellationToken cancellationToken) + { + Option maybeSource = + await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken); + if (maybeSource.IsNone) + { + return ApiResults.NotFoundProblem(); + } + + if (entityLocker.IsRemoteMediaSourceLocked()) + { + return ApiResults.ConflictProblem( + "Jellyfin operation in progress", + "A Jellyfin sign-in or sync is already in progress."); + } + + await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(id), cancellationToken); + return new AcceptedResult(); + } + + // §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking + // (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity). + private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken) + { + if (!entityLocker.LockLibrary(libraryId)) + { + return; + } + + try + { + await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(sourceId), cancellationToken); + await scannerWorkerChannel.WriteAsync( + new SynchronizeJellyfinLibraryByIdIfNeeded(libraryId), + cancellationToken); + } + catch + { + entityLocker.UnlockLibrary(libraryId); + throw; + } + } + + private static UnprocessableEntityObjectResult ValidateLibraryPreferences( + ReplaceRemoteLibraryPreferencesRequest request, + List existingLibraries) + { + List libraries = request.Libraries ?? []; + + if (libraries.Any(l => l.Id < 1)) + { + return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required.")); + } + + var existingIds = existingLibraries.Map(l => l.Id).ToList(); + var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList(); + if (foreignIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + $"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Jellyfin source.")); + } + + var incomingIds = libraries.Map(l => l.Id).ToList(); + var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList(); + if (missingIds.Count > 0) + { + return new UnprocessableEntityObjectResult( + CreateProblemDetails( + "The request must include every library for this source " + + $"(missing id(s) {string.Join(", ", missingIds)}).")); + } + + return null; + } + + private static ProblemDetails CreateProblemDetails(string detail) => + new() + { + Status = StatusCodes.Status422UnprocessableEntity, + Title = "Validation failed", + Detail = detail + }; + + private static RemoteLibraryResponseModel ProjectToResponseModel(JellyfinLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems); + + private static PathReplacementResponseModel ProjectToResponseModel(JellyfinPathReplacementViewModel vm) => + new(vm.Id, vm.JellyfinPath, vm.LocalPath); +} diff --git a/ErsatzTV/Controllers/Api/LocalLibrariesController.cs b/ErsatzTV/Controllers/Api/LocalLibrariesController.cs new file mode 100644 index 000000000..af4b3cb38 --- /dev/null +++ b/ErsatzTV/Controllers/Api/LocalLibrariesController.cs @@ -0,0 +1,216 @@ +using System.ComponentModel.DataAnnotations; +using System.IO.Abstractions; +using ErsatzTV.Application.Libraries; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +// Local library CRUD (design #202 §A.1, endpoints L1-L7). Every id-keyed mutation (L4/L5/L6) gets +// its real 404 from a controller pre-check, NOT from the wrapped handler: `.Apply`/`ToEitherAsync` +// both `.Join()` a `NotFoundError` into a plain `BaseError`, which maps to 422 +// (see `ErsatzTV.Core.LanguageExtensions`) — so relying on the handler for 404 here would be dead +// code (design #202 §A.1 finding 9). This is check-then-act: a delete racing the pre-check falls +// through to the handler's own 422, which is accepted and documented, not silently hidden. +[ApiController] +[EndpointGroupName("general")] +public class LocalLibrariesController( + IMediator mediator, + IEntityLocker entityLocker, + IFileSystem fileSystem, + ILibraryRepository libraryRepository) : ControllerBase +{ + [HttpGet("/api/libraries/local", Name = "GetLocalLibraries")] + [Tags("Libraries")] + [EndpointSummary("Get all local libraries")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) + { + List libraries = await mediator.Send(new GetAllLocalLibraries(), cancellationToken); + return libraries.Map(ProjectToResponseModel).ToList(); + } + + [HttpGet("/api/libraries/local/{id:int}", Name = "GetLocalLibrary")] + [Tags("Libraries")] + [EndpointSummary("Get a local library by id")] + [ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option maybeLibrary = await mediator.Send(new GetLocalLibraryById(id), cancellationToken); + return await maybeLibrary.Match( + Some: async vm => (IActionResult)new OkObjectResult(await ProjectToDetailResponseModel(vm, cancellationToken)), + None: () => Task.FromResult(ApiResults.NotFoundProblem($"Local library {id} does not exist."))); + } + + [HttpPost("/api/libraries/local")] + [Tags("Libraries")] + [EndpointSummary("Create a local library")] + [ProducesResponseType(typeof(LocalLibraryResponseModel), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task Create( + [Required] [FromBody] CreateLocalLibraryRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult( + vm => $"/api/libraries/local/{vm.Id}", + ProjectToResponseModel); + } + + [HttpPut("/api/libraries/local/{id:int}")] + [Tags("Libraries")] + [EndpointSummary("Update a local library")] + [EndpointDescription( + "Replaces the library's name and full path list. MediaKind is immutable after create and is not " + + "part of this request. Paths are identified by normalized path string, not id — a renamed path is a " + + "delete-old + add-new (its id changes).")] + [ProducesResponseType(typeof(LocalLibraryDetailResponseModel), 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] UpdateLocalLibraryRequest request, + CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetLocalLibraryById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem($"Local library {id} does not exist."); + } + + if (entityLocker.IsLibraryLocked(id)) + { + return ApiResults.ConflictProblem( + "Library scan in progress", + $"Local library {id} is locked by an in-progress scan."); + } + + Either result = + await mediator.Send(request.ToCommand(id), cancellationToken); + + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + // reload via the same query GetById uses (api-conventions §7 write-path projection); + // paths.Id may have changed for renamed/added paths. + Option refreshed = + await mediator.Send(new GetLocalLibraryById(id), cancellationToken); + return await refreshed.Match( + Some: async vm => (IActionResult)new OkObjectResult( + await ProjectToDetailResponseModel(vm, cancellationToken)), + None: () => Task.FromResult(ApiResults.NotFoundProblem($"Local library {id} does not exist."))); + }); + } + + [HttpDelete("/api/libraries/local/{id:int}")] + [Tags("Libraries")] + [EndpointSummary("Delete a local library")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Option existing = await mediator.Send(new GetLocalLibraryById(id), cancellationToken); + if (existing.IsNone) + { + return ApiResults.NotFoundProblem($"Local library {id} does not exist."); + } + + if (entityLocker.IsLibraryLocked(id)) + { + return ApiResults.ConflictProblem( + "Library scan in progress", + $"Local library {id} is locked by an in-progress scan."); + } + + Either result = await mediator.Send(new DeleteLocalLibrary(id), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpPost("/api/libraries/local/paths/{pathId:int}/move")] + [Tags("Libraries")] + [EndpointSummary("Move a local library path to another local library")] + [EndpointDescription( + "Moves a path (and its scanned media) from its current library to a different local library of the " + + "same media kind. Blazor's move dialog enforces same-kind/different-library only client-side; this " + + "endpoint enforces both server-side so API/MCP clients cannot bypass them.")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task MovePath( + int pathId, + [Required] [FromBody] MoveLocalLibraryPathRequest request, + CancellationToken cancellationToken) + { + Option maybeSourceLibraryId = await libraryRepository.GetLibraryIdForPath(pathId); + if (maybeSourceLibraryId.IsNone) + { + return ApiResults.NotFoundProblem($"Library path {pathId} does not exist."); + } + + int sourceLibraryId = maybeSourceLibraryId.IfNone(0); + if (entityLocker.IsLibraryLocked(sourceLibraryId)) + { + return ApiResults.ConflictProblem( + "Library scan in progress", + $"Local library {sourceLibraryId} is locked by an in-progress scan."); + } + + // residual check-then-act race (design #202 §C5): a concurrent delete of this path between + // the pre-check above and this Send falls through to the handler's own 422, not a 404. + Either result = await mediator.Send(request.ToCommand(pathId), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpPost("/api/libraries/local/path-exists")] + [Tags("Libraries")] + [EndpointSummary("Check whether a filesystem path exists")] + [EndpointDescription( + "Server-side existence check for the SPA's add-path UX (a browser cannot call Directory.Exists). " + + "This is a check-then-act convenience only — Create/Update still validate new paths at save time " + + "(design #202 §C2).")] + [ProducesResponseType(typeof(LocalPathCheckResponseModel), StatusCodes.Status200OK)] + public IActionResult CheckPathExists([Required] [FromBody] LocalPathCheckRequest request) + { + bool exists = !string.IsNullOrWhiteSpace(request.Path) && fileSystem.Directory.Exists(request.Path); + return new OkObjectResult(new LocalPathCheckResponseModel(exists)); + } + + private LocalLibraryResponseModel ProjectToResponseModel(LocalLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, entityLocker.IsLibraryLocked(vm.Id)); + + private async Task ProjectToDetailResponseModel( + LocalLibraryViewModel vm, + CancellationToken cancellationToken) + { + List paths = + await mediator.Send(new GetLocalLibraryPaths(vm.Id), cancellationToken); + int mediaItemCount = await mediator.Send(new CountMediaItemsByLibrary(vm.Id), cancellationToken); + + var pathModels = new List(); + foreach (LocalLibraryPathViewModel path in paths) + { + int pathCount = await mediator.Send(new CountMediaItemsByLibraryPath(path.Id), cancellationToken); + pathModels.Add(new LocalLibraryPathResponseModel(path.Id, path.Path, pathCount)); + } + + return new LocalLibraryDetailResponseModel( + vm.Id, + vm.Name, + vm.MediaKind, + entityLocker.IsLibraryLocked(vm.Id), + mediaItemCount, + pathModels); + } +} diff --git a/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs b/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs new file mode 100644 index 000000000..e1cc451d6 --- /dev/null +++ b/ErsatzTV/Controllers/Api/PlexMediaSourcesController.cs @@ -0,0 +1,324 @@ +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Plex; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.MediaSources; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Extensions; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class PlexMediaSourcesController( + IMediator mediator, + IEntityLocker entityLocker, + IPlexSecretStore plexSecretStore, + ChannelWriter scannerWorkerChannel) : ControllerBase +{ + private const string PlexBusyTitle = "Plex sign-in or sync in progress"; + + private const string PlexBusyDetail = + "A Plex sign-in or synchronization is currently in progress; try again once it completes."; + + private static IActionResult PlexLockedProblem() => + ApiResults.ConflictProblem(PlexBusyTitle, PlexBusyDetail); + + [HttpGet("/api/media-sources/plex", Name = "GetPlexState")] + [Tags("Plex")] + [EndpointSummary("Get Plex connection state")] + [EndpointDescription( + "Returns whether ErsatzTV is authorized with plex.tv, whether a Plex sign-in/sync lock is held, " + + "and the list of discovered Plex servers. Poll this during the pin flow: authorized && !locked = done.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)] + public async Task GetState(CancellationToken cancellationToken) + { + List servers = + await mediator.Send(new GetAllPlexMediaSources(), cancellationToken); + bool isAuthorized = (await plexSecretStore.GetUserAuthTokens()).Count > 0; + return new RemoteMediaSourceStateResponseModel( + isAuthorized, + entityLocker.IsPlexLocked(), + servers.Map(ToItemResponse).ToList()); + } + + [HttpPost("/api/media-sources/plex/pin-flow", Name = "StartPlexPinFlow")] + [Tags("Plex")] + [EndpointSummary("Start the Plex sign-in pin flow")] + [EndpointDescription( + "Acquires the Plex lock and starts the OAuth pin flow, returning the plex.tv authorization URL to " + + "open in a new tab. The lock stays held for the background flow; poll GET /api/media-sources/plex " + + "until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PlexPinFlowResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task StartPinFlow(CancellationToken cancellationToken) + { + // The lock IS the running sign-in flow (§3b): fail to acquire = a flow/sync is already active → 409. + if (!entityLocker.LockPlex()) + { + return PlexLockedProblem(); + } + + // Only the Right/200 path keeps the lock held (handed off to the background pin flow). Release the + // controller-acquired lock on the Left branch AND on any thrown exception from the dispatch/enqueue + // (re-review finding 2 — StartPlexPinFlowHandler awaits a channel WriteAsync that can throw). + try + { + Either result = await mediator.Send(new StartPlexPinFlow(), cancellationToken); + return result.Match( + Left: error => + { + entityLocker.UnlockPlex(); + return error.ToErrorResult(); + }, + Right: url => (IActionResult)new OkObjectResult(new PlexPinFlowResponseModel(url))); + } + catch (Exception) + { + entityLocker.UnlockPlex(); + throw; + } + } + + [HttpPost("/api/media-sources/plex/sign-out", Name = "SignOutOfPlex")] + [Tags("Plex")] + [EndpointSummary("Sign out of Plex")] + [EndpointDescription( + "Purges all Plex servers, synced Plex content, and stored credentials. Acquires the Plex lock; the " + + "handler always releases it (even on failure).")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task SignOutOfPlex(CancellationToken cancellationToken) + { + if (!entityLocker.LockPlex()) + { + return PlexLockedProblem(); + } + + // SignOutOfPlexHandler is the designated releaser (unconditional finally) — the controller acquires + // and hands off; it does NOT release here (that would double-release on the handler's success path). + Either result = await mediator.Send(new SignOutOfPlex(), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpGet("/api/media-sources/plex/{id:int}/libraries", Name = "GetPlexLibraries")] + [Tags("Plex")] + [EndpointSummary("Get a Plex server's libraries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetLibraries(int id, CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = + await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken); + return new OkObjectResult(libraries.Map(ToLibraryResponse).ToList()); + } + + [HttpPut("/api/media-sources/plex/{id:int}/libraries", Name = "ReplacePlexLibraryPreferences")] + [Tags("Plex")] + [EndpointSummary("Replace a Plex server's library sync preferences")] + [EndpointDescription( + "The body is the complete set of the source's libraries with each shouldSyncItems flag. A row absent " + + "from the request is left untouched. Every id must belong to this source (no Id=0 rows). Returns the " + + "reloaded list — library ids change when sync is disabled, so re-key any client draft off the response. " + + "Enabled libraries are queued for sync (locked libraries are skipped).")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplaceLibraryPreferences( + int id, + [Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request, + CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + List libraries = request.Libraries ?? []; + List owned = + await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken); + var ownedIds = owned.Map(l => l.Id).ToHashSet(); + + // Source-scope the id set (§C4a / finding 2a): reject Id=0 rows and any id not owned by this source + // before dispatch — UpdatePlexLibraryPreferences carries no source id, so this is the only guard. + var invalidIds = libraries.Filter(l => l.Id < 1 || !ownedIds.Contains(l.Id)).Map(l => l.Id).ToList(); + if (invalidIds.Count > 0) + { + return BaseError.New($"Library {invalidIds[0]} does not belong to Plex media source {id}") + .ToErrorResult(); + } + + // Require the complete set of this source's libraries (§C4a; matches Jellyfin/Emby): the PUT is a + // full flag document, so an omitted owned id would silently keep its old sync state. + var incomingIds = libraries.Map(l => l.Id).ToList(); + var missingIds = ownedIds.Where(ownedId => !incomingIds.Contains(ownedId)).ToList(); + if (missingIds.Count > 0) + { + return BaseError.New( + $"The request must include every library for Plex media source {id} " + + $"(missing id(s) {string.Join(", ", missingIds)})") + .ToErrorResult(); + } + + Either result = + await mediator.Send(request.ToPlexCommand(), cancellationToken); + if (result.IsLeft) + { + foreach (BaseError error in result.LeftToSeq()) + { + return error.ToErrorResult(); + } + } + + // Reload BEFORE enqueueing — ids change on disable (§C4a); the reloaded list carries the fresh ids + // for both the sync loop and the response (§7 write-path projection: reload via the GET's query). + List reloaded = + await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken); + + await EnqueuePostSaveSync(reloaded, cancellationToken); + + return new OkObjectResult(reloaded.Map(ToLibraryResponse).ToList()); + } + + [HttpGet("/api/media-sources/plex/{id:int}/path-replacements", Name = "GetPlexPathReplacements")] + [Tags("Plex")] + [EndpointSummary("Get a Plex server's path replacements")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetPathReplacements(int id, CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + List replacements = + await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(replacements.Map(ToPathReplacementResponse).ToList()); + } + + [HttpPut("/api/media-sources/plex/{id:int}/path-replacements", Name = "ReplacePlexPathReplacements")] + [Tags("Plex")] + [EndpointSummary("Replace a Plex server's path replacements")] + [EndpointDescription( + "Replaces the source's path replacements: an existing id updates, id<1 adds, an existing id absent from " + + "the body is deleted. Every positive id must belong to this source and every row needs a non-empty " + + "RemotePath and LocalPath (else 422, no partial mutation). Returns the reloaded list.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task ReplacePathReplacements( + int id, + [Required] [FromBody] ReplacePathReplacementsRequest request, + CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + // The handler owns the cross-source ownership guard + nonblank/null validation (→ 422, no mutation). + Either result = + await mediator.Send(request.ToPlexCommand(id), cancellationToken); + if (result.IsLeft) + { + foreach (BaseError error in result.LeftToSeq()) + { + return error.ToErrorResult(); + } + } + + List reloaded = + await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken); + return new OkObjectResult(reloaded.Map(ToPathReplacementResponse).ToList()); + } + + [HttpPost("/api/media-sources/plex/{id:int}/refresh-libraries", Name = "RefreshPlexLibraries")] + [Tags("Plex")] + [EndpointSummary("Refresh a Plex server's libraries")] + [EndpointDescription( + "Queues a synchronization of the server's libraries (fire-and-forget). Returns 409 while a Plex " + + "sign-in/sync lock is held; duplicate refreshes are accepted (the scan is idempotent).")] + [EndpointGroupName("general")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] + public async Task RefreshLibraries(int id, CancellationToken cancellationToken) + { + if (!await PlexSourceExists(id, cancellationToken)) + { + return ApiResults.NotFoundProblem(); + } + + if (entityLocker.IsPlexLocked()) + { + return PlexLockedProblem(); + } + + await scannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(id), cancellationToken); + return new AcceptedResult(); + } + + private async Task PlexSourceExists(int id, CancellationToken cancellationToken) => + (await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome; + + // Post-save re-sync (§C7 / finding 6): per enabled library, one LockLibrary ⇄ one release carried by + // the LAST message. Correcting the Blazor bug: the library message runs Unlock:false so the release + // rides SynchronizePlexNetworks(Unlock:true). Compensating-unlock if the networks enqueue throws. + private async Task EnqueuePostSaveSync( + IEnumerable libraries, + CancellationToken cancellationToken) + { + foreach (PlexLibraryViewModel library in libraries.Filter(l => l.ShouldSyncItems)) + { + if (!entityLocker.LockLibrary(library.Id)) + { + continue; + } + + try + { + await scannerWorkerChannel.WriteAsync( + new SynchronizePlexLibraryByIdIfNeeded(library.Id, Unlock: false), + cancellationToken); + await scannerWorkerChannel.WriteAsync( + new SynchronizePlexNetworks(library.Id, false, Unlock: true), + cancellationToken); + } + catch + { + // the library message carries Unlock:false, so the not-yet-enqueued networks message was + // the sole releaser — release here since it never ran + entityLocker.UnlockLibrary(library.Id); + throw; + } + } + } + + private static RemoteMediaSourceItemResponseModel ToItemResponse(PlexMediaSourceViewModel vm) => + new(vm.Id, vm.Name, vm.Address); + + private static RemoteLibraryResponseModel ToLibraryResponse(PlexLibraryViewModel vm) => + new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems); + + private static PathReplacementResponseModel ToPathReplacementResponse(PlexPathReplacementViewModel vm) => + new(vm.Id, vm.PlexPath, vm.LocalPath); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs new file mode 100644 index 000000000..b882fa247 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateLocalLibraryRequest(string Name, LibraryMediaKind MediaKind, List Paths) +{ + public CreateLocalLibrary ToCommand() => new(Name, MediaKind, Paths ?? []); +} diff --git a/ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs b/ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs new file mode 100644 index 000000000..116b7ef93 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +// No command mapping — the controller checks filesystem existence directly (see design #202 §C2). +public record LocalPathCheckRequest(string Path); diff --git a/ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs b/ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs new file mode 100644 index 000000000..c4581bda8 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Libraries; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record MoveLocalLibraryPathRequest(int TargetLibraryId) +{ + public MoveLocalLibraryPath ToCommand(int pathId) => new(pathId, TargetLibraryId); +} diff --git a/ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs new file mode 100644 index 000000000..7fe37da13 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +// Id 0/absent = new; existing Id = update; existing row whose Id is absent from the request = delete +// (design #202 §C4b) — plus a per-row ownership guard and nonblank validation enforced by the handler. +public record PathReplacementItemRequest(int Id, string RemotePath, string LocalPath); diff --git a/ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs b/ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs new file mode 100644 index 000000000..186b63980 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +public record RemoteLibraryPreferenceRequest(int Id, bool ShouldSyncItems); diff --git a/ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs new file mode 100644 index 000000000..dd93ff02f --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Application.Plex; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplacePathReplacementsRequest(List Items) +{ + public UpdatePlexPathReplacements ToPlexCommand(int sourceId) => + new( + sourceId, + (Items ?? []) + .Select(item => new PlexPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList()); + + public UpdateJellyfinPathReplacements ToJellyfinCommand(int sourceId) => + new( + sourceId, + (Items ?? []) + .Select(item => new JellyfinPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList()); + + public UpdateEmbyPathReplacements ToEmbyCommand(int sourceId) => + new( + sourceId, + (Items ?? []) + .Select(item => new EmbyPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs new file mode 100644 index 000000000..de2665748 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Application.Plex; + +namespace ErsatzTV.Controllers.Api.Requests; + +// Shared by Plex/Jellyfin/Emby (design #202 §C4a) — the PUT body is the complete set of the +// source's libraries; a row absent from the request is left untouched, not deleted. The controller +// validates the id set against that source's known libraries before dispatch. +public record ReplaceRemoteLibraryPreferencesRequest(List Libraries) +{ + public UpdatePlexLibraryPreferences ToPlexCommand() => + new( + (Libraries ?? []) + .Select(item => new PlexLibraryPreference(item.Id, item.ShouldSyncItems)) + .ToList()); + + public UpdateJellyfinLibraryPreferences ToJellyfinCommand() => + new( + (Libraries ?? []) + .Select(item => new JellyfinLibraryPreference(item.Id, item.ShouldSyncItems)) + .ToList()); + + public UpdateEmbyLibraryPreferences ToEmbyCommand() => + new( + (Libraries ?? []) + .Select(item => new EmbyLibraryPreference(item.Id, item.ShouldSyncItems)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs new file mode 100644 index 000000000..e776d98ac --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; + +namespace ErsatzTV.Controllers.Api.Requests; + +// ApiKey blank/omitted = retain the existing key (design #202 secure connection contract, finding 1) +// — the caller passes the source's current key so ToCommand can substitute it when the incoming +// value is blank. A non-blank value sets a new key. Required on first connect (no existing key), +// which the controller/handler enforces before dispatch. +public record SaveRemoteConnectionRequest(string Address, string ApiKey) +{ + public SaveJellyfinSecrets ToJellyfinCommand(string existingApiKey) => + new( + new JellyfinSecrets + { + Address = Address, + ApiKey = string.IsNullOrWhiteSpace(ApiKey) ? existingApiKey : ApiKey + }); + + public SaveEmbySecrets ToEmbyCommand(string existingApiKey) => + new( + new EmbySecrets + { + Address = Address, + ApiKey = string.IsNullOrWhiteSpace(ApiKey) ? existingApiKey : ApiKey + }); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs new file mode 100644 index 000000000..c9c49eb6b --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Application.Libraries; + +namespace ErsatzTV.Controllers.Api.Requests; + +// Id 0/absent = new path (matches UpdateLocalLibraryHandler's normalized-path-string identity merge; +// the Id is advisory only — see design #202 §C4c). +public record UpdateLocalLibraryPathRequest(int Id, string Path) +{ + public UpdateLocalLibraryPath ToCommand() => new(Id, Path); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs new file mode 100644 index 000000000..4e8145f2b --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs @@ -0,0 +1,16 @@ +using ErsatzTV.Application.Libraries; + +namespace ErsatzTV.Controllers.Api.Requests; + +// MediaKind is immutable after create, so it is deliberately absent here (structural immutability +// instead of a 422 — see design #202 §C3). +public record UpdateLocalLibraryRequest(string Name, List Paths) +{ + public UpdateLocalLibrary ToCommand(int id) => + new( + id, + Name, + (Paths ?? []) + .Select(path => path.ToCommand()) + .ToList()); +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 9cdc5a6ca..21666f098 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -4169,6 +4169,635 @@ } } }, + "/api/media-sources/emby": { + "get": { + "tags": [ + "Emby" + ], + "summary": "Get Emby connection state and discovered servers", + "operationId": "GetEmbyState", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + } + } + } + } + } + }, + "/api/media-sources/emby/connection": { + "get": { + "tags": [ + "Emby" + ], + "summary": "Get the Emby connection address", + "description": "Never returns the API key — only whether one is currently configured (design #202 secure connection contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.", + "operationId": "GetEmbyConnection", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Emby" + ], + "summary": "Connect, reconnect, or edit the Emby connection", + "description": "A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required on first connect (no existing secret).", + "operationId": "SaveEmbyConnection", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + } + } + }, + "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": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/emby/disconnect": { + "post": { + "tags": [ + "Emby" + ], + "summary": "Disconnect Emby", + "description": "Purges the Emby connection, discovered servers, and all synced Emby content.", + "operationId": "DisconnectEmby", + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + } + } + } + }, + "/api/media-sources/emby/{id}/libraries": { + "get": { + "tags": [ + "Emby" + ], + "summary": "Get an Emby source's libraries", + "operationId": "GetEmbyLibraries", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Emby" + ], + "summary": "Replace an Emby source's library sync preferences", + "description": "The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this response rather than the request body.", + "operationId": "ReplaceEmbyLibraryPreferences", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/emby/{id}/path-replacements": { + "get": { + "tags": [ + "Emby" + ], + "summary": "Get an Emby source's path replacements", + "operationId": "GetEmbyPathReplacements", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Emby" + ], + "summary": "Replace an Emby source's path replacements", + "operationId": "ReplaceEmbyPathReplacements", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/emby/{id}/refresh-libraries": { + "post": { + "tags": [ + "Emby" + ], + "summary": "Refresh an Emby source's libraries", + "operationId": "RefreshEmbyLibraries", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "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" + } + } + } + }, + "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" + } + } + } + } + } + } + }, "/api/ffmpeg/profiles": { "get": { "tags": [ @@ -5318,6 +5947,635 @@ } } }, + "/api/media-sources/jellyfin": { + "get": { + "tags": [ + "Jellyfin" + ], + "summary": "Get Jellyfin connection state and discovered servers", + "operationId": "GetJellyfinState", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + } + } + } + } + } + }, + "/api/media-sources/jellyfin/connection": { + "get": { + "tags": [ + "Jellyfin" + ], + "summary": "Get the Jellyfin connection address", + "description": "Never returns the API key — only whether one is currently configured (design #202 secure connection contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.", + "operationId": "GetJellyfinConnection", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + } + } + } + } + }, + "put": { + "tags": [ + "Jellyfin" + ], + "summary": "Connect, reconnect, or edit the Jellyfin connection", + "description": "A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required on first connect (no existing secret).", + "operationId": "SaveJellyfinConnection", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/SaveRemoteConnectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteConnectionResponseModel" + } + } + } + }, + "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": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/jellyfin/disconnect": { + "post": { + "tags": [ + "Jellyfin" + ], + "summary": "Disconnect Jellyfin", + "description": "Purges the Jellyfin connection, discovered servers, and all synced Jellyfin content.", + "operationId": "DisconnectJellyfin", + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + } + } + } + }, + "/api/media-sources/jellyfin/{id}/libraries": { + "get": { + "tags": [ + "Jellyfin" + ], + "summary": "Get a Jellyfin source's libraries", + "operationId": "GetJellyfinLibraries", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Jellyfin" + ], + "summary": "Replace a Jellyfin source's library sync preferences", + "description": "The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this response rather than the request body.", + "operationId": "ReplaceJellyfinLibraryPreferences", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/jellyfin/{id}/path-replacements": { + "get": { + "tags": [ + "Jellyfin" + ], + "summary": "Get a Jellyfin source's path replacements", + "operationId": "GetJellyfinPathReplacements", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Jellyfin" + ], + "summary": "Replace a Jellyfin source's path replacements", + "operationId": "ReplaceJellyfinPathReplacements", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/jellyfin/{id}/refresh-libraries": { + "post": { + "tags": [ + "Jellyfin" + ], + "summary": "Refresh a Jellyfin source's libraries", + "operationId": "RefreshJellyfinLibraries", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "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" + } + } + } + }, + "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" + } + } + } + } + } + } + }, "/api/languages": { "get": { "tags": [ @@ -5654,6 +6912,534 @@ } } }, + "/api/libraries/local": { + "get": { + "tags": [ + "Libraries" + ], + "summary": "Get all local libraries", + "operationId": "GetLocalLibraries", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocalLibraryResponseModel" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Libraries" + ], + "summary": "Create a local library", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/CreateLocalLibraryRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocalLibraryRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocalLibraryRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CreateLocalLibraryRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryResponseModel" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/libraries/local/{id}": { + "get": { + "tags": [ + "Libraries" + ], + "summary": "Get a local library by id", + "operationId": "GetLocalLibrary", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryDetailResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryDetailResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryDetailResponseModel" + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Libraries" + ], + "summary": "Update a local library", + "description": "Replaces the library's name and full path list. MediaKind is immutable after create and is not part of this request. Paths are identified by normalized path string, not id — a renamed path is a delete-old + add-new (its id changes).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocalLibraryRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocalLibraryRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocalLibraryRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocalLibraryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryDetailResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryDetailResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LocalLibraryDetailResponseModel" + } + } + } + }, + "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" + } + } + } + }, + "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": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Libraries" + ], + "summary": "Delete a local library", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/libraries/local/paths/{pathId}/move": { + "post": { + "tags": [ + "Libraries" + ], + "summary": "Move a local library path to another local library", + "description": "Moves a path (and its scanned media) from its current library to a different local library of the same media kind. Blazor's move dialog enforces same-kind/different-library only client-side; this endpoint enforces both server-side so API/MCP clients cannot bypass them.", + "parameters": [ + { + "name": "pathId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/MoveLocalLibraryPathRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveLocalLibraryPathRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MoveLocalLibraryPathRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MoveLocalLibraryPathRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + }, + "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": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/libraries/local/path-exists": { + "post": { + "tags": [ + "Libraries" + ], + "summary": "Check whether a filesystem path exists", + "description": "Server-side existence check for the SPA's add-path UX (a browser cannot call Directory.Exists). This is a check-then-act convenience only — Create/Update still validate new paths at save time (design #202 §C2).", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/LocalPathCheckResponseModel" + } + } + } + } + } + } + }, "/api/logs": { "get": { "tags": [ @@ -8912,6 +10698,583 @@ } } }, + "/api/media-sources/plex": { + "get": { + "tags": [ + "Plex" + ], + "summary": "Get Plex connection state", + "description": "Returns whether ErsatzTV is authorized with plex.tv, whether a Plex sign-in/sync lock is held, and the list of discovered Plex servers. Poll this during the pin flow: authorized && !locked = done.", + "operationId": "GetPlexState", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/RemoteMediaSourceStateResponseModel" + } + } + } + } + } + } + }, + "/api/media-sources/plex/pin-flow": { + "post": { + "tags": [ + "Plex" + ], + "summary": "Start the Plex sign-in pin flow", + "description": "Acquires the Plex lock and starts the OAuth pin flow, returning the plex.tv authorization URL to open in a new tab. The lock stays held for the background flow; poll GET /api/media-sources/plex until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.", + "operationId": "StartPlexPinFlow", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PlexPinFlowResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlexPinFlowResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PlexPinFlowResponseModel" + } + } + } + }, + "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": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/plex/sign-out": { + "post": { + "tags": [ + "Plex" + ], + "summary": "Sign out of Plex", + "description": "Purges all Plex servers, synced Plex content, and stored credentials. Acquires the Plex lock; the handler always releases it (even on failure).", + "operationId": "SignOutOfPlex", + "responses": { + "204": { + "description": "No Content" + }, + "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" + } + } + } + } + } + } + }, + "/api/media-sources/plex/{id}/libraries": { + "get": { + "tags": [ + "Plex" + ], + "summary": "Get a Plex server's libraries", + "operationId": "GetPlexLibraries", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Plex" + ], + "summary": "Replace a Plex server's library sync preferences", + "description": "The body is the complete set of the source's libraries with each shouldSyncItems flag. A row absent from the request is left untouched. Every id must belong to this source (no Id=0 rows). Returns the reloaded list — library ids change when sync is disabled, so re-key any client draft off the response. Enabled libraries are queued for sync (locked libraries are skipped).", + "operationId": "ReplacePlexLibraryPreferences", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplaceRemoteLibraryPreferencesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryResponseModel" + } + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/plex/{id}/path-replacements": { + "get": { + "tags": [ + "Plex" + ], + "summary": "Get a Plex server's path replacements", + "operationId": "GetPlexPathReplacements", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + } + } + }, + "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" + } + } + } + } + } + }, + "put": { + "tags": [ + "Plex" + ], + "summary": "Replace a Plex server's path replacements", + "description": "Replaces the source's path replacements: an existing id updates, id<1 adds, an existing id absent from the body is deleted. Every positive id must belong to this source and every row needs a non-empty RemotePath and LocalPath (else 422, no partial mutation). Returns the reloaded list.", + "operationId": "ReplacePlexPathReplacements", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ReplacePathReplacementsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathReplacementResponseModel" + } + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/media-sources/plex/{id}/refresh-libraries": { + "post": { + "tags": [ + "Plex" + ], + "summary": "Refresh a Plex server's libraries", + "description": "Queues a synchronization of the server's libraries (fire-and-forget). Returns 409 while a Plex sign-in/sync lock is held; duplicate refreshes are accepted (the scan is idempotent).", + "operationId": "RefreshPlexLibraries", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "202": { + "description": "Accepted" + }, + "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" + } + } + } + }, + "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" + } + } + } + } + } + } + }, "/api/rerun-collections": { "get": { "tags": [ @@ -16786,6 +19149,34 @@ } } }, + "CreateLocalLibraryRequest": { + "required": [ + "name", + "mediaKind", + "paths" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "mediaKind": { + "$ref": "#/components/schemas/LibraryMediaKind" + }, + "paths": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "CreateMultiCollectionRequest": { "required": [ "name", @@ -18406,6 +20797,124 @@ } } }, + "LocalLibraryDetailResponseModel": { + "required": [ + "id", + "name", + "mediaKind", + "isLocked", + "mediaItemCount", + "paths" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "mediaKind": { + "$ref": "#/components/schemas/LibraryMediaKind" + }, + "isLocked": { + "type": "boolean" + }, + "mediaItemCount": { + "type": "integer", + "format": "int32" + }, + "paths": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/LocalLibraryPathResponseModel" + } + } + } + }, + "LocalLibraryPathResponseModel": { + "required": [ + "id", + "path", + "mediaItemCount" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "mediaItemCount": { + "type": "integer", + "format": "int32" + } + } + }, + "LocalLibraryResponseModel": { + "required": [ + "id", + "name", + "mediaKind", + "isLocked" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "mediaKind": { + "$ref": "#/components/schemas/LibraryMediaKind" + }, + "isLocked": { + "type": "boolean" + } + } + }, + "LocalPathCheckRequest": { + "required": [ + "path" + ], + "type": "object", + "properties": { + "path": { + "type": [ + "null", + "string" + ] + } + } + }, + "LocalPathCheckResponseModel": { + "required": [ + "exists" + ], + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + } + }, "LogEntryResponseModel": { "required": [ "timestamp", @@ -18901,6 +21410,18 @@ ], "type": "string" }, + "MoveLocalLibraryPathRequest": { + "required": [ + "targetLibraryId" + ], + "type": "object", + "properties": { + "targetLibraryId": { + "type": "integer", + "format": "int32" + } + } + }, "MovieDetailResponseModel": { "required": [ "id", @@ -19307,6 +21828,58 @@ } } }, + "PathReplacementItemRequest": { + "required": [ + "id", + "remotePath", + "localPath" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "remotePath": { + "type": [ + "null", + "string" + ] + }, + "localPath": { + "type": [ + "null", + "string" + ] + } + } + }, + "PathReplacementResponseModel": { + "required": [ + "id", + "remotePath", + "localPath" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "remotePath": { + "type": [ + "null", + "string" + ] + }, + "localPath": { + "type": [ + "null", + "string" + ] + } + } + }, "PlaybackOrder": { "enum": [ "None", @@ -20265,6 +22838,20 @@ } } }, + "PlexPinFlowResponseModel": { + "required": [ + "authUrl" + ], + "type": "object", + "properties": { + "authUrl": { + "type": [ + "null", + "string" + ] + } + } + }, "ProblemDetails": { "type": "object", "properties": { @@ -20340,6 +22927,118 @@ } } }, + "RemoteConnectionResponseModel": { + "required": [ + "address", + "hasApiKey" + ], + "type": "object", + "properties": { + "address": { + "type": [ + "null", + "string" + ] + }, + "hasApiKey": { + "type": "boolean" + } + } + }, + "RemoteLibraryPreferenceRequest": { + "required": [ + "id", + "shouldSyncItems" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "shouldSyncItems": { + "type": "boolean" + } + } + }, + "RemoteLibraryResponseModel": { + "required": [ + "id", + "name", + "mediaKind", + "shouldSyncItems" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "mediaKind": { + "$ref": "#/components/schemas/LibraryMediaKind" + }, + "shouldSyncItems": { + "type": "boolean" + } + } + }, + "RemoteMediaSourceItemResponseModel": { + "required": [ + "id", + "name", + "address" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "address": { + "type": [ + "null", + "string" + ] + } + } + }, + "RemoteMediaSourceStateResponseModel": { + "required": [ + "isAuthorized", + "isLocked", + "servers" + ], + "type": "object", + "properties": { + "isAuthorized": { + "type": "boolean" + }, + "isLocked": { + "type": "boolean" + }, + "servers": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/RemoteMediaSourceItemResponseModel" + } + } + } + }, "ReplaceBlockRequest": { "required": [ "name", @@ -20547,6 +23246,23 @@ } } }, + "ReplacePathReplacementsRequest": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/PathReplacementItemRequest" + } + } + } + }, "ReplacePlaylistRequest": { "required": [ "name", @@ -20605,6 +23321,23 @@ } } }, + "ReplaceRemoteLibraryPreferencesRequest": { + "required": [ + "libraries" + ], + "type": "object", + "properties": { + "libraries": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/RemoteLibraryPreferenceRequest" + } + } + } + }, "ReplaceScheduleItemsRequest": { "required": [ "items" @@ -20752,6 +23485,27 @@ } } }, + "SaveRemoteConnectionRequest": { + "required": [ + "address", + "apiKey" + ], + "type": "object", + "properties": { + "address": { + "type": [ + "null", + "string" + ] + }, + "apiKey": { + "type": [ + "null", + "string" + ] + } + } + }, "ScalingBehavior": { "enum": [ "ScaleAndPad", @@ -22931,6 +25685,49 @@ } } }, + "UpdateLocalLibraryPathRequest": { + "required": [ + "id", + "path" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "path": { + "type": [ + "null", + "string" + ] + } + } + }, + "UpdateLocalLibraryRequest": { + "required": [ + "name", + "paths" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "paths": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/UpdateLocalLibraryPathRequest" + } + } + } + }, "UpdateLoggingSettingsRequest": { "required": [ "defaultMinimumLogLevel", @@ -23535,6 +26332,9 @@ { "name": "DecoTemplates" }, + { + "name": "Emby" + }, { "name": "FFmpeg Profiles" }, @@ -23550,6 +26350,9 @@ { "name": "Images" }, + { + "name": "Jellyfin" + }, { "name": "Languages" }, @@ -23580,6 +26383,9 @@ { "name": "Playouts" }, + { + "name": "Plex" + }, { "name": "Rerun Collections" }, diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 69f99086c..38a944ce1 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -152,6 +152,37 @@ handler's validation when a lookup fails, so the controller-side mapping falls o precedent from the schedules endpoints (see issue #172) and intentional to avoid N+1 validation queries; don't "fix" this without discussing it first. +### 3c. A durable-save PUT that also triggers a background sync as a side effect + +Some PUT-replace endpoints (issue #202: `PUT /api/media-sources/{plex|jellyfin|emby}/{id}/libraries`) +have a synchronous durable write as their primary purpose — the response must reflect that write — +but Blazor's editor also fired off a background sync per newly-enabled library after the save. This +is a **different shape from §3b**: §3b is for an endpoint whose entire job *is* starting a +background operation (so the outcome enum drives the status code); here the enqueue is a fire-and- +forget side effect of an otherwise-ordinary write, and the response must still be **200 with the +durably-saved data**, not 202. + +Pattern (see `PlexMediaSourcesController.ReplaceLibraryPreferences` / `EnqueuePostSaveSync`): +1. Dispatch the write command; on `Left`, return the error — nothing is enqueued. +2. **Reload** the saved rows through the same query the GET uses (per §7) — needed both for the + response and because ids can change as a result of the save (e.g. a disable-then-re-add). +3. Iterate the reloaded rows and, per row that needs a sync, `LockLibrary(id)` — **skip rows whose + lock is already held** (mirrors Blazor's `if (Locker.LockLibrary(id))` loop; no 409 for the save + itself, only a silent skip for that one row's sync). +4. Enqueue the background message(s) for that row; if a multi-message enqueue can throw partway + through, wrap it in `try/catch` and release the lock in the `catch` (compensating unlock) before + rethrowing — the standard `EnqueueWithTraktLock` one-lock-⇄-one-release discipline (§3b), just + invoked from inside a write endpoint instead of a dedicated "start background job" endpoint. +5. Return the reloaded data with `200 OK` — never 202 — since the durable save already happened; + the enqueue is best-effort and its failure (after a caught/compensated exception) surfaces as a + 500 on this same request rather than silently dropping the sync. + +This is server-side (not SPA-orchestrated) because the SPA has no access to the scanner channel, +and having the SPA fire a second request after the save would open a crash window between "saved" +and "synced". See `docs/decisions.md` 2026-07-11 (#202) for the fuller rationale and the specific +bug this pattern corrected (Blazor's Plex `Unlock` ordering released the library lock before a +dependent second message ran). + ## 4. Artwork contract API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`, diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index fec77305b..6ae11a304 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -122,17 +122,35 @@ target query and harmlessly ignored). The incoming query string is merged into ` > | 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 | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** | -> | Media sources | **disproven — moved to Section 3** | **#202** | +> | Media sources | **PARITY-OK** (#202 DONE 2026-07-11 — moved back from Section 3; new SPA screens under `/app/libraries/*` over a new REST write API) | — | > -> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11. +> Bold issues are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11; #202 DONE 2026-07-11. -**As of ersatztv#204, this section is empty** — every route the mutation-depth sweep marked -PARITY-OK now has a redirect entry (exact `Map` or a `PatternRule`) and has moved to **Section 1**. -The SPA screens were verified against `web/src/App.tsx`'s route table and `web/src/screens/` when -each was built (scheduling parity #144/#162; troubleshooting/YAML #145; media detail + image browser -#141/#183); #204 only added the redirects. The one route still deliberately un-redirected is -`/media/sources/*` — disproven and tracked in **Section 3** (#202) — plus the `/system/health` -escape hatch in **Section 4**. +As of ersatztv#204, every route the mutation-depth sweep marked PARITY-OK received a redirect entry +(an exact `Map` entry or a Tier-2 `PatternRule`) and moved to **Section 1** — EXCEPT the 14 +`/media/sources/*` routes. Those became **SPA-READY as of #202 (DONE 2026-07-11)**: new screens under +`/app/libraries/*` (`LibrariesScreen` hub + `LocalLibraryEditScreen`, `PlexSourceScreen`, +`RemoteSourceScreen`, `RemoteConnectionEditScreen`, `RemoteLibrariesEditScreen`, +`PathReplacementsEditScreen`) over a new REST write API. They are listed below; their redirect entries +are **deferred to the #91b removal PR** (matching the pre-removal spot-check convention). Once those +redirects land, this section is empty. + +| Blazor route | Blazor file | SPA route | Notes | +|---|---|---|---| +| `/media/sources/local` | `LocalLibraries.razor` | `/app/libraries` | local-library rows on the hub (list, scan, gear → editor); **#202** | +| `/media/sources/local/add` | `LocalLibraryEditor.razor` (add mode) | `/app/libraries/local/new` | `LocalLibraryEditScreen` create mode: name/media-kind/paths (`path-exists` pre-check); **#202** | +| `/media/sources/local/{Id:int}/edit` | `LocalLibraryEditor.razor` (edit mode) | `/app/libraries/local/{id}` | `LocalLibraryEditScreen` edit mode: name + path add/delete (count-confirm)/move (incl. "(New Library)"), media-kind immutable; **#202** | +| `/media/sources/plex` | `PlexMediaSources.razor` | `/app/libraries/plex` | `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out, server table, per-server Refresh; **#202** | +| `/media/sources/plex/{Id:int}/libraries` | `PlexLibrariesEditor.razor` | `/app/libraries/plex/{id}/sync` | `RemoteLibrariesEditScreen` (family=plex): sortable Name/MediaKind, per-library sync toggle + Save; **#202** | +| `/media/sources/plex/{Id:int}/paths` | `PlexPathReplacementsEditor.razor` | `/app/libraries/plex/{id}/path-replacements` | `PathReplacementsEditScreen` (family=plex); **#202** | +| `/media/sources/jellyfin` | `JellyfinMediaSources.razor` | `/app/libraries/jellyfin` | `RemoteSourceScreen` (family=jellyfin): connect/edit-connection/disconnect + server table; **#202** | +| `/media/sources/jellyfin/edit` | `JellyfinMediaSourceEditor.razor` | `/app/libraries/jellyfin/connection` | `RemoteConnectionEditScreen` (family=jellyfin): address prefilled, api-key set-affordance (never displayed once set); **#202** | +| `/media/sources/jellyfin/{Id:int}/libraries` | `JellyfinLibrariesEditor.razor` | `/app/libraries/jellyfin/{id}/sync` | `RemoteLibrariesEditScreen` (family=jellyfin); **#202** | +| `/media/sources/jellyfin/{Id:int}/paths` | `JellyfinPathReplacementsEditor.razor` | `/app/libraries/jellyfin/{id}/path-replacements` | `PathReplacementsEditScreen` (family=jellyfin); **#202** | +| `/media/sources/emby` | `EmbyMediaSources.razor` | `/app/libraries/emby` | `RemoteSourceScreen` (family=emby); **#202** | +| `/media/sources/emby/edit` | `EmbyMediaSourceEditor.razor` | `/app/libraries/emby/connection` | `RemoteConnectionEditScreen` (family=emby); **#202** | +| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (family=emby); **#202** | +| `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** | ## Section 3 — BLAZOR-ONLY (blocking issues) @@ -164,17 +182,26 @@ search preview. The `POST /api/collections/{id}/items` bogus-id case already ret Nothing remains here. The playback troubleshooting screen (`/app/troubleshooting/playback`, `PlaybackTroubleshootingScreen.tsx`) is built and now lives in Section 2. -### Media sources — #202 (found by the #91 cold review, 2026-07-09) +### Media sources — #202 DONE (2026-07-11) -| Blazor route | Blazor file | Blocking issue | -|---|---|---| -| `/media/sources/{local,plex,jellyfin,emby}/...` | `LocalLibraries.razor`, `PlexMediaSources.razor`, `JellyfinMediaSources.razor`, `EmbyMediaSources.razor` + editors | **#202** | +Nothing remains here. Found by the #91 cold review (2026-07-09): the SPA `/app/libraries` screen +listed sources/libraries and could trigger scans, but source add/edit, connection parameters, +path replacements, and library enable/disable had **no SPA UI and no write REST API** +(`MediaSourcesController` was GET-only; "Add Source" was a hardcoded disabled stub) — a fresh +install could not connect any library without Blazor. -Previously (wrongly) listed in Section 2 as "merged into libraries screen": the SPA -`/app/libraries` screen lists sources/libraries and can trigger scans, but source add/edit, -connection parameters, path replacements, and library enable/disable have **no SPA UI and no -write REST API** (`MediaSourcesController` is GET-only; the "Add Source" button is a hardcoded -disabled stub). A fresh install could not connect any library without Blazor. +**Resolved by #202**: new write controllers (`LocalLibrariesController` under +`/api/libraries/local/*`; `PlexMediaSourcesController`/`JellyfinMediaSourcesController`/ +`EmbyMediaSourcesController` under `/api/media-sources/{plex|jellyfin|emby}/*`) wrap the existing +MediatR commands (no new commands, no DB migration), plus new SPA screens +(`LocalLibraryEditScreen`, `PlexSourceScreen`, `RemoteSourceScreen`, `RemoteConnectionEditScreen`, +`RemoteLibrariesEditScreen`, `PathReplacementsEditScreen`) under the now-`allowSubPaths` +`/app/libraries/*` route. All 14 Blazor routes now have SPA equivalents — see the per-route table +in Section 2 (moved there from this section). Fixed along the way (pre-existing bugs #202 chose to +own, not just parity-copy): the Plex pin-flow lock leak on an abandoned OAuth flow, cross-source +overwrite in the path-replacement repo SQL, non-`finally` lock release in sign-out/disconnect +handlers, and the secure `apiKey` contract (never served over GET). Full design rationale in +`docs/decisions.md` (2026-07-11 entries) and the capability matrix posted on issue #202. ### Schedule editors — #207 DONE (2026-07-11) @@ -242,9 +269,9 @@ intentional exit ramp until phase (b) removes Blazor entirely. ## Section 5 — Removal execution runbook (#91 phase b, Step 2/3) -The removal PR is **gated** — it starts only after these clear: #202 (media-source write API + SPA), -#235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor` can't be deleted -yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern +The removal PR is **gated** — it starts only after these clear: ~~#202 (media-source write API + SPA)~~ +**DONE 2026-07-11**, #235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor` +can't be deleted yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine executes, in order: diff --git a/docs/decisions.md b/docs/decisions.md index 1a9c1f7d7..ce6d41b94 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -460,6 +460,92 @@ into the moved `progressFromChannelState` (behavior-identical — `ChannelState. dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign is deferred to #247 (epic phase 4). +## 2026-07-11 — Media-source management REST write API + SPA (#202) + +Replaced the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages (14 routes) with SPA +screens under `/app/libraries/*` over new write controllers (`LocalLibrariesController`, +`Plex|Jellyfin|EmbyMediaSourcesController`), wrapping existing MediatR commands 1:1 (no new +commands, no DB migration). Full design + adversarial-review reconciliation: +`docs/handoffs/` session record and issue #202. The design surfaced and fixed several pre-existing +Application/Infrastructure bugs newly reachable from a programmatic client; each is recorded here +because it changes documented behavior, not just adds a route. + +**Secure `apiKey` contract (Jellyfin/Emby connection).** The connection GET +(`RemoteConnectionResponseModel`) returns `{ address, hasApiKey }` — the stored key **never** +leaves the server, closing a leak where the old design would have served the raw key from an +unauthenticated GET under any-origin CORS. On the connection PUT, a blank/omitted `apiKey` means +*retain the existing key*; a non-blank value sets a new one; the key is **required on first +connect** (no existing secret) → 422. Rationale: GETs aren't behind `X-Api-Key` +(`ApiKeyAuthorizationFilter` only guards mutating verbs), so a secret-bearing GET is a real +exposure regardless of how obscure the route is. **Stated explicitly as an input to #197** (the +planned read-side-auth review for secret-bearing GETs) — #197 should treat "does any GET return a +credential" as one of its checks, not just this one instance. + +**Three list-replace identity contracts, not one uniform one.** An earlier draft assumed a single +"`Id<1`=add / missing=delete / id-preserved" contract across all three PUT-replace families; source +inspection proved that false for two of them: +- *Remote library sync preferences* (`PUT .../{id}/libraries`) — the command carries no source id + and the handler toggles only the ids present in the body; a row **absent** from the request is + left untouched, not deleted (libraries are sync-discovered, never created via this PUT, so there + are no `Id=0` adds either). The controller validates the submitted id set against + `Get{Family}LibrariesBySourceId(id)` (422 on any id not owned by the route's source — closes a + cross-source hole). Identity is **not stable across a disable**: `Disable{Family}LibrarySync` + removes and re-adds the row with a fresh id, so the SPA keys its draft to `(name, mediaKind)`, + never to `Id`, and refetches after every save (the PUT returns the reloaded list). +- *Path replacements* (`PUT .../{id}/path-replacements`) — id-based (existing `Id`=update, + `Id<1`=add, absent=delete) as documented, **but** the repo UPDATE SQL had no source-id predicate + (`WHERE Id = @id`, no `AND {Family}MediaSourceId = @id`), so a PUT to source A could silently + overwrite source B's row with the same numeric id. Fixed with a handler-level ownership guard + (reject any incoming positive id not in this source's current set → 422, no partial mutation) + **and** the repo SQL predicate itself (defense-in-depth for any other caller of that repo method). +- *Local library paths* (`PUT /api/libraries/local/{id}`) — identity is the **normalized path + string** (full path, trailing-separator/case-insensitive), not `Id`; `Id` in the request is + advisory. Renaming a path is delete-old+add-new under the hood (its `LibraryPath.Id` changes). + Kept as-is (matches the entrenched, tested Blazor behavior and how the SPA edits by value); not + rewritten to id-based identity, which would be a bigger, riskier change out of #202's scope. + +**Plex pin-flow as REST: poll until the lock releases, exception-safe non-handoff unlock.** The +SPA polls `GET /api/media-sources/plex` rather than a per-pin status resource (no pin-addressable +server state exists to expose; SSE/push was already rejected, 2026-07-09). Polling contract is +`isLocked && !isAuthorized` = waiting on the user; `isLocked && isAuthorized` = finalizing +(discovering servers — **do not** stop here, the server list is still empty); +`!isLocked && isAuthorized` = success; `!isLocked && !isAuthorized` = timed out/abandoned. This +required fixing a **latent lock-leak bug**: `TryCompletePlexPinFlowHandler` threw +`OperationCanceledException` on its 2-minute timeout instead of returning `false`, and nothing +unlocked on that path — an abandoned sign-in wedged the Plex lock until restart or manual sign-out. +Fix releases `UnlockPlex()` on the timeout-throw, a poll-exception, and an enqueue-exception — but +**deliberately not** in an unconditional `finally`: on success the lock is handed off to +`SynchronizePlexMediaSources`, the sole releaser after server discovery; a blanket `finally` would +double-release and release *before* discovery completes, re-opening the same race the fix closes. +This is the same non-owner-token discipline as the #231 `EntityLocker` model (2026-07-11 entry +above), applied to the pin-flow's handoff-vs-terminal distinction specifically. + +**404 comes from the controller pre-check, not the handler.** `Apply`/`ToEitherAsync` both +`.Join()` errors, which flattens any `NotFoundError` inside a joined `Validation` down to a plain +422. So every id-taking endpoint's real 404 is a controller-side pre-check +(`Get...ById(id)`-is-`None` → `ApiResults.NotFoundProblem`, the `TemplateController.DeleteGroup` +pattern), not a handler-level conversion — converting the joined validators to `NotFoundError` +would be dead code, since the join discards the distinction anyway. This is check-then-act (a +delete racing between the pre-check and the command falls through to the handler's own 422, not a +404); accepted and tested against the actual runtime error type rather than a hoped-for handler 404. + +**"Scan All" dropped, not implemented.** The disabled SPA header button on the libraries hub was +speculative UI with **no Blazor equivalent** (`Libraries.razor` only ever supported per-library +scan). Removed rather than backed with a new bulk-scan endpoint; per-library scan (#232) and the +new per-source refresh-libraries endpoints (P8/J9/E9) cover the real capability set. + +**App-owned popstate for guarded sub-path routes.** `LocalLibraryEditScreen` and the other +`/app/libraries/*` editors are the first screens to both register a dirty-navigation guard *and* +track their own sub-path pathname — the combination `spa-conventions.md` §8 had flagged as +unvalidated. React commits child passive effects before parent ones, so a sub-path wrapper that +self-registers `popstate` would fire (and switch sub-screen) *before* App's guard-restore listener +could veto. Resolution: App owns `popstate` centrally for the `libraries` route and only pushes an +approved sub-path down to the wrapper (which no longer self-registers `popstate`); on a vetoed pop +App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This is scoped to the +`libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes +(Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar +list and the resolved caveat text. + ## 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204) `LegacyUiRedirects.TryGetRedirect` grew from a single exact-path dictionary to a **two-tier matcher** diff --git a/docs/domain-model.md b/docs/domain-model.md index f07bf32e2..ba0656c19 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -63,8 +63,8 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) | | **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` | | **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` | -| **Library / LibraryPath / LibraryFolder** | `Library` (abstract; Local/Plex/Jellyfin/Emby subclasses) owns one or more `LibraryPath`s (scan roots); each path has a `LibraryFolder` tree used for browsing and image-folder duration metadata. | `Library`, `LibraryPath`, `LibraryFolder` | `/app/libraries` | -| **Media source kind** | `MediaSourceKind`: Local/Plex/Jellyfin/Emby — the origin server type for a `Library`. | `MediaSourceKind` | `/app/libraries` | +| **Library / LibraryPath / LibraryFolder** | `Library` (abstract; Local/Plex/Jellyfin/Emby subclasses) owns one or more `LibraryPath`s (scan roots); each path has a `LibraryFolder` tree used for browsing and image-folder duration metadata. | `Library`, `LibraryPath`, `LibraryFolder` | `/app/libraries` (hub: list + scan); `/app/libraries/local/{id}` (`/new`) for local library name/media-kind/path CRUD + move-path (#202) | +| **Media source kind** | `MediaSourceKind`: Local/Plex/Jellyfin/Emby — the origin server type for a `Library`. | `MediaSourceKind` | `/app/libraries` (hub); connect/edit/disconnect and per-source library-sync + path-replacements now live at `/app/libraries/{plex\|jellyfin\|emby}` (+ `/connection`, `/{id}/sync`, `/{id}/path-replacements`) — #202, previously Blazor-only | | **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` | | **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) | | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | @@ -82,7 +82,23 @@ Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/guid Media nav: `/app/media` (generic kind-filtered browse, `?kind=movies|shows|artists|music-videos| other-videos|remote-streams|images`), `/app/search`, `/app/trash`, `/app/collections` (manual + smart), `/app/multi-collections` (#151), `/app/rerun-collections` (#152), -`/app/filler-presets`, `/app/libraries`, `/app/trakt-lists`. +`/app/filler-presets`, `/app/libraries` (+ sub-paths, see below), `/app/trakt-lists`. + +**Media sources / libraries (#202, 2026-07-11)**: `/app/libraries` is an `allowSubPaths` route +(`LibrariesRouteScreen`, App-owned popstate — see `spa-conventions.md` §2/§8). The hub itself lists +sources/libraries and triggers scans; connecting/editing/removing a source and its libraries now +happens at: +- `/app/libraries/local/new`, `/app/libraries/local/{id}` — `LocalLibraryEditScreen`: name, + media-kind (create-only), path add/move/delete. +- `/app/libraries/plex` — `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out. +- `/app/libraries/{jellyfin|emby}` — `RemoteSourceScreen`; `/app/libraries/{jellyfin|emby}/connection` + — `RemoteConnectionEditScreen` (address + api-key, key never displayed once set). +- `/app/libraries/{plex|jellyfin|emby}/{id}/sync` — `RemoteLibrariesEditScreen` (per-library sync + toggle); `/app/libraries/{plex|jellyfin|emby}/{id}/path-replacements` — `PathReplacementsEditScreen`. + +These replace the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages over the new +`/api/libraries/local/*` and `/api/media-sources/{plex|jellyfin|emby}/*` write endpoints — see +`docs/blazor-route-parity.md` Section 2 for the full per-route mapping. System nav: `/app/settings` (sub-tabs: streaming/system/logging/playout/scanner/general/xmltv — all mapped 1:1 from legacy `/settings/*` Blazor routes), `/app/logs`, `/app/troubleshooting` (+ diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 0b50cc62c..d1b1ad3e5 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -133 endpoints, 207 operations. +155 endpoints, 240 operations. ## Artists @@ -109,6 +109,20 @@ | GET | `/api/decos/{id}` | GetDecoById | Get a deco by id | | PUT | `/api/decos/{id}` | | Replace a deco | +## Emby + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/media-sources/emby` | GetEmbyState | Get Emby connection state and discovered servers | +| GET | `/api/media-sources/emby/connection` | GetEmbyConnection | Get the Emby connection address | +| PUT | `/api/media-sources/emby/connection` | SaveEmbyConnection | Connect, reconnect, or edit the Emby connection | +| POST | `/api/media-sources/emby/disconnect` | DisconnectEmby | Disconnect Emby | +| GET | `/api/media-sources/emby/{id}/libraries` | GetEmbyLibraries | Get an Emby source's libraries | +| PUT | `/api/media-sources/emby/{id}/libraries` | ReplaceEmbyLibraryPreferences | Replace an Emby source's library sync preferences | +| GET | `/api/media-sources/emby/{id}/path-replacements` | GetEmbyPathReplacements | Get an Emby source's path replacements | +| PUT | `/api/media-sources/emby/{id}/path-replacements` | ReplaceEmbyPathReplacements | Replace an Emby source's path replacements | +| POST | `/api/media-sources/emby/{id}/refresh-libraries` | RefreshEmbyLibraries | Refresh an Emby source's libraries | + ## FFmpeg Profiles | Method | Path | Operation | Summary | @@ -149,6 +163,20 @@ | GET | `/api/images/folders` | GetImageFolders | List image library folders | | PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration | +## Jellyfin + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/media-sources/jellyfin` | GetJellyfinState | Get Jellyfin connection state and discovered servers | +| GET | `/api/media-sources/jellyfin/connection` | GetJellyfinConnection | Get the Jellyfin connection address | +| PUT | `/api/media-sources/jellyfin/connection` | SaveJellyfinConnection | Connect, reconnect, or edit the Jellyfin connection | +| POST | `/api/media-sources/jellyfin/disconnect` | DisconnectJellyfin | Disconnect Jellyfin | +| GET | `/api/media-sources/jellyfin/{id}/libraries` | GetJellyfinLibraries | Get a Jellyfin source's libraries | +| PUT | `/api/media-sources/jellyfin/{id}/libraries` | ReplaceJellyfinLibraryPreferences | Replace a Jellyfin source's library sync preferences | +| GET | `/api/media-sources/jellyfin/{id}/path-replacements` | GetJellyfinPathReplacements | Get a Jellyfin source's path replacements | +| PUT | `/api/media-sources/jellyfin/{id}/path-replacements` | ReplaceJellyfinPathReplacements | Replace a Jellyfin source's path replacements | +| POST | `/api/media-sources/jellyfin/{id}/refresh-libraries` | RefreshJellyfinLibraries | Refresh a Jellyfin source's libraries | + ## Languages | Method | Path | Operation | Summary | @@ -159,6 +187,13 @@ | Method | Path | Operation | Summary | |---|---|---|---| +| GET | `/api/libraries/local` | GetLocalLibraries | Get all local libraries | +| POST | `/api/libraries/local` | | Create a local library | +| POST | `/api/libraries/local/path-exists` | | Check whether a filesystem path exists | +| POST | `/api/libraries/local/paths/{pathId}/move` | | Move a local library path to another local library | +| DELETE | `/api/libraries/local/{id}` | | Delete a local library | +| GET | `/api/libraries/local/{id}` | GetLocalLibrary | Get a local library by id | +| PUT | `/api/libraries/local/{id}` | | Update a local library | | GET | `/api/libraries/scan-status` | GetLibraryScanStatus | Get active library scan status | | POST | `/api/libraries/{id}/scan` | | Scan library | | POST | `/api/libraries/{id}/scan-show` | | Scan show | @@ -248,6 +283,19 @@ | GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates | | PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates | +## Plex + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/media-sources/plex` | GetPlexState | Get Plex connection state | +| POST | `/api/media-sources/plex/pin-flow` | StartPlexPinFlow | Start the Plex sign-in pin flow | +| POST | `/api/media-sources/plex/sign-out` | SignOutOfPlex | Sign out of Plex | +| GET | `/api/media-sources/plex/{id}/libraries` | GetPlexLibraries | Get a Plex server's libraries | +| PUT | `/api/media-sources/plex/{id}/libraries` | ReplacePlexLibraryPreferences | Replace a Plex server's library sync preferences | +| GET | `/api/media-sources/plex/{id}/path-replacements` | GetPlexPathReplacements | Get a Plex server's path replacements | +| PUT | `/api/media-sources/plex/{id}/path-replacements` | ReplacePlexPathReplacements | Replace a Plex server's path replacements | +| POST | `/api/media-sources/plex/{id}/refresh-libraries` | RefreshPlexLibraries | Refresh a Plex server's libraries | + ## Rerun Collections | Method | Path | Operation | Summary | diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 8e4ce5927..046652659 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -42,6 +42,13 @@ Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `Templa (which owns two sibling sub-paths, `/playouts/{id}/alternate-schedules` and `/playouts/{id}/templates`, dispatching internally via `parsePlayoutSubRoute`). +**Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's +screens ALSO register a dirty guard (§8), the wrapper must **not** self-listen for `popstate`; App +owns the pathname and passes the approved sub-path down as a prop. The `LibrariesRouteScreen` wrapper +in `App.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched via a flat +`switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent +effect order makes the self-listening pattern unsafe here. + ## 3. Data loading pattern Reference implementation: `web/src/screens/LogsScreen.tsx`. Structure to copy for any screen that @@ -229,10 +236,25 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam: `activeRoute` untouched, effectively undoing the browser's URL change. This same handler also runs for the **synthetic** pop `navigateToPath()` dispatches, so programmatic in-app navigation is guarded as well. The re-push does **not** re-fire `popstate`; that's safe because only one screen - is mounted at a time and the guard-registering screen (the schedules editor) owns no internal - `popstate`/sub-path listener — so a blocked pop cannot desync a sub-path screen's own pathname - state (§2). A sub-path screen that both registers a dirty guard *and* tracks its own pathname would - need to re-validate this; none does today. + is mounted at a time. + +- **Guarded sub-path routes: App owns pathname/popstate (resolves the old §2 caveat).** A sub-path + wrapper that BOTH tracks its own pathname (§2) AND whose sub-screens register a dirty guard cannot + self-listen for `popstate`: React commits **child** passive effects before the **parent**, so a + wrapper-owned `popstate` listener fires *before* App's guard-restore handler and would switch + sub-screen before App could veto and re-push — desyncing the two. **Resolution (design #202 §D.2, + finding 4):** App is the single `popstate` owner. On a pop it consults `canLeaveCurrentScreen()`, + and **only on approval** does it update a `currentSubPath` state value (`librariesSubPath`) that it + passes **down** into the wrapper; on a veto it re-pushes the pre-pop path and updates nothing, so + the wrapper never sees a vetoed path. The wrapper (`LibrariesRouteScreen`) derives its sub-path + purely from that prop — never from the raw event — so effect-commit order is irrelevant. **Scope + the state write to the guarded route only:** the `setLibrariesSubPath` call is gated on the arrived + route being `libraries`, so unguarded sub-path routes (Playouts/Media) skip it entirely and stay + byte-identical (they still bail App's `setActiveRoute` via `Object.is` and self-own their pathname + per §2). The regression test is the App-owned-popstate case in `App.test.tsx` (dirty editor at + `/app/libraries/local/3`: `confirm→false` keeps URL + mounted sub-screen; `confirm→true` + navigates). Unguarded sub-path screens (`PlayoutsRouteScreen`, `MediaRouteScreen`) keep + self-owning their pathname — only screens that register a dirty guard defer to App. - The screen **also** guards the paths the module still can't see — an in-screen action that would replace the draft (schedule switch, opening the properties editor) uses its own `window.confirm(...)`, and a `beforeunload` listener (installed while `dirty`) covers full-page diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e519a56f2..c5d52b106 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } 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 { App } from './App'; import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components'; @@ -514,6 +514,57 @@ describe('ChicoryTV SPA scaffold', () => { confirmSpy.mockRestore(); }); + // Design §D.2 (finding 4): the libraries route is the first that BOTH tracks its own sub-path AND + // registers a dirty guard. App owns pathname/popstate for it (the wrapper never self-listens), so a + // dirty-editor Back is vetoed by App BEFORE the wrapper can switch sub-screen. S6a landed the real + // LocalLibraryEditScreen, so this exercises its own dirty guard (typing into Name) rather than a + // manually-armed stand-in guard. + it('App-owned popstate: a dirty libraries editor vetoes Back, then navigates when confirmed', async () => { + mockDashboardApi({ + mediaSources: [mediaSource({ id: 30, kind: 'Local', libraries: [], name: 'Local' })], + localLibraryDetail: { + id: 3, + name: 'Feature Films', + mediaKind: 'Movies', + isLocked: false, + mediaItemCount: 0, + paths: [] + } + }); + + window.history.replaceState(null, '', '/app/libraries/local/3'); + render(); + + // The S6a editor is mounted for the local-edit sub-path. + const nameInput = await screen.findByDisplayValue('Feature Films'); + + // Make the editor dirty for real (not a manually-armed stand-in guard). + fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } }); + expect(await screen.findByText('Unsaved changes')).toBeInTheDocument(); + + // Browser Back to the hub, vetoed: URL is restored and the editor stays put (App decided BEFORE + // the wrapper could react — the wrapper only ever sees an approved sub-path). + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + window.history.pushState(null, '', '/app/libraries'); + window.dispatchEvent(new PopStateEvent('popstate')); + + expect(confirmSpy).toHaveBeenCalled(); + expect(window.location.pathname).toBe('/app/libraries/local/3'); + expect(screen.getByDisplayValue('Feature Films (renamed)')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Media Libraries' })).not.toBeInTheDocument(); + + // Back again, this time confirmed: the wrapper switches to the hub and the editor unmounts. + confirmSpy.mockReturnValue(true); + window.history.pushState(null, '', '/app/libraries'); + window.dispatchEvent(new PopStateEvent('popstate')); + + expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument(); + expect(window.location.pathname).toBe('/app/libraries'); + expect(screen.queryByDisplayValue('Feature Films (renamed)')).not.toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + it('opens the create-schedule dialog from the TopBar Add Schedule button', async () => { mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] }); @@ -590,44 +641,15 @@ describe('ChicoryTV SPA scaffold', () => { expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); }); - it('renders the Libraries screen from live media source and scan status APIs', async () => { + it('renders the extracted Libraries screen from live media source and scan status APIs (#202)', async () => { + // Thin route/composition smoke test: the full Libraries behavioral suite lives colocated in + // web/src/screens/LibrariesScreen.test.tsx. This only asserts the nav wires the extracted screen + // into the shell and it mounts + hits its own endpoints. mockDashboardApi({ - libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }], + libraryScanStatuses: [{ libraryId: 31, percent: 0.5 }], mediaSources: [ mediaSource({ - connectionAddress: null, - id: 30, - kind: 'Local', - libraries: [ - library({ id: 31, itemCount: 1250, lastScan: '2026-07-05T14:30:00Z', mediaKind: 'Movies', name: 'Movies' }), - library({ id: 32, itemCount: 14, lastScan: null, mediaKind: 'OtherVideos', name: 'Station IDs' }) - ], - name: 'Local' - }), - mediaSource({ - connectionAddress: 'https://plex.example.test', - id: 40, - kind: 'Plex', - libraries: [ - library({ id: 41, itemCount: 80, lastScan: '2026-07-04T08:00:00Z', mediaKind: 'Shows', name: 'TV Shows' }) - ], - name: 'Plex Server' - }), - mediaSource({ - connectionAddress: 'https://jellyfin.example.test', - id: 50, - kind: 'Jellyfin', - libraries: [], - name: 'Jellyfin Home' - }), - mediaSource({ - connectionAddress: 'https://emby.example.test', - id: 60, - kind: 'Emby', - libraries: [ - library({ id: 61, itemCount: 22, lastScan: '2026-07-01T00:00:00Z', mediaKind: 'MusicVideos', name: 'Music Videos' }) - ], - name: 'Emby Archive' + libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) ] }); @@ -637,436 +659,11 @@ describe('ChicoryTV SPA scaffold', () => { fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument(); - expect(screen.getByText('4 sources')).toBeInTheDocument(); - expect(screen.getByText('1,366 items')).toBeInTheDocument(); - expect(screen.getByRole('region', { name: 'Local media source' })).toBeInTheDocument(); - expect(screen.getByText('Local connection')).toBeInTheDocument(); - expect(screen.getByRole('region', { name: 'Plex Server media source' })).toBeInTheDocument(); - expect(screen.getByText('https://plex.example.test')).toBeInTheDocument(); expect(screen.getAllByText('Movies').length).toBeGreaterThan(0); - expect(screen.getByText('Station IDs')).toBeInTheDocument(); - expect(screen.getByText('TV Shows')).toBeInTheDocument(); - expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0); - expect(screen.getByText('Other Videos')).toBeInTheDocument(); - expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0); - // "Scanning" now appears twice: once as the source-header status label (paired with - // the StatusDot, not color alone) and once as the per-library scanning Badge. - expect(screen.getAllByText('Scanning').length).toBeGreaterThan(0); - // Wire contract: percent is a 0-1 fraction (0.625) - the UI must render it as 63%, not 0.625%. - expect(screen.getByText('63%')).toBeInTheDocument(); - expect(screen.getAllByText('Synced').length).toBeGreaterThan(0); - expect(screen.getAllByText('Never scanned').length).toBeGreaterThan(0); - expect(screen.getByRole('button', { name: 'Add Source' })).toBeDisabled(); - expect(screen.getByText('Adding sources is deferred to the existing server setup screens.')).toBeInTheDocument(); expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object)); expect(window.fetch).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object)); }); - it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => { - mockDashboardApi({ - libraryScanStatuses: [ - { libraryId: 31, percent: 0 }, - { libraryId: 32, percent: 1 } - ], - mediaSources: [ - mediaSource({ - libraries: [ - library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }), - library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' }) - ] - }) - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - - expect(await screen.findByText('0%')).toBeInTheDocument(); - expect(screen.getByText('100%')).toBeInTheDocument(); - }); - - it('fetches Libraries data as a route delta and does not poll sources', async () => { - const intervalHandlers: Array<() => void> = []; - vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => { - if (typeof handler === 'function' && (timeout ?? 0) >= 10000) { - intervalHandlers.push(handler as () => void); - } - return intervalHandlers.length; - }); - vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); - mockDashboardApi({ - libraryScanStatuses: [{ libraryId: 31, percent: 0.1 }], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ] - }); - - render(); - - await waitFor(() => { - expect(fetchCount('/api/media-sources')).toBeGreaterThan(0); - }); - const sourceFetchesBeforeNavigation = fetchCount('/api/media-sources'); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument(); - - expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeNavigation + 1); - expect(fetchCount('/api/libraries/scan-status')).toBe(1); - - const sourceFetchesBeforePoll = fetchCount('/api/media-sources'); - await runPollTick(intervalHandlers); - - expect(fetchCount('/api/libraries/scan-status')).toBeGreaterThan(1); - expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforePoll); - }); - - it('stops Libraries scan polling and refreshes sources once when scans complete', async () => { - const intervalHandlers: Array<() => void> = []; - let clearCount = 0; - vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => { - if (typeof handler === 'function' && (timeout ?? 0) >= 10000) { - intervalHandlers.push(handler as () => void); - } - - return intervalHandlers.length; - }); - vi.spyOn(window, 'clearInterval').mockImplementation(() => { - clearCount += 1; - }); - mockDashboardApi({ - libraryScanStatusSequence: [ - [{ libraryId: 31, percent: 0.75 }], - [] - ], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect(await screen.findByText('75%')).toBeInTheDocument(); - - const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources'); - await runPollTick(intervalHandlers); - - expect(screen.queryByText('75%')).not.toBeInTheDocument(); - expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1); - expect(clearCount).toBeGreaterThan(0); - }); - - it('triggers a library scan, disables that library while in flight, and starts polling scan status', async () => { - const scanStatusesAfterMutation = [{ libraryId: 31, percent: 0.05 }]; - mockDashboardApi({ - libraryScanStatuses: [], - libraryScanStatusesAfterMutation: scanStatusesAfterMutation, - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' })); - }); - expect(await screen.findByText('5%')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - }); - - it('keeps the scan button disabled and polling armed through the queue-to-start race, then clears once the scan finishes', async () => { - const intervalHandlers: Array<() => void> = []; - let clearCount = 0; - vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => { - if (typeof handler === 'function' && (timeout ?? 0) >= 10000) { - intervalHandlers.push(handler as () => void); - } - return intervalHandlers.length; - }); - vi.spyOn(window, 'clearInterval').mockImplementation(() => { - clearCount += 1; - }); - - mockDashboardApi({ - libraryScanStatusSequence: [ - [], // initial screen load - [], // trigger's immediate post-POST fetch: scanner hasn't started yet - [{ libraryId: 31, percent: 0.3 }], // first poll tick: now active - [] // second poll tick: scan finished - ], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' })); - }); - - // The immediate post-trigger status fetch returned [] - without pending-id tracking - // the button would re-enable here and polling would never start. Both must hold. - await waitFor(() => { - expect(fetchCount('/api/libraries/scan-status')).toBe(2); - }); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - expect(intervalHandlers.length).toBeGreaterThan(0); - - const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources'); - - await runPollTick(intervalHandlers); - expect(screen.getByText('30%')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - await runPollTick(intervalHandlers); - expect(screen.queryByText('30%')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled(); - expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1); - expect(clearCount).toBeGreaterThan(0); - }); - - it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => { - // POST /scan now returns 409 when a scan is already in progress. That is benign - the SPA - // must NOT show an error; it reconciles against scan-status (which reports the active scan) - // and keeps the button disabled while that scan runs. - mockDashboardApi({ - libraryScanStatusSequence: [ - [], // initial screen load: nothing active yet - [{ libraryId: 31, percent: 0.3 }] // post-409 reconcile fetch: the in-progress scan - ], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { status: 409 } - } - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' })); - }); - - // The reconcile fetch surfaces the already-running scan; the button stays disabled... - expect(await screen.findByText('30%')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - // ...and no error toast is shown for the benign conflict. - expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument(); - }); - - it('surfaces a 422 "sync disabled" scan rejection and leaves the button enabled (#232)', async () => { - // POST /scan now returns 422 when item sync is disabled for the library. Nothing was queued, - // so the button must not be stuck disabled and the error must be surfaced. - mockDashboardApi({ - libraryScanStatuses: [], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { detail: 'Item sync is disabled for library 31.', status: 422 } - } - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - expect(await screen.findByText('Item sync is disabled for library 31.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled(); - }); - - it('re-enables the scan button after the grace window if a 202 scan never appears active (#232)', async () => { - // A 202 genuinely queues a (forced) scan, but a short/empty library can finish before the next - // 10s poll ever observes it in scan-status - or the scanner may never register it. The optimistic - // "pending" flag must not wedge the button disabled forever: it expires after PENDING_GRACE_TICKS - // polls that never see the library active, then the button re-enables. - const intervalHandlers: Array<() => void> = []; - vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => { - if (typeof handler === 'function' && (timeout ?? 0) >= 10000) { - intervalHandlers.push(handler as () => void); - } - return intervalHandlers.length; - }); - vi.spyOn(window, 'clearInterval').mockImplementation(() => 0); - - mockDashboardApi({ - // initial load, immediate post-POST fetch, then every poll: the scan never shows up - libraryScanStatusSequence: [[], [], [], [], []], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' })); - }); - - // Optimistically disabled; the immediate post-POST fetch ([]) burned one grace tick but keeps it pending. - await waitFor(() => { - expect(fetchCount('/api/libraries/scan-status')).toBe(2); - }); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - // Grace is PENDING_GRACE_TICKS (3): the immediate fetch used one, two more unseen polls expire it. - await runPollTick(intervalHandlers); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - await runPollTick(intervalHandlers); - expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled(); - }); - - it('keeps the scan button disabled on a 409 even when scan-status has not yet caught up (#232)', async () => { - // 409 means the library is already scanning (a lock is held). The SPA must NOT re-enable the - // button just because scan-status has not yet listed the in-progress scan (the queue->active - // lag) - otherwise the user can fire repeated 409s. The pending flag is retained (button stays - // disabled, no error toast) and is reconciled/expired by the normal polling path. - mockDashboardApi({ - libraryScanStatusSequence: [ - [], // initial screen load - [] // post-409 reconcile fetch: the in-progress scan is not visible in scan-status yet - ], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { status: 409 } - } - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' })); - }); - // Reconcile fetch happened and returned nothing active, yet the button stays disabled... - await waitFor(() => { - expect(fetchCount('/api/libraries/scan-status')).toBe(2); - }); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - // ...and no error toast is shown for the benign conflict. - expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument(); - }); - - it('shows Libraries API errors and retries', async () => { - // The backend has no exception middleware, so a real failure is a bare 500 with no - // ProblemDetails body - do not invent one here (mirrors the scan-trigger-404 test below). - mockDashboardApi({ - mediaSourcesFailuresBeforeSuccess: 2, - mediaSources: [mediaSource({ libraries: [] })] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - - expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Retry' })); - - expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument(); - }); - - it('shows scan trigger failures without inventing application ProblemDetails', async () => { - mockDashboardApi({ - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { - status: 404 - } - } - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' })); - - expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument(); - }); - - it('scans every library in a source when its Scan-all button is clicked', async () => { - mockDashboardApi({ - mediaSources: [ - mediaSource({ - id: 30, - libraries: [ - library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }), - library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' }) - ], - name: 'Local' - }) - ] - }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - expect(await screen.findByText('TV Shows')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Scan all libraries in Local' })); - - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' })); - }); - await waitFor(() => { - expect(window.fetch).toHaveBeenCalledWith('/api/libraries/32/scan', expect.objectContaining({ method: 'POST' })); - }); - expect(fetchCount('/api/libraries/31/scan')).toBe(1); - expect(fetchCount('/api/libraries/32/scan')).toBe(1); - }); - - it('shows an empty Libraries state when no media sources exist', async () => { - mockDashboardApi({ mediaSources: [] }); - - render(); - - fireEvent.click(screen.getByRole('link', { name: 'Libraries' })); - - expect(await screen.findByText('No media sources returned')).toBeInTheDocument(); - }); - it('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => { mockDashboardApi({ playoutItems: [ @@ -3216,6 +2813,7 @@ function mockDashboardApi({ health = [], libraryScanStatusFailAfterTrigger = false, libraryScanStatuses = [], + localLibraryDetail = null, libraryScanStatusesAfterMutation = null, libraryScanStatusSequence = null, mediaSources = [], @@ -3285,6 +2883,7 @@ function mockDashboardApi({ health?: unknown[]; libraryScanStatusFailAfterTrigger?: boolean; libraryScanStatuses?: unknown[]; + localLibraryDetail?: unknown; libraryScanStatusesAfterMutation?: unknown[] | null; libraryScanStatusSequence?: unknown[][] | null; mediaSources?: unknown[]; @@ -3514,6 +3113,13 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(mediaSources)); } + if (path.startsWith('/api/libraries/local/')) { + if (localLibraryDetail) { + return Promise.resolve(jsonResponse(localLibraryDetail)); + } + return Promise.resolve(new Response(null, { status: 404 })); + } + if (path === '/api/libraries/scan-status') { if (libraryScanStatusFailAfterTrigger && scanStatusShouldFail) { return Promise.resolve(new Response(null, { status: 500 })); @@ -3810,24 +3416,6 @@ function mockDashboardApi({ }); } -// Deterministically fires one captured poll tick for the mocked-setInterval polling tests. -// -// The Libraries poll interval is registered in a passive useEffect that only runs after -// an out-of-act state commit (the async fetch that flips hasActiveScans), so `findBy*` -// queries - which resolve on the DOM mutation, via MutationObserver - can win the race -// against React's effect flush and observe an empty handler list (the #157 flake). -// The leading empty act() flushes those pending effects, the length assertion turns a -// still-missing registration into a clear failure instead of a silent no-op forEach, -// and the act() around the handlers applies the fetch microtasks + state updates before -// the caller asserts, so no assertion depends on wall-clock scheduling. -async function runPollTick(intervalHandlers: Array<() => void>): Promise { - await act(async () => {}); - expect(intervalHandlers.length).toBeGreaterThan(0); - await act(async () => { - intervalHandlers.forEach((handler) => handler()); - }); -} - function fetchCount(path: string): number { return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length; } diff --git a/web/src/App.tsx b/web/src/App.tsx index bb4f2fae1..a66d29410 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,11 +23,8 @@ import { ClipboardCopy, Crosshair, Film, - FileImage, Eraser, - Folder, FolderTree, - HardDrive, Info, Layers, LayoutDashboard, @@ -37,8 +34,6 @@ import { ListChecks, ListMusic, ListVideo, - MonitorPlay, - Music, Plus, Pencil, Play, @@ -47,7 +42,6 @@ import { Repeat, ScrollText, Search, - Server, Settings, SlidersHorizontal, Sparkles, @@ -69,6 +63,13 @@ import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen'; import { PlaylistsScreen } from './screens/PlaylistsScreen'; import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen'; import { ChannelsScreen } from './screens/ChannelsScreen'; +import { LibrariesScreen } from './screens/LibrariesScreen'; +import { LocalLibraryEditScreen } from './screens/LocalLibraryEditScreen'; +import { PlexSourceScreen } from './screens/PlexSourceScreen'; +import { RemoteSourceScreen } from './screens/RemoteSourceScreen'; +import { RemoteConnectionEditScreen } from './screens/RemoteConnectionEditScreen'; +import { RemoteLibrariesEditScreen } from './screens/RemoteLibrariesEditScreen'; +import { PathReplacementsEditScreen } from './screens/PathReplacementsEditScreen'; import { SchedulesScreen } from './screens/SchedulesScreen'; import { canLeaveCurrentScreen } from './navigationGuard'; import { @@ -95,7 +96,12 @@ import { PlayoutAlternateSchedulesScreen, PlayoutTemplatesEditorScreen } from './screens/PlayoutScheduleEditors'; -import { navigateToPath, parseMediaSubRoute, parsePlayoutSubRoute } from './routing'; +import { + navigateToPath, + parseLibrariesSubRoute, + parseMediaSubRoute, + parsePlayoutSubRoute +} from './routing'; import { Badge, Button, @@ -135,7 +141,6 @@ import { defaultGuideWindowStart, GUIDE_WINDOW_MS, useGuideScreenQuery, - useLibrariesScreenQuery, type ChannelState, type ChannelGuideChannel, type ChannelGuideProgramme, @@ -144,9 +149,6 @@ import { type DecoListItem, type DashboardChannelState, type DashboardHealthQueryState, - type LibraryScanStatus, - type MediaSource, - type MediaSourceLibrary, type PlayoutDetail, type PlayoutScheduleKind, type ProgramSchedule, @@ -436,6 +438,11 @@ const routes: ScreenRoute[] = [ allowSubPaths: true }, { + // The media-source editors live at sub-paths (/app/libraries/local/{id}, /app/libraries/plex, + // /app/libraries/{jellyfin|emby}/connection, .../{id}/sync, .../{id}/path-replacements). + // Unlike other allowSubPaths screens, the libraries wrapper does NOT self-listen for popstate: + // its editors register a dirty guard, so App owns pathname/popstate and passes the approved + // sub-path down (see LibrariesRouteScreen + spa-conventions §8, design §D.2). id: 'libraries', path: '/app/libraries', label: 'Libraries', @@ -444,7 +451,8 @@ const routes: ScreenRoute[] = [ description: 'Local, Plex, Jellyfin, and Emby media source monitoring.', icon: