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: @@ -182,7 +182,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
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/
- name: Build

11
CHANGELOG.md

@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. @@ -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/).
## [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
- Fix QSV acceleration in docker with older Intel devices

2
ErsatzTV.Application/Channels/ChannelViewModel.cs

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

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

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

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

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

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

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

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

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

2
ErsatzTV.Application/Channels/Mapper.cs

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

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

@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper; @@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
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) =>
_channelRepository.GetChannel(request.Id)
channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
}

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

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

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

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

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

@ -3,4 +3,6 @@ @@ -3,4 +3,6 @@
public record FakeFileEntry(string Path)
{
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 @@ -57,6 +57,12 @@ public class FakeLocalFileSystem : ILocalFileSystem
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();
private static List<DirectoryInfo> Split(DirectoryInfo path)

2
ErsatzTV.Core/Domain/Channel.cs

@ -23,6 +23,8 @@ public class Channel @@ -23,6 +23,8 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { 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 PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }

7
ErsatzTV.Core/Domain/ChannelStreamSelectorMode.cs

@ -0,0 +1,7 @@ @@ -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 @@ @@ -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 @@ -20,6 +20,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly ICustomStreamSelector _customStreamSelector;
private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly IPipelineBuilderFactory _pipelineBuilderFactory;
private readonly ITempFilePool _tempFilePool;
@ -27,6 +28,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -27,6 +28,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public FFmpegLibraryProcessService(
FFmpegProcessService ffmpegProcessService,
IFFmpegStreamSelector ffmpegStreamSelector,
ICustomStreamSelector customStreamSelector,
ITempFilePool tempFilePool,
IPipelineBuilderFactory pipelineBuilderFactory,
IConfigElementRepository configElementRepository,
@ -34,6 +36,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -34,6 +36,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
_ffmpegProcessService = ffmpegProcessService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_customStreamSelector = customStreamSelector;
_tempFilePool = tempFilePool;
_pipelineBuilderFactory = pipelineBuilderFactory;
_configElementRepository = configElementRepository;
@ -73,20 +76,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -73,20 +76,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Action<FFmpegPipeline> pipelineAction)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
Option<MediaStream> maybeAudioStream =
await _ffmpegStreamSelector.SelectAudioStream(
audioVersion,
channel.StreamingMode,
channel,
preferredAudioLanguage,
preferredAudioTitle);
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
videoVersion,
videoStream,
maybeAudioStream,
start,
now,
inPoint,
@ -96,12 +91,40 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -96,12 +91,40 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
List<Subtitle> allSubtitles = await getSubtitles(playbackSettings);
Option<Subtitle> maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
allSubtitles,
channel,
preferredSubtitleLanguage,
subtitleMode);
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom)
{
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)
{

9
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

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

3
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

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

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

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

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

@ -0,0 +1,9 @@ @@ -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 @@ @@ -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 @@ -14,4 +14,5 @@ public interface ILocalFileSystem
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
Task<string> ReadAllText(string path);
}

2
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -164,4 +164,6 @@ public class LocalFileSystem : ILocalFileSystem @@ -164,4 +164,6 @@ public class LocalFileSystem : ILocalFileSystem
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( @@ -357,7 +357,7 @@ public class YamlPlayoutBuilder(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error loading YAML");
logger.LogWarning(ex, "Error loading YAML playout definition");
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -283,6 +283,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -283,6 +283,12 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("SongVideoMode")
.HasColumnType("int");
b.Property<string>("StreamSelector")
.HasColumnType("longtext");
b.Property<int>("StreamSelectorMode")
.HasColumnType("int");
b.Property<int>("StreamingMode")
.HasColumnType("int");
@ -2756,10 +2762,10 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2756,10 +2762,10 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Album")
.HasColumnType("longtext");
b.Property<string>("AlbumArtists")
b.PrimitiveCollection<string>("AlbumArtists")
.HasColumnType("longtext");
b.Property<string>("Artists")
b.PrimitiveCollection<string>("Artists")
.HasColumnType("longtext");
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.15");
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -270,6 +270,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -270,6 +270,12 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("SongVideoMode")
.HasColumnType("INTEGER");
b.Property<string>("StreamSelector")
.HasColumnType("TEXT");
b.Property<int>("StreamSelectorMode")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
@ -2615,10 +2621,10 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2615,10 +2621,10 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Album")
.HasColumnType("TEXT");
b.Property<string>("AlbumArtists")
b.PrimitiveCollection<string>("AlbumArtists")
.HasColumnType("TEXT");
b.Property<string>("Artists")
b.PrimitiveCollection<string>("Artists")
.HasColumnType("TEXT");
b.Property<string>("Comment")

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

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

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

@ -57,7 +57,7 @@ public class FakeLocalFileSystem : ILocalFileSystem @@ -57,7 +57,7 @@ public class FakeLocalFileSystem : ILocalFileSystem
Task.FromResult(Right<BaseError, Unit>(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();
private static List<DirectoryInfo> Split(DirectoryInfo path)

93
ErsatzTV/Pages/ChannelEditor.razor

@ -48,35 +48,55 @@ @@ -48,35 +48,55 @@
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
<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 Class="mt-3" Label="Stream Selector Mode" @bind-Value="_model.StreamSelectorMode" For="@(() => _model.StreamSelectorMode)">
<MudSelectItem Value="@(ChannelStreamSelectorMode.Default)">Default</MudSelectItem>
<MudSelectItem Value="@(ChannelStreamSelectorMode.Custom)">Custom</MudSelectItem>
</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)">
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem>
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem>
@ -156,11 +176,12 @@ @@ -156,11 +176,12 @@
private EditContext _editContext;
private ValidationMessageStore _messageStore;
private List<FFmpegProfileViewModel> _ffmpegProfiles = new();
private List<LanguageCodeViewModel> _availableCultures = new();
private List<WatermarkViewModel> _watermarks = new();
private List<FillerPresetViewModel> _fillerPresets = new();
private List<string> _musicVideoCreditsTemplates = new();
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<LanguageCodeViewModel> _availableCultures = [];
private List<WatermarkViewModel> _watermarks = [];
private List<FillerPresetViewModel> _fillerPresets = [];
private List<string> _musicVideoCreditsTemplates = [];
private List<string> _streamSelectors = [];
public void Dispose()
{
@ -175,6 +196,7 @@ @@ -175,6 +196,7 @@
await LoadWatermarks(_cts.Token);
await LoadFillerPresets(_cts.Token);
await LoadMusicVideoCreditsTemplates(_cts.Token);
await LoadChannelStreamSelectors(_cts.Token);
if (Id.HasValue)
{
@ -200,6 +222,8 @@ @@ -200,6 +222,8 @@
_model.ProgressMode = channelViewModel.ProgressMode;
_model.StreamingMode = channelViewModel.StreamingMode;
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
_model.StreamSelector = channelViewModel.StreamSelector;
_model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode;
_model.PreferredAudioTitle = channelViewModel.PreferredAudioTitle;
_model.WatermarkId = channelViewModel.WatermarkId;
@ -249,6 +273,9 @@ @@ -249,6 +273,9 @@
private async Task LoadMusicVideoCreditsTemplates(CancellationToken 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()
{
_messageStore.Clear();

2
ErsatzTV/Startup.cs

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

6
ErsatzTV/ViewModels/ChannelEditViewModel.cs

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

Loading…
Cancel
Save