Files
ersatztv/ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.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

959 lines
46 KiB
C#

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<BaseError, Unit> 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<Artist> 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<MusicVideo> 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<JellyfinMusicVideo> 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<int[]>(ids => ids.Contains(goneId)),
Arg.Any<CancellationToken>());
await scannerProxy.DidNotReceive().RemoveMediaItems(
Arg.Is<int[]>(ids => ids.Contains(goneId)),
Arg.Any<CancellationToken>());
}
// 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<JellyfinMusicVideo> 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<CollectionItem> 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#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<MovieMetadata>() });
seedContext.Add(new Show { LibraryPathId = id, ShowMetadata = new List<ShowMetadata>() });
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<MovieMetadata>(),
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = "/data/standup/show.mkv", PathHash = "hash-standup" }
},
Streams = new List<MediaStream>()
}
}
});
seedContext.Add(new Show { LibraryPathId = id, ShowMetadata = new List<ShowMetadata>() });
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<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.
// 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<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" } }
}
}
};
(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<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"));
}
// 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.
// #484: music videos are HARD-deleted by this sweep (there is no per-item FileNotFound seam), so a
// silently dropped projection is immediately destructive here. Parameterised with its own positive
// control: identical arrangement, only the failure count differs.
[TestCase(1, false, TestName = "MusicVideo_Sweep_Is_Skipped_When_A_Projection_Failed")]
[TestCase(0, true, TestName = "MusicVideo_Sweep_Still_Runs_With_No_Projection_Failures")]
public async Task MusicVideo_Sweep_Respects_Projection_Failures(int failures, bool expectDelete)
{
JellyfinLibrary library = BuildLibrary(1, "/data/music");
var connectionParameters = new JellyfinConnectionParameters("http://jellyfin", "api-key", 1);
const string KeepPath = "/data/music/artist1/song1.mkv";
const string GonePath = "/data/music/artist1/song2.mkv";
var existing = new MusicVideo
{
Id = 7,
ArtistId = 3,
MediaVersions =
[
new MediaVersion
{
MediaFiles = [new MediaFile { Path = KeepPath }],
Streams = []
}
],
MusicVideoMetadata =
[
new MusicVideoMetadata { Id = 11, Genres = [], Tags = [], Studios = [], Artists = [] }
]
};
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 }));
// two rows exist locally, only one comes back — a genuine partial deletion, so #477's empty
// branch cannot mask the assertion
musicVideoRepository.FindMusicVideoPaths(Arg.Any<LibraryPath>())
.Returns(new List<string> { KeepPath, GonePath }.AsEnumerable());
musicVideoRepository.DeleteByPath(Arg.Any<LibraryPath>(), Arg.Any<string>()).Returns(new List<int> { 7 });
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);
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(
FakeApiWithProjectionFailures(failures, () => BuildIncoming(KeepPath, "Artist 1", "Song 1")),
artistRepository,
musicVideoRepository,
libraryRepository,
metadataRepository);
(await scanner.ScanLibrary(connectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
if (expectDelete)
{
await musicVideoRepository.Received(1).DeleteByPath(Arg.Any<LibraryPath>(), GonePath);
}
else
{
await musicVideoRepository.DidNotReceive().DeleteByPath(Arg.Any<LibraryPath>(), Arg.Any<string>());
}
}
[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<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Synthwave"));
await musicVideoRepository.Received(1)
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "DupTag"));
await musicVideoRepository.Received(1)
.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Is<Studio>(s => s.Name == "DupStudio"));
await musicVideoRepository.Received(1)
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(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 (JellyfinMusicVideoLibraryScanner Scanner, IMusicVideoRepository MusicVideoRepository,
IMetadataRepository MetadataRepository) BuildScannerWithSubstitutes(
JellyfinMusicVideo existing,
IJellyfinApiClient apiClient)
{
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 jellyfinMusicVideoRepository = Substitute.For<IJellyfinMusicVideoRepository>();
jellyfinMusicVideoRepository.GetOrAdd(
Arg.Any<JellyfinLibrary>(),
Arg.Any<Artist>(),
Arg.Any<LibraryFolder>(),
Arg.Any<JellyfinMusicVideo>(),
Arg.Any<bool>(),
Arg.Any<CancellationToken>())
.Returns(Right<BaseError, MediaItemScanResult<JellyfinMusicVideo>>(
new MediaItemScanResult<JellyfinMusicVideo>(existing) { IsAdded = false }));
jellyfinMusicVideoRepository.GetExistingMusicVideos(Arg.Any<JellyfinLibrary>())
.Returns(new List<JellyfinItemEtag>
{
new() { ItemId = existing.ItemId, Etag = existing.Etag, State = MediaItemState.Normal }
});
jellyfinMusicVideoRepository.FlagFileNotFound(Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>())
.Returns(new List<int>());
var musicVideoRepository = Substitute.For<IMusicVideoRepository>();
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" });
var scanner = new JellyfinMusicVideoLibraryScanner(
BuildScannerProxy(),
apiClient,
jellyfinMusicVideoRepository,
BuildPathReplacement(),
BuildMediaSourceRepository(),
artistRepository,
musicVideoRepository,
libraryRepository,
metadataRepository,
BuildFileSystem(),
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
return (scanner, musicVideoRepository, metadataRepository);
}
private (JellyfinMusicVideoLibraryScanner Scanner, IScannerProxy ScannerProxy) BuildScanner(
IJellyfinApiClient apiClient)
{
IScannerProxy scannerProxy = BuildScannerProxy();
var scanner = new JellyfinMusicVideoLibraryScanner(
scannerProxy,
apiClient,
new JellyfinMusicVideoRepository(_db.Factory, NullLogger<JellyfinMusicVideoRepository>.Instance),
BuildPathReplacement(),
BuildMediaSourceRepository(),
new ArtistRepository(_db.Factory),
new MusicVideoRepository(_db.Factory),
new LibraryRepository(Substitute.For<IFileSystem>(), _db.Factory),
Substitute.For<IMetadataRepository>(),
BuildFileSystem(),
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
return (scanner, scannerProxy);
}
private static IScannerProxy BuildScannerProxy()
{
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 scannerProxy;
}
private static IJellyfinPathReplacementService BuildPathReplacement()
{
var pathReplacement = Substitute.For<IJellyfinPathReplacementService>();
pathReplacement
.GetReplacementJellyfinPath(Arg.Any<List<JellyfinPathReplacement>>(), Arg.Any<string>(), Arg.Any<bool>())
.Returns(ci => ci.ArgAt<string>(1));
return pathReplacement;
}
private static IMediaSourceRepository BuildMediaSourceRepository()
{
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
mediaSourceRepository.GetJellyfinPathReplacements(Arg.Any<int>())
.Returns(Task.FromResult(new List<JellyfinPathReplacement>()));
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<IFileSystem>();
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
return fileSystem;
}
private async Task<int> MusicVideoId(string path)
{
await using TvContext context = _db.CreateContext();
List<MusicVideo> 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<MediaItemState> 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<List<string>> MusicVideoPaths(int libraryPathId)
{
await using TvContext context = _db.CreateContext();
List<MusicVideo> 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<List<string>> ArtistTitles(int libraryPathId)
{
await using TvContext context = _db.CreateContext();
List<Artist> 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<JellyfinMusicVideo>[] items)
{
var apiClient = Substitute.For<IJellyfinApiClient>();
apiClient.GetMusicVideoLibraryItems(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<JellyfinLibrary>(),
Arg.Any<MediaServerProjectionFailureCounter>())
.Returns(_ => Items(items));
return apiClient;
}
private static async IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> Items(Func<JellyfinMusicVideo>[] items)
{
foreach (Func<JellyfinMusicVideo> item in items)
{
yield return new Tuple<JellyfinMusicVideo, int>(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> { libraryPath }
};
}
private static JellyfinMusicVideo BuildIncoming(
string path,
string artistName,
string title,
IEnumerable<string> genres = null,
IEnumerable<string> tags = null,
IEnumerable<string> studios = null,
IEnumerable<string> artists = null) =>
new()
{
ItemId = ItemIdFor(path),
Etag = $"etag-{title}",
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile> { new() { Path = path } },
Streams = new List<MediaStream>(),
Chapters = new List<MediaChapter>()
}
},
MusicVideoMetadata = new List<MusicVideoMetadata>
{
new()
{
Title = title,
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()
}
}
};
// 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<int> 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> { 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<int> SeedPlainMusicVideo(int libraryPathId, string path, string artistName)
{
await using TvContext context = _db.CreateContext();
var artist = new Artist
{
LibraryPathId = libraryPathId,
ArtistMetadata = new List<ArtistMetadata> { new() { Title = artistName } }
};
await context.Artists.AddAsync(artist);
await context.SaveChangesAsync();
var musicVideo = new MusicVideo
{
ArtistId = artist.Id,
LibraryPathId = libraryPathId,
MusicVideoMetadata = new List<MusicVideoMetadata> { new() { Title = "Song 1" } },
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path, PathHash = PathUtils.GetPathHash(path) }
},
Streams = new List<MediaStream>()
}
}
};
await context.MusicVideos.AddAsync(musicVideo);
await context.SaveChangesAsync();
return musicVideo.Id;
}
private async Task<int> SeedCollectionWith(int mediaItemId)
{
await using TvContext context = _db.CreateContext();
var collection = new Collection
{
Name = "Vaporwave",
CollectionItems = new List<CollectionItem> { new() { MediaItemId = mediaItemId } }
};
await context.Collections.AddAsync(collection);
await context.SaveChangesAsync();
return collection.Id;
}
}