Files
ersatztv/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
T
timothyandtimothy 2cf90fb44f
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
feat(489): support Jellyfin mixed-content libraries (#493)
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>
2026-07-20 16:34:51 +00:00

261 lines
12 KiB
C#

using System.Diagnostics;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Core.Interfaces;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
using Humanizer;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Application.MediaSources;
public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IImageFolderScanner _imageFolderScanner;
private readonly IScannerProxy _scannerProxy;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly IRemoteStreamFolderScanner _remoteStreamFolderScanner;
private readonly ISongFolderScanner _songFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
IScannerProxy scannerProxy,
ILibraryRepository libraryRepository,
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner,
ISongFolderScanner songFolderScanner,
IImageFolderScanner imageFolderScanner,
IRemoteStreamFolderScanner remoteStreamFolderScanner,
ILogger<ScanLocalLibraryHandler> logger)
{
_scannerProxy = scannerProxy;
_libraryRepository = libraryRepository;
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_songFolderScanner = songFolderScanner;
_imageFolderScanner = imageFolderScanner;
_remoteStreamFolderScanner = remoteStreamFolderScanner;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(ScanLocalLibrary request, CancellationToken cancellationToken) =>
Validate(request, cancellationToken)
.MapT(parameters => PerformScan(parameters, cancellationToken).Map(_ => parameters.LocalLibrary.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> PerformScan(RequestParameters parameters, CancellationToken cancellationToken)
{
(LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
int libraryRefreshInterval, string baseUrl) = parameters;
var sw = new Stopwatch();
sw.Start();
_scannerProxy.SetBaseUrl(baseUrl);
var scanned = false;
var anyFailed = false;
for (var i = 0; i < localLibrary.Paths.Count; i++)
{
LibraryPath libraryPath = localLibrary.Paths[i];
decimal progressMin = (decimal)i / localLibrary.Paths.Count;
decimal progressMax = (decimal)(i + 1) / localLibrary.Paths.Count;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
if (forceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now)
{
scanned = true;
Either<BaseError, Unit> result = localLibrary.MediaKind switch
{
LibraryMediaKind.Movies =>
await _movieFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.Shows =>
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.MusicVideos =>
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.OtherVideos =>
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.Songs =>
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.Images =>
await _imageFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
LibraryMediaKind.RemoteStreams =>
await _remoteStreamFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffprobePath,
progressMin,
progressMax,
cancellationToken),
// returning success here would stamp LastScan as though the library had been
// scanned; a local library has no scanner for Mixed and never should
_ => BaseError.New(
$"Local library {localLibrary.Name} has unsupported media kind {localLibrary.MediaKind}")
};
if (result.IsRight)
{
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
}
else
{
anyFailed = true;
// a failed path now suppresses the library-level scan time below, so without this the
// user sees "Never scanned" with nothing explaining why. The remote scanners log the
// same way. A cancellation is user-initiated, not a failure, so it's logged separately
// at a lower level; genuine errors still log at ERROR.
foreach (BaseError error in result.LeftToSeq())
{
if (error is ScanCanceled)
{
_logger.LogInformation(
"Scan of local library path {Path} was canceled",
libraryPath.Path);
}
else
{
_logger.LogError(
"Error scanning local library path {Path}: {Error}",
libraryPath.Path,
error);
}
}
}
}
await _scannerProxy.UpdateProgress(progressMax, cancellationToken);
}
sw.Stop();
// the library-level LastScan drives the "last scanned" / "Never scanned" badge in the API and SPA,
// and is separate from the per-path LastScan that gates the refresh interval above. Only record it
// when every path that ran succeeded, mirroring the remote scanners (which set it solely on IsRight).
if (scanned && !anyFailed)
{
localLibrary.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(localLibrary);
}
if (scanned)
{
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
sw.Elapsed.Humanize());
}
else
{
_logger.LogDebug(
"Skipping unforced scan of local media library {Name}",
localLibrary.Name);
}
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(
ScanLocalLibrary request,
CancellationToken cancellationToken)
{
Validation<BaseError, LocalLibrary> libraryResult = await LocalLibraryMustExist(request);
Validation<BaseError, string> ffprobePathResult = await ValidateFFprobePath(cancellationToken);
Validation<BaseError, string> ffmpegPathResult = await ValidateFFmpegPath(cancellationToken);
Validation<BaseError, int> refreshIntervalResult = await ValidateLibraryRefreshInterval(cancellationToken);
return (libraryResult, ffprobePathResult, ffmpegPathResult, refreshIntervalResult)
.Apply((library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval,
request.BaseUrl));
}
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(ScanLocalLibrary request) =>
_libraryRepository.GetLibrary(request.LibraryId)
.Map(maybeLibrary => maybeLibrary.OfType<LocalLibrary>().HeadOrNone())
.Map(v => v.ToValidation<BaseError>($"Local library {request.LibraryId} does not exist."));
private Task<Validation<BaseError, string>> ValidateFFprobePath(CancellationToken cancellationToken) =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
.FilterT(File.Exists)
.Map(ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private Task<Validation<BaseError, string>> ValidateFFmpegPath(CancellationToken cancellationToken) =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken)
.FilterT(File.Exists)
.Map(ffmpegPath =>
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval(CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private record RequestParameters(
LocalLibrary LocalLibrary,
string FFprobePath,
string FFmpegPath,
bool ForceScan,
int LibraryRefreshInterval,
string BaseUrl);
}