Files
ersatztv/ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs
T
timothyandClaude Opus 5.5 1f7b763a7b
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m30s
fix(924): media-server music videos read their statistics from the server's playback info (#928)
Jellyfin's library-items projection builds every music video's MediaVersion with Streams = [] and no width/height; the server reports those only through per-item PlaybackInfo. MediaServerMusicVideoLibraryScanner.UpdateStatistics wrote that empty projection straight through (also removing any streams a stored row already had), so every Jellyfin music video was stored without streams and FFmpegStreamSelector.SelectVideoStream threw "Sequence contains no matching element" on play. Prod 2026-10-21: the Jellyfin "Music Videos" library (id 16), 1520/1520 versions with zero MediaStream rows.

- UpdateStatistics mirrors the movie scanner: on deep scan, new item, changed etag or no stored streams it asks the abstract GetMediaServerStatistics, and writes nothing when the server cannot answer.
- JellyfinMusicVideoLibraryScanner implements it via GetPlaybackInfo; chapters come from the incoming projection.
- SelectVideoStream fails with the version id, file and remediation (deep scan, or remove a file that has no video stream) instead of a bare First() throw.
- Other-video and TV scanners checked: no such gap.
- Record scan.mediaserver-statistics-from-server.

Existing rows heal only on a DEEP scan (a regular scan skips unchanged etags): POST /api/v1/libraries/16/scan?deep=true after deploy.

fixes #924

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: 99d1dedb-f027-4e05-baab-2111f3a7d951
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-10-22 06:24:18 +00:00

256 lines
9.3 KiB
C#

using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Scripting;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class FFmpegStreamSelectorTests
{
[TestFixture]
public class SelectAudioStream
{
[Test]
[CancelAfter(1000)]
public async Task Should_Select_Audio_Stream_With_Preferred_Language(CancellationToken cancellationToken)
{
// skip movie/episode script paths by using other video
var mediaItem = new OtherVideo();
var mediaVersion = new MediaVersion
{
Streams =
[
new MediaStream
{
Index = 0,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 2,
Language = "ja",
Title = "Some Title"
},
new MediaStream
{
Index = 1,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 6,
Language = "eng",
Title = "Another Title",
Default = true
}
]
};
var audioVersion = new MediaItemAudioVersion(mediaItem, mediaVersion);
var channel = new Channel(Guid.NewGuid())
{
PreferredAudioLanguageCode = "eng"
};
ILanguageCodeService languageCodeService = Substitute.For<ILanguageCodeService>();
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>())
.Returns(["jpn"]);
var selector = new FFmpegStreamSelector(
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(),
new MockFileSystem(),
languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>());
Option<MediaStream> selectedStream = await selector.SelectAudioStream(
audioVersion,
StreamingMode.TransportStream,
channel,
"jpn",
"Whatever",
cancellationToken);
selectedStream.IsSome.ShouldBeTrue();
foreach (MediaStream stream in selectedStream)
{
stream.Language.ShouldBe("ja");
}
}
[Test]
[CancelAfter(1000)]
public async Task Should_Select_Audio_Stream_With_Preferred_Title(CancellationToken cancellationToken)
{
// skip movie/episode script paths by using other video
var mediaItem = new OtherVideo();
var mediaVersion = new MediaVersion
{
Streams =
[
new MediaStream
{
Index = 0,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 2,
Language = "ja",
Title = "Some Title"
},
new MediaStream
{
Index = 1,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 6,
Language = "eng",
Title = "Another Title",
Default = true
}
]
};
var audioVersion = new MediaItemAudioVersion(mediaItem, mediaVersion);
var channel = new Channel(Guid.NewGuid())
{
PreferredAudioTitle = "Some"
};
ILanguageCodeService languageCodeService = Substitute.For<ILanguageCodeService>();
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>())
.Returns(["jpn", "eng"]);
var selector = new FFmpegStreamSelector(
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(),
new MockFileSystem(),
languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>());
Option<MediaStream> selectedStream = await selector.SelectAudioStream(
audioVersion,
StreamingMode.TransportStream,
channel,
null,
channel.PreferredAudioTitle,
cancellationToken);
selectedStream.IsSome.ShouldBeTrue();
foreach (MediaStream stream in selectedStream)
{
stream.Language.ShouldBe("ja");
}
}
[Test]
[CancelAfter(1000)]
public async Task Should_Select_Subtitle_Stream_With_Preferred_Language(CancellationToken cancellationToken)
{
// skip movie/episode script paths by using other video
var subtitles = new List<Subtitle>
{
new()
{
StreamIndex = 0,
SubtitleKind = SubtitleKind.Sidecar,
Language = "eng",
Default = true
},
new()
{
StreamIndex = 1,
SubtitleKind = SubtitleKind.Sidecar,
Language = "he"
}
};
var channel = new Channel(Guid.NewGuid());
ILanguageCodeService languageCodeService = Substitute.For<ILanguageCodeService>();
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>())
.Returns(["heb"]);
var selector = new FFmpegStreamSelector(
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(),
new MockFileSystem(),
languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>());
Option<Subtitle> selectedStream = await selector.SelectSubtitleStream(
subtitles.ToImmutableList(),
channel,
"heb",
ChannelSubtitleMode.Any,
cancellationToken);
selectedStream.IsSome.ShouldBeTrue();
foreach (Subtitle stream in selectedStream)
{
stream.Language.ShouldBe("he");
}
}
}
// 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");
}
}
}