From f5cf23c952c834ac10c70ca1f5d22cd39e643a2d Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 15:43:10 +0200 Subject: [PATCH] feat(api): local libraries REST endpoints (#202 slice S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds LocalLibrariesController (L1-L7: list/get/create/update/delete/move-path/ path-exists) wrapping the existing local-library MediatR commands, mapping to the shared S0 response DTOs. Per design #202 §A.1/§C5/§C6: - 404 for L4/L5/L6 comes from a controller pre-check (GetLocalLibraryById is None), not the handler -- .Apply/.ToEitherAsync both .Join() a NotFoundError into a plain 422, so relying on the handler would be dead code. This is check-then-act; a delete racing the pre-check falls through to the handler's 422, documented in the controller. - L4/L5 409 via IEntityLocker.IsLibraryLocked(id); L6 resolves the source library from the path id (new ILibraryRepository.GetLibraryIdForPath) before its own lock check. - MoveLocalLibraryPathHandler gains same-MediaKind and different-library validation (finding 3) -- Blazor only filtered these client-side in the move dialog, so an API/MCP client could bypass them. - CreateLocalLibraryHandler/UpdateLocalLibraryHandler gain a shared NewPathsMustExist validation (LocalLibraryHandlerBase) that Directory.Exists- checks only new paths (Id < 1); existing rows stay exempt so an unmounted share doesn't block a rename. L7 (path-exists) is a controller-local IFileSystem check with no command. Tests: controller route/mediator-arg tests incl. 404-pre-check vs fall-through-422 and 409-lock cases; handler tests for the move-path cross-kind/same-library 422s, new-path 422 (missing/mixed), and a lossless round-trip proving local paths are identified by normalized path string, not id. Full solution test suite (Scanner/Core/Architecture/Tests/Infrastructure) green, 0 regressions. Deviations: none from the S1 slice description. Did not touch MediaSourceRepository.cs or any Plex/Jellyfin/Emby file (S2/S3 scope). Did not run the OpenAPI regen scripts (separate gate after S1-S3 merge per design §E). --- .../Commands/CreateLocalLibraryHandler.cs | 12 +- .../Commands/LocalLibraryHandlerBase.cs | 25 +- .../Commands/MoveLocalLibraryPathHandler.cs | 29 +- .../Commands/UpdateLocalLibraryHandler.cs | 13 +- .../Repositories/ILibraryRepository.cs | 8 + .../Data/Repositories/LibraryRepository.cs | 9 + .../CreateLocalLibraryHandlerTests.cs | 102 +++++ .../MoveLocalLibraryPathHandlerTests.cs | 138 +++++++ .../UpdateLocalLibraryHandlerTests.cs | 161 ++++++++ .../LocalLibrariesControllerTests.cs | 363 ++++++++++++++++++ .../Api/LocalLibrariesController.cs | 216 +++++++++++ 11 files changed, 1065 insertions(+), 11 deletions(-) create mode 100644 ErsatzTV.Tests/Application/Libraries/CreateLocalLibraryHandlerTests.cs create mode 100644 ErsatzTV.Tests/Application/Libraries/MoveLocalLibraryPathHandlerTests.cs create mode 100644 ErsatzTV.Tests/Application/Libraries/UpdateLocalLibraryHandlerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/LocalLibrariesControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/LocalLibrariesController.cs 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.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.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/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/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); + } +}