Browse Source

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
pull/2078/head
Jason Dove 1 year ago committed by GitHub
parent
commit
f94a440b62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      CHANGELOG.md
  2. 242
      ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs
  3. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  4. 81
      ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs
  5. 16
      ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs
  6. 2
      ErsatzTV/Pages/_Host.cshtml
  7. 2
      docker/arm64/Dockerfile

15
CHANGELOG.md

@ -14,6 +14,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

242
ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs

@ -33,7 +33,10 @@ public class CustomStreamSelectorTests @@ -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: @@ -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: @@ -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<CustomStreamSelector>());
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<CustomStreamSelector>());
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<CustomStreamSelector>());
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<CustomStreamSelector>());
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<CustomStreamSelector>());
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<CustomStreamSelector>());
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<CustomStreamSelector>());
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<CustomStreamSelector>());
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: @@ -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
}
]

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -24,6 +24,7 @@ @@ -24,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NCalcSync" Version="5.4.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

81
ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs

@ -80,6 +80,14 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust @@ -80,6 +80,14 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust
}
}
if (!string.IsNullOrWhiteSpace(streamSelectorItem.AudioCondition))
{
if (!AudioMatchesCondition(audioStream, streamSelectorItem.AudioCondition))
{
matches = false;
}
}
if (!matches)
{
candidateAudioStreams.Remove(audioStream);
@ -108,6 +116,7 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust @@ -108,6 +116,7 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust
foreach (Subtitle subtitle in allSubtitles.ToList())
{
var matches = false;
string safeTitle = subtitle.Title ?? string.Empty;
if (streamSelectorItem.SubtitleLanguages.Count > 0)
{
@ -126,24 +135,49 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust @@ -126,24 +135,49 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust
}
}
else
{
matches = true;
}
if (streamSelectorItem.SubtitleTitleBlocklist
.Any(block => 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<Cust @@ -166,6 +200,49 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust
return StreamSelectorResult.None;
}
private static bool AudioMatchesCondition(MediaStream audioStream, string audioCondition)
{
var expression = new NCalc.Expression(audioCondition);
expression.EvaluateParameter += (name, e) =>
{
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<StreamSelector> LoadStreamSelector(string streamSelectorFile)
{
try

16
ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs

@ -7,21 +7,27 @@ public class StreamSelectorItem @@ -7,21 +7,27 @@ public class StreamSelectorItem
[YamlMember(Alias = "audio_language", ApplyNamingConventions = false)]
public List<string> 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<string> AudioTitleAllowlist { get; set; } = [];
[YamlMember(Alias = "audio_title_blocklist", ApplyNamingConventions = false)]
public List<string> 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<string> 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<string> SubtitleTitleAllowlist { get; set; } = [];
[YamlMember(Alias = "subtitle_title_blocklist", ApplyNamingConventions = false)]
public List<string> SubtitleTitleBlocklist { get; set; } = [];
[YamlMember(Alias = "subtitle_condition", ApplyNamingConventions = false)]
public string SubtitleCondition { get; set; }
}

2
ErsatzTV/Pages/_Host.cshtml

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
<base href="~/"/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css" rel="stylesheet">
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
<link href="_content/MudBlazor/MudBlazor.min.css?v=@(Assembly.GetAssembly(typeof(MudBlazor.AbstractLocalizationInterceptor))?.GetName().Version?.ToString())" rel="stylesheet"/>
<link href="css/site.css" rel="stylesheet"/>
<link href="ErsatzTV.styles.css" rel="stylesheet"/>
<link href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" rel="stylesheet">

2
docker/arm64/Dockerfile

@ -4,7 +4,7 @@ FROM jasongdove/ersatztv-ffmpeg:7.1.1-arm64 AS runtime-base @@ -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

Loading…
Cancel
Save