fix(497): reconcile music-video metadata collections on Jellyfin rescan
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
JellyfinMusicVideoLibraryScanner.UpdateMetadata copied only scalar fields for an EXISTING music video, so genres/tags/studios/artists edited in Jellyfin never reached ErsatzTV — the update path silently dropped every child collection (only the Add path ever persisted them). Root cause is inherited from upstream: unlike the movie/episode Jellyfin path, which reconciles collections inside the tracked repository GetOrAdd, MusicVideoRepository.GetOrAdd is AsNoTracking and the scanner never reconciled the collections itself. Fix mirrors PlexMovieLibraryScanner.UpdateMetadata's remove-stale + add-new idiom, reconciling exactly the collections that BOTH the Add path persists AND GetOrAdd eager-loads: Genres, Tags, Studios, Artists. Guids (add-persisted but not eager-loaded — would duplicate) and Directors (eager-loaded but not add-persisted for music videos) are deliberately out of scope. Movie/Episode paths do NOT have this gap (they reconcile in the tracked repo GetOrAdd), so no separate fix is needed there. Test is an interaction test (substituted repos, canned existing item) verifying the exact reconcile calls; proven non-vacuous. The real-DB double-scan approach can't drive this: the in-memory harness shares one SQLite connection across contexts and mid-scan GetOrAdd's First()-nav path predicate mis-resolves once the existing item carries metadata children — a harness-only quirk (prod uses per-context pooled connections). fixes #497 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user