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
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.
1100 lines
54 KiB
C#
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;
|
|
}
|
|
}
|