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
243 lines
10 KiB
C#
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;
|
|
}
|
|
}
|