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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user