fix(497): reconcile music-video metadata collections on Jellyfin rescan #499

Merged
timothy merged 1 commits from fix/497-musicvideo-metadata-update into main 2026-07-20 18:53:42 +02:00
2 changed files with 263 additions and 4 deletions
@@ -263,11 +263,112 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
existing.DateUpdated = DateTime.UtcNow;
existing.MetadataKind = MetadataKind.External;
return await _metadataRepository.Update(existing);
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 MusicVideoRepository.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 ??= [];
List<Genre> incomingGenres = incoming.Genres ?? [];
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 ?? [];
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 ?? [];
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 ?? [];
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;
}
}
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Scanner.Core.Interfaces;
@@ -16,6 +17,7 @@ using NUnit.Framework;
using Shouldly;
using IFileSystem = System.IO.Abstractions.IFileSystem;
using Unit = LanguageExt.Unit;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Integration;
@@ -276,6 +278,150 @@ public class JellyfinMusicVideoLibraryScannerTests
await scannerProxy.DidNotReceive().RemoveMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>());
}
// ersatztv#497: metadata edits (genres/tags/studios/artists) made in Jellyfin to an EXISTING music video
// must reach ErsatzTV on the next scan. Before the fix, UpdateMetadata copied only scalar fields, so the
// update path silently dropped every child collection — add-new AND remove-stale. This is an interaction
// test: the repositories are substituted and GetOrAdd returns a canned existing item so we can verify the
// scanner issues the exact reconcile calls. (The real-DB double-scan approach can't drive this here: the
// in-memory harness shares ONE SQLite connection across contexts, and mid-scan GetOrAdd's
// `MediaVersions.First().MediaFiles.First().Path` predicate mis-resolves once the existing item carries
// metadata children — a harness-only quirk; prod uses per-context pooled connections and looks music videos
// up by that predicate only because they carry no server ItemId. See the #497 close comment.)
// Non-vacuous: reverting the Reconcile* calls in UpdateMetadata drops every Received() below.
[Test]
public async Task ScanLibrary_Should_Reconcile_Metadata_Collections_On_Rescan_Of_Existing_Item()
{
JellyfinLibrary library = BuildLibrary(1, "/data/music");
var connectionParameters = new JellyfinConnectionParameters("http://jellyfin", "api-key", 1);
const string VideoPath = "/data/music/artist1/song1.mkv";
// the EXISTING item already in ErsatzTV, with the collections Jellyfin first gave it
var existing = new MusicVideo
{
Id = 7,
ArtistId = 3,
MediaVersions = new List<MediaVersion>
{
new() { MediaFiles = new List<MediaFile> { new() { Path = VideoPath } }, Streams = new List<MediaStream>() }
},
MusicVideoMetadata = new List<MusicVideoMetadata>
{
new()
{
Id = 11,
Genres = new List<Genre> { new() { Name = "Synthwave" }, new() { Name = "Retro" } },
Tags = new List<Tag> { new() { Name = "KeepTag" }, new() { Name = "DropTag" } },
Studios = new List<Studio> { new() { Name = "OldStudio" } },
Artists = new List<MusicVideoArtist> { new() { Name = "Artist 1" }, new() { Name = "Featured X" } }
}
}
};
var artistRepository = Substitute.For<IArtistRepository>();
artistRepository.GetArtistByMetadata(Arg.Any<int>(), Arg.Any<ArtistMetadata>())
.Returns(Some(new Artist { Id = 3, ArtistMetadata = new List<ArtistMetadata>() }));
artistRepository.DeleteEmptyArtists(Arg.Any<LibraryPath>()).Returns(new List<int>());
var musicVideoRepository = Substitute.For<IMusicVideoRepository>();
musicVideoRepository
.GetOrAdd(Arg.Any<Artist>(), Arg.Any<LibraryPath>(), Arg.Any<LibraryFolder>(), Arg.Any<string>())
.Returns(Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(existing) { IsAdded = false }));
musicVideoRepository.FindMusicVideoPaths(Arg.Any<LibraryPath>())
.Returns(new List<string> { VideoPath }.AsEnumerable());
musicVideoRepository.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Any<Genre>()).Returns(true);
musicVideoRepository.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Any<Tag>()).Returns(true);
musicVideoRepository.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Any<Studio>()).Returns(true);
musicVideoRepository.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Any<MusicVideoArtist>()).Returns(true);
musicVideoRepository.RemoveArtist(Arg.Any<MusicVideoArtist>()).Returns(true);
var metadataRepository = Substitute.For<IMetadataRepository>();
metadataRepository.Update(Arg.Any<ErsatzTV.Core.Domain.Metadata>()).Returns(true);
metadataRepository.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>()).Returns(false);
metadataRepository.RemoveGenre(Arg.Any<Genre>()).Returns(true);
metadataRepository.RemoveTag(Arg.Any<Tag>()).Returns(true);
metadataRepository.RemoveStudio(Arg.Any<Studio>()).Returns(true);
var libraryRepository = Substitute.For<ILibraryRepository>();
libraryRepository.GetParentFolderId(Arg.Any<LibraryPath>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
libraryRepository.GetOrAddFolder(Arg.Any<LibraryPath>(), Arg.Any<Option<int>>(), Arg.Any<string>())
.Returns(new LibraryFolder { Id = 5, Path = "/data/music/artist1" });
JellyfinMusicVideoLibraryScanner scanner = BuildScannerWith(
FakeApi(() => BuildIncoming(
VideoPath, "Artist 1", "Song 1",
genres: new[] { "Synthwave", "Vaporwave" },
tags: new[] { "KeepTag", "NewTag" },
studios: new[] { "NewStudio" },
artists: new[] { "Artist 1", "Featured Y" })),
artistRepository,
musicVideoRepository,
libraryRepository,
metadataRepository);
(await scanner.ScanLibrary(connectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
// remove-stale: Retro / DropTag / OldStudio / "Featured X" gone; kept items are NOT removed
await metadataRepository.Received(1).RemoveGenre(Arg.Is<Genre>(g => g.Name == "Retro"));
await metadataRepository.DidNotReceive().RemoveGenre(Arg.Is<Genre>(g => g.Name == "Synthwave"));
await metadataRepository.Received(1).RemoveTag(Arg.Is<Tag>(t => t.Name == "DropTag"));
await metadataRepository.DidNotReceive().RemoveTag(Arg.Is<Tag>(t => t.Name == "KeepTag"));
await metadataRepository.Received(1).RemoveStudio(Arg.Is<Studio>(s => s.Name == "OldStudio"));
await musicVideoRepository.Received(1).RemoveArtist(Arg.Is<MusicVideoArtist>(a => a.Name == "Featured X"));
await musicVideoRepository.DidNotReceive().RemoveArtist(Arg.Is<MusicVideoArtist>(a => a.Name == "Artist 1"));
// add-new: Vaporwave / NewTag / NewStudio / "Featured Y" added; kept items are NOT re-added
await musicVideoRepository.Received(1)
.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Vaporwave"));
await musicVideoRepository.DidNotReceive()
.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Synthwave"));
await musicVideoRepository.Received(1)
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "NewTag"));
await musicVideoRepository.DidNotReceive()
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "KeepTag"));
await musicVideoRepository.Received(1)
.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Is<Studio>(s => s.Name == "NewStudio"));
await musicVideoRepository.Received(1)
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(a => a.Name == "Featured Y"));
await musicVideoRepository.DidNotReceive()
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(a => a.Name == "Artist 1"));
}
private JellyfinMusicVideoLibraryScanner BuildScannerWith(
IJellyfinApiClient apiClient,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMetadataRepository metadataRepository)
{
var pathReplacement = Substitute.For<IJellyfinPathReplacementService>();
pathReplacement
.GetReplacementJellyfinPath(Arg.Any<List<JellyfinPathReplacement>>(), Arg.Any<string>(), Arg.Any<bool>())
.Returns(ci => ci.ArgAt<string>(1));
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
mediaSourceRepository.GetJellyfinPathReplacements(Arg.Any<int>())
.Returns(Task.FromResult(new List<JellyfinPathReplacement>()));
var scannerProxy = Substitute.For<IScannerProxy>();
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
scannerProxy.RemoveMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
return new JellyfinMusicVideoLibraryScanner(
scannerProxy,
apiClient,
pathReplacement,
mediaSourceRepository,
artistRepository,
musicVideoRepository,
libraryRepository,
metadataRepository,
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
}
private async Task<int> MusicVideoId(string path)
{
await using TvContext context = _db.CreateContext();
@@ -377,7 +523,14 @@ public class JellyfinMusicVideoLibraryScannerTests
};
}
private static MusicVideo BuildIncoming(string path, string artistName, string title) =>
private static MusicVideo BuildIncoming(
string path,
string artistName,
string title,
IEnumerable<string> genres = null,
IEnumerable<string> tags = null,
IEnumerable<string> studios = null,
IEnumerable<string> artists = null) =>
new()
{
MediaVersions = new List<MediaVersion>
@@ -385,7 +538,8 @@ public class JellyfinMusicVideoLibraryScannerTests
new()
{
MediaFiles = new List<MediaFile> { new() { Path = path } },
Streams = new List<MediaStream>()
Streams = new List<MediaStream>(),
Chapters = new List<MediaChapter>()
}
},
MusicVideoMetadata = new List<MusicVideoMetadata>
@@ -393,7 +547,11 @@ public class JellyfinMusicVideoLibraryScannerTests
new()
{
Title = title,
Artists = new List<MusicVideoArtist> { new() { Name = artistName } }
Genres = (genres ?? Enumerable.Empty<string>()).Select(g => new Genre { Name = g }).ToList(),
Tags = (tags ?? Enumerable.Empty<string>()).Select(t => new Tag { Name = t }).ToList(),
Studios = (studios ?? Enumerable.Empty<string>()).Select(s => new Studio { Name = s }).ToList(),
Artists = (artists ?? new[] { artistName })
.Select(a => new MusicVideoArtist { Name = a }).ToList()
}
}
};