From e6c37c28ca84868bc6d76f306f65e13b4f551f6f Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:37:46 -0500 Subject: [PATCH] fix: subtitle playback fixes --- CHANGELOG.md | 4 +- .../Channels/Commands/UpdateChannelHandler.cs | 4 +- .../Scheduling/PlayoutItemConverter.cs | 14 ++++--- ErsatzTV/Controllers/InternalController.cs | 41 +++++++++++++++---- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c487393..a9dccc4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,10 @@ 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 +- Pass extracted subtitle paths to next engine; this fixes text subtitle burn-in when extraction is enabled - Embedded text subtitles will otherwise be ignored and unused (when extraction is disabled) +- Fix next engine music video playback when music video credits are disabled +- Fix playback when seeking into content beyond final text subtitle cue ## [26.6.0] - 2026-07-09 ### Added diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index 61e68dca4..c55e6a7a3 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -49,7 +49,9 @@ public class UpdateChannelHandler( bool hasPlayoutChange = hasEpgChange || c.WatermarkId != update.WatermarkId || c.PreferredAudioLanguageCode != update.PreferredAudioLanguageCode || c.PreferredAudioTitle != update.PreferredAudioTitle || - c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode; + c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode || + c.MusicVideoCreditsMode != update.MusicVideoCreditsMode || + c.SubtitleMode != update.SubtitleMode; c.Name = update.Name; c.Number = update.Number; diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index b58ee5b42..89923a48e 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -362,7 +362,7 @@ public class PlayoutItemConverter( ChannelSubtitleMode subtitleMode, CancellationToken cancellationToken) { - List allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); + List allSubtitles = await GetSubtitles(channel, audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); Option maybeAudioStream = Option.None; Option maybeSubtitle = Option.None; @@ -469,8 +469,6 @@ public class PlayoutItemConverter( KeepAlive = false, Reconnect = true }; - - SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source); } } } @@ -558,6 +556,7 @@ public class PlayoutItemConverter( } private static async Task> GetSubtitles( + Channel channel, MediaItem mediaItem, int playoutItemId, TimeSpan playoutItemInPoint) @@ -570,7 +569,7 @@ public class PlayoutItemConverter( Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), - MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), + MusicVideo => GetMusicVideoSubtitles(channel, playoutItemId, playoutItemInPoint), OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), @@ -592,8 +591,13 @@ public class PlayoutItemConverter( return allSubtitles; } - private static List GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) + private static List GetMusicVideoSubtitles(Channel channel, int playoutItemId, TimeSpan playoutItemInPoint) { + if (channel.MusicVideoCreditsMode is not ChannelMusicVideoCreditsMode.GenerateSubtitles) + { + return []; + } + string seekToMs = playoutItemInPoint > TimeSpan.Zero ? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" : string.Empty; diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index b2929a84f..9840be53f 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -1,5 +1,6 @@ using System.CommandLine.Parsing; using System.Diagnostics; +using System.Text; using CliWrap; using ErsatzTV.Application.Emby; using ErsatzTV.Application.Jellyfin; @@ -63,7 +64,8 @@ public class InternalController : StreamingControllerBase [HttpGet("ffmpeg/music-video-credits/{playoutItemId:int}")] public async Task GetMusicVideoCredits( int playoutItemId, - [FromQuery] long? seekToMs, + [FromQuery] + long? seekToMs, CancellationToken cancellationToken) { Option maybeCreditsFile = await _mediator.Send( @@ -74,7 +76,7 @@ public class InternalController : StreamingControllerBase return new PhysicalFileResult(creditsFile, "text/x-ssa"); } - return NotFound(); + return File(Encoding.UTF8.GetBytes(EmptySubtitleDocument("text/x-ssa")), "text/x-ssa"); } [HttpGet("ffmpeg/remote-stream/{remoteStreamId}")] @@ -218,9 +220,14 @@ public class InternalController : StreamingControllerBase } [HttpGet("/media/subtitle/{id:int}")] - public async Task GetSubtitle(int id, [FromQuery] long? seekToMs) + public async Task GetSubtitle( + int id, + [FromQuery] long? seekToMs, + CancellationToken cancellationToken) { - Either maybePath = await _mediator.Send(new GetSubtitlePathById(id)); + Either maybePath = await _mediator.Send( + new GetSubtitlePathById(id), + cancellationToken); foreach (SubtitlePathAndCodec pathAndCodec in maybePath.RightToSeq()) { @@ -236,7 +243,8 @@ public class InternalController : StreamingControllerBase if (seekToMs is > 0) { Either maybeProcess = await _mediator.Send( - new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value))); + new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value)), + cancellationToken); foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq()) { Command command = processModel.Process; @@ -264,10 +272,20 @@ public class InternalController : StreamingControllerBase } process.Start(); - return new FileStreamResult(process.StandardOutput.BaseStream, mimeType); + using var buffer = new MemoryStream(); + await process.StandardOutput.BaseStream.CopyToAsync(buffer, cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + byte[] bytes = buffer.ToArray(); + if (bytes.Length == 0) + { + return Content(EmptySubtitleDocument(mimeType), mimeType); + } + + return File(bytes, mimeType); } - return new NotFoundResult(); + return Content(EmptySubtitleDocument(mimeType), mimeType); } if (pathAndCodec.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) @@ -347,4 +365,13 @@ public class InternalController : StreamingControllerBase return GetProcessResponse(result, channelNumber, StreamingMode.TransportStream); } + + private static string EmptySubtitleDocument(string mimeType) => mimeType switch + { + "text/x-ssa" => "[Script Info]\nScriptType: v4.00+\n\n" + + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20\n\n" + + "[Events]\nFormat: Layer, Start, End, Style, Text\n", + "text/vtt" => "WEBVTT\n\n", + _ => "1\n00:00:00,000 --> 00:00:00,001\n \n\n" + }; }