mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add some basic channel stream selector models * change windows ffmpeg url * implement basic stream selection * fixespull/2078/head
50 changed files with 35986 additions and 115 deletions
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
|
public record GetChannelStreamSelectors : IRequest<List<string>>; |
@ -0,0 +1,14 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Channels; |
||||||
|
|
||||||
|
public class GetChannelStreamSelectorsHandler(ILocalFileSystem localFileSystem) |
||||||
|
: IRequestHandler<GetChannelStreamSelectors, List<string>> |
||||||
|
{ |
||||||
|
public Task<List<string>> Handle(GetChannelStreamSelectors request, CancellationToken cancellationToken) => |
||||||
|
localFileSystem.ListFiles(FileSystemLayout.ChannelStreamSelectorsFolder) |
||||||
|
.Map(Path.GetFileName) |
||||||
|
.ToList() |
||||||
|
.AsTask(); |
||||||
|
} |
@ -0,0 +1,390 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using ErsatzTV.Core.Tests.Fakes; |
||||||
|
using Microsoft.Extensions.Logging.Abstractions; |
||||||
|
using NUnit.Framework; |
||||||
|
using Shouldly; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.FFmpeg; |
||||||
|
|
||||||
|
[TestFixture] |
||||||
|
public class CustomStreamSelectorTests |
||||||
|
{ |
||||||
|
[TestFixture] |
||||||
|
public class SelectStreams |
||||||
|
{ |
||||||
|
private static readonly string TestFileName = Path.Combine(FileSystemLayout.ChannelStreamSelectorsFolder, "test.yml"); |
||||||
|
|
||||||
|
private Channel _channel; |
||||||
|
private MediaItemAudioVersion _audioVersion; |
||||||
|
private List<Subtitle> _subtitles; |
||||||
|
|
||||||
|
[SetUp] |
||||||
|
public void SetUp() |
||||||
|
{ |
||||||
|
_channel = new Channel(Guid.Empty) |
||||||
|
{ |
||||||
|
StreamSelectorMode = ChannelStreamSelectorMode.Custom, |
||||||
|
StreamSelector = TestFileName |
||||||
|
}; |
||||||
|
|
||||||
|
_audioVersion = GetTestAudioVersion("eng"); |
||||||
|
|
||||||
|
_subtitles = |
||||||
|
[ |
||||||
|
new Subtitle { Id = 1, Language = "eng", Title = "Words" } |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_eng_Audio_Exact_Match() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "eng" |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
audioStream.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_eng_Audio_Exact_Match_Multiple_Audio_Languages() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "en" |
||||||
|
- "eng" |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
audioStream.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_eng_Audio_Exact_Match_Multiple_Items() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "de" |
||||||
|
subtitle_language: |
||||||
|
- "eng" |
||||||
|
- audio_language: |
||||||
|
- "eng" |
||||||
|
disable_subtitles: true |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
audioStream.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_eng_Audio_Pattern_Match() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "en*" |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
audioStream.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_en_Audio_Pattern_Match() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "en*" |
||||||
|
""";
|
||||||
|
_audioVersion = GetTestAudioVersion("en"); |
||||||
|
|
||||||
|
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(1); |
||||||
|
audioStream.Language.ShouldBe("en"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task disable_subtitles_Should_Select_No_Subtitles() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "eng" |
||||||
|
disable_subtitles: true |
||||||
|
""";
|
||||||
|
|
||||||
|
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.ShouldBeFalse(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_eng_Subtitle_Exact_Match() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "ja" |
||||||
|
subtitle_language: |
||||||
|
- "eng" |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
subtitle.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_eng_Subtitle_Pattern_Match() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "ja" |
||||||
|
subtitle_language: |
||||||
|
- "en*" |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
subtitle.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_en_Subtitle_Pattern_Match() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "ja" |
||||||
|
subtitle_language: |
||||||
|
- "en*" |
||||||
|
""";
|
||||||
|
_audioVersion = GetTestAudioVersion("en"); |
||||||
|
|
||||||
|
_subtitles = |
||||||
|
[ |
||||||
|
new Subtitle { Id = 1, Language = "en", Title = "Words" } |
||||||
|
]; |
||||||
|
|
||||||
|
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(1); |
||||||
|
subtitle.Language.ShouldBe("en"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_no_Subtitle_Exact_Match_Multiple_Items() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "de" |
||||||
|
subtitle_language: |
||||||
|
- "eng" |
||||||
|
- audio_language: |
||||||
|
- "eng" |
||||||
|
disable_subtitles: true |
||||||
|
""";
|
||||||
|
|
||||||
|
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(1); |
||||||
|
audioStream.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
|
||||||
|
result.Subtitle.IsSome.ShouldBeFalse(); |
||||||
|
} |
||||||
|
|
||||||
|
[Test] |
||||||
|
public async Task Should_Select_Foreign_Audio_And_English_Subtitle_Multiple_Items() |
||||||
|
{ |
||||||
|
const string YAML = |
||||||
|
"""
|
||||||
|
--- |
||||||
|
items: |
||||||
|
- audio_language: |
||||||
|
- "ja" |
||||||
|
subtitle_language: |
||||||
|
- "eng" |
||||||
|
- audio_language: |
||||||
|
- "eng" |
||||||
|
disable_subtitles: true |
||||||
|
""";
|
||||||
|
|
||||||
|
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(0); |
||||||
|
audioStream.Language.ShouldBe("ja"); |
||||||
|
} |
||||||
|
|
||||||
|
result.Subtitle.IsSome.ShouldBeTrue(); |
||||||
|
|
||||||
|
foreach (Subtitle subtitle in result.Subtitle) |
||||||
|
{ |
||||||
|
subtitle.Id.ShouldBe(1); |
||||||
|
subtitle.Language.ShouldBe("eng"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static MediaItemAudioVersion GetTestAudioVersion(string englishLanguage) |
||||||
|
{ |
||||||
|
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 = englishLanguage, |
||||||
|
Title = "Another Title", |
||||||
|
Default = true |
||||||
|
} |
||||||
|
] |
||||||
|
}; |
||||||
|
|
||||||
|
return new MediaItemAudioVersion(mediaItem, mediaVersion); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
namespace ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
public enum ChannelStreamSelectorMode |
||||||
|
{ |
||||||
|
Default = 0, |
||||||
|
Custom = 1 |
||||||
|
} |
@ -0,0 +1,187 @@ |
|||||||
|
using System.IO.Enumeration; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.FFmpeg.Selector; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using YamlDotNet.Serialization; |
||||||
|
using YamlDotNet.Serialization.NamingConventions; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.FFmpeg; |
||||||
|
|
||||||
|
public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<CustomStreamSelector> logger) : ICustomStreamSelector |
||||||
|
{ |
||||||
|
public async Task<StreamSelectorResult> SelectStreams(Channel channel, MediaItemAudioVersion audioVersion, List<Subtitle> allSubtitles) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
string streamSelectorFile = Path.Combine( |
||||||
|
FileSystemLayout.ChannelStreamSelectorsFolder, |
||||||
|
channel.StreamSelector); |
||||||
|
|
||||||
|
if (!localFileSystem.FileExists(streamSelectorFile)) |
||||||
|
{ |
||||||
|
logger.LogWarning("YAML stream selector file {File} does not exist; aborting.", channel.StreamSelector); |
||||||
|
return StreamSelectorResult.None; |
||||||
|
} |
||||||
|
|
||||||
|
StreamSelector streamSelector = await LoadStreamSelector(streamSelectorFile); |
||||||
|
|
||||||
|
var audioStreams = audioVersion.MediaVersion.Streams |
||||||
|
.Where(s => s.MediaStreamKind == MediaStreamKind.Audio) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
foreach (StreamSelectorItem streamSelectorItem in streamSelector.Items) |
||||||
|
{ |
||||||
|
var candidateAudioStreams = audioStreams.ToList(); |
||||||
|
var candidateSubtitles = allSubtitles.ToList(); |
||||||
|
|
||||||
|
// try to find matching audio stream
|
||||||
|
foreach (MediaStream audioStream in audioStreams.ToList()) |
||||||
|
{ |
||||||
|
var matches = false; |
||||||
|
string safeTitle = audioStream.Title ?? string.Empty; |
||||||
|
|
||||||
|
if (streamSelectorItem.AudioLanguages.Count > 0) |
||||||
|
{ |
||||||
|
// match any of the listed languages
|
||||||
|
foreach (string audioLanguage in streamSelectorItem.AudioLanguages) |
||||||
|
{ |
||||||
|
// special case
|
||||||
|
if (audioLanguage == "*") |
||||||
|
{ |
||||||
|
matches = true; |
||||||
|
} |
||||||
|
|
||||||
|
matches = matches || FileSystemName.MatchesSimpleExpression( |
||||||
|
audioLanguage.ToLowerInvariant(), |
||||||
|
audioStream.Language.ToLowerInvariant()); |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
matches = true; |
||||||
|
} |
||||||
|
|
||||||
|
if (streamSelectorItem.AudioTitleBlocklist |
||||||
|
.Any(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase))) |
||||||
|
{ |
||||||
|
matches = false; |
||||||
|
} |
||||||
|
|
||||||
|
if (streamSelectorItem.AudioTitleAllowlist.Count > 0) |
||||||
|
{ |
||||||
|
int matchCount = streamSelectorItem.AudioTitleAllowlist |
||||||
|
.Count(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase)); |
||||||
|
|
||||||
|
if (matchCount == 0) |
||||||
|
{ |
||||||
|
matches = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!matches) |
||||||
|
{ |
||||||
|
candidateAudioStreams.Remove(audioStream); |
||||||
|
|
||||||
|
logger.LogDebug( |
||||||
|
"Audio stream {@Stream} does not match selector item {@SelectorItem}", |
||||||
|
new { audioStream.Language, audioStream.Title }, |
||||||
|
streamSelectorItem); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
logger.LogDebug( |
||||||
|
"Audio stream {@Stream} matches selector item {@SelectorItem}", |
||||||
|
new { audioStream.Language, audioStream.Title }, |
||||||
|
streamSelectorItem); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// try to find matching subtitle stream
|
||||||
|
if (streamSelectorItem.DisableSubtitles) |
||||||
|
{ |
||||||
|
candidateSubtitles.Clear(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
foreach (Subtitle subtitle in allSubtitles.ToList()) |
||||||
|
{ |
||||||
|
var matches = false; |
||||||
|
|
||||||
|
if (streamSelectorItem.SubtitleLanguages.Count > 0) |
||||||
|
{ |
||||||
|
// match any of the listed languages
|
||||||
|
foreach (string subtitleLanguage in streamSelectorItem.SubtitleLanguages) |
||||||
|
{ |
||||||
|
// special case
|
||||||
|
if (subtitleLanguage == "*") |
||||||
|
{ |
||||||
|
matches = true; |
||||||
|
} |
||||||
|
|
||||||
|
matches = matches || FileSystemName.MatchesSimpleExpression( |
||||||
|
subtitleLanguage, |
||||||
|
subtitle.Language); |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
matches = false; |
||||||
|
} |
||||||
|
|
||||||
|
if (!matches) |
||||||
|
{ |
||||||
|
candidateSubtitles.Remove(subtitle); |
||||||
|
|
||||||
|
logger.LogDebug( |
||||||
|
"Subtitle {@Subtitle} does not match selector item {@SelectorItem}", |
||||||
|
new { subtitle.Language }, |
||||||
|
streamSelectorItem); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
logger.LogDebug( |
||||||
|
"Subtitle {@Subtitle} matches selector item {@SelectorItem}", |
||||||
|
new { subtitle.Language }, |
||||||
|
streamSelectorItem); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Option<MediaStream> maybeAudioStream = candidateAudioStreams.HeadOrNone(); |
||||||
|
Option<Subtitle> maybeSubtitle = candidateSubtitles.HeadOrNone(); |
||||||
|
|
||||||
|
if (maybeAudioStream.IsSome) |
||||||
|
{ |
||||||
|
return new StreamSelectorResult(maybeAudioStream, maybeSubtitle); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
logger.LogError(ex, "Unexpected error selecting streams"); |
||||||
|
} |
||||||
|
|
||||||
|
return StreamSelectorResult.None; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<StreamSelector> LoadStreamSelector(string streamSelectorFile) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
string yaml = await localFileSystem.ReadAllText(streamSelectorFile); |
||||||
|
|
||||||
|
IDeserializer deserializer = new DeserializerBuilder() |
||||||
|
.WithNamingConvention(CamelCaseNamingConvention.Instance) |
||||||
|
.Build(); |
||||||
|
|
||||||
|
return deserializer.Deserialize<StreamSelector>(yaml); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
logger.LogWarning(ex, "Error loading YAML stream selector"); |
||||||
|
throw; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
namespace ErsatzTV.Core.FFmpeg.Selector; |
||||||
|
|
||||||
|
public enum StreamMetadata |
||||||
|
{ |
||||||
|
None = 0, |
||||||
|
Default = 1, |
||||||
|
Forced = 2 |
||||||
|
} |
@ -0,0 +1,6 @@ |
|||||||
|
namespace ErsatzTV.Core.FFmpeg.Selector; |
||||||
|
|
||||||
|
public sealed class StreamSelector |
||||||
|
{ |
||||||
|
public List<StreamSelectorItem> Items { get; set; } = []; |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
using YamlDotNet.Serialization; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.FFmpeg.Selector; |
||||||
|
|
||||||
|
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 = "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;
|
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
|
||||||
|
public interface ICustomStreamSelector |
||||||
|
{ |
||||||
|
Task<StreamSelectorResult> SelectStreams(Channel channel, MediaItemAudioVersion audioVersion, List<Subtitle> allSubtitles); |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
|
||||||
|
public record StreamSelectorResult(Option<MediaStream> AudioStream, Option<Subtitle> Subtitle) |
||||||
|
{ |
||||||
|
public static readonly StreamSelectorResult None = new(Option<MediaStream>.None, Option<Subtitle>.None); |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_ChannelStreamSelectorModes : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
type: "int", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SubtitleStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
type: "int", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SubtitleStreamSelectorMode", |
||||||
|
table: "Channel"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_ChannelStreamSelectors : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
type: "longtext", |
||||||
|
nullable: true) |
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "SubtitleStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
type: "longtext", |
||||||
|
nullable: true) |
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SubtitleStreamSelector", |
||||||
|
table: "Channel"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,60 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Refactor_ChannelStreamSelector : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "SubtitleStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
newName: "StreamSelectorMode"); |
||||||
|
|
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "SubtitleStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
newName: "StreamSelector"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "StreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
newName: "SubtitleStreamSelectorMode"); |
||||||
|
|
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "StreamSelector", |
||||||
|
table: "Channel", |
||||||
|
newName: "SubtitleStreamSelector"); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
type: "longtext", |
||||||
|
nullable: true) |
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
type: "int", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_ChannelStreamSelectorModes : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SubtitleStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SubtitleStreamSelectorMode", |
||||||
|
table: "Channel"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,38 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Add_ChannelStreamSelectors : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "SubtitleStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SubtitleStreamSelector", |
||||||
|
table: "Channel"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,59 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Refactor_ChannelStreamSelector : Migration |
||||||
|
{ |
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel"); |
||||||
|
|
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "SubtitleStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
newName: "StreamSelectorMode"); |
||||||
|
|
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "SubtitleStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
newName: "StreamSelector"); |
||||||
|
} |
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "StreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
newName: "SubtitleStreamSelectorMode"); |
||||||
|
|
||||||
|
migrationBuilder.RenameColumn( |
||||||
|
name: "StreamSelector", |
||||||
|
table: "Channel", |
||||||
|
newName: "SubtitleStreamSelector"); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "AudioStreamSelector", |
||||||
|
table: "Channel", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "AudioStreamSelectorMode", |
||||||
|
table: "Channel", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue