fix(924): media-server music videos read their statistics from the server's playback info

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 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
This commit is contained in:
2026-10-21 22:28:23 +02:00
co-authored by Claude Opus 5.5
parent b8ea62bfa0
commit ec862ce95e
5 changed files with 415 additions and 11 deletions
@@ -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<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(),
new MockFileSystem(),
Substitute.For<ILanguageCodeService>(),
Substitute.For<ILogger<FFmpegStreamSelector>>());
[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<InvalidOperationException>(() => 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<InvalidOperationException>(() => Selector().SelectVideoStream(version));
ex.Message.ShouldContain("Media version 7 ((no media file)) has no video stream");
}
}
}
+26 -4
View File
@@ -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<MediaStream> 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<MediaStream> 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<MediaStream>(
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<Option<MediaStream>> SelectAudioStream(
MediaItemAudioVersion version,
@@ -115,7 +137,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}
break;
// let default fall through
// let default fall through
}
}
catch (Exception ex)
@@ -19,6 +19,7 @@ public class JellyfinMusicVideoLibraryScanner :
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMusicVideoRepository _jellyfinMusicVideoRepository;
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _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<Either<BaseError, Unit>> ScanLibrary(
@@ -97,6 +99,41 @@ public class JellyfinMusicVideoLibraryScanner :
library,
projectionFailures);
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Jellyfin Statistics", result.LocalPath);
Either<BaseError, MediaVersion> 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<Either<BaseError, MediaItemScanResult<JellyfinMusicVideo>>> UpdateMetadata(
MediaItemScanResult<JellyfinMusicVideo> result,
JellyfinMusicVideo incoming,
@@ -131,6 +131,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
Either<BaseError, MediaItemScanResult<TMusicVideo>> maybeMusicVideo =
await ProcessMusicVideo(
musicVideoRepository,
connectionParameters,
library,
libraryPath,
incoming,
@@ -250,6 +251,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
private async Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> ProcessMusicVideo(
IMediaServerMusicVideoRepository<TLibrary, TMusicVideo, TEtag> musicVideoRepository,
TConnectionParameters connectionParameters,
TLibrary library,
LibraryPath libraryPath,
TMusicVideo incoming,
@@ -279,7 +281,7 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
return result;
})
.BindT(result => UpdateMetadata(result, incoming, cancellationToken))
.BindT(result => UpdateStatistics(result, incoming));
.BindT(result => UpdateStatistics(connectionParameters, library, result, incoming, deepScan));
}
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, TMusicVideo musicVideo)
@@ -315,13 +317,38 @@ public abstract class MediaServerMusicVideoLibraryScanner<TConnectionParameters,
return result.Map(scanResult => 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<Either<BaseError, MediaItemScanResult<TMusicVideo>>> UpdateStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMusicVideo> 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<MediaVersion> 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<TConnectionParameters,
MediaItemScanResult<TMusicVideo> 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<Option<MediaVersion>> GetMediaServerStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMusicVideo> result,
TMusicVideo incoming);
}
@@ -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<string>();
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<MediaChapter> 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<BaseError, MediaVersion> 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<MediaVersion> HeadVersionOf(string path)
{
await using TvContext context = _db.CreateContext();
List<MusicVideo> 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<string, string> replacePath = null)
Func<string, string> 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<IFileSystem>(), _db.Factory),
Substitute.For<IMetadataRepository>(),
metadataRepository ?? Substitute.For<IMetadataRepository>(),
BuildFileSystem(),
NullLogger<JellyfinMusicVideoLibraryScanner>.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<JellyfinMusicVideo>[] items)
// Playback info succeeds by default (ersatztv#924: the scanner reads statistics from it for every scanned item).
private static IJellyfinApiClient FakeApi(params Func<JellyfinMusicVideo>[] 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<string, Either<BaseError, MediaVersion>> playbackInfo,
params Func<JellyfinMusicVideo>[] items)
{
var apiClient = Substitute.For<IJellyfinApiClient>();
apiClient.GetMusicVideoLibraryItems(
@@ -921,9 +1161,16 @@ public class JellyfinMusicVideoLibraryScannerTests
Arg.Any<JellyfinLibrary>(),
Arg.Any<MediaServerProjectionFailureCounter>())
.Returns(_ => Items(items));
StubPlaybackInfo(apiClient, playbackInfo);
return apiClient;
}
private static void StubPlaybackInfo(
IJellyfinApiClient apiClient,
Func<string, Either<BaseError, MediaVersion>> playbackInfo) =>
apiClient.GetPlaybackInfo(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<JellyfinLibrary>(), Arg.Any<string>())
.Returns(ci => Task.FromResult(playbackInfo(ci.ArgAt<string>(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<MediaServerProjectionFailureCounter>(3),
failures));
StubPlaybackInfo(apiClient, _ => PlaybackInfo(1920, 1080));
return apiClient;
}