From 1b5d6af777aaed11829901ccab6b1e671fd25442 Mon Sep 17 00:00:00 2001
From: Jason Dove <1695733+jasongdove@users.noreply.github.com>
Date: Wed, 29 Apr 2026 11:38:57 -0500
Subject: [PATCH] feat: convert text subtitles using next engine (#2869)
* feat: convert text subtitles using next engine
* update dependencies
---
.../Channels/ChannelViewModel.cs | 1 +
.../Channels/Commands/CreateChannel.cs | 1 +
.../Channels/Commands/CreateChannelHandler.cs | 1 +
.../Channels/Commands/UpdateChannel.cs | 1 +
.../Channels/Commands/UpdateChannelHandler.cs | 1 +
ErsatzTV.Application/Channels/Mapper.cs | 1 +
.../ErsatzTV.Application.csproj | 4 +-
.../Commands/SyncNextPlayoutHandler.cs | 29 +-
.../Commands/StartFFmpegNextSessionHandler.cs | 26 +-
.../ErsatzTV.Core.Tests.csproj | 12 +-
ErsatzTV.Core/Domain/Channel.cs | 1 +
.../Domain/NextEngineSubtitleMode.cs | 7 +
ErsatzTV.Core/ErsatzTV.Core.csproj | 12 +-
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs | 223 +-
ErsatzTV.Core/Next/Config/ChannelConfig.cs | 59 +
.../ErsatzTV.FFmpeg.Tests.csproj | 6 +-
ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj | 4 +-
...nnelNextEngineTextSubtitleMode.Designer.cs | 7051 +++++++++++++++++
...0_Add_ChannelNextEngineTextSubtitleMode.cs | 29 +
.../Migrations/TvContextModelSnapshot.cs | 3 +
...nnelNextEngineTextSubtitleMode.Designer.cs | 6878 ++++++++++++++++
...2_Add_ChannelNextEngineTextSubtitleMode.cs | 29 +
.../Migrations/TvContextModelSnapshot.cs | 3 +
.../ErsatzTV.Infrastructure.Tests.csproj | 2 +-
.../ErsatzTV.Infrastructure.csproj | 2 +-
.../ErsatzTV.Scanner.Tests.csproj | 2 +-
ErsatzTV.Scanner/ErsatzTV.Scanner.csproj | 8 +-
ErsatzTV/ErsatzTV.csproj | 18 +-
ErsatzTV/Pages/ChannelEditor.razor | 14 +
ErsatzTV/Shared/ChannelPreviewDialog.razor | 1 +
ErsatzTV/ViewModels/ChannelEditViewModel.cs | 3 +
31 files changed, 14306 insertions(+), 126 deletions(-)
create mode 100644 ErsatzTV.Core/Domain/NextEngineSubtitleMode.cs
create mode 100644 ErsatzTV.Infrastructure.MySql/Migrations/20260429142640_Add_ChannelNextEngineTextSubtitleMode.Designer.cs
create mode 100644 ErsatzTV.Infrastructure.MySql/Migrations/20260429142640_Add_ChannelNextEngineTextSubtitleMode.cs
create mode 100644 ErsatzTV.Infrastructure.Sqlite/Migrations/20260429142552_Add_ChannelNextEngineTextSubtitleMode.Designer.cs
create mode 100644 ErsatzTV.Infrastructure.Sqlite/Migrations/20260429142552_Add_ChannelNextEngineTextSubtitleMode.cs
diff --git a/ErsatzTV.Application/Channels/ChannelViewModel.cs b/ErsatzTV.Application/Channels/ChannelViewModel.cs
index e4f55ec05..e4693c7a8 100644
--- a/ErsatzTV.Application/Channels/ChannelViewModel.cs
+++ b/ErsatzTV.Application/Channels/ChannelViewModel.cs
@@ -22,6 +22,7 @@ public record ChannelViewModel(
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingEngine StreamingEngine,
+ NextEngineTextSubtitleMode NextEngineTextSubtitleMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs
index 9a5b138d1..feaa51f67 100644
--- a/ErsatzTV.Application/Channels/Commands/CreateChannel.cs
+++ b/ErsatzTV.Application/Channels/Commands/CreateChannel.cs
@@ -21,6 +21,7 @@ public record CreateChannel(
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingEngine StreamingEngine,
+ NextEngineTextSubtitleMode NextEngineTextSubtitleMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
index b6d24197b..37ce3c9b7 100644
--- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
@@ -86,6 +86,7 @@ public class CreateChannelHandler(
MirrorSourceChannelId = request.MirrorSourceChannelId,
PlayoutOffset = request.PlayoutOffset,
StreamingEngine = request.StreamingEngine,
+ NextEngineTextSubtitleMode = request.NextEngineTextSubtitleMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
index 844893cda..9e8bee01a 100644
--- a/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
+++ b/ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
@@ -22,6 +22,7 @@ public record UpdateChannel(
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingEngine StreamingEngine,
+ NextEngineTextSubtitleMode NextEngineTextSubtitleMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
index c05af5cdf..445b1d4bd 100644
--- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
+++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
@@ -133,6 +133,7 @@ public class UpdateChannelHandler(
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingEngine = update.StreamingEngine;
+ c.NextEngineTextSubtitleMode = update.NextEngineTextSubtitleMode;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs
index 4c59ba569..401b9d7cc 100644
--- a/ErsatzTV.Application/Channels/Mapper.cs
+++ b/ErsatzTV.Application/Channels/Mapper.cs
@@ -25,6 +25,7 @@ internal static class Mapper
channel.MirrorSourceChannelId,
channel.PlayoutOffset,
channel.StreamingEngine,
+ channel.NextEngineTextSubtitleMode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj
index 00f38e17b..32369f792 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj
@@ -14,8 +14,8 @@
-
-
+
+
diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
index b57ccbac5..c459723e9 100644
--- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
+++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
@@ -17,6 +17,7 @@ using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
+using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem;
namespace ErsatzTV.Application.Playouts;
@@ -320,11 +321,27 @@ public partial class SyncNextPlayoutHandler(
foreach (Subtitle subtitle in maybeSubtitle)
{
- if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null)
+ if (subtitle.SubtitleKind is SubtitleKind.Embedded)
{
- nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks();
- nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection();
- nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex;
+ if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null)
+ {
+ nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks();
+ nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection();
+ 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,
+ };
+ }
}
}
}
@@ -484,8 +501,8 @@ public partial class SyncNextPlayoutHandler(
//allSubtitles.RemoveAll(s => s.Codec == "eia_608");
}
- // TODO: support text subtitles; external image subtitles
- allSubtitles.RemoveAll(s => !s.IsImage || s.SubtitleKind is not SubtitleKind.Embedded);
+ // TODO: external image subtitles
+ allSubtitles.RemoveAll(s => s.IsImage && s.SubtitleKind is not SubtitleKind.Embedded);
return allSubtitles;
}
diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
index d54eb5099..81bb2eaeb 100644
--- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
+++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
@@ -1,6 +1,5 @@
using System.Globalization;
using System.IO.Abstractions;
-using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
@@ -18,6 +17,7 @@ using ErsatzTV.Core.Next.Config;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Subtitle = ErsatzTV.Core.Next.Config.Subtitle;
namespace ErsatzTV.Application.Streaming;
@@ -67,7 +67,7 @@ public class StartFFmpegNextSessionHandler(
await mediator.Send(new RefreshGraphicsElements(), cancellationToken);
ChannelConfig config = await MapConfig(
- request.ChannelNumber,
+ validationResult.Channel,
validationResult.FfmpegProfile,
cancellationToken);
@@ -230,6 +230,9 @@ public class StartFFmpegNextSessionHandler(
var variantPlaylist =
$"{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 maybeStreamingSpecs =
await mediator.Send(new GetChannelStreamingSpecs(request.ChannelNumber));
string resolution = string.Empty;
@@ -268,13 +271,14 @@ public class StartFFmpegNextSessionHandler(
}
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}
{variantPlaylist}";
}
private async Task MapConfig(
- string channelNumber,
+ ChannelViewModel channel,
FFmpegProfileViewModel ffmpegProfile,
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
{
@@ -367,7 +380,8 @@ public class StartFFmpegNextSessionHandler(
Normalization = new Normalization
{
Audio = audioNormalization,
- Video = videoNormalization
+ Video = videoNormalization,
+ Subtitle = subtitleNormalization
}
};
}
diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
index 1bd40e366..20287e7a8 100644
--- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
+++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
@@ -9,12 +9,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/ErsatzTV.Core/Domain/Channel.cs b/ErsatzTV.Core/Domain/Channel.cs
index fb7970e21..64c3ba362 100644
--- a/ErsatzTV.Core/Domain/Channel.cs
+++ b/ErsatzTV.Core/Domain/Channel.cs
@@ -23,6 +23,7 @@ public class Channel
public int? FallbackFillerId { get; set; }
public FillerPreset FallbackFiller { get; set; }
public StreamingEngine StreamingEngine { get; set; }
+ public NextEngineTextSubtitleMode NextEngineTextSubtitleMode { get; set; }
public StreamingMode StreamingMode { get; set; }
public List Playouts { get; set; }
public List Artwork { get; set; }
diff --git a/ErsatzTV.Core/Domain/NextEngineSubtitleMode.cs b/ErsatzTV.Core/Domain/NextEngineSubtitleMode.cs
new file mode 100644
index 000000000..b387b6251
--- /dev/null
+++ b/ErsatzTV.Core/Domain/NextEngineSubtitleMode.cs
@@ -0,0 +1,7 @@
+namespace ErsatzTV.Core.Domain;
+
+public enum NextEngineTextSubtitleMode
+{
+ Burn = 0,
+ Convert = 1,
+}
diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj
index 7656ee9a2..98e6a64c3 100644
--- a/ErsatzTV.Core/ErsatzTV.Core.csproj
+++ b/ErsatzTV.Core/ErsatzTV.Core.csproj
@@ -16,10 +16,10 @@
-
-
-
-
+
+
+
+
@@ -27,10 +27,10 @@
-
+
-
+
diff --git a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
index 57d0fde00..e1e74f711 100644
--- a/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
+++ b/ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
@@ -107,7 +107,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
channel,
allLanguageCodes,
version.MediaItem.Id,
- version.MediaVersion);
+ version.MediaVersion,
+ shouldLogMessages);
sw.Stop();
if (shouldLogMessages)
{
@@ -126,7 +127,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
channel,
allLanguageCodes,
version.MediaItem.Id,
- version.MediaVersion);
+ version.MediaVersion,
+ shouldLogMessages);
sw2.Stop();
if (shouldLogMessages)
{
@@ -144,10 +146,13 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic");
+ if (shouldLogMessages)
+ {
+ _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