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> { 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; } public async Task> Handle( CreateLocalLibrary request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(_fileSystem, dbContext, request); return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary)); } private async Task 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> Validate( IFileSystem fileSystem, TvContext dbContext, CreateLocalLibrary request) => MediaSourceMustExist(dbContext, request) .BindT(localLibrary => NameMustBeValid(request, localLibrary)) .BindT(MediaKindMustBeSupportedLocally) .BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary)) .BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary)); /// /// Mixed is only ever produced for remote (Jellyfin) libraries, where the media server classifies /// each item for us. No local folder scanner handles it, so a local Mixed library would fail every /// scan forever. The API takes a raw LibraryMediaKind, so this must be enforced here rather than /// left to the SPA's media-kind options. /// private static Validation MediaKindMustBeSupportedLocally( LocalLibrary localLibrary) => localLibrary.MediaKind is LibraryMediaKind.Mixed ? BaseError.New( "Local libraries cannot use the Mixed media kind; it is only valid for Jellyfin libraries.") : localLibrary; private static Task> 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("LocalMediaSource does not exist.")); }