Files
ersatztv/ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.cs
T
timothy b5b6e7f636
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
test(496,484): record projection failures during enumeration, not eagerly [decisions-edit]
Re-review Low. The substitute incremented the failure counter inside .Returns(...), i.e. when
the enumerable was handed out, while the real paginator records from ProjectToMusicVideo's catch
DURING enumeration. A refactor that snapshotted Count before the enumeration completed would
then break production while both replacement tests kept passing — exactly the regression the
guard exists to prevent.

Moves the recording into an async iterator, and corrects the decision record to describe #484's
removed music-video test accurately and name its two replacements.
2026-07-25 17:36:34 +02:00

1100 lines
54 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#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<MusicVideo> 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<JellyfinMusicVideo> 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<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.
[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 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<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<string>(),
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>());
jellyfinMusicVideoRepository.GetExistingLegacyMusicVideoPaths(Arg.Any<JellyfinLibrary>())
.Returns(new List<string>());
jellyfinMusicVideoRepository.FlagFileNotFoundByPaths(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,
Func<string, string> replacePath = null)
{
IScannerProxy scannerProxy = BuildScannerProxy();
var scanner = new JellyfinMusicVideoLibraryScanner(
scannerProxy,
apiClient,
new JellyfinMusicVideoRepository(_db.Factory, NullLogger<JellyfinMusicVideoRepository>.Instance),
BuildPathReplacement(replacePath),
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;
}
// 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<string, string> replacePath = null)
{
var pathReplacement = Substitute.For<IJellyfinPathReplacementService>();
pathReplacement
.GetReplacementJellyfinPath(Arg.Any<List<JellyfinPathReplacement>>(), Arg.Any<string>(), Arg.Any<bool>())
.Returns(ci => (replacePath ?? (p => p))(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;
}
// 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<JellyfinMusicVideo>[] items)
{
var apiClient = Substitute.For<IJellyfinApiClient>();
apiClient.GetMusicVideoLibraryItems(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<JellyfinLibrary>(),
Arg.Any<MediaServerProjectionFailureCounter>())
.Returns(ci => ItemsWithProjectionFailures(
items,
ci.ArgAt<MediaServerProjectionFailureCounter>(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<Tuple<JellyfinMusicVideo, int>> ItemsWithProjectionFailures(
Func<JellyfinMusicVideo>[] items,
MediaServerProjectionFailureCounter projectionFailures,
int failures)
{
for (var i = 0; i < failures; i++)
{
projectionFailures.RecordFailure();
}
foreach (Func<JellyfinMusicVideo> item in items)
{
yield return new Tuple<JellyfinMusicVideo, int>(item(), items.Length);
}
await Task.CompletedTask;
}
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;
}
}