using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces.Metadata; using ErsatzTV.Scanner.Core.Metadata; using Microsoft.Extensions.Logging; namespace ErsatzTV.Scanner.Core.Jellyfin; public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner, IJellyfinTelevisionLibraryScanner { private readonly IJellyfinApiClient _jellyfinApiClient; private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IJellyfinPathReplacementService _pathReplacementService; private readonly IMetadataRepository _metadataRepository; private readonly IJellyfinTelevisionRepository _jellyfinTelevisionRepository; private readonly ITelevisionRepository _televisionRepository; public JellyfinTelevisionLibraryScanner( IScannerProxy scannerProxy, IJellyfinApiClient jellyfinApiClient, IMediaSourceRepository mediaSourceRepository, IJellyfinTelevisionRepository jellyfinTelevisionRepository, ITelevisionRepository televisionRepository, IJellyfinPathReplacementService pathReplacementService, IFileSystem fileSystem, ILocalChaptersProvider localChaptersProvider, IMetadataRepository metadataRepository, ILogger logger) : base( scannerProxy, fileSystem, localChaptersProvider, metadataRepository, logger) { _jellyfinApiClient = jellyfinApiClient; _mediaSourceRepository = mediaSourceRepository; _jellyfinTelevisionRepository = jellyfinTelevisionRepository; _televisionRepository = televisionRepository; _pathReplacementService = pathReplacementService; _metadataRepository = metadataRepository; _logger = logger; } protected override bool ServerSupportsRemoteStreaming => true; public async Task> ScanLibrary( string address, string apiKey, JellyfinLibrary library, bool deepScan, CancellationToken cancellationToken) { List pathReplacements = await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); string GetLocalPath(JellyfinEpisode episode) { return _pathReplacementService.GetReplacementJellyfinPath( pathReplacements, episode.GetHeadVersion().MediaFiles.Head().Path, false); } return await ScanLibrary( _jellyfinTelevisionRepository, new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId), library, GetLocalPath, deepScan, cancellationToken); } public async Task> ScanSingleShow( string address, string apiKey, JellyfinLibrary library, string showId, string showTitle, bool deepScan, CancellationToken cancellationToken) { List pathReplacements = await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); string GetLocalPath(JellyfinEpisode episode) { return _pathReplacementService.GetReplacementJellyfinPath( pathReplacements, episode.GetHeadVersion().MediaFiles.Head().Path, false); } // Search for the specific show Either> searchResult = await _jellyfinApiClient.GetSingleShow( address, apiKey, library, showId); return await searchResult.Match( async maybeShow => { foreach (JellyfinShow show in maybeShow) { _logger.LogInformation( "Found show '{ShowTitle}' with id {ShowId}, starting targeted scan", showTitle, show.ItemId); return await ScanSingleShowInternal( _jellyfinTelevisionRepository, new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId), library, show, GetLocalPath, deepScan, cancellationToken); } _logger.LogWarning("No show found with id {ShowId} in library {LibraryName}", showId, library.Name); return Right(Unit.Default); }, error => Task.FromResult>(error)); } protected override IAsyncEnumerable> GetShowLibraryItems( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => _jellyfinApiClient.GetShowLibraryItemsWithoutPeople( connectionParameters.Address, connectionParameters.ApiKey, library); protected override string MediaServerItemId(JellyfinShow show) => show.ItemId; protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId; protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId; protected override string MediaServerEtag(JellyfinShow show) => show.Etag; protected override string MediaServerEtag(JellyfinSeason season) => season.Etag; protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag; protected override IAsyncEnumerable> GetSeasonLibraryItems( JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show) => _jellyfinApiClient.GetSeasonLibraryItems( connectionParameters.Address, connectionParameters.ApiKey, library, show.ItemId); protected override IAsyncEnumerable> GetEpisodeLibraryItems( JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show, JellyfinSeason season, bool isNewSeason) { if (isNewSeason) { return _jellyfinApiClient.GetEpisodeLibraryItems( connectionParameters.Address, connectionParameters.ApiKey, library, season.ItemId); } return _jellyfinApiClient.GetEpisodeLibraryItemsWithoutPeople( connectionParameters.Address, connectionParameters.ApiKey, library, season.ItemId); } protected override async Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinShow incoming, bool deepScan) { if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan) { Either> maybeShowResult = await _jellyfinApiClient.GetSingleShow( connectionParameters.Address, connectionParameters.ApiKey, library, incoming.ItemId); foreach (BaseError error in maybeShowResult.LeftToSeq()) { _logger.LogWarning("Failed to get show metadata from Jellyfin: {Error}", error.ToString()); } foreach (Option maybeShow in maybeShowResult.RightToSeq()) { foreach (JellyfinShow show in maybeShow) { return show.ShowMetadata.HeadOrNone(); } } } return None; } protected override Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinSeason incoming, bool deepScan) => Task.FromResult(Option.None); protected override async Task> GetFullMetadata( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinEpisode incoming, bool deepScan) { if (result.Item.Season is JellyfinSeason jellyfinSeason && (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)) { Either> maybeEpisodeResult = await _jellyfinApiClient.GetSingleEpisode( connectionParameters.Address, connectionParameters.ApiKey, library, jellyfinSeason.ItemId, incoming.ItemId); foreach (BaseError error in maybeEpisodeResult.LeftToSeq()) { _logger.LogWarning("Failed to get episode metadata from Jellyfin: {Error}", error.ToString()); } foreach (Option maybeEpisode in maybeEpisodeResult.RightToSeq()) { foreach (JellyfinEpisode episode in maybeEpisode) { return episode.EpisodeMetadata.HeadOrNone(); } } } return None; } protected override Task>> GetFullMetadataAndStatistics( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinEpisode incoming) => Task.FromResult(Option>.None); protected override async Task> GetMediaServerStatistics( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, MediaItemScanResult result, JellyfinEpisode incoming) { _logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath); Either maybeVersion = await _jellyfinApiClient.GetPlaybackInfo( connectionParameters.Address, connectionParameters.ApiKey, library, incoming.ItemId); foreach (BaseError error in maybeVersion.LeftToSeq()) { _logger.LogWarning("Failed to get episode statistics from Jellyfin: {Error}", error.ToString()); } // chapters are pulled with metadata, not with statistics, but we need to save them here foreach (MediaVersion version in maybeVersion.RightToSeq()) { version.Chapters = result.Item.GetHeadVersion().Chapters; } return maybeVersion.ToOption(); } protected override async Task>> UpdateMetadata( MediaItemScanResult result, ShowMetadata fullMetadata) { JellyfinShow existing = result.Item; ShowMetadata existingMetadata = existing.ShowMetadata.Head(); foreach (Actor actor in existingMetadata.Actors .Filter(a => fullMetadata.Actors.All(a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) .ToList()) { existingMetadata.Actors.Remove(actor); if (await _metadataRepository.RemoveActor(actor)) { result.IsUpdated = true; } } foreach (Actor actor in fullMetadata.Actors .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) .ToList()) { existingMetadata.Actors.Add(actor); if (await _televisionRepository.AddActor(existingMetadata, actor)) { result.IsUpdated = true; } } if (result.IsUpdated) { await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); } return result; } protected override Task>> UpdateMetadata( MediaItemScanResult result, SeasonMetadata fullMetadata) => Task.FromResult>>(result); protected override async Task>> UpdateMetadata( MediaItemScanResult result, EpisodeMetadata fullMetadata, CancellationToken cancellationToken) { JellyfinEpisode existing = result.Item; EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head(); foreach (Actor actor in existingMetadata.Actors .Filter(a => fullMetadata.Actors.All(a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) .ToList()) { existingMetadata.Actors.Remove(actor); if (await _metadataRepository.RemoveActor(actor)) { result.IsUpdated = true; } } foreach (Actor actor in fullMetadata.Actors .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) .ToList()) { existingMetadata.Actors.Add(actor); if (await _televisionRepository.AddActor(existingMetadata, actor)) { result.IsUpdated = true; } } foreach (Director director in existingMetadata.Directors .Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name)) .ToList()) { existingMetadata.Directors.Remove(director); if (await _metadataRepository.RemoveDirector(director)) { result.IsUpdated = true; } } foreach (Director director in fullMetadata.Directors .Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name)) .ToList()) { existingMetadata.Directors.Add(director); if (await _televisionRepository.AddDirector(existingMetadata, director)) { result.IsUpdated = true; } } foreach (Writer writer in existingMetadata.Writers .Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name)) .ToList()) { existingMetadata.Writers.Remove(writer); if (await _metadataRepository.RemoveWriter(writer)) { result.IsUpdated = true; } } foreach (Writer writer in fullMetadata.Writers .Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name)) .ToList()) { existingMetadata.Writers.Add(writer); if (await _televisionRepository.AddWriter(existingMetadata, writer)) { result.IsUpdated = true; } } if (result.IsUpdated) { await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); } return result; } private async Task> ScanSingleShowInternal( IJellyfinTelevisionRepository televisionRepository, JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, JellyfinShow targetShow, Func getLocalPath, bool deepScan, CancellationToken cancellationToken) { try { async IAsyncEnumerable> GetSingleShow() { yield return new Tuple(targetShow, 1); await Task.CompletedTask; } return await ScanLibraryWithoutCleanup( televisionRepository, connectionParameters, library, getLocalPath, GetSingleShow(), deepScan, cancellationToken); } catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { return new ScanCanceled(); } } }