diff --git a/CHANGELOG.md b/CHANGELOG.md index 839e34343..97c487393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix case where block playouts would occasionally get stuck building forever - Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes +- Pass extracted subtitle paths to next engine; this should fix text subtitle burn-in when extraction is enabled + - Embedded text subtitles will otherwise be ignored and unused (when extraction is disabled) ## [26.6.0] - 2026-07-09 ### Added diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs index 43c93161b..c2504bee2 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs @@ -353,7 +353,8 @@ public class StartFFmpegNextSessionHandler( { NextEngineTextSubtitleMode.Convert => Mode.Convert, _ => Mode.Burn - } + }, + FontsFolder = FileSystemLayout.FontsCacheFolder }; string playoutFolder = _fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channel.Number, "current"); diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs index 4c08b0a6c..fcc721a93 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Application.Maintenance; +using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; @@ -168,6 +169,7 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa .Map(pi => pi.MediaItemId) .ToList(); + bool extractedAnything = false; foreach (int mediaItemId in toUpdate) { if (cancellationToken.IsCancellationRequested) @@ -176,8 +178,16 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa } // extract subtitles and fonts for each item and update db - await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken); - await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken); + extractedAnything |= await ExtractSubtitles( + dbContext, + mediaItemId, + ffmpegPath, + cancellationToken); + extractedAnything |= await ExtractFonts( + dbContext, + mediaItemId, + ffmpegPath, + cancellationToken); } _logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); @@ -186,6 +196,21 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa { await _entityLocker.UnlockPlayout(playoutId); } + + if (extractedAnything) + { + List channelNumbers = await dbContext.Channels + .AsNoTracking() + .Where(c => c.StreamingEngine == StreamingEngine.Next && + c.Playouts.Any(p => playoutIdsToCheck.Contains(p.Id))) + .Select(c => c.Number) + .ToListAsync(cancellationToken); + + foreach (string channelNumber in channelNumbers) + { + await _workerChannel.WriteAsync(new SyncNextPlayout(channelNumber), cancellationToken); + } + } } catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) { diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs index 52776461e..7f70d6de3 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs @@ -89,12 +89,14 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem return result; } - protected async Task ExtractSubtitles( + protected async Task ExtractSubtitles( TvContext dbContext, int mediaItemId, string ffmpegPath, CancellationToken cancellationToken) { + bool extractedAnything = false; + foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) { foreach (List allSubtitles in GetSubtitles(mediaItem)) @@ -133,9 +135,10 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem .Add("-hide_banner") .Add("-i").Add(mediaItemPath); - var subtitleIndexes = subtitlesToExtract - .OrderBy(s => s.Subtitle.StreamIndex) - .Select(s => s.Subtitle.StreamIndex) + var subtitleIndexes = allSubtitles + .Filter(s => s.SubtitleKind is SubtitleKind.Embedded) + .OrderBy(s => s.StreamIndex) + .Select(s => s.StreamIndex) .ToList(); foreach (SubtitleToExtract subtitle in subtitlesToExtract) @@ -172,6 +175,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem "Successfully extracted {Count} subtitles in {Duration}", subtitlesToExtract.Count, sw.Elapsed.Humanize()); + + extractedAnything = true; } else { @@ -182,14 +187,18 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem } } } + + return extractedAnything; } - protected async Task ExtractFonts( + protected async Task ExtractFonts( TvContext dbContext, int mediaItemId, string ffmpegPath, CancellationToken cancellationToken) { + bool extractedAnything = false; + foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) { MediaVersion headVersion = mediaItem.GetHeadVersion(); @@ -232,6 +241,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem if (fileSystem.File.Exists(fullOutputPath)) { logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); + extractedAnything = true; } else { @@ -242,6 +252,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem } } } + + return extractedAnything; } diff --git a/ErsatzTV.Core/Next/Config/ChannelConfig.cs b/ErsatzTV.Core/Next/Config/ChannelConfig.cs index 102711b1d..1da6b968b 100644 --- a/ErsatzTV.Core/Next/Config/ChannelConfig.cs +++ b/ErsatzTV.Core/Next/Config/ChannelConfig.cs @@ -104,6 +104,9 @@ namespace ErsatzTV.Core.Next.Config public partial class Subtitle { + [JsonPropertyName("fonts_folder")] + public string? FontsFolder { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("mode")] public Mode? Mode { get; set; } diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index 9d07bded9..b58ee5b42 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -103,20 +103,7 @@ public class PlayoutItemConverter( foreach (Core.Next.Source source in maybeSource) { - if (playoutItem is not DynamicPlayoutItem) - { - if (playoutItem.InPoint > TimeSpan.Zero) - { - source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; - } - - var duration = playoutItem.MediaItem.GetDurationForPlayout(); - if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) - { - source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; - } - } - + SetInOutPoints(playoutItem, source); nextPlayoutItem.Source = source; } @@ -158,7 +145,7 @@ public class PlayoutItemConverter( Codec = s.Codec }).ToList(); - nextPlayoutItem.Source.ProbeHint = new Core.Next.ProbeHint + nextPlayoutItem.Source!.ProbeHint = new Core.Next.ProbeHint { Audio = sourceAudioHints, Video = sourceVideoHints, @@ -428,11 +415,30 @@ public class PlayoutItemConverter( { if (subtitle.SubtitleKind is SubtitleKind.Embedded) { - if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) + if (subtitle.IsImage) { - 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; + } + } + // next only supports sidecar text subtitles at the moment; ignore non-extracted text subs + else if (subtitle.IsExtracted && !string.IsNullOrWhiteSpace(subtitle.Path)) + { + 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 = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path), + }; + + SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); + } } } else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) @@ -446,6 +452,8 @@ public class PlayoutItemConverter( SourceType = Core.Next.SourceType.Local, Path = subtitle.Path, }; + + SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); } } else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) @@ -461,6 +469,8 @@ public class PlayoutItemConverter( KeepAlive = false, Reconnect = true }; + + SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); } } } @@ -602,4 +612,21 @@ public class PlayoutItemConverter( } ]; } + + private static void SetInOutPoints(PlayoutItem playoutItem, Core.Next.Source source) + { + if (playoutItem is not DynamicPlayoutItem) + { + if (playoutItem.InPoint > TimeSpan.Zero) + { + source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; + } + + var duration = playoutItem.MediaItem.GetDurationForPlayout(); + if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) + { + source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; + } + } + } }