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
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>
256 lines
9.3 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|