Files
ersatztv/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
T
timothy f5cf23c952 feat(api): local libraries REST endpoints (#202 slice S1)
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).
2026-07-11 15:43:10 +02:00

92 lines
3.5 KiB
C#

using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries;
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public CreateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
IFileSystem fileSystem,
IDbContextFactory<TvContext> dbContextFactory)
{
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_fileSystem = fileSystem;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
CreateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(_fileSystem, dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
TvContext dbContext,
LocalLibrary localLibrary)
{
await dbContext.LocalLibraries.AddAsync(localLibrary);
await dbContext.SaveChangesAsync();
if (_entityLocker.LockLibrary(localLibrary.Id))
{
try
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
catch
{
// the scanner only unlocks when it receives the message; if the enqueue fails
// after we acquired the lock, release it here or it is held forever.
_entityLocker.UnlockLibrary(localLibrary.Id);
throw;
}
}
return ProjectToViewModel(localLibrary);
}
private static Task<Validation<BaseError, LocalLibrary>> Validate(
IFileSystem fileSystem,
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
dbContext.LocalMediaSources
.OrderBy(lms => lms.Id)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(lms => new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
MediaKind = request.MediaKind,
MediaSourceId = lms.Id
})
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
}