using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; 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.Metadata; using Microsoft.Extensions.Logging; namespace ErsatzTV.Scanner.Core.Jellyfin; public class JellyfinMusicVideoLibraryScanner : MediaServerMusicVideoLibraryScanner, IJellyfinMusicVideoLibraryScanner { private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMetadataRepository _metadataRepository; private readonly IMusicVideoRepository _musicVideoRepository; private readonly IJellyfinPathReplacementService _pathReplacementService; public JellyfinMusicVideoLibraryScanner( IScannerProxy scannerProxy, IJellyfinApiClient jellyfinApiClient, IJellyfinMusicVideoRepository jellyfinMusicVideoRepository, IJellyfinPathReplacementService pathReplacementService, IMediaSourceRepository mediaSourceRepository, IArtistRepository artistRepository, IMusicVideoRepository musicVideoRepository, ILibraryRepository libraryRepository, IMetadataRepository metadataRepository, IFileSystem fileSystem, ILogger logger) : base( scannerProxy, fileSystem, artistRepository, libraryRepository, metadataRepository, logger) { _jellyfinApiClient = jellyfinApiClient; _jellyfinMusicVideoRepository = jellyfinMusicVideoRepository; _pathReplacementService = pathReplacementService; _mediaSourceRepository = mediaSourceRepository; _musicVideoRepository = musicVideoRepository; _metadataRepository = metadataRepository; } public async Task> ScanLibrary( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library, bool deepScan, CancellationToken cancellationToken) { Option maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone(); foreach (LibraryPath libraryPath in maybeLibraryPath) { List pathReplacements = await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId); string GetLocalPath(JellyfinMusicVideo musicVideo) => _pathReplacementService.GetReplacementJellyfinPath( pathReplacements, musicVideo.GetHeadVersion().MediaFiles.Head().Path, false); return await ScanLibrary( _jellyfinMusicVideoRepository, connectionParameters, library, libraryPath, GetLocalPath, deepScan, cancellationToken); } return BaseError.New($"Jellyfin library {library.Id} has no library path"); } protected override string MediaServerItemId(JellyfinMusicVideo musicVideo) => musicVideo.ItemId; protected override string MediaServerEtag(JellyfinMusicVideo musicVideo) => musicVideo.Etag; protected override IAsyncEnumerable> GetMusicVideoLibraryItems( JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => _jellyfinApiClient.GetMusicVideoLibraryItems( connectionParameters.Address, connectionParameters.AuthorizationHeader, library); protected override async Task>> UpdateMetadata( MediaItemScanResult result, JellyfinMusicVideo incoming, CancellationToken cancellationToken) { if (await UpdateMetadata(result.Item, incoming.MusicVideoMetadata.Head())) { result.IsUpdated = true; } return result; } private async Task UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming) { Option maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone(); foreach (MusicVideoMetadata existing in maybeExisting) { existing.Title = incoming.Title; existing.SortTitle = incoming.SortTitle; existing.Plot = incoming.Plot; existing.Year = incoming.Year; // ersatztv#177: Album/Track are scalars, so they must be copied here too — otherwise the // projection fix only ever reaches music videos ADDED after it, and an existing item whose // album/track is set (or corrected) in Jellyfin never picks it up. Same shape as the #497 // collection bug below, one layer up. existing.Album = incoming.Album; existing.Track = incoming.Track; existing.ReleaseDate = incoming.ReleaseDate; existing.DateUpdated = DateTime.UtcNow; existing.MetadataKind = MetadataKind.External; bool updated = await _metadataRepository.Update(existing); // ersatztv#497: the scalar Update above marks only the metadata row Modified; it does NOT touch // child collections, and the repository's GetOrAdd loads them AsNoTracking — so tag/genre/studio/ // artist edits made in Jellyfin never reached an EXISTING music video (only the Add path persisted // them). Reconcile the collections that BOTH the Add path persists AND GetOrAdd eager-loads: // Genres, Tags, Studios, Artists. (Guids are add-persisted but not eager-loaded here — reconciling // them would see an empty `existing` and duplicate-insert every scan; Directors are eager-loaded // but not add-persisted for music videos — both are deliberately out of scope.) Mirrors the // remove-stale + add-new idiom PlexMovieLibraryScanner.UpdateMetadata uses. updated = await ReconcileGenres(existing, incoming) || updated; updated = await ReconcileTags(existing, incoming) || updated; updated = await ReconcileStudios(existing, incoming) || updated; updated = await ReconcileArtists(existing, incoming) || updated; return updated; } incoming.MusicVideoId = musicVideo.Id; musicVideo.MusicVideoMetadata = [incoming]; return await _metadataRepository.Add(incoming); } private async Task ReconcileGenres(MusicVideoMetadata existing, MusicVideoMetadata incoming) { existing.Genres ??= []; // ersatztv#500: dedup on Name — the add filter below is materialized (.ToList()) BEFORE the loop // mutates existing.Genres, so two identically-named incoming entries would both pass and both insert. // Deduping here (not just in the add loop) is safe: the remove filter only asks "is this name present". List incomingGenres = (incoming.Genres ?? []).DistinctBy(g => g.Name).ToList(); var updated = false; foreach (Genre genre in existing.Genres.Filter(g => incomingGenres.All(g2 => g2.Name != g.Name)).ToList()) { existing.Genres.Remove(genre); updated = await _metadataRepository.RemoveGenre(genre) || updated; } foreach (Genre genre in incomingGenres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)).ToList()) { existing.Genres.Add(genre); updated = await _musicVideoRepository.AddGenre(existing, genre) || updated; } return updated; } private async Task ReconcileTags(MusicVideoMetadata existing, MusicVideoMetadata incoming) { existing.Tags ??= []; List incomingTags = (incoming.Tags ?? []).DistinctBy(t => t.Name).ToList(); var updated = false; foreach (Tag tag in existing.Tags.Filter(t => incomingTags.All(t2 => t2.Name != t.Name)).ToList()) { existing.Tags.Remove(tag); updated = await _metadataRepository.RemoveTag(tag) || updated; } foreach (Tag tag in incomingTags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name)).ToList()) { existing.Tags.Add(tag); updated = await _musicVideoRepository.AddTag(existing, tag) || updated; } return updated; } private async Task ReconcileStudios(MusicVideoMetadata existing, MusicVideoMetadata incoming) { existing.Studios ??= []; List incomingStudios = (incoming.Studios ?? []).DistinctBy(s => s.Name).ToList(); var updated = false; foreach (Studio studio in existing.Studios.Filter(s => incomingStudios.All(s2 => s2.Name != s.Name)).ToList()) { existing.Studios.Remove(studio); updated = await _metadataRepository.RemoveStudio(studio) || updated; } foreach (Studio studio in incomingStudios.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name)).ToList()) { existing.Studios.Add(studio); updated = await _musicVideoRepository.AddStudio(existing, studio) || updated; } return updated; } private async Task ReconcileArtists(MusicVideoMetadata existing, MusicVideoMetadata incoming) { existing.Artists ??= []; List incomingArtists = (incoming.Artists ?? []).DistinctBy(a => a.Name).ToList(); var updated = false; foreach (MusicVideoArtist artist in existing.Artists .Filter(a => incomingArtists.All(a2 => a2.Name != a.Name)).ToList()) { existing.Artists.Remove(artist); updated = await _musicVideoRepository.RemoveArtist(artist) || updated; } foreach (MusicVideoArtist artist in incomingArtists .Filter(a => existing.Artists.All(a2 => a2.Name != a.Name)).ToList()) { existing.Artists.Add(artist); updated = await _musicVideoRepository.AddArtist(existing, artist) || updated; } return updated; } }