Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has started running
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Jellyfin libraries typed `mixed` were dropped by JellyfinApiClient.Project's `_ => None` with no log line, so music and standup content could not be ingested without a local-library workaround that bypassed Jellyfin entirely. Adds LibraryMediaKind.Mixed, maps "mixed"/absent/blank CollectionType onto it, and gives SynchronizeJellyfinLibraryByIdHandler a Mixed arm composing the three existing per-kind scanners. Jellyfin classifies items server-side via includeItemTypes, so the passes see disjoint sets; reconciliation is type-scoped and cannot cross-delete. No new scanner and no DB migration -- MediaItem is TPT keyed on LibraryPathId, so heterogeneous contents were already legal. Segregation falls out of the model: a library is a place (one path <-> one Jellyfin library <-> one ErsatzTV library), so music/standup cannot leak into Movies or TV Shows. Also removes the silent-success `_ => Unit.Default` from both scanner dispatchers, which returned Right for an unhandled kind and stamped LastScan as though a scan had run, and rejects Mixed for local libraries at the API. Deliberately Jellyfin-only: local scanners share one video extension list and would claim each other's files, and LibraryFolder etags are keyed by LibraryPathId with no notion of kind. Verified by live E2E against a real Jellyfin, including the interaction with #494's reconciliation sweep. Four cold review rounds, all MERGEABLE. fixes #489 Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
106 lines
4.3 KiB
C#
106 lines
4.3 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(MediaKindMustBeSupportedLocally)
|
|
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
|
|
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static Validation<BaseError, LocalLibrary> 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<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."));
|
|
}
|