Files
ersatztv/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs
T
timothy 64decd492e fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to
reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served
by two libraries with overlapping local paths is a single row owned by whichever library
scanned it first, so that owner's sweep destroyed a row another library still served —
taking collection membership and playout references with it, irreversibly.

This is #494's deferred "option 2":

- New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table,
  varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo.
- New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed
  existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per
  library via LibraryPath.LibraryId.
- New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds
  onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim.
- The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible
  and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep.
- Pre-identity rows are ADOPTED in place: the identity row is inserted against the same
  MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership
  survives and a local/second-library row is never hijacked.
- AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection
  fills them from the server-reported path, which would break every later PathHash lookup.

Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as
superseded; new active record scan.musicvideo-server-identity.

fixes #496
2026-07-25 17:20:53 +02:00

243 lines
10 KiB
C#

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<JellyfinConnectionParameters, JellyfinLibrary, JellyfinMusicVideo,
JellyfinItemEtag>,
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<JellyfinMusicVideoLibraryScanner> logger)
: base(
scannerProxy,
fileSystem,
artistRepository,
libraryRepository,
metadataRepository,
logger)
{
_jellyfinApiClient = jellyfinApiClient;
_jellyfinMusicVideoRepository = jellyfinMusicVideoRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_musicVideoRepository = musicVideoRepository;
_metadataRepository = metadataRepository;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken)
{
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
foreach (LibraryPath libraryPath in maybeLibraryPath)
{
List<JellyfinPathReplacement> 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<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetMusicVideoLibraryItems(
connectionParameters.Address,
connectionParameters.AuthorizationHeader,
library);
protected override async Task<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> UpdateMetadata(
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming,
CancellationToken cancellationToken)
{
if (await UpdateMetadata(result.Item, incoming.MusicVideoMetadata.Head()))
{
result.IsUpdated = true;
}
return result;
}
private async Task<bool> UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming)
{
Option<MusicVideoMetadata> 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<bool> 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<Genre> 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<bool> ReconcileTags(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Tags ??= [];
List<Tag> 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<bool> ReconcileStudios(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Studios ??= [];
List<Studio> 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<bool> ReconcileArtists(MusicVideoMetadata existing, MusicVideoMetadata incoming)
{
existing.Artists ??= [];
List<MusicVideoArtist> 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;
}
}