Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served by two libraries with overlapping local paths is a single row owned by whichever library scanned it first, so that owner's sweep destroyed a row another library still served — taking collection membership and playout references with it, irreversibly. This is #494's deferred "option 2": - New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table, varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo. - New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per library via LibraryPath.LibraryId. - New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim. - The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep. - Pre-identity rows are ADOPTED in place: the identity row is inserted against the same MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership survives and a local/second-library row is never hijacked. - AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection fills them from the server-reported path, which would break every later PathHash lookup. Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as superseded; new active record scan.musicvideo-server-identity. fixes #496
959 lines
46 KiB
C#
959 lines
46 KiB
C#
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Jellyfin;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Core.Jellyfin;
|
|
using ErsatzTV.Core.Metadata;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Data.Repositories;
|
|
using ErsatzTV.Scanner.Core.Interfaces;
|
|
using ErsatzTV.Scanner.Core.Jellyfin;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using IFileSystem = System.IO.Abstractions.IFileSystem;
|
|
using Unit = LanguageExt.Unit;
|
|
using static LanguageExt.Prelude;
|
|
|
|
namespace ErsatzTV.Tests.Integration;
|
|
|
|
// End-to-end regression for ersatztv#488. Unlike the existing MediaServer*LibraryScanner tests (which
|
|
// substitute every repository, and therefore could never have exhibited the null-navigation bug — see the
|
|
// mocked GetOrAddFolder in MovieFolderScannerTests), this test wires the REAL LibraryRepository /
|
|
// ArtistRepository / JellyfinMusicVideoRepository against in-memory SQLite so the actual crash path runs. The
|
|
// deviation from the mock-and-verify house style is deliberate and required: a substituted
|
|
// ILibraryRepository cannot exhibit the defect this issue is about.
|
|
//
|
|
// ersatztv#496 reshaped this suite: music videos now carry a per-library server identity
|
|
// (JellyfinMusicVideo.ItemId/Etag), so reconciliation diffs on the item id and SOFT-trashes (FileNotFound)
|
|
// instead of hard-deleting by path. The #494 "row disappears" assertions became "row is flagged" assertions.
|
|
[TestFixture]
|
|
public class JellyfinMusicVideoLibraryScannerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Complete_And_Create_Artist_And_MusicVideo_Rows()
|
|
{
|
|
int libraryPathId = await SeedLibraryPath("/data/music");
|
|
|
|
// the remote-path shape that used to crash: Paths is populated, LibraryFolders is null
|
|
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
|
|
|
|
const string VideoPath = "/data/music/artist1/song1.mkv";
|
|
|
|
(JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming(VideoPath, "Artist 1", "Song 1")));
|
|
|
|
Either<BaseError, Unit> result = await scanner.ScanLibrary(
|
|
ConnectionParameters,
|
|
library,
|
|
deepScan: false,
|
|
CancellationToken.None);
|
|
|
|
// the scan runs to completion instead of crashing on GetOrAddFolder
|
|
result.IsRight.ShouldBeTrue(result.Match(Right: _ => "", Left: e => e.Value));
|
|
|
|
await using TvContext context = _db.CreateContext();
|
|
|
|
// Artist row created from the incoming metadata
|
|
List<Artist> artists = await context.Artists
|
|
.Include(a => a.ArtistMetadata)
|
|
.Where(a => a.LibraryPathId == libraryPathId)
|
|
.ToListAsync();
|
|
artists.Count.ShouldBe(1);
|
|
artists[0].ArtistMetadata.Single().Title.ShouldBe("Artist 1");
|
|
|
|
// MusicVideo row created, wired to a real LibraryFolder (proving GetOrAddFolder returned a persisted row)
|
|
List<MusicVideo> musicVideos = await context.MusicVideos
|
|
.Include(mv => mv.MediaVersions)
|
|
.ThenInclude(v => v.MediaFiles)
|
|
.Where(mv => mv.LibraryPathId == libraryPathId)
|
|
.ToListAsync();
|
|
musicVideos.Count.ShouldBe(1);
|
|
MediaFile file = musicVideos[0].MediaVersions.Single().MediaFiles.Single();
|
|
file.Path.ShouldBe(VideoPath);
|
|
file.LibraryFolderId.ShouldNotBeNull();
|
|
|
|
// the folder was created by the fixed GetOrAddFolder
|
|
LibraryFolder folder = await context.LibraryFolders.SingleAsync(f => f.Id == file.LibraryFolderId);
|
|
folder.Path.ShouldBe("/data/music/artist1");
|
|
folder.LibraryPathId.ShouldBe(libraryPathId);
|
|
|
|
// ersatztv#496: the row carries the server item id, so it is no longer identified by its path
|
|
List<JellyfinMusicVideo> identities = await context.JellyfinMusicVideos.ToListAsync();
|
|
identities.Count.ShouldBe(1);
|
|
identities[0].ItemId.ShouldBe(ItemIdFor(VideoPath));
|
|
}
|
|
|
|
// ersatztv#494 as reshaped by ersatztv#496: a music video removed from Jellyfin is SOFT-trashed
|
|
// (State = FileNotFound), not hard-deleted, matching the movie/TV/other-video scanners. The row survives, so
|
|
// collection membership and playout references survive with it and EmptyTrash governs the real removal.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Flag_FileNotFound_For_MusicVideo_Missing_From_Jellyfin()
|
|
{
|
|
int id = await SeedLibraryPath("/data/music");
|
|
JellyfinLibrary library = BuildLibrary(id, "/data/music");
|
|
|
|
// first scan seeds two music videos
|
|
(JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"),
|
|
() => BuildIncoming("/data/music/artist2/gone.mkv", "Artist 2", "Gone")));
|
|
(await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
(await MusicVideoPaths(id)).Count.ShouldBe(2);
|
|
int goneId = await MusicVideoId("/data/music/artist2/gone.mkv");
|
|
|
|
// second scan: only "keep" is still present upstream
|
|
(JellyfinMusicVideoLibraryScanner scanner, IScannerProxy scannerProxy) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep")));
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
// both rows still exist — the missing one is flagged, not deleted
|
|
(await MusicVideoPaths(id)).Count.ShouldBe(2);
|
|
(await MediaItemStateOf(goneId)).ShouldBe(MediaItemState.FileNotFound);
|
|
(await MediaItemStateOf(await MusicVideoId("/data/music/artist1/keep.mkv")))
|
|
.ShouldBe(MediaItemState.Normal);
|
|
|
|
await scannerProxy.Received().ReindexMediaItems(
|
|
Arg.Is<int[]>(ids => ids.Contains(goneId)),
|
|
Arg.Any<CancellationToken>());
|
|
await scannerProxy.DidNotReceive().RemoveMediaItems(
|
|
Arg.Is<int[]>(ids => ids.Contains(goneId)),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ersatztv#496: the artist of a soft-trashed music video is NOT emptied, because the music video row is still
|
|
// there. This is the deliberate consequence of replacing #494's hard delete — artist cleanup now happens only
|
|
// once the trash is actually emptied.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Keep_Artist_Of_Trashed_MusicVideo()
|
|
{
|
|
int id = await SeedLibraryPath("/data/music");
|
|
JellyfinLibrary library = BuildLibrary(id, "/data/music");
|
|
|
|
(JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep"),
|
|
() => BuildIncoming("/data/music/artist2/gone.mkv", "Artist 2", "Gone")));
|
|
(await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
(await ArtistTitles(id)).Count.ShouldBe(2);
|
|
|
|
// "Artist 2" loses its only music video from the server's point of view
|
|
(JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep")));
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
(await ArtistTitles(id)).ShouldBe(new[] { "Artist 1", "Artist 2" });
|
|
}
|
|
|
|
// ersatztv#496 (the issue's Done-when 2): one library's sweep must never reach another library's music
|
|
// videos. Identity is now (server item id, owning library), so library B reporting nothing cannot flag a row
|
|
// owned by library A — even though both libraries point at the SAME local path.
|
|
//
|
|
// Under the pre-#496 path diff this was the live hazard: the sweep resolved rows by (LibraryPathId, path) and
|
|
// HARD-deleted them, so the row a second library still served was destroyed outright.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Not_Flag_MusicVideos_Owned_By_Another_Library()
|
|
{
|
|
int pathA = await SeedLibraryPath("/data/music", libraryId: 42);
|
|
int pathB = await SeedLibraryPath("/data/music-overlap", libraryId: 43);
|
|
|
|
JellyfinLibrary libraryA = BuildLibrary(pathA, "/data/music", libraryId: 42);
|
|
JellyfinLibrary libraryB = BuildLibrary(pathB, "/data/music-overlap", libraryId: 43);
|
|
|
|
// library A owns the row
|
|
(JellyfinMusicVideoLibraryScanner seedA, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music/artist1/song.mkv", "Artist 1", "Song")));
|
|
(await seedA.ScanLibrary(ConnectionParameters, libraryA, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
int ownedId = await MusicVideoId("/data/music/artist1/song.mkv");
|
|
|
|
// library B has its own, different item and reports only that one
|
|
(JellyfinMusicVideoLibraryScanner seedB, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music-overlap/artist9/other.mkv", "Artist 9", "Other")));
|
|
(await seedB.ScanLibrary(ConnectionParameters, libraryB, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
// library B now reports nothing but its own item disappearing — A's row must be untouched
|
|
(JellyfinMusicVideoLibraryScanner scannerB, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music-overlap/artist9/other2.mkv", "Artist 9", "Other 2")));
|
|
(await scannerB.ScanLibrary(ConnectionParameters, libraryB, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
(await MediaItemStateOf(ownedId)).ShouldBe(MediaItemState.Normal);
|
|
(await MediaItemStateOf(await MusicVideoId("/data/music-overlap/artist9/other.mkv")))
|
|
.ShouldBe(MediaItemState.FileNotFound);
|
|
}
|
|
|
|
// ersatztv#496: rows that predate per-item identity (created by the path-keyed scanner, or by the local
|
|
// MusicVideoFolderScanner) must be ADOPTED in place — the same MediaItem id gains a JellyfinMusicVideo
|
|
// identity row. A delete-and-re-add would silently drop collection membership, which is exactly what a
|
|
// populated music collection depends on.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Adopt_PreExisting_MusicVideo_Preserving_Identity_And_Collections()
|
|
{
|
|
int libraryPathId = await SeedLibraryPath("/data/music");
|
|
const string VideoPath = "/data/music/artist1/song1.mkv";
|
|
|
|
int existingId = await SeedPlainMusicVideo(libraryPathId, VideoPath, "Artist 1");
|
|
int collectionId = await SeedCollectionWith(existingId);
|
|
|
|
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
|
|
(JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming(VideoPath, "Artist 1", "Song 1")));
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
await using TvContext context = _db.CreateContext();
|
|
|
|
// no duplicate row was created
|
|
(await context.MusicVideos.CountAsync(mv => mv.LibraryPathId == libraryPathId)).ShouldBe(1);
|
|
|
|
// the SAME row now carries the server identity
|
|
List<JellyfinMusicVideo> identities = await context.JellyfinMusicVideos.ToListAsync();
|
|
identities.Count.ShouldBe(1);
|
|
identities[0].Id.ShouldBe(existingId);
|
|
identities[0].ItemId.ShouldBe(ItemIdFor(VideoPath));
|
|
|
|
// and collection membership survived the adoption
|
|
List<CollectionItem> items = await context.CollectionItems
|
|
.Where(ci => ci.CollectionId == collectionId)
|
|
.ToListAsync();
|
|
items.Count.ShouldBe(1);
|
|
items[0].MediaItemId.ShouldBe(existingId);
|
|
}
|
|
|
|
// ersatztv#496: adoption is scoped to the scanned library's own library path, so a music video owned by a
|
|
// LOCAL library (MusicVideoFolderScanner writes the same MusicVideo table) is never hijacked into a Jellyfin
|
|
// library's identity.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Not_Adopt_A_MusicVideo_Owned_By_Another_LibraryPath()
|
|
{
|
|
int localPathId = await SeedLibraryPath("/data/local-music", libraryId: 99);
|
|
int jellyfinPathId = await SeedLibraryPath("/data/music", libraryId: 42);
|
|
const string VideoPath = "/data/shared/song1.mkv";
|
|
|
|
int localId = await SeedPlainMusicVideo(localPathId, VideoPath, "Artist 1");
|
|
|
|
JellyfinLibrary library = BuildLibrary(jellyfinPathId, "/data/music");
|
|
(JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming(VideoPath, "Artist 1", "Song 1")));
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
await using TvContext context = _db.CreateContext();
|
|
|
|
// the local row was left alone: no identity row, still owned by the local library path
|
|
(await context.JellyfinMusicVideos.CountAsync()).ShouldBe(0);
|
|
MusicVideo local = await context.MusicVideos.SingleAsync(mv => mv.Id == localId);
|
|
local.LibraryPathId.ShouldBe(localPathId);
|
|
}
|
|
|
|
// ersatztv#494 (Done-when 3): the music-video sweep must never touch a Movie or Show that shares the
|
|
// same LibraryPath — the cross-flag risk is a LibraryPathId property, not a Mixed-library property.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Not_CrossFlag_Movie_Or_Show_Sharing_The_LibraryPath()
|
|
{
|
|
int id = await SeedLibraryPath("/data/mixed");
|
|
|
|
await using (TvContext seedContext = _db.CreateContext())
|
|
{
|
|
seedContext.Add(new Movie { LibraryPathId = id, MovieMetadata = new List<MovieMetadata>() });
|
|
seedContext.Add(new Show { LibraryPathId = id, ShowMetadata = new List<ShowMetadata>() });
|
|
await seedContext.SaveChangesAsync();
|
|
}
|
|
|
|
JellyfinLibrary library = BuildLibrary(id, "/data/mixed");
|
|
|
|
// seed one music video under the same LibraryPath
|
|
(JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/mixed/song.mkv", "Artist 1", "Song")));
|
|
(await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
// next scan drops "song.mkv" and adds "song2.mkv" — the sweep flags song.mkv only
|
|
(JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/mixed/song2.mkv", "Artist 1", "Song 2")));
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
await using TvContext context = _db.CreateContext();
|
|
(await context.Movies.CountAsync(m => m.LibraryPathId == id)).ShouldBe(1);
|
|
(await context.Shows.CountAsync(s => s.LibraryPathId == id)).ShouldBe(1);
|
|
(await context.MediaItems.CountAsync(mi => mi.LibraryPathId == id && mi.State != MediaItemState.Normal))
|
|
.ShouldBe(1);
|
|
(await MediaItemStateOf(await MusicVideoId("/data/mixed/song.mkv")))
|
|
.ShouldBe(MediaItemState.FileNotFound);
|
|
}
|
|
|
|
// ersatztv#477 guard: a successful-but-empty fetch must NOT flag the library. Negative control — if the
|
|
// sweep were ungated, an empty incoming set would flag every existing music video.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items()
|
|
{
|
|
int id = await SeedLibraryPath("/data/music");
|
|
JellyfinLibrary library = BuildLibrary(id, "/data/music");
|
|
|
|
(JellyfinMusicVideoLibraryScanner seed, _) = BuildScanner(FakeApi(
|
|
() => BuildIncoming("/data/music/artist1/keep.mkv", "Artist 1", "Keep")));
|
|
(await seed.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
int keepId = await MusicVideoId("/data/music/artist1/keep.mkv");
|
|
|
|
// Jellyfin returns zero items (mid-restore / transient) — the sweep must be skipped
|
|
(JellyfinMusicVideoLibraryScanner scanner, _) = BuildScanner(FakeApi());
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
(await MusicVideoPaths(id)).ShouldBe(new[] { "/data/music/artist1/keep.mkv" });
|
|
(await MediaItemStateOf(keepId)).ShouldBe(MediaItemState.Normal);
|
|
}
|
|
|
|
// #493 session field data: a MIXED Jellyfin library runs the music-video arm with a legitimately EMPTY
|
|
// incoming set on every scan while Movies/Shows exist under the same LibraryPath (the real "Standup" case).
|
|
// Identity is now per-item and per-library, so a movie can never enter the music-video diff at all.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Not_Touch_Movies_Or_Shows_When_No_MusicVideos_Present()
|
|
{
|
|
int id = await SeedLibraryPath("/data/standup");
|
|
|
|
await using (TvContext seedContext = _db.CreateContext())
|
|
{
|
|
seedContext.Add(new Movie
|
|
{
|
|
LibraryPathId = id,
|
|
MovieMetadata = new List<MovieMetadata>(),
|
|
MediaVersions = new List<MediaVersion>
|
|
{
|
|
new()
|
|
{
|
|
MediaFiles = new List<MediaFile>
|
|
{
|
|
new() { Path = "/data/standup/show.mkv", PathHash = "hash-standup" }
|
|
},
|
|
Streams = new List<MediaStream>()
|
|
}
|
|
}
|
|
});
|
|
seedContext.Add(new Show { LibraryPathId = id, ShowMetadata = new List<ShowMetadata>() });
|
|
await seedContext.SaveChangesAsync();
|
|
}
|
|
|
|
JellyfinLibrary library = BuildLibrary(id, "/data/standup");
|
|
|
|
// the music-video arm returns zero incoming — steady state for a mixed library
|
|
(JellyfinMusicVideoLibraryScanner scanner, IScannerProxy scannerProxy) = BuildScanner(FakeApi());
|
|
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
await using TvContext context = _db.CreateContext();
|
|
(await context.Movies.CountAsync(m => m.LibraryPathId == id)).ShouldBe(1);
|
|
(await context.Shows.CountAsync(s => s.LibraryPathId == id)).ShouldBe(1);
|
|
(await context.MediaItems.CountAsync(mi => mi.LibraryPathId == id && mi.State != MediaItemState.Normal))
|
|
.ShouldBe(0);
|
|
await scannerProxy.DidNotReceive().RemoveMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ersatztv#497: metadata edits (genres/tags/studios/artists) made in Jellyfin to an EXISTING music video
|
|
// must reach ErsatzTV on the next scan. Before the fix, UpdateMetadata copied only scalar fields, so the
|
|
// update path silently dropped every child collection — add-new AND remove-stale. This is an interaction
|
|
// test: the repositories are substituted and GetOrAdd returns a canned existing item so we can verify the
|
|
// scanner issues the exact reconcile calls.
|
|
// Non-vacuous: reverting the Reconcile* calls in UpdateMetadata drops every Received() below.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Reconcile_Metadata_Collections_On_Rescan_Of_Existing_Item()
|
|
{
|
|
const string VideoPath = "/data/music/artist1/song1.mkv";
|
|
|
|
// the EXISTING item already in ErsatzTV, with the collections Jellyfin first gave it
|
|
var existing = new JellyfinMusicVideo
|
|
{
|
|
Id = 7,
|
|
ArtistId = 3,
|
|
ItemId = ItemIdFor(VideoPath),
|
|
Etag = "old-etag",
|
|
MediaVersions = new List<MediaVersion>
|
|
{
|
|
new() { MediaFiles = new List<MediaFile> { new() { Path = VideoPath } }, Streams = new List<MediaStream>() }
|
|
},
|
|
MusicVideoMetadata = new List<MusicVideoMetadata>
|
|
{
|
|
new()
|
|
{
|
|
Id = 11,
|
|
Genres = new List<Genre> { new() { Name = "Synthwave" }, new() { Name = "Retro" } },
|
|
Tags = new List<Tag> { new() { Name = "KeepTag" }, new() { Name = "DropTag" } },
|
|
Studios = new List<Studio> { new() { Name = "OldStudio" } },
|
|
Artists = new List<MusicVideoArtist> { new() { Name = "Artist 1" }, new() { Name = "Featured X" } }
|
|
}
|
|
}
|
|
};
|
|
|
|
(JellyfinMusicVideoLibraryScanner scanner, IMusicVideoRepository musicVideoRepository,
|
|
IMetadataRepository metadataRepository) =
|
|
BuildScannerWithSubstitutes(
|
|
existing,
|
|
FakeApi(() => BuildIncoming(
|
|
VideoPath, "Artist 1", "Song 1",
|
|
genres: new[] { "Synthwave", "Vaporwave" },
|
|
tags: new[] { "KeepTag", "NewTag" },
|
|
studios: new[] { "NewStudio" },
|
|
artists: new[] { "Artist 1", "Featured Y" })));
|
|
|
|
(await scanner.ScanLibrary(ConnectionParameters, BuildLibrary(1, "/data/music"), deepScan: false,
|
|
CancellationToken.None)).IsRight.ShouldBeTrue();
|
|
|
|
// remove-stale: Retro / DropTag / OldStudio / "Featured X" gone; kept items are NOT removed
|
|
await metadataRepository.Received(1).RemoveGenre(Arg.Is<Genre>(g => g.Name == "Retro"));
|
|
await metadataRepository.DidNotReceive().RemoveGenre(Arg.Is<Genre>(g => g.Name == "Synthwave"));
|
|
await metadataRepository.Received(1).RemoveTag(Arg.Is<Tag>(t => t.Name == "DropTag"));
|
|
await metadataRepository.DidNotReceive().RemoveTag(Arg.Is<Tag>(t => t.Name == "KeepTag"));
|
|
await metadataRepository.Received(1).RemoveStudio(Arg.Is<Studio>(s => s.Name == "OldStudio"));
|
|
await musicVideoRepository.Received(1).RemoveArtist(Arg.Is<MusicVideoArtist>(a => a.Name == "Featured X"));
|
|
await musicVideoRepository.DidNotReceive().RemoveArtist(Arg.Is<MusicVideoArtist>(a => a.Name == "Artist 1"));
|
|
|
|
// add-new: Vaporwave / NewTag / NewStudio / "Featured Y" added; kept items are NOT re-added
|
|
await musicVideoRepository.Received(1)
|
|
.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Vaporwave"));
|
|
await musicVideoRepository.DidNotReceive()
|
|
.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Synthwave"));
|
|
await musicVideoRepository.Received(1)
|
|
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "NewTag"));
|
|
await musicVideoRepository.DidNotReceive()
|
|
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "KeepTag"));
|
|
await musicVideoRepository.Received(1)
|
|
.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Is<Studio>(s => s.Name == "NewStudio"));
|
|
await musicVideoRepository.Received(1)
|
|
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(a => a.Name == "Featured Y"));
|
|
await musicVideoRepository.DidNotReceive()
|
|
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(a => a.Name == "Artist 1"));
|
|
}
|
|
|
|
// ersatztv#177: Album/Track are scalars copied field-by-field in UpdateMetadata, so the projection fix
|
|
// alone would only ever reach music videos ADDED after it — an EXISTING item whose album/track is set or
|
|
// corrected in Jellyfin would keep its stale value forever. Same shape as the #497 collection bug, one
|
|
// layer up. Non-vacuous: reverting the two assignments in UpdateMetadata fails both assertions below.
|
|
// #484: music videos are HARD-deleted by this sweep (there is no per-item FileNotFound seam), so a
|
|
// silently dropped projection is immediately destructive here. Parameterised with its own positive
|
|
// control: identical arrangement, only the failure count differs.
|
|
[TestCase(1, false, TestName = "MusicVideo_Sweep_Is_Skipped_When_A_Projection_Failed")]
|
|
[TestCase(0, true, TestName = "MusicVideo_Sweep_Still_Runs_With_No_Projection_Failures")]
|
|
public async Task MusicVideo_Sweep_Respects_Projection_Failures(int failures, bool expectDelete)
|
|
{
|
|
JellyfinLibrary library = BuildLibrary(1, "/data/music");
|
|
var connectionParameters = new JellyfinConnectionParameters("http://jellyfin", "api-key", 1);
|
|
const string KeepPath = "/data/music/artist1/song1.mkv";
|
|
const string GonePath = "/data/music/artist1/song2.mkv";
|
|
|
|
var existing = new MusicVideo
|
|
{
|
|
Id = 7,
|
|
ArtistId = 3,
|
|
MediaVersions =
|
|
[
|
|
new MediaVersion
|
|
{
|
|
MediaFiles = [new MediaFile { Path = KeepPath }],
|
|
Streams = []
|
|
}
|
|
],
|
|
MusicVideoMetadata =
|
|
[
|
|
new MusicVideoMetadata { Id = 11, Genres = [], Tags = [], Studios = [], Artists = [] }
|
|
]
|
|
};
|
|
|
|
var artistRepository = Substitute.For<IArtistRepository>();
|
|
artistRepository.GetArtistByMetadata(Arg.Any<int>(), Arg.Any<ArtistMetadata>())
|
|
.Returns(Some(new Artist { Id = 3, ArtistMetadata = new List<ArtistMetadata>() }));
|
|
artistRepository.DeleteEmptyArtists(Arg.Any<LibraryPath>()).Returns(new List<int>());
|
|
|
|
var musicVideoRepository = Substitute.For<IMusicVideoRepository>();
|
|
musicVideoRepository
|
|
.GetOrAdd(Arg.Any<Artist>(), Arg.Any<LibraryPath>(), Arg.Any<LibraryFolder>(), Arg.Any<string>())
|
|
.Returns(Right<BaseError, MediaItemScanResult<MusicVideo>>(
|
|
new MediaItemScanResult<MusicVideo>(existing) { IsAdded = false }));
|
|
|
|
// two rows exist locally, only one comes back — a genuine partial deletion, so #477's empty
|
|
// branch cannot mask the assertion
|
|
musicVideoRepository.FindMusicVideoPaths(Arg.Any<LibraryPath>())
|
|
.Returns(new List<string> { KeepPath, GonePath }.AsEnumerable());
|
|
musicVideoRepository.DeleteByPath(Arg.Any<LibraryPath>(), Arg.Any<string>()).Returns(new List<int> { 7 });
|
|
|
|
var metadataRepository = Substitute.For<IMetadataRepository>();
|
|
metadataRepository.Update(Arg.Any<ErsatzTV.Core.Domain.Metadata>()).Returns(true);
|
|
metadataRepository.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>())
|
|
.Returns(false);
|
|
|
|
var libraryRepository = Substitute.For<ILibraryRepository>();
|
|
libraryRepository.GetParentFolderId(Arg.Any<LibraryPath>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.None);
|
|
libraryRepository.GetOrAddFolder(Arg.Any<LibraryPath>(), Arg.Any<Option<int>>(), Arg.Any<string>())
|
|
.Returns(new LibraryFolder { Id = 5, Path = "/data/music/artist1" });
|
|
|
|
JellyfinMusicVideoLibraryScanner scanner = BuildScannerWith(
|
|
FakeApiWithProjectionFailures(failures, () => BuildIncoming(KeepPath, "Artist 1", "Song 1")),
|
|
artistRepository,
|
|
musicVideoRepository,
|
|
libraryRepository,
|
|
metadataRepository);
|
|
|
|
(await scanner.ScanLibrary(connectionParameters, library, deepScan: false, CancellationToken.None))
|
|
.IsRight.ShouldBeTrue();
|
|
|
|
if (expectDelete)
|
|
{
|
|
await musicVideoRepository.Received(1).DeleteByPath(Arg.Any<LibraryPath>(), GonePath);
|
|
}
|
|
else
|
|
{
|
|
await musicVideoRepository.DidNotReceive().DeleteByPath(Arg.Any<LibraryPath>(), Arg.Any<string>());
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Update_Album_And_Track_On_Rescan_Of_Existing_Item()
|
|
{
|
|
const string VideoPath = "/data/music/artist1/song1.mkv";
|
|
|
|
var existingMetadata = new MusicVideoMetadata
|
|
{
|
|
Id = 11,
|
|
Album = "Stale Album",
|
|
Track = 1,
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Artists = [new MusicVideoArtist { Name = "Artist 1" }]
|
|
};
|
|
|
|
var existing = new JellyfinMusicVideo
|
|
{
|
|
Id = 7,
|
|
ArtistId = 3,
|
|
ItemId = ItemIdFor(VideoPath),
|
|
Etag = "old-etag",
|
|
MediaVersions =
|
|
[
|
|
new MediaVersion
|
|
{
|
|
MediaFiles = [new MediaFile { Path = VideoPath }],
|
|
Streams = []
|
|
}
|
|
],
|
|
MusicVideoMetadata = [existingMetadata]
|
|
};
|
|
|
|
JellyfinMusicVideo incoming = BuildIncoming(VideoPath, "Artist 1", "Song 1");
|
|
incoming.MusicVideoMetadata[0].Album = "Corrected Album";
|
|
incoming.MusicVideoMetadata[0].Track = 4;
|
|
|
|
(JellyfinMusicVideoLibraryScanner scanner, _, _) =
|
|
BuildScannerWithSubstitutes(existing, FakeApi(() => incoming));
|
|
|
|
(await scanner.ScanLibrary(ConnectionParameters, BuildLibrary(1, "/data/music"), deepScan: false,
|
|
CancellationToken.None)).IsRight.ShouldBeTrue();
|
|
|
|
existingMetadata.Album.ShouldBe("Corrected Album");
|
|
existingMetadata.Track.ShouldBe(4);
|
|
}
|
|
|
|
// ersatztv#500: if Jellyfin reports two identically-named genres/tags/studios/artists for one item and
|
|
// that name is not yet on the existing item, BOTH used to pass the add filter (which is materialized with
|
|
// .ToList() before the loop mutates `existing`) and both got inserted — a duplicate row. Dedup the
|
|
// incoming collection on the same key the filter compares.
|
|
// Non-vacuous: dropping the DistinctBy calls in Reconcile* makes every Received(1) below see 2 calls.
|
|
[Test]
|
|
public async Task ScanLibrary_Should_Not_Double_Insert_Duplicate_Named_Incoming_Collection_Entries()
|
|
{
|
|
const string VideoPath = "/data/music/artist1/song1.mkv";
|
|
|
|
// the existing item carries NONE of the incoming names, so every incoming entry is an "add"
|
|
var existing = new JellyfinMusicVideo
|
|
{
|
|
Id = 7,
|
|
ArtistId = 3,
|
|
ItemId = ItemIdFor(VideoPath),
|
|
Etag = "old-etag",
|
|
MediaVersions =
|
|
[
|
|
new MediaVersion
|
|
{
|
|
MediaFiles = [new MediaFile { Path = VideoPath }],
|
|
Streams = []
|
|
}
|
|
],
|
|
MusicVideoMetadata =
|
|
[
|
|
new MusicVideoMetadata
|
|
{
|
|
Id = 11,
|
|
Genres = [],
|
|
Tags = [],
|
|
Studios = [],
|
|
Artists = []
|
|
}
|
|
]
|
|
};
|
|
|
|
// Jellyfin reports each name TWICE for the same item
|
|
(JellyfinMusicVideoLibraryScanner scanner, IMusicVideoRepository musicVideoRepository, _) =
|
|
BuildScannerWithSubstitutes(
|
|
existing,
|
|
FakeApi(() => BuildIncoming(
|
|
VideoPath, "Artist 1", "Song 1",
|
|
genres: ["Synthwave", "Synthwave"],
|
|
tags: ["DupTag", "DupTag"],
|
|
studios: ["DupStudio", "DupStudio"],
|
|
artists: ["Artist 1", "Artist 1"])));
|
|
|
|
(await scanner.ScanLibrary(ConnectionParameters, BuildLibrary(1, "/data/music"), deepScan: false,
|
|
CancellationToken.None)).IsRight.ShouldBeTrue();
|
|
|
|
await musicVideoRepository.Received(1)
|
|
.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Is<Genre>(g => g.Name == "Synthwave"));
|
|
await musicVideoRepository.Received(1)
|
|
.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Is<Tag>(t => t.Name == "DupTag"));
|
|
await musicVideoRepository.Received(1)
|
|
.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Is<Studio>(s => s.Name == "DupStudio"));
|
|
await musicVideoRepository.Received(1)
|
|
.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Is<MusicVideoArtist>(a => a.Name == "Artist 1"));
|
|
|
|
// and the in-memory collections mirror the DB: one entry each, not two
|
|
MusicVideoMetadata metadata = existing.MusicVideoMetadata.Head();
|
|
metadata.Genres.Count.ShouldBe(1);
|
|
metadata.Tags.Count.ShouldBe(1);
|
|
metadata.Studios.Count.ShouldBe(1);
|
|
metadata.Artists.Count.ShouldBe(1);
|
|
}
|
|
|
|
private static JellyfinConnectionParameters ConnectionParameters =>
|
|
new("http://jellyfin", "api-key", 1);
|
|
|
|
// A stable per-file server item id, so a re-scan of the same file is the same identity.
|
|
private static string ItemIdFor(string path) => $"item-{PathUtils.GetPathHash(path)}";
|
|
|
|
private (JellyfinMusicVideoLibraryScanner Scanner, IMusicVideoRepository MusicVideoRepository,
|
|
IMetadataRepository MetadataRepository) BuildScannerWithSubstitutes(
|
|
JellyfinMusicVideo existing,
|
|
IJellyfinApiClient apiClient)
|
|
{
|
|
var artistRepository = Substitute.For<IArtistRepository>();
|
|
artistRepository.GetArtistByMetadata(Arg.Any<int>(), Arg.Any<ArtistMetadata>())
|
|
.Returns(Some(new Artist { Id = 3, ArtistMetadata = new List<ArtistMetadata>() }));
|
|
artistRepository.DeleteEmptyArtists(Arg.Any<LibraryPath>()).Returns(new List<int>());
|
|
|
|
var jellyfinMusicVideoRepository = Substitute.For<IJellyfinMusicVideoRepository>();
|
|
jellyfinMusicVideoRepository.GetOrAdd(
|
|
Arg.Any<JellyfinLibrary>(),
|
|
Arg.Any<Artist>(),
|
|
Arg.Any<LibraryFolder>(),
|
|
Arg.Any<JellyfinMusicVideo>(),
|
|
Arg.Any<bool>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, MediaItemScanResult<JellyfinMusicVideo>>(
|
|
new MediaItemScanResult<JellyfinMusicVideo>(existing) { IsAdded = false }));
|
|
jellyfinMusicVideoRepository.GetExistingMusicVideos(Arg.Any<JellyfinLibrary>())
|
|
.Returns(new List<JellyfinItemEtag>
|
|
{
|
|
new() { ItemId = existing.ItemId, Etag = existing.Etag, State = MediaItemState.Normal }
|
|
});
|
|
jellyfinMusicVideoRepository.FlagFileNotFound(Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>())
|
|
.Returns(new List<int>());
|
|
|
|
var musicVideoRepository = Substitute.For<IMusicVideoRepository>();
|
|
musicVideoRepository.AddGenre(Arg.Any<MusicVideoMetadata>(), Arg.Any<Genre>()).Returns(true);
|
|
musicVideoRepository.AddTag(Arg.Any<MusicVideoMetadata>(), Arg.Any<Tag>()).Returns(true);
|
|
musicVideoRepository.AddStudio(Arg.Any<MusicVideoMetadata>(), Arg.Any<Studio>()).Returns(true);
|
|
musicVideoRepository.AddArtist(Arg.Any<MusicVideoMetadata>(), Arg.Any<MusicVideoArtist>()).Returns(true);
|
|
musicVideoRepository.RemoveArtist(Arg.Any<MusicVideoArtist>()).Returns(true);
|
|
|
|
var metadataRepository = Substitute.For<IMetadataRepository>();
|
|
metadataRepository.Update(Arg.Any<ErsatzTV.Core.Domain.Metadata>()).Returns(true);
|
|
metadataRepository.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>())
|
|
.Returns(false);
|
|
metadataRepository.RemoveGenre(Arg.Any<Genre>()).Returns(true);
|
|
metadataRepository.RemoveTag(Arg.Any<Tag>()).Returns(true);
|
|
metadataRepository.RemoveStudio(Arg.Any<Studio>()).Returns(true);
|
|
|
|
var libraryRepository = Substitute.For<ILibraryRepository>();
|
|
libraryRepository.GetParentFolderId(Arg.Any<LibraryPath>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<int>.None);
|
|
libraryRepository.GetOrAddFolder(Arg.Any<LibraryPath>(), Arg.Any<Option<int>>(), Arg.Any<string>())
|
|
.Returns(new LibraryFolder { Id = 5, Path = "/data/music/artist1" });
|
|
|
|
var scanner = new JellyfinMusicVideoLibraryScanner(
|
|
BuildScannerProxy(),
|
|
apiClient,
|
|
jellyfinMusicVideoRepository,
|
|
BuildPathReplacement(),
|
|
BuildMediaSourceRepository(),
|
|
artistRepository,
|
|
musicVideoRepository,
|
|
libraryRepository,
|
|
metadataRepository,
|
|
BuildFileSystem(),
|
|
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
|
|
|
|
return (scanner, musicVideoRepository, metadataRepository);
|
|
}
|
|
|
|
private (JellyfinMusicVideoLibraryScanner Scanner, IScannerProxy ScannerProxy) BuildScanner(
|
|
IJellyfinApiClient apiClient)
|
|
{
|
|
IScannerProxy scannerProxy = BuildScannerProxy();
|
|
|
|
var scanner = new JellyfinMusicVideoLibraryScanner(
|
|
scannerProxy,
|
|
apiClient,
|
|
new JellyfinMusicVideoRepository(_db.Factory, NullLogger<JellyfinMusicVideoRepository>.Instance),
|
|
BuildPathReplacement(),
|
|
BuildMediaSourceRepository(),
|
|
new ArtistRepository(_db.Factory),
|
|
new MusicVideoRepository(_db.Factory),
|
|
new LibraryRepository(Substitute.For<IFileSystem>(), _db.Factory),
|
|
Substitute.For<IMetadataRepository>(),
|
|
BuildFileSystem(),
|
|
NullLogger<JellyfinMusicVideoLibraryScanner>.Instance);
|
|
|
|
return (scanner, scannerProxy);
|
|
}
|
|
|
|
private static IScannerProxy BuildScannerProxy()
|
|
{
|
|
var scannerProxy = Substitute.For<IScannerProxy>();
|
|
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
|
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
|
scannerProxy.RemoveMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
|
return scannerProxy;
|
|
}
|
|
|
|
private static IJellyfinPathReplacementService BuildPathReplacement()
|
|
{
|
|
var pathReplacement = Substitute.For<IJellyfinPathReplacementService>();
|
|
pathReplacement
|
|
.GetReplacementJellyfinPath(Arg.Any<List<JellyfinPathReplacement>>(), Arg.Any<string>(), Arg.Any<bool>())
|
|
.Returns(ci => ci.ArgAt<string>(1));
|
|
return pathReplacement;
|
|
}
|
|
|
|
private static IMediaSourceRepository BuildMediaSourceRepository()
|
|
{
|
|
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
|
mediaSourceRepository.GetJellyfinPathReplacements(Arg.Any<int>())
|
|
.Returns(Task.FromResult(new List<JellyfinPathReplacement>()));
|
|
return mediaSourceRepository;
|
|
}
|
|
|
|
// every scanned file "exists" locally, so scanned items settle in Normal and a FileNotFound state can only
|
|
// come from the sweep under test
|
|
private static IFileSystem BuildFileSystem()
|
|
{
|
|
var fileSystem = Substitute.For<IFileSystem>();
|
|
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
|
|
return fileSystem;
|
|
}
|
|
|
|
private async Task<int> MusicVideoId(string path)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
List<MusicVideo> musicVideos = await context.MusicVideos
|
|
.Include(mv => mv.MediaVersions)
|
|
.ThenInclude(v => v.MediaFiles)
|
|
.ToListAsync();
|
|
return musicVideos
|
|
.Single(mv => mv.MediaVersions.Single().MediaFiles.Single().Path == path)
|
|
.Id;
|
|
}
|
|
|
|
private async Task<MediaItemState> MediaItemStateOf(int mediaItemId)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
MediaItem mediaItem = await context.MediaItems.SingleAsync(mi => mi.Id == mediaItemId);
|
|
return mediaItem.State;
|
|
}
|
|
|
|
private async Task<List<string>> MusicVideoPaths(int libraryPathId)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
List<MusicVideo> musicVideos = await context.MusicVideos
|
|
.Include(mv => mv.MediaVersions)
|
|
.ThenInclude(v => v.MediaFiles)
|
|
.Where(mv => mv.LibraryPathId == libraryPathId)
|
|
.ToListAsync();
|
|
return musicVideos
|
|
.Select(mv => mv.MediaVersions.Single().MediaFiles.Single().Path)
|
|
.OrderBy(p => p)
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<List<string>> ArtistTitles(int libraryPathId)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
List<Artist> artists = await context.Artists
|
|
.Include(a => a.ArtistMetadata)
|
|
.Where(a => a.LibraryPathId == libraryPathId)
|
|
.ToListAsync();
|
|
return artists.Select(a => a.ArtistMetadata.Single().Title).OrderBy(t => t).ToList();
|
|
}
|
|
|
|
// Each Func builds a fresh music video so the async stream can be re-enumerated across ScanLibrary calls
|
|
// (an IAsyncEnumerable iterator is single-use, and the scanner mutates the incoming item).
|
|
private static IJellyfinApiClient FakeApi(params Func<JellyfinMusicVideo>[] items)
|
|
{
|
|
var apiClient = Substitute.For<IJellyfinApiClient>();
|
|
apiClient.GetMusicVideoLibraryItems(
|
|
Arg.Any<string>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<JellyfinLibrary>(),
|
|
Arg.Any<MediaServerProjectionFailureCounter>())
|
|
.Returns(_ => Items(items));
|
|
return apiClient;
|
|
}
|
|
|
|
private static async IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> Items(Func<JellyfinMusicVideo>[] items)
|
|
{
|
|
foreach (Func<JellyfinMusicVideo> item in items)
|
|
{
|
|
yield return new Tuple<JellyfinMusicVideo, int>(item(), items.Length);
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private static JellyfinLibrary BuildLibrary(int libraryPathId, string path, int libraryId = 42)
|
|
{
|
|
var libraryPath = new LibraryPath { Id = libraryPathId, Path = path, LibraryFolders = null };
|
|
return new JellyfinLibrary
|
|
{
|
|
Id = libraryId,
|
|
MediaSourceId = 1,
|
|
ItemId = $"lib{libraryId}",
|
|
Name = "Music",
|
|
ShouldSyncItems = true,
|
|
Paths = new List<LibraryPath> { libraryPath }
|
|
};
|
|
}
|
|
|
|
private static JellyfinMusicVideo BuildIncoming(
|
|
string path,
|
|
string artistName,
|
|
string title,
|
|
IEnumerable<string> genres = null,
|
|
IEnumerable<string> tags = null,
|
|
IEnumerable<string> studios = null,
|
|
IEnumerable<string> artists = null) =>
|
|
new()
|
|
{
|
|
ItemId = ItemIdFor(path),
|
|
Etag = $"etag-{title}",
|
|
MediaVersions = new List<MediaVersion>
|
|
{
|
|
new()
|
|
{
|
|
MediaFiles = new List<MediaFile> { new() { Path = path } },
|
|
Streams = new List<MediaStream>(),
|
|
Chapters = new List<MediaChapter>()
|
|
}
|
|
},
|
|
MusicVideoMetadata = new List<MusicVideoMetadata>
|
|
{
|
|
new()
|
|
{
|
|
Title = title,
|
|
Genres = (genres ?? Enumerable.Empty<string>()).Select(g => new Genre { Name = g }).ToList(),
|
|
Tags = (tags ?? Enumerable.Empty<string>()).Select(t => new Tag { Name = t }).ToList(),
|
|
Studios = (studios ?? Enumerable.Empty<string>()).Select(s => new Studio { Name = s }).ToList(),
|
|
Artists = (artists ?? new[] { artistName })
|
|
.Select(a => new MusicVideoArtist { Name = a }).ToList()
|
|
}
|
|
}
|
|
};
|
|
|
|
// Seeds a real JellyfinLibrary row owning the LibraryPath. ersatztv#496 made this load-bearing: identity
|
|
// lookups and the sweep are scoped per LIBRARY (GetExistingMusicVideos / FlagFileNotFound both join
|
|
// LibraryPath.LibraryId), so a dangling LibraryPath with no owning library would silently match nothing.
|
|
private async Task<int> SeedLibraryPath(string path, int libraryId = 42)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
|
|
var libraryPath = new LibraryPath { Path = path };
|
|
var library = new JellyfinLibrary
|
|
{
|
|
Id = libraryId,
|
|
MediaSourceId = 1,
|
|
ItemId = $"lib{libraryId}",
|
|
Name = "Music",
|
|
ShouldSyncItems = true,
|
|
Paths = new List<LibraryPath> { libraryPath }
|
|
};
|
|
|
|
await context.Libraries.AddAsync(library);
|
|
await context.SaveChangesAsync();
|
|
return libraryPath.Id;
|
|
}
|
|
|
|
// a MusicVideo row with NO JellyfinMusicVideo identity — what the pre-#496 scanner (and the local
|
|
// MusicVideoFolderScanner) leaves behind
|
|
private async Task<int> SeedPlainMusicVideo(int libraryPathId, string path, string artistName)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
|
|
var artist = new Artist
|
|
{
|
|
LibraryPathId = libraryPathId,
|
|
ArtistMetadata = new List<ArtistMetadata> { new() { Title = artistName } }
|
|
};
|
|
await context.Artists.AddAsync(artist);
|
|
await context.SaveChangesAsync();
|
|
|
|
var musicVideo = new MusicVideo
|
|
{
|
|
ArtistId = artist.Id,
|
|
LibraryPathId = libraryPathId,
|
|
MusicVideoMetadata = new List<MusicVideoMetadata> { new() { Title = "Song 1" } },
|
|
MediaVersions = new List<MediaVersion>
|
|
{
|
|
new()
|
|
{
|
|
MediaFiles = new List<MediaFile>
|
|
{
|
|
new() { Path = path, PathHash = PathUtils.GetPathHash(path) }
|
|
},
|
|
Streams = new List<MediaStream>()
|
|
}
|
|
}
|
|
};
|
|
await context.MusicVideos.AddAsync(musicVideo);
|
|
await context.SaveChangesAsync();
|
|
|
|
return musicVideo.Id;
|
|
}
|
|
|
|
private async Task<int> SeedCollectionWith(int mediaItemId)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
var collection = new Collection
|
|
{
|
|
Name = "Vaporwave",
|
|
CollectionItems = new List<CollectionItem> { new() { MediaItemId = mediaItemId } }
|
|
};
|
|
await context.Collections.AddAsync(collection);
|
|
await context.SaveChangesAsync();
|
|
return collection.Id;
|
|
}
|
|
}
|