From f94a440b629e9ec623ad4a43cde8e14ca1b91c48 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 27 Jun 2025 06:40:06 -0500 Subject: [PATCH] stream selector improvements (#2077) * add tests for audio blocklist and audio allowlist * add subtitle allow list and block list * add subtitle condition * add audio condition * cache bust mudblazor css --- CHANGELOG.md | 15 ++ .../FFmpeg/CustomStreamSelectorTests.cs | 242 +++++++++++++++++- ErsatzTV.Core/ErsatzTV.Core.csproj | 1 + ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs | 81 +++++- .../FFmpeg/Selector/StreamSelectorItem.cs | 16 +- ErsatzTV/Pages/_Host.cshtml | 2 +- docker/arm64/Dockerfile | 2 +- 7 files changed, 347 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cae67a87..9eaafe5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - As an example, the custom stream selector config can specify (in priority order): - english audio (and disable subtitles) - any other audio (and english subtitles, if they exist) + - Criteria can include + - Stream language + - Stream title (allowed title and/or blocked title) + - Stream condition, which is an expression that can use + - `id` (index) + - `title` + - `lang` + - `default` + - `forced` + - `sdh` (subtitle only) + - `external` (subtitle only) + - `codec` + - `channels` (audio only) + - An example subtitle condition: `lang like 'en%' and external` + - An example audio condition: `title like '%movie%' and channels > 2` ### Fixed - Fix QSV acceleration in docker with older Intel devices diff --git a/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs index 79ffe0a94..dd19ab28a 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs @@ -33,7 +33,10 @@ public class CustomStreamSelectorTests _subtitles = [ - new Subtitle { Id = 1, Language = "eng", Title = "Words" } + new Subtitle { Id = 1, Language = "eng", Title = "Words", SubtitleKind = SubtitleKind.Embedded }, + new Subtitle { Id = 2, Language = "en", Title = "Signs" }, + new Subtitle { Id = 3, Language = "en", Title = "Songs" }, + new Subtitle { Id = 4, Language = "en", Forced = true, SubtitleKind = SubtitleKind.Sidecar } ]; } @@ -286,7 +289,7 @@ items: } [Test] - public async Task Should_Select_no_Subtitle_Exact_Match_Multiple_Items() + public async Task Should_Select_No_Subtitle_Exact_Match_Multiple_Items() { const string YAML = """ @@ -357,6 +360,230 @@ items: } } + [Test] + public async Task Should_Ignore_Blocked_Audio_Title() + { + const string YAML = +""" +--- +items: + - audio_language: + - "en*" + audio_title_blocklist: + - "riff" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(2); + audioStream.Language.ShouldBe("eng"); + } + } + + [Test] + public async Task Should_Select_Allowed_Audio_Title() + { + const string YAML = +""" +--- +items: + - audio_language: + - "en*" + audio_title_allowlist: + - "movie" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(2); + audioStream.Language.ShouldBe("eng"); + } + } + + [Test] + public async Task Should_Ignore_Blocked_Subtitle_Title() + { + const string YAML = +""" +--- +items: + - audio_language: + - "*" + subtitle_language: + - "en" + subtitle_title_blocklist: + - "signs" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(3); + subtitle.Language.ShouldBe("en"); + } + } + + [Test] + public async Task Should_Select_Allowed_Subtitle_Title() + { + const string YAML = +""" +--- +items: + - audio_language: + - "*" + subtitle_language: + - "en" + subtitle_title_allowlist: + - "songs" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(3); + subtitle.Language.ShouldBe("en"); + } + } + + [Test] + public async Task Should_Select_Condition_Forced_Subtitle() + { + const string YAML = +""" +--- +items: + - audio_language: + - "*" + subtitle_condition: "forced" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(4); + subtitle.Language.ShouldBe("en"); + } + } + + [Test] + public async Task Should_Select_Condition_External_Subtitle() + { + const string YAML = +""" +--- +items: + - audio_language: + - "*" + subtitle_condition: "lang like 'en%' and external" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.Subtitle.IsSome.ShouldBeTrue(); + + foreach (Subtitle subtitle in result.Subtitle) + { + subtitle.Id.ShouldBe(4); + subtitle.Language.ShouldBe("en"); + } + } + + [Test] + public async Task Should_Select_Condition_Audio_Title() + { + const string YAML = +""" +--- +items: + - audio_language: + - "en*" + audio_condition: "title like '%movie%'" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(2); + audioStream.Language.ShouldBe("eng"); + } + } + + [Test] + public async Task Should_Select_Condition_Audio_Channels() + { + const string YAML = +""" +--- +items: + - audio_language: + - "en*" + audio_condition: "channels > 2" +"""; + + var streamSelector = new CustomStreamSelector( + new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), + new NullLogger()); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(2); + audioStream.Language.ShouldBe("eng"); + } + } + private static MediaItemAudioVersion GetTestAudioVersion(string englishLanguage) { var mediaItem = new OtherVideo(); @@ -376,9 +603,18 @@ items: { Index = 1, MediaStreamKind = MediaStreamKind.Audio, + Channels = 2, + Language = englishLanguage, + Title = "Riff Title", + Default = true + }, + new MediaStream + { + Index = 2, + MediaStreamKind = MediaStreamKind.Audio, Channels = 6, Language = englishLanguage, - Title = "Another Title", + Title = "Movie Title", Default = true } ] diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj index 03abd0d9c..b9bf628ac 100644 --- a/ErsatzTV.Core/ErsatzTV.Core.csproj +++ b/ErsatzTV.Core/ErsatzTV.Core.csproj @@ -24,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs b/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs index 710d72a4a..63570320d 100644 --- a/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs +++ b/ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs @@ -80,6 +80,14 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger 0) { @@ -126,24 +135,49 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase))) { matches = false; } + if (streamSelectorItem.SubtitleTitleAllowlist.Count > 0) + { + int matchCount = streamSelectorItem.SubtitleTitleAllowlist + .Count(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase)); + + if (matchCount == 0) + { + matches = false; + } + } + + if (!string.IsNullOrWhiteSpace(streamSelectorItem.SubtitleCondition)) + { + if (!SubtitleMatchesCondition(subtitle, streamSelectorItem.SubtitleCondition)) + { + matches = false; + } + } + if (!matches) { candidateSubtitles.Remove(subtitle); logger.LogDebug( "Subtitle {@Subtitle} does not match selector item {@SelectorItem}", - new { subtitle.Language }, + new { subtitle.Language, subtitle.Title }, streamSelectorItem); } else { logger.LogDebug( "Subtitle {@Subtitle} matches selector item {@SelectorItem}", - new { subtitle.Language }, + new { subtitle.Language, subtitle.Title }, streamSelectorItem); } } @@ -166,6 +200,49 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger + { + e.Result = name switch + { + "id" => audioStream.Index, + "title" => (audioStream.Title ?? string.Empty).ToLowerInvariant(), + "lang" => (audioStream.Language ?? string.Empty).ToLowerInvariant(), + "default" => audioStream.Default, + "forced" => audioStream.Forced, + "codec" => (audioStream.Codec ?? string.Empty).ToLowerInvariant(), + "channels" => audioStream.Channels, + _ => e.Result + }; + }; + + return expression.Evaluate() as bool? == true; + } + + private static bool SubtitleMatchesCondition(Subtitle subtitle, string subtitleCondition) + { + var expression = new NCalc.Expression(subtitleCondition); + expression.EvaluateParameter += (name, e) => + { + e.Result = name switch + { + "id" => subtitle.StreamIndex, + "title" => (subtitle.Title ?? string.Empty).ToLowerInvariant(), + "lang" => (subtitle.Language ?? string.Empty).ToLowerInvariant(), + "default" => subtitle.Default, + "forced" => subtitle.Forced, + "sdh" => subtitle.SDH, + "codec" => (subtitle.Codec ?? string.Empty).ToLowerInvariant(), + "external" => subtitle.SubtitleKind is SubtitleKind.Sidecar, + _ => e.Result + }; + }; + + return expression.Evaluate() as bool? == true; + } + private async Task LoadStreamSelector(string streamSelectorFile) { try diff --git a/ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs b/ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs index 5877686c4..124ed391a 100644 --- a/ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs +++ b/ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs @@ -7,21 +7,27 @@ public class StreamSelectorItem [YamlMember(Alias = "audio_language", ApplyNamingConventions = false)] public List AudioLanguages { get; set; } = []; - // [YamlMember(Alias = "audio_metadata", ApplyNamingConventions = false)] - // public StreamMetadata AudioMetadata { get; set; } = StreamMetadata.None; - [YamlMember(Alias = "audio_title_allowlist", ApplyNamingConventions = false)] public List AudioTitleAllowlist { get; set; } = []; [YamlMember(Alias = "audio_title_blocklist", ApplyNamingConventions = false)] public List AudioTitleBlocklist { get; set; } = []; + [YamlMember(Alias = "audio_condition", ApplyNamingConventions = false)] + public string AudioCondition { get; set; } + [YamlMember(Alias = "disable_subtitles", ApplyNamingConventions = false)] public bool DisableSubtitles { get; set; } [YamlMember(Alias = "subtitle_language", ApplyNamingConventions = false)] public List SubtitleLanguages { get; set; } = []; - // [YamlMember(Alias = "subtitle_metadata", ApplyNamingConventions = false)] - // public StreamMetadata SubtitleMetadata { get; set; } = StreamMetadata.None; + [YamlMember(Alias = "subtitle_title_allowlist", ApplyNamingConventions = false)] + public List SubtitleTitleAllowlist { get; set; } = []; + + [YamlMember(Alias = "subtitle_title_blocklist", ApplyNamingConventions = false)] + public List SubtitleTitleBlocklist { get; set; } = []; + + [YamlMember(Alias = "subtitle_condition", ApplyNamingConventions = false)] + public string SubtitleCondition { get; set; } } diff --git a/ErsatzTV/Pages/_Host.cshtml b/ErsatzTV/Pages/_Host.cshtml index f21d2ca06..eb226d328 100644 --- a/ErsatzTV/Pages/_Host.cshtml +++ b/ErsatzTV/Pages/_Host.cshtml @@ -15,7 +15,7 @@ - + diff --git a/docker/arm64/Dockerfile b/docker/arm64/Dockerfile index ed298d3e4..06c1df459 100644 --- a/docker/arm64/Dockerfile +++ b/docker/arm64/Dockerfile @@ -4,7 +4,7 @@ FROM jasongdove/ersatztv-ffmpeg:7.1.1-arm64 AS runtime-base COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet # https://hub.docker.com/_/microsoft-dotnet -FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-amd64 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0-noble-arm64 AS build RUN apt-get update && apt-get install -y ca-certificates gnupg WORKDIR /source