From ec862ce95e77b2e3c103b6251ba4cdd8f08bba6f Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 21 Oct 2026 22:28:23 +0200 Subject: [PATCH] fix(924): media-server music videos read their statistics from the server's playback info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jellyfin's library-items projection builds every music video's version with Streams = [] and no width/height; the server reports those only through per-item PlaybackInfo. MediaServerMovieLibraryScanner fetches them via GetMediaServerStatistics, but MediaServerMusicVideoLibraryScanner (#496) wrote incoming.GetHeadVersion() straight into UpdateStatistics. On prod (2026-10-21) all 1520 music videos in library 8 had zero MediaStream rows, and HLS start on channels 402/403 threw "Sequence contains no matching element" from FFmpegStreamSelector.SelectVideoStream. - MediaServerMusicVideoLibraryScanner.UpdateStatistics now mirrors the movie scanner: under deepScan / added / etag changed / no stored streams it asks the abstract GetMediaServerStatistics, and writes nothing when the server cannot answer. Writing the projection also removed any streams a row already had, since UpdateStatistics reconciles against the given version. Abstract, not virtual-None: a None default is this bug. - JellyfinMusicVideoLibraryScanner implements it with GetPlaybackInfo and takes chapters from the incoming projection (GetByItemId does not load chapters, so the movie scanner's result.Item source would have none). - FFmpegStreamSelector.SelectVideoStream fails with an error naming the version id and file and the remediation (rescan; deep scan for a media-server library) instead of First()'s bare throw. File de-BOMed and whitespace-normalized as touched. Other-video (Plex only, ServerReturnsStatisticsWithMetadata) and TV (Jellyfin/Emby override GetMediaServerStatistics, Plex returns statistics with metadata) have no such gap. The rows already stored without streams heal only on a deep scan of the library (a regular scan skips unchanged etags) — a channel operation owned by media-management. Refs #924 Co-Authored-By: Claude Opus 5.5 Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951 --- .../FFmpeg/FFmpegStreamSelectorTests.cs | 61 +++++ ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs | 30 ++- .../JellyfinMusicVideoLibraryScanner.cs | 37 +++ .../MediaServerMusicVideoLibraryScanner.cs | 44 ++- .../JellyfinMusicVideoLibraryScannerTests.cs | 254 +++++++++++++++++- 5 files changed, 415 insertions(+), 11 deletions(-) diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs index 07a2cba0a..dbcde233a 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs @@ -191,4 +191,65 @@ public class FFmpegStreamSelectorTests } } } + + // ersatztv#924: a version stored without a video stream (Jellyfin music videos whose statistics were never + // read) used to surface First()'s bare "Sequence contains no matching element" from HLS session start. + [TestFixture] + public class SelectVideoStream + { + private static FFmpegStreamSelector Selector() => + new( + new ScriptEngine(Substitute.For>()), + Substitute.For(), + Substitute.For(), + new MockFileSystem(), + Substitute.For(), + Substitute.For>()); + + [Test] + public async Task Should_Select_The_Video_Stream() + { + var version = new MediaVersion + { + Id = 12, + Streams = + [ + new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Audio }, + new MediaStream { Index = 1, MediaStreamKind = MediaStreamKind.Video, Codec = "h264" } + ] + }; + + MediaStream stream = await Selector().SelectVideoStream(version); + + stream.Index.ShouldBe(1); + } + + [Test] + public async Task Should_Name_The_Version_And_File_When_There_Is_No_Video_Stream() + { + var version = new MediaVersion + { + Id = 1520, + MediaFiles = [new MediaFile { Path = "/data/music/artist1/song1.mkv" }], + Streams = [new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Audio }] + }; + + InvalidOperationException ex = + await Should.ThrowAsync(() => Selector().SelectVideoStream(version)); + + ex.Message.ShouldContain("Media version 1520 (/data/music/artist1/song1.mkv) has no video stream"); + ex.Message.ShouldContain("deep scan"); + } + + [Test] + public async Task Should_Fail_Clearly_When_Streams_And_Files_Were_Never_Loaded() + { + var version = new MediaVersion { Id = 7, MediaFiles = null, Streams = null }; + + InvalidOperationException ex = + await Should.ThrowAsync(() => Selector().SelectVideoStream(version)); + + ex.Message.ShouldContain("Media version 7 ((no media file)) has no video stream"); + } + } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs index 401650649..5bc356fae 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -38,8 +38,30 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector _logger = logger; } - public Task SelectVideoStream(MediaVersion version) => - version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask(); + // ersatztv#924: a version with no video stream on record (its statistics were never read, or were stored + // empty) cannot be played. Name the version and its file and say how the statistics get populated, instead + // of surfacing First()'s bare "Sequence contains no matching element". + public Task SelectVideoStream(MediaVersion version) + { + foreach (MediaStream videoStream in Optional(version.Streams).Flatten() + .Filter(s => s.MediaStreamKind == MediaStreamKind.Video) + .HeadOrNone()) + { + return videoStream.AsTask(); + } + + string path = Optional(version.MediaFiles).Flatten() + .HeadOrNone() + .Map(f => f.Path) + .IfNone("(no media file)"); + + return Task.FromException( + new InvalidOperationException( + $"Media version {version.Id} ({path}) has no video stream on record, so it cannot be played. " + + "Its media statistics have not been read: rescan the item's library to read them (for a " + + "Jellyfin/Emby/Plex library, a deep scan, since a regular scan skips items the server reports " + + "unchanged).")); + } public async Task> SelectAudioStream( MediaItemAudioVersion version, @@ -115,7 +137,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector } break; - // let default fall through + // let default fall through } } catch (Exception ex) diff --git a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs index 58a899ff0..b552572b2 100644 --- a/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs @@ -19,6 +19,7 @@ public class JellyfinMusicVideoLibraryScanner : { private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository; + private readonly ILogger _logger; private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMetadataRepository _metadataRepository; private readonly IMusicVideoRepository _musicVideoRepository; @@ -50,6 +51,7 @@ public class JellyfinMusicVideoLibraryScanner : _mediaSourceRepository = mediaSourceRepository; _musicVideoRepository = musicVideoRepository; _metadataRepository = metadataRepository; + _logger = logger; } public async Task> ScanLibrary( @@ -97,6 +99,41 @@ public class JellyfinMusicVideoLibraryScanner : library, projectionFailures); + protected override async Task> GetMediaServerStatistics( + JellyfinConnectionParameters connectionParameters, + JellyfinLibrary library, + MediaItemScanResult result, + JellyfinMusicVideo incoming) + { + _logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath); + + Either maybeVersion = + await _jellyfinApiClient.GetPlaybackInfo( + connectionParameters.Address, + connectionParameters.AuthorizationHeader, + library, + incoming.ItemId); + + foreach (BaseError error in maybeVersion.LeftToSeq()) + { + _logger.LogWarning( + "Failed to get music video statistics from Jellyfin for {Path}: {Error}", + result.LocalPath, + error.ToString()); + } + + // Chapters arrive with the library-items projection, not with playback info. They are taken from the + // INCOMING projection, not from result.Item as JellyfinMovieLibraryScanner does: the music-video + // repository's GetByItemId does not eager-load chapters, so on an existing row result.Item's head version + // carries none to write. + foreach (MediaVersion version in maybeVersion.RightToSeq()) + { + version.Chapters = incoming.GetHeadVersion().Chapters ?? []; + } + + return maybeVersion.ToOption(); + } + protected override async Task>> UpdateMetadata( MediaItemScanResult result, JellyfinMusicVideo incoming, diff --git a/ErsatzTV.Scanner/Core/Metadata/MediaServerMusicVideoLibraryScanner.cs b/ErsatzTV.Scanner/Core/Metadata/MediaServerMusicVideoLibraryScanner.cs index 8eb9dbfa5..4b1c22611 100644 --- a/ErsatzTV.Scanner/Core/Metadata/MediaServerMusicVideoLibraryScanner.cs +++ b/ErsatzTV.Scanner/Core/Metadata/MediaServerMusicVideoLibraryScanner.cs @@ -131,6 +131,7 @@ public abstract class MediaServerMusicVideoLibraryScanner> maybeMusicVideo = await ProcessMusicVideo( musicVideoRepository, + connectionParameters, library, libraryPath, incoming, @@ -250,6 +251,7 @@ public abstract class MediaServerMusicVideoLibraryScanner>> ProcessMusicVideo( IMediaServerMusicVideoRepository musicVideoRepository, + TConnectionParameters connectionParameters, TLibrary library, LibraryPath libraryPath, TMusicVideo incoming, @@ -279,7 +281,7 @@ public abstract class MediaServerMusicVideoLibraryScanner UpdateMetadata(result, incoming, cancellationToken)) - .BindT(result => UpdateStatistics(result, incoming)); + .BindT(result => UpdateStatistics(connectionParameters, library, result, incoming, deepScan)); } private async Task> GetOrAddArtist(LibraryPath libraryPath, TMusicVideo musicVideo) @@ -315,13 +317,38 @@ public abstract class MediaServerMusicVideoLibraryScanner scanResult.Item); } + // ersatztv#924: the library-items projection carries NO streams and no width/height (the server only reports + // those through its per-item playback info), so the projection's version must never be written as the + // statistics — doing so stored a version FFmpegStreamSelector cannot play, and, because + // 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. private async Task>> UpdateStatistics( + TConnectionParameters connectionParameters, + TLibrary library, MediaItemScanResult result, - TMusicVideo incoming) + TMusicVideo incoming, + bool deepScan) { - if (await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion())) + TMusicVideo existing = result.Item; + + if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || + existing.GetHeadVersion().Streams is not { Count: > 0 }) { - result.IsUpdated = true; + Option maybeMediaVersion = await GetMediaServerStatistics( + connectionParameters, + library, + result, + incoming); + + foreach (MediaVersion mediaVersion in maybeMediaVersion) + { + if (await _metadataRepository.UpdateStatistics(result.Item, mediaVersion)) + { + result.IsUpdated = true; + } + } } return result; @@ -405,4 +432,13 @@ public abstract class MediaServerMusicVideoLibraryScanner result, TMusicVideo incoming, CancellationToken cancellationToken); + + // ersatztv#924: abstract rather than the movie scanner's virtual-returning-None, because a None default is + // exactly the defect — an implementation that forgot to override it would scan music videos with no streams. + // The returned version carries the chapters to store alongside the statistics. + protected abstract Task> GetMediaServerStatistics( + TConnectionParameters connectionParameters, + TLibrary library, + MediaItemScanResult result, + TMusicVideo incoming); } diff --git a/ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.cs b/ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.cs index 552ae640e..36d0ee25d 100644 --- a/ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.cs +++ b/ErsatzTV.Tests/Integration/JellyfinMusicVideoLibraryScannerTests.cs @@ -726,6 +726,238 @@ public class JellyfinMusicVideoLibraryScannerTests metadata.Artists.Count.ShouldBe(1); } + // 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. + [Test] + public async Task ScanLibrary_Should_Store_PlaybackInfo_Statistics_For_A_New_MusicVideo() + { + int libraryPathId = await SeedLibraryPath("/data/music"); + const string VideoPath = "/data/music/artist1/song1.mkv"; + + JellyfinMusicVideo Incoming() + { + JellyfinMusicVideo musicVideo = BuildIncoming(VideoPath, "Artist 1", "Song 1"); + musicVideo.MediaVersions[0].Chapters = [Chapter(0, "Intro")]; + return musicVideo; + } + + var playbackCalls = new List(); + IJellyfinApiClient api = FakeApiWithPlaybackInfo( + itemId => + { + playbackCalls.Add(itemId); + return PlaybackInfo(1920, 1080); + }, + Incoming); + + (JellyfinMusicVideoLibraryScanner scanner, _) = + BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory)); + + (await scanner.ScanLibrary( + ConnectionParameters, + BuildLibrary(libraryPathId, "/data/music"), + deepScan: false, + CancellationToken.None)).IsRight.ShouldBeTrue(); + + playbackCalls.ShouldBe([ItemIdFor(VideoPath)]); + + MediaVersion version = await HeadVersionOf(VideoPath); + version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1); + version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Audio).ShouldBe(1); + version.Width.ShouldBe(1920); + version.Height.ShouldBe(1080); + + // the projection's chapters are stored alongside the statistics, not dropped by them + version.Chapters.Select(c => c.Title).ShouldBe(["Intro"]); + } + + // The remediation path for rows already stored without streams: a regular scan skips an item whose etag is + // unchanged (so it cannot heal them), and a deep scan re-reads the statistics. + [Test] + public async Task ScanLibrary_Deep_Scan_Should_Read_Statistics_For_An_Unchanged_MusicVideo_Without_Streams() + { + int libraryPathId = await SeedLibraryPath("/data/music"); + JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); + const string VideoPath = "/data/music/artist1/song1.mkv"; + + var playbackCalls = 0; + var serverAnswers = false; + IJellyfinApiClient api = FakeApiWithPlaybackInfo( + _ => + { + playbackCalls++; + return serverAnswers ? PlaybackInfo(1280, 720) : BaseError.New("playback info unavailable"); + }, + () => BuildIncoming(VideoPath, "Artist 1", "Song 1")); + + (JellyfinMusicVideoLibraryScanner scanner, _) = + BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory)); + + // 1. the item is added while the server cannot supply statistics: stored with no streams + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + (await HeadVersionOf(VideoPath)).Streams.ShouldBeEmpty(); + playbackCalls.ShouldBe(1); + + // 2. a regular scan of the unchanged item does not look at it again + serverAnswers = true; + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + playbackCalls.ShouldBe(1); + (await HeadVersionOf(VideoPath)).Streams.ShouldBeEmpty(); + + // 3. a deep scan reads the statistics + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: true, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + playbackCalls.ShouldBe(2); + MediaVersion version = await HeadVersionOf(VideoPath); + version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1); + version.Width.ShouldBe(1280); + version.Height.ShouldBe(720); + } + + // When the server cannot supply statistics on a rescan, nothing is written: the projection's empty stream + // list must not replace the streams already stored (IMetadataRepository.UpdateStatistics removes every + // stored stream the version it is given does not carry). + [Test] + public async Task ScanLibrary_Should_Keep_Stored_Statistics_When_PlaybackInfo_Fails_On_Rescan() + { + int libraryPathId = await SeedLibraryPath("/data/music"); + JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); + const string VideoPath = "/data/music/artist1/song1.mkv"; + + var title = "Song 1"; + IJellyfinApiClient api = FakeApiWithPlaybackInfo( + _ => BaseError.New("playback info unavailable"), + () => BuildIncoming(VideoPath, "Artist 1", title)); + + (JellyfinMusicVideoLibraryScanner scanner, _) = + BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory)); + + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + + // the row holds statistics from an earlier successful read + int versionId = (await HeadVersionOf(VideoPath)).Id; + await using (TvContext context = _db.CreateContext()) + { + MediaVersion stored = await context.MediaVersions.SingleAsync(v => v.Id == versionId); + stored.Width = 1920; + stored.Height = 1080; + await context.MediaStreams.AddAsync( + new MediaStream + { + MediaVersionId = versionId, + Index = 0, + MediaStreamKind = MediaStreamKind.Video, + Codec = "h264" + }); + await context.SaveChangesAsync(); + } + + // the server reports the item changed (BuildIncoming derives the etag from the title), but playback + // info fails + title = "Song 1 (Remastered)"; + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + + MediaVersion version = await HeadVersionOf(VideoPath); + version.Streams.Select(s => (s.Index, s.MediaStreamKind, s.Codec)) + .ShouldBe([(0, MediaStreamKind.Video, "h264")]); + version.Width.ShouldBe(1920); + version.Height.ShouldBe(1080); + } + + // 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. + [Test] + public async Task ScanLibrary_Should_Refresh_Statistics_And_Projection_Chapters_When_The_Etag_Changes() + { + int libraryPathId = await SeedLibraryPath("/data/music"); + JellyfinLibrary library = BuildLibrary(libraryPathId, "/data/music"); + const string VideoPath = "/data/music/artist1/song1.mkv"; + + var title = "Song 1"; + List chapters = [Chapter(0, "Intro")]; + (int Width, int Height) size = (1280, 720); + + JellyfinMusicVideo Incoming() + { + JellyfinMusicVideo musicVideo = BuildIncoming(VideoPath, "Artist 1", title); + musicVideo.MediaVersions[0].Chapters = chapters.Select(c => Chapter(c.ChapterId, c.Title)).ToList(); + return musicVideo; + } + + IJellyfinApiClient api = FakeApiWithPlaybackInfo(_ => PlaybackInfo(size.Width, size.Height), Incoming); + (JellyfinMusicVideoLibraryScanner scanner, _) = + BuildScanner(api, metadataRepository: new MetadataRepository(_db.Factory)); + + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + + title = "Song 1 (Remastered)"; + chapters = [Chapter(0, "Intro"), Chapter(1, "Chorus")]; + size = (1920, 1080); + (await scanner.ScanLibrary(ConnectionParameters, library, deepScan: false, CancellationToken.None)) + .IsRight.ShouldBeTrue(); + + MediaVersion version = await HeadVersionOf(VideoPath); + version.Width.ShouldBe(1920); + version.Height.ShouldBe(1080); + version.Streams.Count(s => s.MediaStreamKind == MediaStreamKind.Video).ShouldBe(1); + version.Chapters.OrderBy(c => c.ChapterId).Select(c => c.Title).ShouldBe(["Intro", "Chorus"]); + } + + private static MediaChapter Chapter(long chapterId, string title) => + new() + { + ChapterId = chapterId, + Title = title, + StartTime = TimeSpan.FromSeconds(chapterId * 30), + EndTime = TimeSpan.FromSeconds((chapterId + 1) * 30) + }; + + // What JellyfinApiClient.GetPlaybackInfo returns for a playable file: a version carrying the streams and the + // dimensions. Built fresh per call, since UpdateStatistics attaches the stream objects to the stored version. + private static Either PlaybackInfo(int width, int height) => + new MediaVersion + { + Duration = TimeSpan.FromMinutes(4), + Width = width, + Height = height, + SampleAspectRatio = "1:1", + DisplayAspectRatio = "16:9", + RFrameRate = "23.976", + VideoScanKind = VideoScanKind.Progressive, + Streams = + [ + new MediaStream { Index = 0, MediaStreamKind = MediaStreamKind.Video, Codec = "h264" }, + new MediaStream { Index = 1, MediaStreamKind = MediaStreamKind.Audio, Codec = "aac", Channels = 2 } + ], + Chapters = [] + }; + + private async Task HeadVersionOf(string path) + { + await using TvContext context = _db.CreateContext(); + List musicVideos = await context.MusicVideos + .Include(mv => mv.MediaVersions) + .ThenInclude(v => v.MediaFiles) + .Include(mv => mv.MediaVersions) + .ThenInclude(v => v.Streams) + .Include(mv => mv.MediaVersions) + .ThenInclude(v => v.Chapters) + .ToListAsync(); + return musicVideos + .Single(mv => mv.MediaVersions.Single().MediaFiles.Single().Path == path) + .MediaVersions + .Single(); + } + private static JellyfinConnectionParameters ConnectionParameters => new("http://jellyfin", "api-key", 1); @@ -810,7 +1042,8 @@ public class JellyfinMusicVideoLibraryScannerTests private (JellyfinMusicVideoLibraryScanner Scanner, IScannerProxy ScannerProxy) BuildScanner( IJellyfinApiClient apiClient, - Func replacePath = null) + Func replacePath = null, + IMetadataRepository metadataRepository = null) { IScannerProxy scannerProxy = BuildScannerProxy(); @@ -823,7 +1056,7 @@ public class JellyfinMusicVideoLibraryScannerTests new ArtistRepository(_db.Factory), new MusicVideoRepository(_db.Factory), new LibraryRepository(Substitute.For(), _db.Factory), - Substitute.For(), + metadataRepository ?? Substitute.For(), BuildFileSystem(), NullLogger.Instance); @@ -912,7 +1145,14 @@ public class JellyfinMusicVideoLibraryScannerTests // 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[] items) + // Playback info succeeds by default (ersatztv#924: the scanner reads statistics from it for every scanned item). + private static IJellyfinApiClient FakeApi(params Func[] items) => + FakeApiWithPlaybackInfo(_ => PlaybackInfo(1920, 1080), items); + + // `playbackInfo` answers GetPlaybackInfo per item id, standing in for the server's per-item playback info. + private static IJellyfinApiClient FakeApiWithPlaybackInfo( + Func> playbackInfo, + params Func[] items) { var apiClient = Substitute.For(); apiClient.GetMusicVideoLibraryItems( @@ -921,9 +1161,16 @@ public class JellyfinMusicVideoLibraryScannerTests Arg.Any(), Arg.Any()) .Returns(_ => Items(items)); + StubPlaybackInfo(apiClient, playbackInfo); return apiClient; } + private static void StubPlaybackInfo( + IJellyfinApiClient apiClient, + Func> playbackInfo) => + apiClient.GetPlaybackInfo(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => Task.FromResult(playbackInfo(ci.ArgAt(3)))); + // 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( @@ -940,6 +1187,7 @@ public class JellyfinMusicVideoLibraryScannerTests items, ci.ArgAt(3), failures)); + StubPlaybackInfo(apiClient, _ => PlaybackInfo(1920, 1080)); return apiClient; }