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 ErsatzTV.Filters; 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/v1/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/v1/libraries/local/{id:int}", Name = "GetLocalLibrary")] [Tags("Libraries")] [EndpointSummary("Get a local library by id")] [RequiresAuthentication] [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/v1/libraries/local")] [Tags("Libraries")] [EndpointSummary("Create a local library")] [EndpointDescription( "The shared LibraryMediaKind enum includes Mixed, but it is rejected here with 422. Mixed exists " + "only for Jellyfin libraries, where the media server classifies each item; no local folder " + "scanner handles it.")] [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/v1/libraries/local/{vm.Id}", ProjectToResponseModel); } [HttpPut("/api/v1/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/v1/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/v1/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/v1/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); } }