test(924): pin the deep-scan and no-stored-streams arms of the music-video statistics refresh

A clause sweep showed `deepScan ||` survived (the deep-scan test only ran
against an empty stream list, which the streams arm covers) and that
`result.IsAdded ||` is implied by the empty-streams arm while the
projection carries no streams. The deep-scan test now re-reads statistics
for an item that already has them; a returning-Unavailable test covers the
streams arm alone. IsAdded is kept for parity with the movie scanner and
says so.

Refs #924

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
This commit is contained in:
2026-10-21 22:30:53 +02:00
co-authored by Claude Opus 5.5
parent ec862ce95e
commit f90d9ad24c
2 changed files with 55 additions and 6 deletions
@@ -323,7 +323,9 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
// IMetadataRepository.UpdateStatistics reconciles streams against the version it is given, removed every
// stream an existing row already had. Statistics come from the media server, under the same condition
// MediaServerMovieLibraryScanner.UpdateStatistics uses; when the server cannot supply them nothing is written,
// so stored streams are kept rather than replaced by the projection's empty list.
// so stored streams are kept rather than replaced by the projection's empty list. (`result.IsAdded` is implied
// by the empty-streams arm while the projection carries no streams; it is kept so the condition stays the
// movie scanner's.)
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
@@ -728,9 +728,8 @@ public class JellyfinMusicVideoLibraryScannerTests
// ersatztv#924: Jellyfin's library-items projection carries no streams and no width/height — those come only
// from the per-item playback info. These run against the REAL MetadataRepository so the assertions read what
// was persisted. Non-vacuous: restoring the scanner's former
// `UpdateStatistics(result.Item, incoming.GetHeadVersion())` reddens the three `..._Statistics_...` tests
// below — the first two on the missing video stream, the third on the removed stream.
// was persisted. Non-vacuous (measured 2026-10-21): restoring the scanner's former body,
// `UpdateStatistics(result.Item, incoming.GetHeadVersion())`, reddens all five tests below.
[Test]
public async Task ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo()
{
@@ -785,11 +784,14 @@ public class JellyfinMusicVideoLibraryScannerTests
var playbackCalls = 0;
var serverAnswers = false;
(int Width, int Height) size = (1280, 720);
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
_ =>
{
playbackCalls++;
return serverAnswers ? PlaybackInfo(1280, 720) : BaseError.New("playback info unavailable");
return serverAnswers
? PlaybackInfo(size.Width, size.Height)
: BaseError.New("playback info unavailable");
},
() => BuildIncoming(VideoPath, "Artist 1", "Song 1"));
@@ -817,6 +819,51 @@ public class JellyfinMusicVideoLibraryScannerTests
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
version.Width.ShouldBe(1280);
version.Height.ShouldBe(720);
// 4. a deep scan re-reads them even when streams are already stored and the etag is unchanged
size = (1920, 1080);
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: true, CancellationToken.None))
.IsRight.ShouldBeTrue();
playbackCalls.ShouldBe(3);
(await HeadVersionOf(VideoPath)).Width.ShouldBe(1920);
}
// The "no stored streams" arm of the refresh condition: an Unavailable item whose file is back is scanned with
// an UNCHANGED etag and no deep scan, so only the empty stream list says its statistics must be read.
[Test]
public async Task ScanLibrary_Should_Read_Statistics_For_A_Returning_Unavailable_MusicVideo_Without_Streams()
{
int libraryPathId = await SeedLibraryPath("/data/music");
JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music");
const string VideoPath = "/data/music/artist1/song1.mkv";
var serverAnswers = false;
IJellyfinApiClient api = FakeApiWithPlaybackInfo(
_ => serverAnswers ? PlaybackInfo(1280, 720) : BaseError.New("playback info unavailable"),
() => BuildIncoming(VideoPath, "Artist 1", "Song 1"));
(JellyfinMusicVideoLibraryScanner scanner, _) =
BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory));
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
int musicVideoId = await MusicVideoId(VideoPath);
await using (TvContext context = _db.CreateContext())
{
MediaItem mediaItem = await context.MediaItems.SingleAsync(mi => mi.Id == musicVideoId);
mediaItem.State = MediaItemState.Unavailable;
await context.SaveChangesAsync();
}
serverAnswers = true;
(await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None))
.IsRight.ShouldBeTrue();
MediaVersion version = await HeadVersionOf(VideoPath);
version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1);
version.Width.ShouldBe(1280);
(await MediaItemStateOf(musicVideoId)).ShouldBe(MediaItemState.Normal);
}
// When the server cannot supply statistics on a rescan, nothing is written: the projection's empty stream
@@ -873,7 +920,7 @@ public class JellyfinMusicVideoLibraryScannerTests
// Refreshed statistics take their chapters from the INCOMING projection: the repository does not eager-load
// chapters on an existing row, so JellyfinMovieLibraryScanner's source (the stored item) would have none to
// write. This pins that choice; the former code also stored the projection's chapters, so it is green there.
// write.
[Test]
public async Task ScanLibrary_Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes()
{