using ErsatzTV.Core; 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; using ErsatzTV.Scanner.Core.Jellyfin; using ErsatzTV.Tests.Support; using LanguageExt; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NUnit.Framework; using Shouldly; using IFileSystem = System.IO.Abstractions.IFileSystem; using Unit = LanguageExt.Unit; using static LanguageExt.Prelude; namespace ErsatzTV.Tests.Integration; // End-to-end regression for ersatztv#488. Unlike the existing MediaServer*LibraryScanner tests (which // substitute every repository, and therefore could never have exhibited the null-navigation bug — see the // mocked GetOrAddFolder in MovieFolderScannerTests), this test wires the REAL LibraryRepository / // ArtistRepository / JellyfinMusicVideoRepository against in-memory SQLite so the actual crash path runs. The // deviation from the mock-and-verify house style is deliberate and required: a substituted // ILibraryRepository cannot exhibit the defect this issue is about. // // ersatztv#496 reshaped this suite: music videos now carry a per-library server identity // (JellyfinMusicVideo.ItemId/Etag), so reconciliation diffs on the item id and SOFT-trashes (FileNotFound) // instead of hard-deleting by path. The #494 "row disappears" assertions became "row is flagged" assertions. [TestFixture] public class JellyfinMusicVideoLibraryScannerTests { private InMemoryTvContext _db = null!; [SetUp] public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync(); [TearDown] public async Task TearDown() => await _db.DisposeAsync(); [Test] public async Task ScanLibrary_Should_Complete_And_Create_Artist_And_MusicVideo_Rows() { int libraryPathId = await SeedLibraryPath("/data/music"); // the remote-path shape that used to crash: Paths is populated, LibraryFolders is null JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); const string VideoPath = "/data/music/artist1/song1.mkv"; (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi( () => BuildIncoming(VideoPath, "Artist 1", "Song 1"))); Either result = await scanner.ScanLibrary( ConnectionParameters, library, deepScan: false, CancellationToken.None); // the scan runs to completion instead of crashing on GetOrAddFolder result.IsRight.ShouldBeTrue(result.Match(Right: _ => "", Left: e => e.Value)); await using TvContext context = _db.CreateContext(); // Artist row created from the incoming metadata List artists = await context.Artists .Include(a => a.ArtistMetadata) .Where(a => a.LibraryPathId == libraryPathId) .ToListAsync(); artists.Count.ShouldBe(1); artists[0].ArtistMetadata.Single().Title.ShouldBe("Artist 1"); // MusicVideo row created, wired to a real LibraryFolder (proving GetOrAddFolder returned a persisted row) List musicVideos = await context.MusicVideos .Include(mv => mv.MediaVersions) .ThenInclude(v => v.MediaFiles) .Where(mv => mv.LibraryPathId == libraryPathId) .ToListAsync(); musicVideos.Count.ShouldBe(1); MediaFile file = musicVideos[0].MediaVersions.Single().MediaFiles.Single(); file.Path.ShouldBe(VideoPath); file.LibraryFolderId.ShouldNotBeNull(); // the folder was created by the fixed GetOrAddFolder LibraryFolder folder = await context.LibraryFolders.SingleAsync(f => f.Id == file.LibraryFolderId); folder.Path.ShouldBe("/data/music/artist1"); folder.LibraryPathId.ShouldBe(libraryPathId); // ersatztv#496: the row carries the server item id, so it is no longer identified by its path List identities = await context.JellyfinMusicVideos.ToListAsync(); identities.Count.ShouldBe(1); identities[0].ItemId.ShouldBe(ItemIdFor(VideoPath)); } // ersatztv#494 as reshaped by ersatztv#496: a music video removed from Jellyfin is SOFT-trashed // (State = FileNotFound), not hard-deleted, matching the movie/TV/other-video scanners. The row survives, so // collection membership and playout references survive with it and EmptyTrash governs the real removal. [Test] public async Task ScanLibrary_Should_Flag_FileNotFound_For_MusicVideo_Missing_From_Jellyfin() { int id = await SeedLibraryPath("/data/music"); JellyfinLibrary library = BuildLibrary(id, "/data/music"); // first scan seeds two music videos (JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"), () => BuildIncoming("/data/music/artist2/gone.mkv", "Artist 2", "Gone"))); (await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MusicVideoPaths(id)).Count.ShouldBe(2); int goneId = await MusicVideoId("/data/music/artist2/gone.mkv"); // second scan: only "keep" is still present upstream (JellyfinMusicVideoLibraryScanner scanner, IScannerProxy scannerProxy) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); // both rows still exist — the missing one is flagged, not deleted (await MusicVideoPaths(id)).Count.ShouldBe(2); (await MediaItemStateOf(goneId)).ShouldBe(MediaItemState.FileNotFound); (await MediaItemStateOf(await MusicVideoId("/data/music/artist1/keep.mkv"))) .ShouldBe(MediaItemState.Normal); await scannerProxy.Received().ReindexMediaItems( Arg.Is(ids => ids.Contains(goneId)), Arg.Any()); await scannerProxy.DidNotReceive().RemoveMediaItems( Arg.Is(ids => ids.Contains(goneId)), Arg.Any()); } // ersatztv#496: the artist of a soft-trashed music video is NOT emptied, because the music video row is still // there. This is the deliberate consequence of replacing #494's hard delete — artist cleanup now happens only // once the trash is actually emptied. [Test] public async Task ScanLibrary_Should_Keep_Artist_Of_Trashed_MusicVideo() { int id = await SeedLibraryPath("/data/music"); JellyfinLibrary library = BuildLibrary(id, "/data/music"); (JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"), () => BuildIncoming("/data/music/artist2/gone.mkv", "Artist 2", "Gone"))); (await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await ArtistTitles(id)).Count.ShouldBe(2); // "Artist 2" loses its only music video from the server's point of view (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await ArtistTitles(id)).ShouldBe(new[] { "Artist 1", "Artist 2" }); } // ersatztv#496 (the issue's Done-when 2): one library's sweep must never reach another library's music // videos. Identity is now (server item id, owning library), so library B reporting nothing cannot flag a row // owned by library A — even though both libraries point at the SAME local path. // // Under the pre-#496 path diff this was the live hazard: the sweep resolved rows by (LibraryPathId, path) and // HARD-deleted them, so the row a second library still served was destroyed outright. [Test] public async Task ScanLibrary_Should_Not_Flag_MusicVideos_Owned_By_Another_Library() { int pathA = await SeedLibraryPath("/data/music", libraryId: 42); int pathB = await SeedLibraryPath("/data/music-overlap", libraryId: 43); JellyfinLibrary libraryA = BuildLibrary(pathA, "/data/music", libraryId: 42); JellyfinLibrary libraryB = BuildLibrary(pathB, "/data/music-overlap", libraryId: 43); // library A owns the row (JellyfinMusicVideoLibraryScanner seedA, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/song.mkv", "Artist 1", "Song"))); (await seedA.ScanLibrary(ConnectionParameters, libraryA, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); int ownedId = await MusicVideoId("/data/music/artist1/song.mkv"); // library B has its own, different item and reports only that one (JellyfinMusicVideoLibraryScanner seedB, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music-overlap/artist9/other.mkv", "Artist 9", "Other"))); (await seedB.ScanLibrary(ConnectionParameters, libraryB, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); // library B now reports nothing but its own item disappearing — A's row must be untouched (JellyfinMusicVideoLibraryScanner scannerB, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music-overlap/artist9/other2.mkv", "Artist 9", "Other 2"))); (await scannerB.ScanLibrary(ConnectionParameters, libraryB, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MediaItemStateOf(ownedId)).ShouldBe(MediaItemState.Normal); (await MediaItemStateOf(await MusicVideoId("/data/music-overlap/artist9/other.mkv"))) .ShouldBe(MediaItemState.FileNotFound); } // ersatztv#496: rows that predate per-item identity (created by the path-keyed scanner, or by the local // MusicVideoFolderScanner) must be ADOPTED in place — the same MediaItem id gains a JellyfinMusicVideo // identity row. A delete-and-re-add would silently drop collection membership, which is exactly what a // populated music collection depends on. [Test] public async Task ScanLibrary_Should_Adopt_PreExisting_MusicVideo_Preserving_Identity_And_Collections() { int libraryPathId = await SeedLibraryPath("/data/music"); const string VideoPath = "/data/music/artist1/song1.mkv"; int existingId = await SeedPlainMusicVideo(libraryPathId, VideoPath, "Artist 1"); int collectionId = await SeedCollectionWith(existingId); JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi( () => BuildIncoming(VideoPath, "Artist 1", "Song 1"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); await using TvContext context = _db.CreateContext(); // no duplicate row was created (await context.MusicVideos.CountAsync(mv => mv.LibraryPathId == libraryPathId)).ShouldBe(1); // the SAME row now carries the server identity List identities = await context.JellyfinMusicVideos.ToListAsync(); identities.Count.ShouldBe(1); identities[0].Id.ShouldBe(existingId); identities[0].ItemId.ShouldBe(ItemIdFor(VideoPath)); // and collection membership survived the adoption List items = await context.CollectionItems .Where(ci => ci.CollectionId == collectionId) .ToListAsync(); items.Count.ShouldBe(1); items[0].MediaItemId.ShouldBe(existingId); } // ersatztv#496: adoption is scoped to the scanned library's own library path, so a music video owned by a // LOCAL library (MusicVideoFolderScanner writes the same MusicVideo table) is never hijacked into a Jellyfin // library's identity. [Test] public async Task ScanLibrary_Should_Not_Adopt_A_MusicVideo_Owned_By_Another_LibraryPath() { int localPathId = await SeedLibraryPath("/data/local-music", libraryId: 99); int jellyfinPathId = await SeedLibraryPath("/data/music", libraryId: 42); const string VideoPath = "/data/shared/song1.mkv"; int localId = await SeedPlainMusicVideo(localPathId, VideoPath, "Artist 1"); JellyfinLibrary library = BuildLibrary(jellyfinPathId, "/data/music"); (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi( () => BuildIncoming(VideoPath, "Artist 1", "Song 1"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); await using TvContext context = _db.CreateContext(); // the local row was left alone: no identity row, still owned by the local library path (await context.JellyfinMusicVideos.CountAsync()).ShouldBe(0); MusicVideo local = await context.MusicVideos.SingleAsync(mv => mv.Id == localId); local.LibraryPathId.ShouldBe(localPathId); } // ersatztv#496 independent-review finding (Blocker): every path this seam matches on or stores must be the // PATH-REPLACED local path. The media server reports its own path, and on any install with path replacements // configured the two differ. Threading the server path through instead meant adoption hashed the wrong // string, missed the existing row, ALSO slipped past the global duplicate guard (which hashes the same wrong // string), and inserted a second row under a server-side path — while the original, collection-linked row // stayed identity-less forever. // Non-vacuous: reverting GetOrAdd to use item.GetHeadVersion()...Path makes this fail with 2 rows. [Test] public async Task ScanLibrary_Should_Adopt_Using_The_PathReplaced_Local_Path() { int libraryPathId = await SeedLibraryPath("/data/music"); const string ServerPath = "/server/media/artist1/song1.mkv"; const string LocalPath = "/data/music/artist1/song1.mkv"; int existingId = await SeedPlainMusicVideo(libraryPathId, LocalPath, "Artist 1"); int collectionId = await SeedCollectionWith(existingId); JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner( FakeApi(() => BuildIncoming(ServerPath, "Artist 1", "Song 1")), replacePath: p => p.Replace("/server/media", "/data/music", StringComparison.Ordinal)); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); await using TvContext context = _db.CreateContext(); // exactly one row: the pre-existing one was adopted, not duplicated under the server path List musicVideos = await context.MusicVideos .Include(mv => mv.MediaVersions) .ThenInclude(v => v.MediaFiles) .Where(mv => mv.LibraryPathId == libraryPathId) .ToListAsync(); musicVideos.Count.ShouldBe(1); musicVideos[0].Id.ShouldBe(existingId); // and it still stores the LOCAL path, with a matching hash MediaFile file = musicVideos[0].MediaVersions.Single().MediaFiles.Single(); file.Path.ShouldBe(LocalPath); file.PathHash.ShouldBe(PathUtils.GetPathHash(LocalPath)); List identities = await context.JellyfinMusicVideos.ToListAsync(); identities.Count.ShouldBe(1); identities[0].Id.ShouldBe(existingId); (await context.CollectionItems.CountAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == existingId)) .ShouldBe(1); } // ersatztv#496 independent-review finding (Medium): identity lookup is scoped to the scanning library, so two // media sources presenting the SAME item id (a cloned Jellyfin database) cannot resolve to each other's row. // Non-vacuous: dropping the LibraryId filter in GetByItemId makes library B rewrite library A's path. [Test] public async Task ScanLibrary_Should_Not_Resolve_An_ItemId_Owned_By_Another_Library() { int pathA = await SeedLibraryPath("/data/music-a", libraryId: 42); int pathB = await SeedLibraryPath("/data/music-b", libraryId: 43); JellyfinLibrary libraryA = BuildLibrary(pathA, "/data/music-a", libraryId: 42); JellyfinLibrary libraryB = BuildLibrary(pathB, "/data/music-b", libraryId: 43); // both libraries report an item carrying the SAME server item id, at different files JellyfinMusicVideo ForA() => WithItemId(BuildIncoming("/data/music-a/song.mkv", "Artist 1", "A"), "dupe-id"); JellyfinMusicVideo ForB() => WithItemId(BuildIncoming("/data/music-b/song.mkv", "Artist 2", "B"), "dupe-id"); (JellyfinMusicVideoLibraryScanner scannerA, _) = BuildScanner(FakeApi(ForA)); (await scannerA.ScanLibrary(ConnectionParameters, libraryA, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (JellyfinMusicVideoLibraryScanner scannerB, _) = BuildScanner(FakeApi(ForB)); (await scannerB.ScanLibrary(ConnectionParameters, libraryB, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); // library B got its own row; library A's row was not repointed at B's file (await MusicVideoPaths(pathA)).ShouldBe(new[] { "/data/music-a/song.mkv" }); (await MusicVideoPaths(pathB)).ShouldBe(new[] { "/data/music-b/song.mkv" }); } // ersatztv#496 independent-review finding (Medium): a row that predates per-item identity AND is already // gone from the server is never adopted (adoption only runs for an INCOMING item) and carries no identity, so // the itemId diff cannot see it either. Without a legacy path diff it would stay Normal and schedulable // forever — strictly worse than the hard-delete path it replaced. // Non-vacuous: dropping the FlagFileNotFoundByPaths call leaves it Normal. [Test] public async Task ScanLibrary_Should_Flag_A_Legacy_Row_The_Server_No_Longer_Reports() { int libraryPathId = await SeedLibraryPath("/data/music"); int orphanId = await SeedPlainMusicVideo(libraryPathId, "/data/music/artist9/orphan.mkv", "Artist 9"); JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MediaItemStateOf(orphanId)).ShouldBe(MediaItemState.FileNotFound); (await MediaItemStateOf(await MusicVideoId("/data/music/artist1/keep.mkv"))) .ShouldBe(MediaItemState.Normal); } // ersatztv#477 guard extended to legacy rows: on the FIRST scan after identity landed, the legacy rows ARE // the whole library, so a guard that counted only identity rows would see "0 existing" and happily sweep all // of them on a transient empty fetch. Negative control for counting legacy rows into the guard. [Test] public async Task ScanLibrary_Should_Not_Sweep_Legacy_Rows_When_Jellyfin_Returns_Zero_Items() { int libraryPathId = await SeedLibraryPath("/data/music"); int legacyId = await SeedPlainMusicVideo(libraryPathId, "/data/music/artist1/song1.mkv", "Artist 1"); JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi()); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MediaItemStateOf(legacyId)).ShouldBe(MediaItemState.Normal); } // ersatztv#484 carried into the ersatztv#496 scanner. A swallowed projection exception drops an item the // server DID return, which is indistinguishable from a deletion at the reconcile step, so one projection // regression could mass-flag a healthy library. #484's counter is an OPTIONAL parameter defaulting to 0, so a // scanner that simply never passes it compiles and silently opts out — which is exactly what this scanner did // until the guard was threaded through. main's own #484 test covered the pre-#496 hard-delete scanner and is // replaced by these two. // Non-vacuous: dropping projectionFailures.Count from the ShouldFlagMissing call flags "gone" and fails. [Test] public async Task ScanLibrary_Should_Not_Sweep_When_The_Projection_Reported_Failures() { int id = await SeedLibraryPath("/data/music"); JellyfinLibrary library = BuildLibrary(id, "/data/music"); (JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"), () => BuildIncoming("/data/music/artist2/gone.mkv", "Artist 2", "Gone"))); (await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); int goneId = await MusicVideoId("/data/music/artist2/gone.mkv"); // "gone" is absent from the incoming set ONLY because its projection threw — not because it was deleted (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApiWithProjectionFailures( 1, () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MediaItemStateOf(goneId)).ShouldBe(MediaItemState.Normal); } // ersatztv#484 + ersatztv#496: the refusal must also cover the LEGACY (identity-less) path diff, which is // more exposed — a legacy row has no etag to fall back on. [Test] public async Task ScanLibrary_Should_Not_Sweep_Legacy_Rows_When_The_Projection_Reported_Failures() { int libraryPathId = await SeedLibraryPath("/data/music"); int legacyId = await SeedPlainMusicVideo(libraryPathId, "/data/music/artist9/orphan.mkv", "Artist 9"); JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApiWithProjectionFailures( 1, () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MediaItemStateOf(legacyId)).ShouldBe(MediaItemState.Normal); } // ersatztv#494 (Done-when 3): the music-video sweep must never touch a Movie or Show that shares the // same LibraryPath — the cross-flag risk is a LibraryPathId property, not a Mixed-library property. [Test] public async Task ScanLibrary_Should_Not_CrossFlag_Movie_Or_Show_Sharing_The_LibraryPath() { int id = await SeedLibraryPath("/data/mixed"); await using (TvContext seedContext = _db.CreateContext()) { seedContext.Add(new Movie { LibraryPathId = id, MovieMetadata = new List() }); seedContext.Add(new Show { LibraryPathId = id, ShowMetadata = new List() }); await seedContext.SaveChangesAsync(); } JellyfinLibrary library = BuildLibrary(id, "/data/mixed"); // seed one music video under the same LibraryPath (JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/mixed/song.mkv", "Artist 1", "Song"))); (await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); // next scan drops "song.mkv" and adds "song2.mkv" — the sweep flags song.mkv only (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/mixed/song2.mkv", "Artist 1", "Song 2"))); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); await using TvContext context = _db.CreateContext(); (await context.Movies.CountAsync(m => m.LibraryPathId == id)).ShouldBe(1); (await context.Shows.CountAsync(s => s.LibraryPathId == id)).ShouldBe(1); (await context.MediaItems.CountAsync(mi => mi.LibraryPathId == id && mi.State != MediaItemState.Normal)) .ShouldBe(1); (await MediaItemStateOf(await MusicVideoId("/data/mixed/song.mkv"))) .ShouldBe(MediaItemState.FileNotFound); } // ersatztv#477 guard: a successful-but-empty fetch must NOT flag the library. Negative control — if the // sweep were ungated, an empty incoming set would flag every existing music video. [Test] public async Task ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items() { int id = await SeedLibraryPath("/data/music"); JellyfinLibrary library = BuildLibrary(id, "/data/music"); (JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi( () => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"))); (await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); int keepId = await MusicVideoId("/data/music/artist1/keep.mkv"); // Jellyfin returns zero items (mid-restore / transient) — the sweep must be skipped (JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi()); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); (await MusicVideoPaths(id)).ShouldBe(new[] { "/data/music/artist1/keep.mkv" }); (await MediaItemStateOf(keepId)).ShouldBe(MediaItemState.Normal); } // #493 session field data: a MIXED Jellyfin library runs the music-video arm with a legitimately EMPTY // incoming set on every scan while Movies/Shows exist under the same LibraryPath (the real "Standup" case). // Identity is now per-item and per-library, so a movie can never enter the music-video diff at all. [Test] public async Task ScanLibrary_Should_Not_Touch_Movies_Or_Shows_When_No_MusicVideos_Present() { int id = await SeedLibraryPath("/data/standup"); await using (TvContext seedContext = _db.CreateContext()) { seedContext.Add(new Movie { LibraryPathId = id, MovieMetadata = new List(), MediaVersions = new List { new() { MediaFiles = new List { new() { Path = "/data/standup/show.mkv", PathHash = "hash-standup" } }, Streams = new List() } } }); seedContext.Add(new Show { LibraryPathId = id, ShowMetadata = new List() }); await seedContext.SaveChangesAsync(); } JellyfinLibrary library = BuildLibrary(id, "/data/standup"); // the music-video arm returns zero incoming — steady state for a mixed library (JellyfinMusicVideoLibraryScanner scanner, IScannerProxy scannerProxy) = BuildScanner(FakeApi()); (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) .IsRight.ShouldBeTrue(); await using TvContext context = _db.CreateContext(); (await context.Movies.CountAsync(m => m.LibraryPathId == id)).ShouldBe(1); (await context.Shows.CountAsync(s => s.LibraryPathId == id)).ShouldBe(1); (await context.MediaItems.CountAsync(mi => mi.LibraryPathId == id && mi.State != MediaItemState.Normal)) .ShouldBe(0); await scannerProxy.DidNotReceive().RemoveMediaItems(Arg.Any(), Arg.Any()); } // 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. // 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() { const string VideoPath = "/data/music/artist1/song1.mkv"; // the EXISTING item already in ErsatzTV, with the collections Jellyfin first gave it var existing = new JellyfinMusicVideo { Id = 7, ArtistId = 3, ItemId = ItemIdFor(VideoPath), Etag = "old-etag", MediaVersions = new List { new() { MediaFiles = new List { new() { Path = VideoPath } }, Streams = new List() } }, MusicVideoMetadata = new List { new() { Id = 11, Genres = new List { new() { Name = "Synthwave" }, new() { Name = "Retro" } }, Tags = new List { new() { Name = "KeepTag" }, new() { Name = "DropTag" } }, Studios = new List { new() { Name = "OldStudio" } }, Artists = new List { new() { Name = "Artist 1" }, new() { Name = "Featured X" } } } } }; (JellyfinMusicVideoLibraryScanner scanner, IMusicVideoRepository musicVideoRepository, IMetadataRepository metadataRepository) = BuildScannerWithSubstitutes( existing, FakeApi(() => BuildIncoming( VideoPath, "Artist 1", "Song 1", genres: new[] { "Synthwave", "Vaporwave" }, tags: new[] { "KeepTag", "NewTag" }, studios: new[] { "NewStudio" }, artists: new[] { "Artist 1", "Featured Y" }))); (await scanner.ScanLibrary(ConnectionParameters, BuildLibrary(1, "/data/music"), 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(g => g.Name == "Retro")); await metadataRepository.DidNotReceive().RemoveGenre(Arg.Is(g => g.Name == "Synthwave")); await metadataRepository.Received(1).RemoveTag(Arg.Is(t => t.Name == "DropTag")); await metadataRepository.DidNotReceive().RemoveTag(Arg.Is(t => t.Name == "KeepTag")); await metadataRepository.Received(1).RemoveStudio(Arg.Is(s => s.Name == "OldStudio")); await musicVideoRepository.Received(1).RemoveArtist(Arg.Is(a => a.Name == "Featured X")); await musicVideoRepository.DidNotReceive().RemoveArtist(Arg.Is(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(), Arg.Is(g => g.Name == "Vaporwave")); await musicVideoRepository.DidNotReceive() .AddGenre(Arg.Any(), Arg.Is(g => g.Name == "Synthwave")); await musicVideoRepository.Received(1) .AddTag(Arg.Any(), Arg.Is(t => t.Name == "NewTag")); await musicVideoRepository.DidNotReceive() .AddTag(Arg.Any(), Arg.Is(t => t.Name == "KeepTag")); await musicVideoRepository.Received(1) .AddStudio(Arg.Any(), Arg.Is(s => s.Name == "NewStudio")); await musicVideoRepository.Received(1) .AddArtist(Arg.Any(), Arg.Is(a => a.Name == "Featured Y")); await musicVideoRepository.DidNotReceive() .AddArtist(Arg.Any(), Arg.Is(a => a.Name == "Artist 1")); } // ersatztv#177: Album/Track are scalars copied field-by-field in UpdateMetadata, so the projection fix // alone would only ever reach music videos ADDED after it — an EXISTING item whose album/track is set or // corrected in Jellyfin would keep its stale value forever. Same shape as the #497 collection bug, one // layer up. Non-vacuous: reverting the two assignments in UpdateMetadata fails both assertions below. [Test] public async Task ScanLibrary_Should_Update_Album_And_Track_On_Rescan_Of_Existing_Item() { const string VideoPath = "/data/music/artist1/song1.mkv"; var existingMetadata = new MusicVideoMetadata { Id = 11, Album = "Stale Album", Track = 1, Genres = [], Tags = [], Studios = [], Artists = [new MusicVideoArtist { Name = "Artist 1" }] }; var existing = new JellyfinMusicVideo { Id = 7, ArtistId = 3, ItemId = ItemIdFor(VideoPath), Etag = "old-etag", MediaVersions = [ new MediaVersion { MediaFiles = [new MediaFile { Path = VideoPath }], Streams = [] } ], MusicVideoMetadata = [existingMetadata] }; JellyfinMusicVideo incoming = BuildIncoming(VideoPath, "Artist 1", "Song 1"); incoming.MusicVideoMetadata[0].Album = "Corrected Album"; incoming.MusicVideoMetadata[0].Track = 4; (JellyfinMusicVideoLibraryScanner scanner, _, _) = BuildScannerWithSubstitutes(existing, FakeApi(() => incoming)); (await scanner.ScanLibrary(ConnectionParameters, BuildLibrary(1, "/data/music"), deepScan: false, CancellationToken.None)).IsRight.ShouldBeTrue(); existingMetadata.Album.ShouldBe("Corrected Album"); existingMetadata.Track.ShouldBe(4); } // ersatztv#500: if Jellyfin reports two identically-named genres/tags/studios/artists for one item and // that name is not yet on the existing item, BOTH used to pass the add filter (which is materialized with // .ToList() before the loop mutates `existing`) and both got inserted — a duplicate row. Dedup the // incoming collection on the same key the filter compares. // Non-vacuous: dropping the DistinctBy calls in Reconcile* makes every Received(1) below see 2 calls. [Test] public async Task ScanLibrary_Should_Not_Double_Insert_Duplicate_Named_Incoming_Collection_Entries() { const string VideoPath = "/data/music/artist1/song1.mkv"; // the existing item carries NONE of the incoming names, so every incoming entry is an "add" var existing = new JellyfinMusicVideo { Id = 7, ArtistId = 3, ItemId = ItemIdFor(VideoPath), Etag = "old-etag", MediaVersions = [ new MediaVersion { MediaFiles = [new MediaFile { Path = VideoPath }], Streams = [] } ], MusicVideoMetadata = [ new MusicVideoMetadata { Id = 11, Genres = [], Tags = [], Studios = [], Artists = [] } ] }; // Jellyfin reports each name TWICE for the same item (JellyfinMusicVideoLibraryScanner scanner, IMusicVideoRepository musicVideoRepository, _) = BuildScannerWithSubstitutes( existing, FakeApi(() => BuildIncoming( VideoPath, "Artist 1", "Song 1", genres: ["Synthwave", "Synthwave"], tags: ["DupTag", "DupTag"], studios: ["DupStudio", "DupStudio"], artists: ["Artist 1", "Artist 1"]))); (await scanner.ScanLibrary(ConnectionParameters, BuildLibrary(1, "/data/music"), deepScan: false, CancellationToken.None)).IsRight.ShouldBeTrue(); await musicVideoRepository.Received(1) .AddGenre(Arg.Any(), Arg.Is(g => g.Name == "Synthwave")); await musicVideoRepository.Received(1) .AddTag(Arg.Any(), Arg.Is(t => t.Name == "DupTag")); await musicVideoRepository.Received(1) .AddStudio(Arg.Any(), Arg.Is(s => s.Name == "DupStudio")); await musicVideoRepository.Received(1) .AddArtist(Arg.Any(), Arg.Is(a => a.Name == "Artist 1")); // and the in-memory collections mirror the DB: one entry each, not two MusicVideoMetadata metadata = existing.MusicVideoMetadata.Head(); metadata.Genres.Count.ShouldBe(1); metadata.Tags.Count.ShouldBe(1); metadata.Studios.Count.ShouldBe(1); metadata.Artists.Count.ShouldBe(1); } private static JellyfinConnectionParameters ConnectionParameters => new("http://jellyfin", "api-key", 1); // A stable per-file server item id, so a re-scan of the same file is the same identity. private static string ItemIdFor(string path) => $"item-{PathUtils.GetPathHash(path)}"; private static JellyfinMusicVideo WithItemId(JellyfinMusicVideo musicVideo, string itemId) { musicVideo.ItemId = itemId; return musicVideo; } private (JellyfinMusicVideoLibraryScanner Scanner, IMusicVideoRepository MusicVideoRepository, IMetadataRepository MetadataRepository) BuildScannerWithSubstitutes( JellyfinMusicVideo existing, IJellyfinApiClient apiClient) { var artistRepository = Substitute.For(); artistRepository.GetArtistByMetadata(Arg.Any(), Arg.Any()) .Returns(Some(new Artist { Id = 3, ArtistMetadata = new List() })); artistRepository.DeleteEmptyArtists(Arg.Any()).Returns(new List()); var jellyfinMusicVideoRepository = Substitute.For(); jellyfinMusicVideoRepository.GetOrAdd( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Right>( new MediaItemScanResult(existing) { IsAdded = false })); jellyfinMusicVideoRepository.GetExistingMusicVideos(Arg.Any()) .Returns(new List { new() { ItemId = existing.ItemId, Etag = existing.Etag, State = MediaItemState.Normal } }); jellyfinMusicVideoRepository.FlagFileNotFound(Arg.Any(), Arg.Any>()) .Returns(new List()); jellyfinMusicVideoRepository.GetExistingLegacyMusicVideoPaths(Arg.Any()) .Returns(new List()); jellyfinMusicVideoRepository.FlagFileNotFoundByPaths(Arg.Any(), Arg.Any>()) .Returns(new List()); var musicVideoRepository = Substitute.For(); musicVideoRepository.AddGenre(Arg.Any(), Arg.Any()).Returns(true); musicVideoRepository.AddTag(Arg.Any(), Arg.Any()).Returns(true); musicVideoRepository.AddStudio(Arg.Any(), Arg.Any()).Returns(true); musicVideoRepository.AddArtist(Arg.Any(), Arg.Any()).Returns(true); musicVideoRepository.RemoveArtist(Arg.Any()).Returns(true); var metadataRepository = Substitute.For(); metadataRepository.Update(Arg.Any()).Returns(true); metadataRepository.UpdateStatistics(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); metadataRepository.RemoveGenre(Arg.Any()).Returns(true); metadataRepository.RemoveTag(Arg.Any()).Returns(true); metadataRepository.RemoveStudio(Arg.Any()).Returns(true); var libraryRepository = Substitute.For(); libraryRepository.GetParentFolderId(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Option.None); libraryRepository.GetOrAddFolder(Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new LibraryFolder { Id = 5, Path = "/data/music/artist1" }); var scanner = new JellyfinMusicVideoLibraryScanner( BuildScannerProxy(), apiClient, jellyfinMusicVideoRepository, BuildPathReplacement(), BuildMediaSourceRepository(), artistRepository, musicVideoRepository, libraryRepository, metadataRepository, BuildFileSystem(), NullLogger.Instance); return (scanner, musicVideoRepository, metadataRepository); } private (JellyfinMusicVideoLibraryScanner Scanner, IScannerProxy ScannerProxy) BuildScanner( IJellyfinApiClient apiClient, Func replacePath = null) { IScannerProxy scannerProxy = BuildScannerProxy(); var scanner = new JellyfinMusicVideoLibraryScanner( scannerProxy, apiClient, new JellyfinMusicVideoRepository(_db.Factory, NullLogger.Instance), BuildPathReplacement(replacePath), BuildMediaSourceRepository(), new ArtistRepository(_db.Factory), new MusicVideoRepository(_db.Factory), new LibraryRepository(Substitute.For(), _db.Factory), Substitute.For(), BuildFileSystem(), NullLogger.Instance); return (scanner, scannerProxy); } private static IScannerProxy BuildScannerProxy() { var scannerProxy = Substitute.For(); scannerProxy.UpdateProgress(Arg.Any(), Arg.Any()).Returns(true); scannerProxy.ReindexMediaItems(Arg.Any(), Arg.Any()).Returns(true); scannerProxy.RemoveMediaItems(Arg.Any(), Arg.Any()).Returns(true); return scannerProxy; } // Defaults to an identity replacement. Pass a real mapping to exercise the case where the media-server path // and the local path DIFFER — the identity default is what hid the ersatztv#496 path-replacement blocker. private static IJellyfinPathReplacementService BuildPathReplacement(Func replacePath = null) { var pathReplacement = Substitute.For(); pathReplacement .GetReplacementJellyfinPath(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(ci => (replacePath ?? (p => p))(ci.ArgAt(1))); return pathReplacement; } private static IMediaSourceRepository BuildMediaSourceRepository() { var mediaSourceRepository = Substitute.For(); mediaSourceRepository.GetJellyfinPathReplacements(Arg.Any()) .Returns(Task.FromResult(new List())); return mediaSourceRepository; } // every scanned file "exists" locally, so scanned items settle in Normal and a FileNotFound state can only // come from the sweep under test private static IFileSystem BuildFileSystem() { var fileSystem = Substitute.For(); fileSystem.File.Exists(Arg.Any()).Returns(true); return fileSystem; } private async Task MusicVideoId(string path) { await using TvContext context = _db.CreateContext(); List musicVideos = await context.MusicVideos .Include(mv => mv.MediaVersions) .ThenInclude(v => v.MediaFiles) .ToListAsync(); return musicVideos .Single(mv => mv.MediaVersions.Single().MediaFiles.Single().Path == path) .Id; } private async Task MediaItemStateOf(int mediaItemId) { await using TvContext context = _db.CreateContext(); MediaItem mediaItem = await context.MediaItems.SingleAsync(mi => mi.Id == mediaItemId); return mediaItem.State; } private async Task> MusicVideoPaths(int libraryPathId) { await using TvContext context = _db.CreateContext(); List musicVideos = await context.MusicVideos .Include(mv => mv.MediaVersions) .ThenInclude(v => v.MediaFiles) .Where(mv => mv.LibraryPathId == libraryPathId) .ToListAsync(); return musicVideos .Select(mv => mv.MediaVersions.Single().MediaFiles.Single().Path) .OrderBy(p => p) .ToList(); } private async Task> ArtistTitles(int libraryPathId) { await using TvContext context = _db.CreateContext(); List artists = await context.Artists .Include(a => a.ArtistMetadata) .Where(a => a.LibraryPathId == libraryPathId) .ToListAsync(); return artists.Select(a => a.ArtistMetadata.Single().Title).OrderBy(t => t).ToList(); } // Each Func builds a fresh music video so the async stream can be re-enumerated across ScanLibrary calls // (an IAsyncEnumerable iterator is single-use, and the scanner mutates the incoming item). private static IJellyfinApiClient FakeApi(params Func[] items) { var apiClient = Substitute.For(); apiClient.GetMusicVideoLibraryItems( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => Items(items)); return apiClient; } // Same as FakeApi, but the enumeration also reports `failures` swallowed projection exceptions into the // counter the scanner handed in — what the real JellyfinApiClient does from ProjectToMusicVideo's catch. private static IJellyfinApiClient FakeApiWithProjectionFailures( int failures, params Func[] items) { var apiClient = Substitute.For(); apiClient.GetMusicVideoLibraryItems( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => ItemsWithProjectionFailures( items, ci.ArgAt(3), failures)); return apiClient; } // Records the failures DURING enumeration, like the real paginator does from ProjectToMusicVideo's catch — // not eagerly when the enumerable is handed out. The distinction is the point: a scanner that snapshotted // Count before the enumeration completed would read 0 and sweep anyway, and an eager substitute would keep // passing while production broke. private static async IAsyncEnumerable> ItemsWithProjectionFailures( Func[] items, MediaServerProjectionFailureCounter projectionFailures, int failures) { for (var i = 0; i < failures; i++) { projectionFailures.RecordFailure(); } foreach (Func item in items) { yield return new Tuple(item(), items.Length); } await Task.CompletedTask; } private static async IAsyncEnumerable> Items(Func[] items) { foreach (Func item in items) { yield return new Tuple(item(), items.Length); } await Task.CompletedTask; } private static JellyfinLibrary BuildLibrary(int libraryPathId, string path, int libraryId = 42) { var libraryPath = new LibraryPath { Id = libraryPathId, Path = path, LibraryFolders = null }; return new JellyfinLibrary { Id = libraryId, MediaSourceId = 1, ItemId = $"lib{libraryId}", Name = "Music", ShouldSyncItems = true, Paths = new List { libraryPath } }; } private static JellyfinMusicVideo BuildIncoming( string path, string artistName, string title, IEnumerable genres = null, IEnumerable tags = null, IEnumerable studios = null, IEnumerable artists = null) => new() { ItemId = ItemIdFor(path), Etag = $"etag-{title}", MediaVersions = new List { new() { MediaFiles = new List { new() { Path = path } }, Streams = new List(), Chapters = new List() } }, MusicVideoMetadata = new List { new() { Title = title, Genres = (genres ?? Enumerable.Empty()).Select(g => new Genre { Name = g }).ToList(), Tags = (tags ?? Enumerable.Empty()).Select(t => new Tag { Name = t }).ToList(), Studios = (studios ?? Enumerable.Empty()).Select(s => new Studio { Name = s }).ToList(), Artists = (artists ?? new[] { artistName }) .Select(a => new MusicVideoArtist { Name = a }).ToList() } } }; // Seeds a real JellyfinLibrary row owning the LibraryPath. ersatztv#496 made this load-bearing: identity // lookups and the sweep are scoped per LIBRARY (GetExistingMusicVideos / FlagFileNotFound both join // LibraryPath.LibraryId), so a dangling LibraryPath with no owning library would silently match nothing. private async Task SeedLibraryPath(string path, int libraryId = 42) { await using TvContext context = _db.CreateContext(); var libraryPath = new LibraryPath { Path = path }; var library = new JellyfinLibrary { Id = libraryId, MediaSourceId = 1, ItemId = $"lib{libraryId}", Name = "Music", ShouldSyncItems = true, Paths = new List { libraryPath } }; await context.Libraries.AddAsync(library); await context.SaveChangesAsync(); return libraryPath.Id; } // a MusicVideo row with NO JellyfinMusicVideo identity — what the pre-#496 scanner (and the local // MusicVideoFolderScanner) leaves behind private async Task SeedPlainMusicVideo(int libraryPathId, string path, string artistName) { await using TvContext context = _db.CreateContext(); var artist = new Artist { LibraryPathId = libraryPathId, ArtistMetadata = new List { new() { Title = artistName } } }; await context.Artists.AddAsync(artist); await context.SaveChangesAsync(); var musicVideo = new MusicVideo { ArtistId = artist.Id, LibraryPathId = libraryPathId, MusicVideoMetadata = new List { new() { Title = "Song 1" } }, MediaVersions = new List { new() { MediaFiles = new List { new() { Path = path, PathHash = PathUtils.GetPathHash(path) } }, Streams = new List() } } }; await context.MusicVideos.AddAsync(musicVideo); await context.SaveChangesAsync(); return musicVideo.Id; } private async Task SeedCollectionWith(int mediaItemId) { await using TvContext context = _db.CreateContext(); var collection = new Collection { Name = "Vaporwave", CollectionItems = new List { new() { MediaItemId = mediaItemId } } }; await context.Collections.AddAsync(collection); await context.SaveChangesAsync(); return collection.Id; } }