Browse Source

feat: convert text subtitles using next engine (#2869)

* feat: convert text subtitles using next engine

* update dependencies
pull/2870/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
1b5d6af777
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  2. 1
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  3. 1
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  4. 1
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  5. 1
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  6. 1
      ErsatzTV.Application/Channels/Mapper.cs
  7. 4
      ErsatzTV.Application/ErsatzTV.Application.csproj
  8. 21
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  9. 26
      ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
  10. 12
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  11. 1
      ErsatzTV.Core/Domain/Channel.cs
  12. 7
      ErsatzTV.Core/Domain/NextEngineSubtitleMode.cs
  13. 12
      ErsatzTV.Core/ErsatzTV.Core.csproj
  14. 101
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  15. 59
      ErsatzTV.Core/Next/Config/ChannelConfig.cs
  16. 6
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  17. 4
      ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
  18. 7051
      ErsatzTV.Infrastructure.MySql/Migrations/20260429142640_Add_ChannelNextEngineTextSubtitleMode.Designer.cs
  19. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20260429142640_Add_ChannelNextEngineTextSubtitleMode.cs
  20. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  21. 6878
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260429142552_Add_ChannelNextEngineTextSubtitleMode.Designer.cs
  22. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260429142552_Add_ChannelNextEngineTextSubtitleMode.cs
  23. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  24. 2
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  25. 2
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  26. 2
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  27. 8
      ErsatzTV.Scanner/ErsatzTV.Scanner.csproj
  28. 18
      ErsatzTV/ErsatzTV.csproj
  29. 14
      ErsatzTV/Pages/ChannelEditor.razor
  30. 1
      ErsatzTV/Shared/ChannelPreviewDialog.razor
  31. 3
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

1
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -22,6 +22,7 @@ public record ChannelViewModel(
int? MirrorSourceChannelId, int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset, TimeSpan? PlayoutOffset,
StreamingEngine StreamingEngine, StreamingEngine StreamingEngine,
NextEngineTextSubtitleMode NextEngineTextSubtitleMode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId, int? WatermarkId,
int? FallbackFillerId, int? FallbackFillerId,

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

@ -21,6 +21,7 @@ public record CreateChannel(
int? MirrorSourceChannelId, int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset, TimeSpan? PlayoutOffset,
StreamingEngine StreamingEngine, StreamingEngine StreamingEngine,
NextEngineTextSubtitleMode NextEngineTextSubtitleMode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId, int? WatermarkId,
int? FallbackFillerId, int? FallbackFillerId,

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

@ -86,6 +86,7 @@ public class CreateChannelHandler(
MirrorSourceChannelId = request.MirrorSourceChannelId, MirrorSourceChannelId = request.MirrorSourceChannelId,
PlayoutOffset = request.PlayoutOffset, PlayoutOffset = request.PlayoutOffset,
StreamingEngine = request.StreamingEngine, StreamingEngine = request.StreamingEngine,
NextEngineTextSubtitleMode = request.NextEngineTextSubtitleMode,
StreamingMode = request.StreamingMode, StreamingMode = request.StreamingMode,
Artwork = artwork, Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode, StreamSelectorMode = request.StreamSelectorMode,

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

@ -22,6 +22,7 @@ public record UpdateChannel(
int? MirrorSourceChannelId, int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset, TimeSpan? PlayoutOffset,
StreamingEngine StreamingEngine, StreamingEngine StreamingEngine,
NextEngineTextSubtitleMode NextEngineTextSubtitleMode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId, int? WatermarkId,
int? FallbackFillerId, int? FallbackFillerId,

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

@ -133,6 +133,7 @@ public class UpdateChannelHandler(
c.MirrorSourceChannelId = update.MirrorSourceChannelId; c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset; c.PlayoutOffset = update.PlayoutOffset;
c.StreamingEngine = update.StreamingEngine; c.StreamingEngine = update.StreamingEngine;
c.NextEngineTextSubtitleMode = update.NextEngineTextSubtitleMode;
c.StreamingMode = update.StreamingMode; c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId; c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId; c.FallbackFillerId = update.FallbackFillerId;

1
ErsatzTV.Application/Channels/Mapper.cs

@ -25,6 +25,7 @@ internal static class Mapper
channel.MirrorSourceChannelId, channel.MirrorSourceChannelId,
channel.PlayoutOffset, channel.PlayoutOffset,
channel.StreamingEngine, channel.StreamingEngine,
channel.NextEngineTextSubtitleMode,
channel.StreamingMode, channel.StreamingMode,
channel.WatermarkId, channel.WatermarkId,
channel.FallbackFillerId, channel.FallbackFillerId,

4
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -14,8 +14,8 @@
<PackageReference Include="CliWrap" Version="3.10.1" /> <PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="Humanizer.Core" Version="3.0.10" /> <PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="MediatR" Version="[12.5.0]" /> <PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" /> <PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.21.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.21.0" />

21
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -17,6 +17,7 @@ using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
@ -319,6 +320,8 @@ public partial class SyncNextPlayoutHandler(
} }
foreach (Subtitle subtitle in maybeSubtitle) foreach (Subtitle subtitle in maybeSubtitle)
{
if (subtitle.SubtitleKind is SubtitleKind.Embedded)
{ {
if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null)
{ {
@ -327,6 +330,20 @@ public partial class SyncNextPlayoutHandler(
nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex;
} }
} }
else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
if (nextPlayoutItem.Tracks?.Subtitle?.Source is null)
{
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks();
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection();
nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source
{
SourceType = Core.Next.SourceType.Local,
Path = subtitle.Path,
};
}
}
}
} }
private async Task<Option<Core.Next.Source>> SourceForItem( private async Task<Option<Core.Next.Source>> SourceForItem(
@ -484,8 +501,8 @@ public partial class SyncNextPlayoutHandler(
//allSubtitles.RemoveAll(s => s.Codec == "eia_608"); //allSubtitles.RemoveAll(s => s.Codec == "eia_608");
} }
// TODO: support text subtitles; external image subtitles // TODO: external image subtitles
allSubtitles.RemoveAll(s => !s.IsImage || s.SubtitleKind is not SubtitleKind.Embedded); allSubtitles.RemoveAll(s => s.IsImage && s.SubtitleKind is not SubtitleKind.Embedded);
return allSubtitles; return allSubtitles;
} }

26
ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs

@ -1,6 +1,5 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
@ -18,6 +17,7 @@ using ErsatzTV.Core.Next.Config;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Subtitle = ErsatzTV.Core.Next.Config.Subtitle;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
@ -67,7 +67,7 @@ public class StartFFmpegNextSessionHandler(
await mediator.Send(new RefreshGraphicsElements(), cancellationToken); await mediator.Send(new RefreshGraphicsElements(), cancellationToken);
ChannelConfig config = await MapConfig( ChannelConfig config = await MapConfig(
request.ChannelNumber, validationResult.Channel,
validationResult.FfmpegProfile, validationResult.FfmpegProfile,
cancellationToken); cancellationToken);
@ -230,6 +230,9 @@ public class StartFFmpegNextSessionHandler(
var variantPlaylist = var variantPlaylist =
$"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live.m3u8{request.AccessTokenQuery}"; $"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live.m3u8{request.AccessTokenQuery}";
var subtitlePlaylist =
$"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live_sub.m3u8{request.AccessTokenQuery}";
Option<ChannelStreamingSpecsViewModel> maybeStreamingSpecs = Option<ChannelStreamingSpecsViewModel> maybeStreamingSpecs =
await mediator.Send(new GetChannelStreamingSpecs(request.ChannelNumber)); await mediator.Send(new GetChannelStreamingSpecs(request.ChannelNumber));
string resolution = string.Empty; string resolution = string.Empty;
@ -268,13 +271,14 @@ public class StartFFmpegNextSessionHandler(
} }
return $@"#EXTM3U return $@"#EXTM3U
#EXT-X-VERSION:3 #EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=""subs"",NAME=""English"",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO,LANGUAGE=""en"",URI=""{subtitlePlaylist}""
#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} #EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution}
{variantPlaylist}"; {variantPlaylist}";
} }
private async Task<ChannelConfig> MapConfig( private async Task<ChannelConfig> MapConfig(
string channelNumber, ChannelViewModel channel,
FFmpegProfileViewModel ffmpegProfile, FFmpegProfileViewModel ffmpegProfile,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@ -355,7 +359,16 @@ public class StartFFmpegNextSessionHandler(
} }
}; };
string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channelNumber, "current"); var subtitleNormalization = new Subtitle
{
Mode = channel.NextEngineTextSubtitleMode switch
{
NextEngineTextSubtitleMode.Convert => Mode.Convert,
_ => Mode.Burn
}
};
string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channel.Number, "current");
return new ChannelConfig return new ChannelConfig
{ {
@ -367,7 +380,8 @@ public class StartFFmpegNextSessionHandler(
Normalization = new Normalization Normalization = new Normalization
{ {
Audio = audioNormalization, Audio = audioNormalization,
Video = videoNormalization Video = videoNormalization,
Subtitle = subtitleNormalization
} }
}; };
} }

12
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -9,12 +9,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.1" /> <PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.5.1" /> <PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" /> <PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />

1
ErsatzTV.Core/Domain/Channel.cs

@ -23,6 +23,7 @@ public class Channel
public int? FallbackFillerId { get; set; } public int? FallbackFillerId { get; set; }
public FillerPreset FallbackFiller { get; set; } public FillerPreset FallbackFiller { get; set; }
public StreamingEngine StreamingEngine { get; set; } public StreamingEngine StreamingEngine { get; set; }
public NextEngineTextSubtitleMode NextEngineTextSubtitleMode { get; set; }
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; }

7
ErsatzTV.Core/Domain/NextEngineSubtitleMode.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum NextEngineTextSubtitleMode
{
Burn = 0,
Convert = 1,
}

12
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -16,10 +16,10 @@
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" /> <PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
<PackageReference Include="MediatR" Version="[12.5.0]" /> <PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="NCalcSync" Version="5.12.0" /> <PackageReference Include="NCalcSync" Version="5.12.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
@ -27,10 +27,10 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="SkiaSharp" Version="3.119.2" /> <PackageReference Include="SkiaSharp" Version="3.119.2" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" />
<PackageReference Include="System.CommandLine" Version="2.0.6" /> <PackageReference Include="System.CommandLine" Version="2.0.7" />
<PackageReference Include="Testably.Abstractions" Version="10.2.0" /> <PackageReference Include="Testably.Abstractions" Version="10.2.0" />
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" /> <PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
<PackageReference Include="YamlDotNet" Version="17.0.1" /> <PackageReference Include="YamlDotNet" Version="17.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

101
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -107,7 +107,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
channel, channel,
allLanguageCodes, allLanguageCodes,
version.MediaItem.Id, version.MediaItem.Id,
version.MediaVersion); version.MediaVersion,
shouldLogMessages);
sw.Stop(); sw.Stop();
if (shouldLogMessages) if (shouldLogMessages)
{ {
@ -126,7 +127,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
channel, channel,
allLanguageCodes, allLanguageCodes,
version.MediaItem.Id, version.MediaItem.Id,
version.MediaVersion); version.MediaVersion,
shouldLogMessages);
sw2.Stop(); sw2.Stop();
if (shouldLogMessages) if (shouldLogMessages)
{ {
@ -143,11 +145,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
} }
} }
catch (Exception ex) catch (Exception ex)
{
if (shouldLogMessages)
{ {
_logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic"); _logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic");
} }
}
return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle); return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle, shouldLogMessages);
} }
public async Task<Option<Subtitle>> SelectSubtitleStream( public async Task<Option<Subtitle>> SelectSubtitleStream(
@ -176,6 +181,9 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
var candidateSubtitles = subtitles.ToList(); var candidateSubtitles = subtitles.ToList();
// next engine doesn't need to specifically enable or pre-extract embedded subtitles
if (channel.StreamingEngine != StreamingEngine.Next)
{
bool useEmbeddedSubtitles = await _configElementRepository bool useEmbeddedSubtitles = await _configElementRepository
.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken) .GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken)
.IfNoneAsync(true); .IfNoneAsync(true);
@ -187,7 +195,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
_logger.LogDebug("Ignoring embedded subtitles for channel {Number}", channel.Number); _logger.LogDebug("Ignoring embedded subtitles for channel {Number}", channel.Number);
} }
candidateSubtitles = candidateSubtitles.Filter(s => s.SubtitleKind is not SubtitleKind.Embedded).ToList(); candidateSubtitles =
candidateSubtitles.Filter(s => s.SubtitleKind is not SubtitleKind.Embedded).ToList();
} }
if (channel.StreamingMode is not StreamingMode.HttpLiveStreamingDirect) if (channel.StreamingMode is not StreamingMode.HttpLiveStreamingDirect)
@ -220,6 +229,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
} }
} }
} }
}
var allCodes = new List<string>(); var allCodes = new List<string>();
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant(); string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
@ -294,7 +304,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
private Option<MediaStream> DefaultSelectAudioStream( private Option<MediaStream> DefaultSelectAudioStream(
MediaVersion version, MediaVersion version,
IReadOnlyCollection<string> preferredLanguageCodes, IReadOnlyCollection<string> preferredLanguageCodes,
string preferredAudioTitle) string preferredAudioTitle,
bool shouldLogMessages)
{ {
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList(); var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
@ -303,27 +314,33 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
.ToList(); .ToList();
if (correctLanguage.Count != 0) if (correctLanguage.Count != 0)
{
if (shouldLogMessages)
{ {
_logger.LogDebug( _logger.LogDebug(
"Found {Count} audio streams with preferred audio language code(s) {Code}", "Found {Count} audio streams with preferred audio language code(s) {Code}",
correctLanguage.Count, correctLanguage.Count,
preferredLanguageCodes); preferredLanguageCodes);
}
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty); return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty, shouldLogMessages);
} }
if (shouldLogMessages)
{
_logger.LogDebug( _logger.LogDebug(
"Unable to find audio stream with preferred audio language code(s) {Code}", "Unable to find audio stream with preferred audio language code(s) {Code}",
preferredLanguageCodes); preferredLanguageCodes);
}
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty); return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty, shouldLogMessages);
} }
private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title) private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title, bool shouldLogMessages)
{ {
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
{ {
return PrioritizeDefault(streams); return PrioritizeDefault(streams, shouldLogMessages);
} }
// prioritize matching titles // prioritize matching titles
@ -331,31 +348,44 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
.Filter(ms => (ms.Title ?? string.Empty).Contains(title, StringComparison.OrdinalIgnoreCase)) .Filter(ms => (ms.Title ?? string.Empty).Contains(title, StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
if (matchingTitle.Count != 0) if (matchingTitle.Count != 0)
{
if (shouldLogMessages)
{ {
_logger.LogDebug( _logger.LogDebug(
"Found {Count} audio streams with preferred title {Title}", "Found {Count} audio streams with preferred title {Title}",
matchingTitle.Count, matchingTitle.Count,
title); title);
}
return PrioritizeDefault(matchingTitle); return PrioritizeDefault(matchingTitle, shouldLogMessages);
} }
if (shouldLogMessages)
{
_logger.LogDebug("Unable to find audio stream with preferred title {Title}", title); _logger.LogDebug("Unable to find audio stream with preferred title {Title}", title);
}
return PrioritizeDefault(streams); return PrioritizeDefault(streams, shouldLogMessages);
} }
private Option<MediaStream> PrioritizeDefault(IReadOnlyCollection<MediaStream> streams) private Option<MediaStream> PrioritizeDefault(IReadOnlyCollection<MediaStream> streams, bool shouldLogMessages)
{ {
var sorted = streams.OrderByDescending(s => s.Channels).ToList(); var sorted = streams.OrderByDescending(s => s.Channels).ToList();
Option<MediaStream> maybeDefault = Optional(sorted.Find(s => s.Default)); Option<MediaStream> maybeDefault = Optional(sorted.Find(s => s.Default));
foreach (MediaStream stream in maybeDefault) foreach (MediaStream stream in maybeDefault)
{
if (shouldLogMessages)
{ {
_logger.LogDebug("Found audio stream flagged as default"); _logger.LogDebug("Found audio stream flagged as default");
}
return stream; return stream;
} }
if (shouldLogMessages)
{
_logger.LogDebug("Unable to find default audio stream; selecting stream with most channels"); _logger.LogDebug("Unable to find default audio stream; selecting stream with most channels");
}
return streams.HeadOrNone(); return streams.HeadOrNone();
} }
@ -364,20 +394,33 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
Channel channel, Channel channel,
List<string> preferredLanguageCodes, List<string> preferredLanguageCodes,
int episodeId, int episodeId,
MediaVersion version) MediaVersion version,
bool shouldLogMessages)
{ {
string jsScriptPath = Path.ChangeExtension( string jsScriptPath = Path.ChangeExtension(
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "episode"), Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "episode"),
"js"); "js");
if (shouldLogMessages)
{
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
}
if (!_fileSystem.File.Exists(jsScriptPath)) if (!_fileSystem.File.Exists(jsScriptPath))
{ {
_logger.LogDebug("Unable to locate episode audio stream selector script; falling back to built-in logic"); if (shouldLogMessages)
{
_logger.LogDebug(
"Unable to locate episode audio stream selector script; falling back to built-in logic");
}
return Option<MediaStream>.None; return Option<MediaStream>.None;
} }
if (shouldLogMessages)
{
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath); _logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
}
await _scriptEngine.LoadAsync(jsScriptPath); await _scriptEngine.LoadAsync(jsScriptPath);
@ -397,28 +440,40 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
preferredLanguageCodes.ToArray(), preferredLanguageCodes.ToArray(),
audioStreams); audioStreams);
return ProcessScriptResult(version, result); return ProcessScriptResult(version, result, shouldLogMessages);
} }
private async Task<Option<MediaStream>> SelectMovieAudioStream( private async Task<Option<MediaStream>> SelectMovieAudioStream(
Channel channel, Channel channel,
List<string> preferredLanguageCodes, List<string> preferredLanguageCodes,
int movieId, int movieId,
MediaVersion version) MediaVersion version,
bool shouldLogMessages)
{ {
string jsScriptPath = Path.ChangeExtension( string jsScriptPath = Path.ChangeExtension(
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "movie"), Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "movie"),
"js"); "js");
if (shouldLogMessages)
{
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
}
if (!_fileSystem.File.Exists(jsScriptPath)) if (!_fileSystem.File.Exists(jsScriptPath))
{
if (shouldLogMessages)
{ {
_logger.LogDebug( _logger.LogDebug(
"Unable to locate movie audio stream selector script; falling back to built-in logic"); "Unable to locate movie audio stream selector script; falling back to built-in logic");
}
return Option<MediaStream>.None; return Option<MediaStream>.None;
} }
if (shouldLogMessages)
{
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath); _logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
}
await _scriptEngine.LoadAsync(jsScriptPath); await _scriptEngine.LoadAsync(jsScriptPath);
@ -435,34 +490,44 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
preferredLanguageCodes.ToArray(), preferredLanguageCodes.ToArray(),
audioStreams); audioStreams);
return ProcessScriptResult(version, result); return ProcessScriptResult(version, result, shouldLogMessages);
} }
private Option<MediaStream> ProcessScriptResult(MediaVersion version, object result) private Option<MediaStream> ProcessScriptResult(MediaVersion version, object result, bool shouldLogMessages)
{ {
if (result is double d) if (result is double d)
{ {
var streamIndex = (int)d; var streamIndex = (int)d;
Option<MediaStream> maybeStream = version.Streams.Find(s => s.Index == streamIndex); Option<MediaStream> maybeStream = version.Streams.Find(s => s.Index == streamIndex);
foreach (MediaStream stream in maybeStream) foreach (MediaStream stream in maybeStream)
{
if (shouldLogMessages)
{ {
_logger.LogDebug( _logger.LogDebug(
"JS Script returned audio stream index {Index} with language {Language} and {Channels} audio channel(s)", "JS Script returned audio stream index {Index} with language {Language} and {Channels} audio channel(s)",
streamIndex, streamIndex,
stream.Language, stream.Language,
stream.Channels); stream.Channels);
}
return stream; return stream;
} }
if (shouldLogMessages)
{
_logger.LogWarning( _logger.LogWarning(
"JS Script returned audio stream index {Index} which does not exist", "JS Script returned audio stream index {Index} which does not exist",
streamIndex); streamIndex);
} }
}
else else
{
if (shouldLogMessages)
{ {
_logger.LogInformation( _logger.LogInformation(
"JS Script did not return an audio stream index; falling back to built-in logic"); "JS Script did not return an audio stream index; falling back to built-in logic");
} }
}
return Option<MediaStream>.None; return Option<MediaStream>.None;
} }

59
ErsatzTV.Core/Next/Config/ChannelConfig.cs

@ -37,6 +37,9 @@ namespace ErsatzTV.Core.Next.Config
[JsonProperty("ffprobe_path")] [JsonProperty("ffprobe_path")]
public string FfprobePath { get; set; } public string FfprobePath { get; set; }
[JsonProperty("preferred_filters", NullValueHandling = NullValueHandling.Ignore)]
public List<string> PreferredFilters { get; set; }
} }
public partial class Normalization public partial class Normalization
@ -44,6 +47,9 @@ namespace ErsatzTV.Core.Next.Config
[JsonProperty("audio")] [JsonProperty("audio")]
public Audio Audio { get; set; } public Audio Audio { get; set; }
[JsonProperty("subtitle", NullValueHandling = NullValueHandling.Ignore)]
public Subtitle Subtitle { get; set; }
[JsonProperty("video")] [JsonProperty("video")]
public Video Video { get; set; } public Video Video { get; set; }
} }
@ -84,6 +90,12 @@ namespace ErsatzTV.Core.Next.Config
public double? TruePeak { get; set; } public double? TruePeak { get; set; }
} }
public partial class Subtitle
{
[JsonProperty("mode", NullValueHandling = NullValueHandling.Ignore)]
public Mode? Mode { get; set; }
}
public partial class Video public partial class Video
{ {
[JsonProperty("accel")] [JsonProperty("accel")]
@ -98,6 +110,9 @@ namespace ErsatzTV.Core.Next.Config
[JsonProperty("buffer_kbps")] [JsonProperty("buffer_kbps")]
public long? BufferKbps { get; set; } public long? BufferKbps { get; set; }
[JsonProperty("deinterlace", NullValueHandling = NullValueHandling.Ignore)]
public bool? Deinterlace { get; set; }
[JsonProperty("format")] [JsonProperty("format")]
public VideoFormat? Format { get; set; } public VideoFormat? Format { get; set; }
@ -131,6 +146,8 @@ namespace ErsatzTV.Core.Next.Config
public enum AudioFormat { Aac, Ac3 }; public enum AudioFormat { Aac, Ac3 };
public enum Mode { Burn, Convert };
public enum AccelEnum { Cuda, Qsv, Vaapi, Videotoolbox, Vulkan }; public enum AccelEnum { Cuda, Qsv, Vaapi, Videotoolbox, Vulkan };
public enum VideoFormat { H264, Hevc }; public enum VideoFormat { H264, Hevc };
@ -156,6 +173,7 @@ namespace ErsatzTV.Core.Next.Config
Converters = Converters =
{ {
AudioFormatConverter.Singleton, AudioFormatConverter.Singleton,
ModeConverter.Singleton,
AccelEnumConverter.Singleton, AccelEnumConverter.Singleton,
VideoFormatConverter.Singleton, VideoFormatConverter.Singleton,
VaapiDriverEnumConverter.Singleton, VaapiDriverEnumConverter.Singleton,
@ -205,6 +223,47 @@ namespace ErsatzTV.Core.Next.Config
public static readonly AudioFormatConverter Singleton = new AudioFormatConverter(); public static readonly AudioFormatConverter Singleton = new AudioFormatConverter();
} }
internal class ModeConverter : JsonConverter
{
public override bool CanConvert(Type t) => t == typeof(Mode) || t == typeof(Mode?);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null) return null;
var value = serializer.Deserialize<string>(reader);
switch (value)
{
case "burn":
return Mode.Burn;
case "convert":
return Mode.Convert;
}
throw new Exception("Cannot unmarshal type Mode");
}
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer)
{
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (Mode)untypedValue;
switch (value)
{
case Mode.Burn:
serializer.Serialize(writer, "burn");
return;
case Mode.Convert:
serializer.Serialize(writer, "convert");
return;
}
throw new Exception("Cannot marshal type Mode");
}
public static readonly ModeConverter Singleton = new ModeConverter();
}
internal class AccelEnumConverter : JsonConverter internal class AccelEnumConverter : JsonConverter
{ {
public override bool CanConvert(Type t) => t == typeof(AccelEnum) || t == typeof(AccelEnum?); public override bool CanConvert(Type t) => t == typeof(AccelEnum) || t == typeof(AccelEnum?);

6
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -8,9 +8,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" /> <PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.5.1" /> <PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" /> <PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />

4
ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj

@ -15,8 +15,8 @@
<PackageReference Include="Hardware.Info" Version="101.1.1.1" /> <PackageReference Include="Hardware.Info" Version="101.1.1.1" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Lennox.NvEncSharp" Version="2.0.0" /> <PackageReference Include="Lennox.NvEncSharp" Version="2.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
</ItemGroup> </ItemGroup>

7051
ErsatzTV.Infrastructure.MySql/Migrations/20260429142640_Add_ChannelNextEngineTextSubtitleMode.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20260429142640_Add_ChannelNextEngineTextSubtitleMode.cs

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelNextEngineTextSubtitleMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "NextEngineTextSubtitleMode",
table: "Channel",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NextEngineTextSubtitleMode",
table: "Channel");
}
}
}

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

@ -331,6 +331,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("varchar(50)") .HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci"); .UseCollation("utf8mb4_general_ci");
b.Property<int>("NextEngineTextSubtitleMode")
.HasColumnType("int");
b.Property<string>("Number") b.Property<string>("Number")
.HasColumnType("varchar(255)"); .HasColumnType("varchar(255)");

6878
ErsatzTV.Infrastructure.Sqlite/Migrations/20260429142552_Add_ChannelNextEngineTextSubtitleMode.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20260429142552_Add_ChannelNextEngineTextSubtitleMode.cs

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelNextEngineTextSubtitleMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "NextEngineTextSubtitleMode",
table: "Channel",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NextEngineTextSubtitleMode",
table: "Channel");
}
}
}

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

@ -318,6 +318,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("varchar(50)") .HasColumnType("varchar(50)")
.UseCollation("NOCASE"); .UseCollation("NOCASE");
b.Property<int>("NextEngineTextSubtitleMode")
.HasColumnType("INTEGER");
b.Property<string>("Number") b.Property<string>("Number")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

2
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.5.1" /> <PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" /> <PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />

2
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -16,7 +16,7 @@
<PackageReference Include="CliWrap" Version="3.10.1" /> <PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="Dapper" Version="2.1.72" /> <PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="EFCore.BulkExtensions" Version="[9.0.2,10)" /> <PackageReference Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.3.4" /> <PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.3.6" />
<PackageReference Include="Humanizer.Core" Version="3.0.10" /> <PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="Jint" Version="4.8.0" /> <PackageReference Include="Jint" Version="4.8.0" />
<PackageReference Include="JsonSchema.Net" Version="9.2.0" /> <PackageReference Include="JsonSchema.Net" Version="9.2.0" />

2
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -10,7 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.5.1" /> <PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" /> <PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />

8
ErsatzTV.Scanner/ErsatzTV.Scanner.csproj

@ -26,16 +26,16 @@
<PackageReference Include="Humanizer.Core" Version="3.0.10" /> <PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="MediatR" Version="[12.5.0]" /> <PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" /> <PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" /> <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.6" /> <PackageReference Include="System.CommandLine" Version="2.0.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

18
ErsatzTV/ErsatzTV.csproj

@ -31,7 +31,7 @@
<ItemGroup> <ItemGroup>
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" /> --> <!-- <PackageReference Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" /> -->
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" /> <PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageReference Include="BlazorSortable" Version="6.0.0" /> <PackageReference Include="BlazorSortable" Version="6.0.1" />
<PackageReference Include="Chronic.Core" Version="0.4.0" /> <PackageReference Include="Chronic.Core" Version="0.4.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" /> <PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
@ -39,23 +39,23 @@
<PackageReference Include="HtmlSanitizer" Version="9.0.892" /> <PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" /> <PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" /> <PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.15"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.15">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.6"> <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MudBlazor" Version="9.3.0" /> <PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="NaturalSort.Extension" Version="4.4.1" /> <PackageReference Include="NaturalSort.Extension" Version="4.4.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" /> <PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.1" /> <PackageReference Include="Scalar.AspNetCore" Version="2.14.6" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />

14
ErsatzTV/Pages/ChannelEditor.razor

@ -140,6 +140,18 @@ else
<MudSelectItem Value="@(StreamingEngine.Next)">Next</MudSelectItem> <MudSelectItem Value="@(StreamingEngine.Next)">Next</MudSelectItem>
</MudSelect> </MudSelect>
</MudStack> </MudStack>
@if (_model.StreamingEngine is StreamingEngine.Next)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Next Engine Text Subtitle Mode</MudText>
</div>
<MudSelect @bind-Value="_model.NextEngineTextSubtitleMode" For="@(() => _model.NextEngineTextSubtitleMode)">
<MudSelectItem Value="@(NextEngineTextSubtitleMode.Burn)">Burn</MudSelectItem>
<MudSelectItem Value="@(NextEngineTextSubtitleMode.Convert)">Convert If Possible</MudSelectItem>
</MudSelect>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Streaming Mode</MudText> <MudText>Streaming Mode</MudText>
@ -421,6 +433,7 @@ else
_model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId; _model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId;
_model.PlayoutOffset = channelViewModel.PlayoutOffset; _model.PlayoutOffset = channelViewModel.PlayoutOffset;
_model.StreamingEngine = channelViewModel.StreamingEngine; _model.StreamingEngine = channelViewModel.StreamingEngine;
_model.NextEngineTextSubtitleMode = channelViewModel.NextEngineTextSubtitleMode;
_model.StreamingMode = channelViewModel.StreamingMode; _model.StreamingMode = channelViewModel.StreamingMode;
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode; _model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
_model.StreamSelector = channelViewModel.StreamSelector; _model.StreamSelector = channelViewModel.StreamSelector;
@ -453,6 +466,7 @@ else
_model.Group = "ErsatzTV"; _model.Group = "ErsatzTV";
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId; _model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingEngine = StreamingEngine.Legacy; _model.StreamingEngine = StreamingEngine.Legacy;
_model.NextEngineTextSubtitleMode = NextEngineTextSubtitleMode.Burn;
_model.StreamingMode = StreamingMode.TransportStreamHybrid; _model.StreamingMode = StreamingMode.TransportStreamHybrid;
_model.IsEnabled = true; _model.IsEnabled = true;
_model.ShowInEpg = true; _model.ShowInEpg = true;

1
ErsatzTV/Shared/ChannelPreviewDialog.razor

@ -9,6 +9,7 @@
<media-control-bar> <media-control-bar>
<media-mute-button></media-mute-button> <media-mute-button></media-mute-button>
<media-volume-range></media-volume-range> <media-volume-range></media-volume-range>
<media-captions-button></media-captions-button>
<media-fullscreen-button></media-fullscreen-button> <media-fullscreen-button></media-fullscreen-button>
</media-control-bar> </media-control-bar>
</media-controller> </media-controller>

3
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -44,6 +44,7 @@ public class ChannelEditViewModel
} }
} }
public NextEngineTextSubtitleMode NextEngineTextSubtitleMode { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public int? WatermarkId { get; set; } public int? WatermarkId { get; set; }
public int? FallbackFillerId { get; set; } public int? FallbackFillerId { get; set; }
@ -86,6 +87,7 @@ public class ChannelEditViewModel
MirrorSourceChannelId, MirrorSourceChannelId,
PlayoutOffset, PlayoutOffset,
StreamingEngine, StreamingEngine,
NextEngineTextSubtitleMode,
StreamingMode, StreamingMode,
WatermarkId, WatermarkId,
FallbackFillerId, FallbackFillerId,
@ -119,6 +121,7 @@ public class ChannelEditViewModel
MirrorSourceChannelId, MirrorSourceChannelId,
PlayoutOffset, PlayoutOffset,
StreamingEngine, StreamingEngine,
NextEngineTextSubtitleMode,
StreamingMode, StreamingMode,
WatermarkId, WatermarkId,
FallbackFillerId, FallbackFillerId,

Loading…
Cancel
Save