Browse Source

add custom channel stream selector system (#2076)

* add some basic channel stream selector models

* change windows ffmpeg url

* implement basic stream selection

* fixes
pull/2078/head
Jason Dove 2 months ago committed by GitHub
parent
commit
f80069bb97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/artifacts.yml
  2. 11
      CHANGELOG.md
  3. 2
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  4. 2
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  5. 2
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  6. 2
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  7. 2
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  8. 2
      ErsatzTV.Application/Channels/Mapper.cs
  9. 9
      ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs
  10. 3
      ErsatzTV.Application/Channels/Queries/GetChannelStreamSelectors.cs
  11. 14
      ErsatzTV.Application/Channels/Queries/GetChannelStreamSelectorsHandler.cs
  12. 14
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  13. 390
      ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs
  14. 37
      ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs
  15. 2
      ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs
  16. 6
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  17. 2
      ErsatzTV.Core/Domain/Channel.cs
  18. 7
      ErsatzTV.Core/Domain/ChannelStreamSelectorMode.cs
  19. 187
      ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs
  20. 51
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  21. 9
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  22. 3
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  23. 8
      ErsatzTV.Core/FFmpeg/Selector/StreamMetadata.cs
  24. 6
      ErsatzTV.Core/FFmpeg/Selector/StreamSelector.cs
  25. 27
      ErsatzTV.Core/FFmpeg/Selector/StreamSelectorItem.cs
  26. 4
      ErsatzTV.Core/FileSystemLayout.cs
  27. 9
      ErsatzTV.Core/Interfaces/FFmpeg/ICustomStreamSelector.cs
  28. 8
      ErsatzTV.Core/Interfaces/FFmpeg/StreamSelectorResult.cs
  29. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  30. 2
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  31. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs
  32. 5890
      ErsatzTV.Infrastructure.MySql/Migrations/20250626210903_Add_ChannelStreamSelectorModes.Designer.cs
  33. 40
      ErsatzTV.Infrastructure.MySql/Migrations/20250626210903_Add_ChannelStreamSelectorModes.cs
  34. 5896
      ErsatzTV.Infrastructure.MySql/Migrations/20250626211047_Add_ChannelStreamSelectors.Designer.cs
  35. 40
      ErsatzTV.Infrastructure.MySql/Migrations/20250626211047_Add_ChannelStreamSelectors.cs
  36. 5890
      ErsatzTV.Infrastructure.MySql/Migrations/20250626214848_Refactor_ChannelStreamSelector.Designer.cs
  37. 60
      ErsatzTV.Infrastructure.MySql/Migrations/20250626214848_Refactor_ChannelStreamSelector.cs
  38. 12
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  39. 5729
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250626210832_Add_ChannelStreamSelectorModes.Designer.cs
  40. 40
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250626210832_Add_ChannelStreamSelectorModes.cs
  41. 5735
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250626211019_Add_ChannelStreamSelectors.Designer.cs
  42. 38
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250626211019_Add_ChannelStreamSelectors.cs
  43. 5729
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250626214819_Refactor_ChannelStreamSelector.Designer.cs
  44. 59
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250626214819_Refactor_ChannelStreamSelector.cs
  45. 12
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  46. 2
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  47. 2
      ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs
  48. 93
      ErsatzTV/Pages/ChannelEditor.razor
  49. 2
      ErsatzTV/Startup.cs
  50. 6
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

2
.github/workflows/artifacts.yml

@ -182,7 +182,7 @@ jobs:
id: downloadffmpeg id: downloadffmpeg
name: Download ffmpeg name: Download ffmpeg
with: with:
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip" url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
target: ffmpeg/ target: ffmpeg/
- name: Build - name: Build

11
CHANGELOG.md

@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Add new channel stream (audio and subtitle) selector system
- Channel editor has a new field `Stream Selector Mode`
- `Default` maintains existing behavior
- `Custom` uses a YAML config file
- The YAML config contains a prioritized list of stream selector "items" (audio and subtitle criteria pairs)
- The items are tested against the media from top to bottom, and when (at least) a matching audio track is found, stream selection occurs
- 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)
### Fixed ### Fixed
- Fix QSV acceleration in docker with older Intel devices - Fix QSV acceleration in docker with older Intel devices

2
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -11,6 +11,8 @@ public record ChannelViewModel(
string Categories, string Categories,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,
string PreferredAudioTitle, string PreferredAudioTitle,
ChannelProgressMode ProgressMode, ChannelProgressMode ProgressMode,

2
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -10,6 +10,8 @@ public record CreateChannel(
string Categories, string Categories,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,
string PreferredAudioTitle, string PreferredAudioTitle,
ChannelProgressMode ProgressMode, ChannelProgressMode ProgressMode,

2
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -77,6 +77,8 @@ public class CreateChannelHandler(
ProgressMode = request.ProgressMode, ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode, StreamingMode = request.StreamingMode,
Artwork = artwork, Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
StreamSelector = request.StreamSelector,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode, PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle, PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode, PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,

2
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -11,6 +11,8 @@ public record UpdateChannel(
string Categories, string Categories,
int FFmpegProfileId, int FFmpegProfileId,
string Logo, string Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode, string PreferredAudioLanguageCode,
string PreferredAudioTitle, string PreferredAudioTitle,
ChannelProgressMode ProgressMode, ChannelProgressMode ProgressMode,

2
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -35,6 +35,8 @@ public class UpdateChannelHandler(
c.Group = update.Group; c.Group = update.Group;
c.Categories = update.Categories; c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId; c.FFmpegProfileId = update.FFmpegProfileId;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode; c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredAudioTitle = update.PreferredAudioTitle; c.PreferredAudioTitle = update.PreferredAudioTitle;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode; c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;

2
ErsatzTV.Application/Channels/Mapper.cs

@ -14,6 +14,8 @@ internal static class Mapper
channel.Categories, channel.Categories,
channel.FFmpegProfileId, channel.FFmpegProfileId,
GetLogo(channel), GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode, channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle, channel.PreferredAudioTitle,
channel.ProgressMode, channel.ProgressMode,

9
ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs

@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>> public class GetChannelByIdHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
{ {
private readonly IChannelRepository _channelRepository;
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) => public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
_channelRepository.GetChannel(request.Id) channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel); .MapT(ProjectToViewModel);
} }

3
ErsatzTV.Application/Channels/Queries/GetChannelStreamSelectors.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelStreamSelectors : IRequest<List<string>>;

14
ErsatzTV.Application/Channels/Queries/GetChannelStreamSelectorsHandler.cs

@ -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();
}

14
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -410,16 +410,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
{ {
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone() Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>()) .Map(mm => mm.Subtitles ?? [])
.IfNoneAsync(new List<Subtitle>()), .IfNoneAsync([]),
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>()) .Map(mm => mm.Subtitles ?? [])
.IfNoneAsync(new List<Subtitle>()), .IfNoneAsync([]),
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel, settings), MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel, settings),
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>()) .Map(mm => mm.Subtitles ?? [])
.IfNoneAsync(new List<Subtitle>()), .IfNoneAsync([]),
_ => new List<Subtitle>() _ => []
}; };
bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or

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

@ -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);
}
}
}

37
ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs

@ -24,7 +24,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -49,7 +48,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -70,7 +68,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -91,7 +88,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -114,7 +110,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -137,7 +132,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -158,7 +152,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -181,7 +174,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
now, now,
now.AddMinutes(5), now.AddMinutes(5),
TimeSpan.Zero, TimeSpan.Zero,
@ -205,7 +197,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
now, now,
now.AddMinutes(5), now.AddMinutes(5),
TimeSpan.Zero, TimeSpan.Zero,
@ -233,7 +224,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -260,7 +250,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -287,7 +276,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -315,7 +303,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -342,7 +329,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -372,7 +358,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -402,7 +387,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -432,7 +416,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -462,7 +445,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -494,7 +476,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" }, new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -525,7 +506,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" }, new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -556,7 +536,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "h264" }, new MediaStream { Codec = "h264" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -586,7 +565,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -617,7 +595,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" }, new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -647,7 +624,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -678,7 +654,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream { Codec = "mpeg2video" }, new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -704,7 +679,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "aac" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -728,7 +702,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -752,7 +725,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -777,7 +749,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -802,7 +773,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -827,7 +797,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -852,7 +821,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -876,7 +844,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -900,7 +867,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -930,7 +896,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
version, version,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -954,7 +919,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
@ -980,7 +944,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile, ffmpegProfile,
TestVersion, TestVersion,
new MediaStream(), new MediaStream(),
new MediaStream(),
DateTimeOffset.Now, DateTimeOffset.Now,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,

2
ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs

@ -3,4 +3,6 @@
public record FakeFileEntry(string Path) public record FakeFileEntry(string Path)
{ {
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc; public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
public string Contents { get; set; }
} }

6
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -57,6 +57,12 @@ public class FakeLocalFileSystem : ILocalFileSystem
public Unit EmptyFolder(string folder) => Unit.Default; public Unit EmptyFolder(string folder) => Unit.Default;
public async Task<string> ReadAllText(string path) => await _files
.Filter(f => f.Path == path)
.HeadOrNone()
.Select(f => f.Contents)
.IfNoneAsync(string.Empty);
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path) private static List<DirectoryInfo> Split(DirectoryInfo path)

2
ErsatzTV.Core/Domain/Channel.cs

@ -23,6 +23,8 @@ public class Channel
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; } public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; } public List<Artwork> Artwork { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; } public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; } public string PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; } public string PreferredSubtitleLanguageCode { get; set; }

7
ErsatzTV.Core/Domain/ChannelStreamSelectorMode.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelStreamSelectorMode
{
Default = 0,
Custom = 1
}

187
ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs

@ -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;
}
}
}

51
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -20,6 +20,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService; private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector; private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly ICustomStreamSelector _customStreamSelector;
private readonly ILogger<FFmpegLibraryProcessService> _logger; private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly IPipelineBuilderFactory _pipelineBuilderFactory; private readonly IPipelineBuilderFactory _pipelineBuilderFactory;
private readonly ITempFilePool _tempFilePool; private readonly ITempFilePool _tempFilePool;
@ -27,6 +28,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public FFmpegLibraryProcessService( public FFmpegLibraryProcessService(
FFmpegProcessService ffmpegProcessService, FFmpegProcessService ffmpegProcessService,
IFFmpegStreamSelector ffmpegStreamSelector, IFFmpegStreamSelector ffmpegStreamSelector,
ICustomStreamSelector customStreamSelector,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IPipelineBuilderFactory pipelineBuilderFactory, IPipelineBuilderFactory pipelineBuilderFactory,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
@ -34,6 +36,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_ffmpegStreamSelector = ffmpegStreamSelector; _ffmpegStreamSelector = ffmpegStreamSelector;
_customStreamSelector = customStreamSelector;
_tempFilePool = tempFilePool; _tempFilePool = tempFilePool;
_pipelineBuilderFactory = pipelineBuilderFactory; _pipelineBuilderFactory = pipelineBuilderFactory;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
@ -73,20 +76,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Action<FFmpegPipeline> pipelineAction) Action<FFmpegPipeline> pipelineAction)
{ {
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion); MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
Option<MediaStream> maybeAudioStream =
await _ffmpegStreamSelector.SelectAudioStream(
audioVersion,
channel.StreamingMode,
channel,
preferredAudioLanguage,
preferredAudioTitle);
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings( FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings(
channel.StreamingMode, channel.StreamingMode,
channel.FFmpegProfile, channel.FFmpegProfile,
videoVersion, videoVersion,
videoStream, videoStream,
maybeAudioStream,
start, start,
now, now,
inPoint, inPoint,
@ -96,12 +91,40 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
List<Subtitle> allSubtitles = await getSubtitles(playbackSettings); List<Subtitle> allSubtitles = await getSubtitles(playbackSettings);
Option<Subtitle> maybeSubtitle = Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
await _ffmpegStreamSelector.SelectSubtitleStream( Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;
allSubtitles,
channel, if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom)
preferredSubtitleLanguage, {
subtitleMode); StreamSelectorResult result = await _customStreamSelector.SelectStreams(channel, audioVersion, allSubtitles);
maybeAudioStream = result.AudioStream;
maybeSubtitle = result.Subtitle;
if (maybeAudioStream.IsNone)
{
_logger.LogWarning(
"No audio stream found using custom stream selector {StreamSelector}; will use default stream selection logic",
channel.StreamSelector);
}
}
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone)
{
maybeAudioStream =
await _ffmpegStreamSelector.SelectAudioStream(
audioVersion,
channel.StreamingMode,
channel,
preferredAudioLanguage,
preferredAudioTitle);
maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
allSubtitles,
channel,
preferredSubtitleLanguage,
subtitleMode);
}
foreach (Subtitle subtitle in maybeSubtitle) foreach (Subtitle subtitle in maybeSubtitle)
{ {

9
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -45,7 +45,6 @@ public static class FFmpegPlaybackSettingsCalculator
FFmpegProfile ffmpegProfile, FFmpegProfile ffmpegProfile,
MediaVersion videoVersion, MediaVersion videoVersion,
Option<MediaStream> videoStream, Option<MediaStream> videoStream,
Option<MediaStream> audioStream,
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset now, DateTimeOffset now,
TimeSpan inPoint, TimeSpan inPoint,
@ -151,13 +150,7 @@ public static class FFmpegPlaybackSettingsCalculator
result.AudioFormat = ffmpegProfile.AudioFormat; result.AudioFormat = ffmpegProfile.AudioFormat;
result.AudioBitrate = ffmpegProfile.AudioBitrate; result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize; result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
result.AudioChannels = ffmpegProfile.AudioChannels;
foreach (MediaStream _ in audioStream)
{
// this can be optimized out later, depending on the audio codec
result.AudioChannels = ffmpegProfile.AudioChannels;
}
result.AudioSampleRate = ffmpegProfile.AudioSampleRate; result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint; result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode; result.NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode;

3
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -97,7 +97,6 @@ public class FFmpegProcessService
channel.FFmpegProfile, channel.FFmpegProfile,
videoVersion, videoVersion,
videoStream, videoStream,
None,
DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch,
TimeSpan.Zero, TimeSpan.Zero,
@ -105,6 +104,8 @@ public class FFmpegProcessService
false, false,
Option<int>.None); Option<int>.None);
scalePlaybackSettings.AudioChannels = Option<int>.None;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath) FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(1) .WithThreads(1)
.WithQuiet() .WithQuiet()

8
ErsatzTV.Core/FFmpeg/Selector/StreamMetadata.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.FFmpeg.Selector;
public enum StreamMetadata
{
None = 0,
Default = 1,
Forced = 2
}

6
ErsatzTV.Core/FFmpeg/Selector/StreamSelector.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Core.FFmpeg.Selector;
public sealed class StreamSelector
{
public List<StreamSelectorItem> Items { get; set; } = [];
}

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

@ -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;
}

4
ErsatzTV.Core/FileSystemLayout.cs

@ -107,6 +107,8 @@ public static class FileSystemLayout
MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle"); MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle");
AudioStreamSelectorScriptsFolder = Path.Combine(ScriptsFolder, "audio-stream-selector"); AudioStreamSelectorScriptsFolder = Path.Combine(ScriptsFolder, "audio-stream-selector");
ChannelStreamSelectorsFolder = Path.Combine(ScriptsFolder, "channel-stream-selectors");
} }
public static readonly string AppDataFolder; public static readonly string AppDataFolder;
@ -156,6 +158,8 @@ public static class FileSystemLayout
public static readonly string AudioStreamSelectorScriptsFolder; public static readonly string AudioStreamSelectorScriptsFolder;
public static readonly string ChannelStreamSelectorsFolder;
public static readonly string MacOsOldAppDataFolder = Path.Combine( public static readonly string MacOsOldAppDataFolder = Path.Combine(
Environment.GetEnvironmentVariable("HOME") ?? string.Empty, Environment.GetEnvironmentVariable("HOME") ?? string.Empty,
".local", ".local",

9
ErsatzTV.Core/Interfaces/FFmpeg/ICustomStreamSelector.cs

@ -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);
}

8
ErsatzTV.Core/Interfaces/FFmpeg/StreamSelectorResult.cs

@ -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);
}

1
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -14,4 +14,5 @@ public interface ILocalFileSystem
bool FolderExists(string folder); bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination); Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder); Unit EmptyFolder(string folder);
Task<string> ReadAllText(string path);
} }

2
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -164,4 +164,6 @@ public class LocalFileSystem : ILocalFileSystem
return Unit.Default; return Unit.Default;
} }
public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path);
} }

2
ErsatzTV.Core/Scheduling/YamlScheduling/YamlPlayoutBuilder.cs

@ -357,7 +357,7 @@ public class YamlPlayoutBuilder(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Error loading YAML"); logger.LogWarning(ex, "Error loading YAML playout definition");
throw; throw;
} }
} }

5890
ErsatzTV.Infrastructure.MySql/Migrations/20250626210903_Add_ChannelStreamSelectorModes.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.MySql/Migrations/20250626210903_Add_ChannelStreamSelectorModes.cs

@ -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");
}
}
}

5896
ErsatzTV.Infrastructure.MySql/Migrations/20250626211047_Add_ChannelStreamSelectors.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.MySql/Migrations/20250626211047_Add_ChannelStreamSelectors.cs

@ -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");
}
}
}

5890
ErsatzTV.Infrastructure.MySql/Migrations/20250626214848_Refactor_ChannelStreamSelector.Designer.cs generated

File diff suppressed because it is too large Load Diff

60
ErsatzTV.Infrastructure.MySql/Migrations/20250626214848_Refactor_ChannelStreamSelector.cs

@ -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);
}
}
}

12
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.15") .HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 64); .HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -283,6 +283,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("SongVideoMode") b.Property<int>("SongVideoMode")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("StreamSelector")
.HasColumnType("longtext");
b.Property<int>("StreamSelectorMode")
.HasColumnType("int");
b.Property<int>("StreamingMode") b.Property<int>("StreamingMode")
.HasColumnType("int"); .HasColumnType("int");
@ -2756,10 +2762,10 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Album") b.Property<string>("Album")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("AlbumArtists") b.PrimitiveCollection<string>("AlbumArtists")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("Artists") b.PrimitiveCollection<string>("Artists")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("Comment") b.Property<string>("Comment")

5729
ErsatzTV.Infrastructure.Sqlite/Migrations/20250626210832_Add_ChannelStreamSelectorModes.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.Sqlite/Migrations/20250626210832_Add_ChannelStreamSelectorModes.cs

@ -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");
}
}
}

5735
ErsatzTV.Infrastructure.Sqlite/Migrations/20250626211019_Add_ChannelStreamSelectors.Designer.cs generated

File diff suppressed because it is too large Load Diff

38
ErsatzTV.Infrastructure.Sqlite/Migrations/20250626211019_Add_ChannelStreamSelectors.cs

@ -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");
}
}
}

5729
ErsatzTV.Infrastructure.Sqlite/Migrations/20250626214819_Refactor_ChannelStreamSelector.Designer.cs generated

File diff suppressed because it is too large Load Diff

59
ErsatzTV.Infrastructure.Sqlite/Migrations/20250626214819_Refactor_ChannelStreamSelector.cs

@ -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);
}
}
}

12
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.15"); modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{ {
@ -270,6 +270,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("SongVideoMode") b.Property<int>("SongVideoMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("StreamSelector")
.HasColumnType("TEXT");
b.Property<int>("StreamSelectorMode")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode") b.Property<int>("StreamingMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -2615,10 +2621,10 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Album") b.Property<string>("Album")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AlbumArtists") b.PrimitiveCollection<string>("AlbumArtists")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Artists") b.PrimitiveCollection<string>("Artists")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Comment") b.Property<string>("Comment")

2
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -262,6 +262,7 @@ public class TranscodingTests
var service = new FFmpegLibraryProcessService( var service = new FFmpegLibraryProcessService(
oldService, oldService,
new FakeStreamSelector(), new FakeStreamSelector(),
Substitute.For<ICustomStreamSelector>(),
tempFilePool, tempFilePool,
new PipelineBuilderFactory( new PipelineBuilderFactory(
//new FakeNvidiaCapabilitiesFactory(), //new FakeNvidiaCapabilitiesFactory(),
@ -900,6 +901,7 @@ public class TranscodingTests
var service = new FFmpegLibraryProcessService( var service = new FFmpegLibraryProcessService(
oldService, oldService,
new FakeStreamSelector(), new FakeStreamSelector(),
Substitute.For<ICustomStreamSelector>(),
Substitute.For<ITempFilePool>(), Substitute.For<ITempFilePool>(),
new PipelineBuilderFactory( new PipelineBuilderFactory(
//new FakeNvidiaCapabilitiesFactory(), //new FakeNvidiaCapabilitiesFactory(),

2
ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs

@ -57,7 +57,7 @@ public class FakeLocalFileSystem : ILocalFileSystem
Task.FromResult(Right<BaseError, Unit>(Unit.Default)); Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default; public Unit EmptyFolder(string folder) => Unit.Default;
public Task<string> ReadAllText(string path) => throw new NotImplementedException();
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask(); public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path) private static List<DirectoryInfo> Split(DirectoryInfo path)

93
ErsatzTV/Pages/ChannelEditor.razor

@ -48,35 +48,55 @@
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem> <MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" <MudSelect Class="mt-3" Label="Stream Selector Mode" @bind-Value="_model.StreamSelectorMode" For="@(() => _model.StreamSelectorMode)">
Label="Preferred Audio Language" <MudSelectItem Value="@(ChannelStreamSelectorMode.Default)">Default</MudSelectItem>
@bind-Value="_model.PreferredAudioLanguageCode" <MudSelectItem Value="@(ChannelStreamSelectorMode.Custom)">Custom</MudSelectItem>
For="@(() => _model.PreferredAudioLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudTextField Label="Preferred Audio Title" @bind-Value="_model.PreferredAudioTitle" For="@(() => _model.PreferredAudioTitle)"/>
<MudSelect Class="mt-3"
Label="Preferred Subtitle Language"
@bind-Value="_model.PreferredSubtitleLanguageCode"
For="@(() => _model.PreferredSubtitleLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3" Label="Subtitle Mode" @bind-Value="_model.SubtitleMode" For="@(() => _model.SubtitleMode)">
<MudSelectItem Value="@(ChannelSubtitleMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
</MudSelect> </MudSelect>
@if (_model.StreamSelectorMode is ChannelStreamSelectorMode.Default)
{
<MudSelect Class="mt-3"
Label="Preferred Audio Language"
@bind-Value="_model.PreferredAudioLanguageCode"
For="@(() => _model.PreferredAudioLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudTextField Label="Preferred Audio Title" @bind-Value="_model.PreferredAudioTitle" For="@(() => _model.PreferredAudioTitle)"/>
<MudSelect Class="mt-3"
Label="Preferred Subtitle Language"
@bind-Value="_model.PreferredSubtitleLanguageCode"
For="@(() => _model.PreferredSubtitleLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (LanguageCodeViewModel culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3" Label="Subtitle Mode" @bind-Value="_model.SubtitleMode" For="@(() => _model.SubtitleMode)">
<MudSelectItem Value="@(ChannelSubtitleMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
</MudSelect>
}
else
{
<MudSelect Class="mt-3"
Label="Stream Selector"
@bind-Value="_model.StreamSelector"
For="@(() => _model.StreamSelector)">
<MudSelectItem T="string" Value="@((string)null)">(none)</MudSelectItem>
@foreach (string selector in _streamSelectors)
{
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
}
</MudSelect>
}
<MudSelect Class="mt-3" Label="Music Video Credits Mode" @bind-Value="_model.MusicVideoCreditsMode" For="@(() => _model.MusicVideoCreditsMode)"> <MudSelect Class="mt-3" Label="Music Video Credits Mode" @bind-Value="_model.MusicVideoCreditsMode" For="@(() => _model.MusicVideoCreditsMode)">
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem> <MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem> <MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem>
@ -156,11 +176,12 @@
private EditContext _editContext; private EditContext _editContext;
private ValidationMessageStore _messageStore; private ValidationMessageStore _messageStore;
private List<FFmpegProfileViewModel> _ffmpegProfiles = new(); private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<LanguageCodeViewModel> _availableCultures = new(); private List<LanguageCodeViewModel> _availableCultures = [];
private List<WatermarkViewModel> _watermarks = new(); private List<WatermarkViewModel> _watermarks = [];
private List<FillerPresetViewModel> _fillerPresets = new(); private List<FillerPresetViewModel> _fillerPresets = [];
private List<string> _musicVideoCreditsTemplates = new(); private List<string> _musicVideoCreditsTemplates = [];
private List<string> _streamSelectors = [];
public void Dispose() public void Dispose()
{ {
@ -175,6 +196,7 @@
await LoadWatermarks(_cts.Token); await LoadWatermarks(_cts.Token);
await LoadFillerPresets(_cts.Token); await LoadFillerPresets(_cts.Token);
await LoadMusicVideoCreditsTemplates(_cts.Token); await LoadMusicVideoCreditsTemplates(_cts.Token);
await LoadChannelStreamSelectors(_cts.Token);
if (Id.HasValue) if (Id.HasValue)
{ {
@ -200,6 +222,8 @@
_model.ProgressMode = channelViewModel.ProgressMode; _model.ProgressMode = channelViewModel.ProgressMode;
_model.StreamingMode = channelViewModel.StreamingMode; _model.StreamingMode = channelViewModel.StreamingMode;
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
_model.StreamSelector = channelViewModel.StreamSelector;
_model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode; _model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode;
_model.PreferredAudioTitle = channelViewModel.PreferredAudioTitle; _model.PreferredAudioTitle = channelViewModel.PreferredAudioTitle;
_model.WatermarkId = channelViewModel.WatermarkId; _model.WatermarkId = channelViewModel.WatermarkId;
@ -249,6 +273,9 @@
private async Task LoadMusicVideoCreditsTemplates(CancellationToken cancellationToken) => private async Task LoadMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
_musicVideoCreditsTemplates = await Mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken); _musicVideoCreditsTemplates = await Mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
private async Task LoadChannelStreamSelectors(CancellationToken cancellationToken) =>
_streamSelectors = await Mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
private async Task HandleSubmitAsync() private async Task HandleSubmitAsync()
{ {
_messageStore.Clear(); _messageStore.Clear();

2
ErsatzTV/Startup.cs

@ -341,6 +341,7 @@ public class Startup
FileSystemLayout.FontsCacheFolder, FileSystemLayout.FontsCacheFolder,
FileSystemLayout.TemplatesFolder, FileSystemLayout.TemplatesFolder,
FileSystemLayout.MusicVideoCreditsTemplatesFolder, FileSystemLayout.MusicVideoCreditsTemplatesFolder,
FileSystemLayout.ChannelStreamSelectorsFolder,
FileSystemLayout.ChannelGuideTemplatesFolder, FileSystemLayout.ChannelGuideTemplatesFolder,
FileSystemLayout.ScriptsFolder, FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
@ -703,6 +704,7 @@ public class Startup
services.AddScoped<IEmbyMovieRepository, EmbyMovieRepository>(); services.AddScoped<IEmbyMovieRepository, EmbyMovieRepository>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>(); services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>(); services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<ICustomStreamSelector, CustomStreamSelector>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>(); services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IStreamSelectorRepository, StreamSelectorRepository>(); services.AddScoped<IStreamSelectorRepository, StreamSelectorRepository>();
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>(); services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>();

6
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -12,6 +12,8 @@ public class ChannelEditViewModel
public string Categories { get; set; } public string Categories { get; set; }
public string Number { get; set; } public string Number { get; set; }
public int FFmpegProfileId { get; set; } public int FFmpegProfileId { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; } public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; } public string PreferredAudioTitle { get; set; }
public string Logo { get; set; } public string Logo { get; set; }
@ -41,6 +43,8 @@ public class ChannelEditViewModel
Categories, Categories,
FFmpegProfileId, FFmpegProfileId,
string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl, string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode, PreferredAudioLanguageCode,
PreferredAudioTitle, PreferredAudioTitle,
ProgressMode, ProgressMode,
@ -61,6 +65,8 @@ public class ChannelEditViewModel
Categories, Categories,
FFmpegProfileId, FFmpegProfileId,
string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl, string.IsNullOrWhiteSpace(ExternalLogoUrl) ? Logo : ExternalLogoUrl,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode, PreferredAudioLanguageCode,
PreferredAudioTitle, PreferredAudioTitle,
ProgressMode, ProgressMode,

Loading…
Cancel
Save