using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.MediaSources; using Microsoft.Extensions.Logging; namespace ErsatzTV.Scanner.Application.Jellyfin; public class SynchronizeJellyfinLibraryByIdHandler : IRequestHandler> { private readonly IConfigElementRepository _configElementRepository; private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner; private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner; private readonly ILibraryRepository _libraryRepository; private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediator _mediator; public SynchronizeJellyfinLibraryByIdHandler( IMediator mediator, IMediaSourceRepository mediaSourceRepository, IJellyfinSecretStore jellyfinSecretStore, IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner, IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner, ILibraryRepository libraryRepository, IConfigElementRepository configElementRepository, ILogger logger) { _mediator = mediator; _mediaSourceRepository = mediaSourceRepository; _jellyfinSecretStore = jellyfinSecretStore; _jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner; _jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner; _libraryRepository = libraryRepository; _configElementRepository = configElementRepository; _logger = logger; } public async Task> Handle(SynchronizeJellyfinLibraryById request, CancellationToken cancellationToken) { Validation validation = await Validate(request); return await validation.Match( parameters => Synchronize(parameters, cancellationToken), error => Task.FromResult>(error.Join())); } private async Task> Synchronize( RequestParameters parameters, CancellationToken cancellationToken) { var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero); DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval); if (parameters.ForceScan || parameters.LibraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now) { // need the jellyfin admin user id for now Either syncAdminResult = await _mediator.Send( new SynchronizeJellyfinAdminUserId(parameters.Library.MediaSourceId), cancellationToken); foreach (BaseError error in syncAdminResult.LeftToSeq()) { _logger.LogError("Error synchronizing jellyfin admin user id: {Error}", error); return error; } Either result = parameters.Library.MediaKind switch { LibraryMediaKind.Movies => await _jellyfinMovieLibraryScanner.ScanLibrary( parameters.ConnectionParameters.ActiveConnection.Address, parameters.ConnectionParameters.ApiKey, parameters.Library, parameters.DeepScan, cancellationToken), LibraryMediaKind.Shows => await _jellyfinTelevisionLibraryScanner.ScanLibrary( parameters.ConnectionParameters.ActiveConnection.Address, parameters.ConnectionParameters.ApiKey, parameters.Library, parameters.DeepScan, cancellationToken), _ => Unit.Default }; if (result.IsRight) { parameters.Library.LastScan = DateTime.UtcNow; await _libraryRepository.UpdateLastScan(parameters.Library); } foreach (BaseError error in result.LeftToSeq()) { _logger.LogError("Error synchronizing jellyfin library: {Error}", error); } return result.Map(_ => parameters.Library.Name); } _logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name); // send an empty progress update for the library name await _mediator.Publish( new ScannerProgressUpdate( parameters.Library.Id, parameters.Library.Name, 0, Array.Empty(), Array.Empty()), cancellationToken); return parameters.Library.Name; } private async Task> Validate( SynchronizeJellyfinLibraryById request) => (await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateLibraryRefreshInterval()) .Apply( (connectionParameters, jellyfinLibrary, libraryRefreshInterval) => new RequestParameters( connectionParameters, jellyfinLibrary, request.ForceScan, libraryRefreshInterval, request.DeepScan )); private Task> ValidateConnection( SynchronizeJellyfinLibraryById request) => JellyfinMediaSourceMustExist(request) .BindT(MediaSourceMustHaveActiveConnection) .BindT(MediaSourceMustHaveApiKey); private Task> JellyfinMediaSourceMustExist( SynchronizeJellyfinLibraryById request) => _mediaSourceRepository.GetJellyfinByLibraryId(request.JellyfinLibraryId) .Map( v => v.ToValidation( $"Jellyfin media source for library {request.JellyfinLibraryId} does not exist.")); private Validation MediaSourceMustHaveActiveConnection( JellyfinMediaSource jellyfinMediaSource) { Option maybeConnection = jellyfinMediaSource.Connections.HeadOrNone(); return maybeConnection.Map(connection => new ConnectionParameters(connection)) .ToValidation("Jellyfin media source requires an active connection"); } private async Task> MediaSourceMustHaveApiKey( ConnectionParameters connectionParameters) { JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) .Where(match => match) .Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) .ToValidation("Jellyfin media source requires an api key"); } private Task> JellyfinLibraryMustExist( SynchronizeJellyfinLibraryById request) => _mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId) .Map(v => v.ToValidation($"Jellyfin library {request.JellyfinLibraryId} does not exist.")); private Task> ValidateLibraryRefreshInterval() => _configElementRepository.GetValue(ConfigElementKey.LibraryRefreshInterval) .FilterT(lri => lri is >= 0 and < 1_000_000) .Map(lri => lri.ToValidation("Library refresh interval is invalid")); private record RequestParameters( ConnectionParameters ConnectionParameters, JellyfinLibrary Library, bool ForceScan, int LibraryRefreshInterval, bool DeepScan); private record ConnectionParameters(JellyfinConnection ActiveConnection) { public string? ApiKey { get; init; } } }