From 941298ee8108d79bd2f9bc010415103fddf54b0e Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Fri, 8 May 2026 21:29:13 +0200 Subject: Write subtitles using SubtitleEdit We've been using SubtitleEdit to parse since 2021 https://github.com/jellyfin/jellyfin/pull/4984 I think it's time we start using it to write too --- .../Subtitles/SubtitleEncoder.cs | 65 ++++++++++++---------- 1 file changed, 35 insertions(+), 30 deletions(-) (limited to 'MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs') diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index e0c5f3ad39..2dc71d08c4 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -26,7 +26,10 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; using UtfUnknown; +using SubtitleFormat = MediaBrowser.Model.MediaInfo.SubtitleFormat; namespace MediaBrowser.MediaEncoding.Subtitles { @@ -72,7 +75,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles private MemoryStream ConvertSubtitles( Stream stream, - string inputFormat, + SubtitleInfo inputInfo, string outputFormat, long startTimeTicks, long endTimeTicks, @@ -83,13 +86,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { - var trackInfo = _subtitleParser.Parse(stream, inputFormat); + var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); - FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); + FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); - var writer = GetWriter(outputFormat); + var formatter = GetWriter(outputFormat); + + var text = formatter.ToText(subtitle, "untitled"); + using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + { + writer.Write(text); + } - writer.Write(trackInfo, ms, cancellationToken); ms.Position = 0; } catch @@ -101,26 +109,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles return ms; } - internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) + internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) { // Drop subs that have fully elapsed before the requested start position - track.TrackEvents = track.TrackEvents - .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0) - .ToArray(); + track.Paragraphs + .RemoveAll(i => (i.StartTime.TimeSpan.Ticks - startPositionTicks) < 0 && (i.EndTime.TimeSpan.Ticks - startPositionTicks) < 0); if (endTimeTicks > 0) { - track.TrackEvents = track.TrackEvents - .TakeWhile(i => i.StartPositionTicks <= endTimeTicks) - .ToArray(); + track.Paragraphs + .RemoveAll(i => i.StartTime.TimeSpan.Ticks > endTimeTicks); } if (!preserveTimestamps) { - foreach (var trackEvent in track.TrackEvents) + foreach (var trackEvent in track.Paragraphs) { - trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks); - trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks); + trackEvent.StartTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.StartTime.TimeSpan.Ticks - startPositionTicks))); + trackEvent.EndTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.EndTime.TimeSpan.Ticks - startPositionTicks))); } } } @@ -142,14 +148,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles var subtitleStream = mediaSource.MediaStreams .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex); - var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) + var (stream, info) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken) .ConfigureAwait(false); // Return the original if the same format is being requested // Character encoding was already handled in GetSubtitleStream // ASS is a superset of SSA, skipping the conversion and preserving the styles - if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase) - || (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) + if (string.Equals(info.Format, outputFormat, StringComparison.OrdinalIgnoreCase) + || (string.Equals(info.Format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) && string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))) { return stream; @@ -157,11 +163,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (stream) { - return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); + return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); } } - private async Task<(Stream Stream, string Format)> GetSubtitleStream( + private async Task<(Stream Stream, SubtitleInfo Info)> GetSubtitleStream( MediaSourceInfo mediaSource, MediaStream subtitleStream, CancellationToken cancellationToken) @@ -170,7 +176,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false); - return (stream, fileInfo.Format); + return (stream, fileInfo); } private async Task GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) @@ -267,43 +273,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles }; } - private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value) + private bool TryGetWriter(string format, [NotNullWhen(true)] out Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat? value) { ArgumentException.ThrowIfNullOrEmpty(format); if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)) { - value = new AssWriter(); + value = new AdvancedSubStationAlpha(); return true; } if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { - value = new JsonWriter(); - return true; + throw new NotImplementedException(); } if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) { - value = new SrtWriter(); + value = new SubRip(); return true; } if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)) { - value = new SsaWriter(); + value = new SubStationAlpha(); return true; } if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) { - value = new VttWriter(); + value = new WebVTT(); return true; } if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase)) { - value = new TtmlWriter(); + value = new TimedText10(); return true; } @@ -311,7 +316,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles return false; } - private ISubtitleWriter GetWriter(string format) + private Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat GetWriter(string format) { if (TryGetWriter(format, out var writer)) { -- cgit v1.2.3 From 2b6da4481574cf67db6fd1325e08f59015884459 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sat, 30 May 2026 21:42:19 +0200 Subject: Clean up ConvertSubtitles --- .../Subtitles/SubtitleEncoder.cs | 32 ++++++---------------- 1 file changed, 8 insertions(+), 24 deletions(-) (limited to 'MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs') diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 2dc71d08c4..0192aa57f2 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -79,34 +79,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles string outputFormat, long startTimeTicks, long endTimeTicks, - bool preserveOriginalTimestamps, - CancellationToken cancellationToken) + bool preserveOriginalTimestamps) { - var ms = new MemoryStream(); - - try - { - var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); - - FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); + var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); - var formatter = GetWriter(outputFormat); + FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); - var text = formatter.ToText(subtitle, "untitled"); - using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - writer.Write(text); - } + var formatter = GetWriter(outputFormat); - ms.Position = 0; - } - catch - { - ms.Dispose(); - throw; - } + var text = formatter.ToText(subtitle, "untitled"); + var bytes = Encoding.UTF8.GetBytes(text); - return ms; + return new MemoryStream(bytes, 0, bytes.Length, false, true); } internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) @@ -163,7 +147,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles using (stream) { - return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken); + return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); } } -- cgit v1.2.3 From 1dd02b0e30938ea4874da700aad048cc7ada637c Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sat, 30 May 2026 21:42:57 +0200 Subject: Add JsonWriter back --- MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs | 58 ++++++++++++++++++++++ .../Subtitles/SubtitleEncoder.cs | 9 ++-- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs (limited to 'MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs') diff --git a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs new file mode 100644 index 0000000000..0e40181016 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; + +namespace MediaBrowser.MediaEncoding.Subtitles; + +/// +/// JSON subtitle writer. +/// +public class JsonWriter : SubtitleFormat +{ + /// + public override string Extension => ".json"; + + /// + public override string Name => "JSON Jellyfin"; + + /// + public override string ToText(Subtitle subtitle, string title) + { + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms)) + { + var trackevents = subtitle.Paragraphs; + writer.WriteStartObject(); + writer.WriteStartArray("TrackEvents"); + + for (int i = 0; i < trackevents.Count; i++) + { + var current = trackevents[i]; + writer.WriteStartObject(); + + writer.WriteString("Id", current.Number.ToString(CultureInfo.InvariantCulture)); + writer.WriteString("Text", current.Text); + writer.WriteNumber("StartPositionTicks", current.StartTime.TimeSpan.Ticks); + writer.WriteNumber("EndPositionTicks", current.EndTime.TimeSpan.Ticks); + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + + writer.Flush(); + } + + return Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length); + } + + /// + public override void LoadSubtitle(Subtitle subtitle, List lines, string fileName) + => throw new NotImplementedException(); +} diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 0192aa57f2..e8bc0f1ffb 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -269,10 +269,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) { - throw new NotImplementedException(); + value = new JsonWriter(); + return true; } - if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) + || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase)) { value = new SubRip(); return true; @@ -284,7 +286,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles return true; } - if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) + || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) { value = new WebVTT(); return true; -- cgit v1.2.3 From 6f0ff89bdcc1961ec630f43f012528ab79022a9f Mon Sep 17 00:00:00 2001 From: Neptune Date: Sun, 31 May 2026 22:18:25 +0700 Subject: Add support for VobSub subtitle streams (#16552) * Add support for VobSub subtitle streams * update logic to determine separate extraction for VobSub subtitles * simplify VobSub extraction logic and fix ffmpeg command * Match `ExtractAllExtractableSubtitlesMKS` with `ExtractAllExtractableSubtitlesInternal` Matroska's VobSub option * Add a comments clarify why MKS was used, and remove the redundant VobSub extension branch * remove redundant VobSub format check * fix type errors --- .../Library/MediaSourceManager.cs | 5 +++ .../Subtitles/SubtitleEncoder.cs | 36 +++++++++++++++------- MediaBrowser.Model/Dlna/StreamBuilder.cs | 18 +++++++++-- MediaBrowser.Model/Entities/MediaStream.cs | 30 +++++++++++++++++- 4 files changed, 74 insertions(+), 15 deletions(-) (limited to 'MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 66614c6725..0caf66555a 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -127,6 +127,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsVobSubSubtitleStream) + { + return true; + } + return false; } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index e0c5f3ad39..8d237473a3 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -220,12 +220,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles Path = outputPath, Protocol = MediaProtocol.File, Format = outputFormat, - IsExternal = false + IsExternal = MediaStream.IsVobSubFormat(outputFormat) }; } - var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path) - .TrimStart('.'); + var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.'); // Handle PGS subtitles as raw streams for the client to render if (MediaStream.IsPgsFormat(currentFormat)) @@ -475,6 +474,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return subtitleStream.Codec; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + return "mks"; + } else { return "srt"; @@ -488,6 +491,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { return "sup"; } + else if (MediaStream.IsVobSubFormat(subtitleStream.Codec)) + { + // FFmpeg cannot mux VobSub subtitle streams back into the .idx/.sub pair, so we use .mks container instead. + return "mks"; + } else { return GetExtractableSubtitleFormat(subtitleStream); @@ -500,7 +508,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase); + || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase) + || MediaStream.IsVobSubFormat(codec); } /// @@ -516,7 +525,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles foreach (var subtitleStream in subtitleStreams) { - if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) + if (subtitleStream.IsExternal + && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -603,6 +613,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles } var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files. + var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); if (streamIndex == -1) @@ -616,9 +628,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", + " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"", streamIndex, outputCodec, + outputFormatOption, outputPath); } @@ -653,6 +666,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles } var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; + // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files. + var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); if (streamIndex == -1) @@ -666,18 +681,17 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", + " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"", streamIndex, outputCodec, + outputFormatOption, outputPath); } - if (outputPaths.Count == 0) + if (outputPaths.Count > 0) { - return; + await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); } - - await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); } private async Task ExtractSubtitlesForFile( diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 2ccd2a6c28..d875bbe8ed 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -575,7 +575,12 @@ namespace MediaBrowser.Model.Dlna { foreach (var profile in subtitleProfiles) { - if (profile.Method == SubtitleDeliveryMethod.External && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)) + if (profile.Method == SubtitleDeliveryMethod.External + && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase) + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. + || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) + && stream.IsVobSubSubtitleStream + && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))))) { return stream.Index; } @@ -1577,10 +1582,17 @@ namespace MediaBrowser.Model.Dlna continue; } - if ((profile.Method == SubtitleDeliveryMethod.External && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)) || + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles. + bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) + && subtitleStream.IsVobSubSubtitleStream + && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)); + + if ((profile.Method == SubtitleDeliveryMethod.External + && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) || (profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream)) { - bool requiresConversion = !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase); + bool requiresConversion = !isVobSubMksProfile + && !string.Equals(subtitleStream.Codec, profile.Format, StringComparison.OrdinalIgnoreCase); if (!requiresConversion) { diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index dad4a6e149..f057714bea 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -644,13 +644,32 @@ namespace MediaBrowser.Model.Entities } } + [JsonIgnore] + public bool IsVobSubSubtitleStream + { + get + { + if (Type != MediaStreamType.Subtitle) + { + return false; + } + + if (string.IsNullOrEmpty(Codec) && !IsExternal) + { + return false; + } + + return IsVobSubFormat(Codec); + } + } + /// /// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg. /// All text-based and pgs subtitles can be extracted. /// /// true if this is a extractable subtitle steam otherwise, false. [JsonIgnore] - public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream; + public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream || IsVobSubSubtitleStream; /// /// Gets or sets a value indicating whether [supports external stream]. @@ -728,6 +747,7 @@ namespace MediaBrowser.Model.Entities return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase) || (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) && !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + && !codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase) && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase) && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)); @@ -741,6 +761,14 @@ namespace MediaBrowser.Model.Entities || string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase); } + public static bool IsVobSubFormat(string format) + { + string codec = format ?? string.Empty; + + return codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + || codec.Contains("vobsub", StringComparison.OrdinalIgnoreCase); + } + public bool SupportsSubtitleConversionTo(string toCodec) { if (!IsTextSubtitleStream) -- cgit v1.2.3 From d69de6ccc4cc1e3f6309364ea65efd92e8f91bf0 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Mon, 1 Jun 2026 20:57:59 +0200 Subject: Prefer subtitle extension over codec --- MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs') diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index ddb078127b..77aadee704 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -214,7 +214,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles }; } - var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.'); + var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.'); // Handle PGS subtitles as raw streams for the client to render if (MediaStream.IsPgsFormat(currentFormat)) -- cgit v1.2.3 From efb0336369a2738825f0f0940c3d969c94a81d4e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Thu, 28 May 2026 19:55:15 +0200 Subject: Fix subtitle replacement not invalidating cache --- .../Subtitles/SubtitleEncoder.cs | 102 ++++++++++++++++++++- 1 file changed, 97 insertions(+), 5 deletions(-) (limited to 'MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs') diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index ddb078127b..3e4c9a6f52 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -214,7 +214,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles }; } - var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.'); + // Normalize ffmpeg codec names to the file extensions the parser is keyed on + var currentFormat = NormalizeCodecToParserExtension(subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.')); // Handle PGS subtitles as raw streams for the client to render if (MediaStream.IsPgsFormat(currentFormat)) @@ -324,13 +325,91 @@ namespace MediaBrowser.MediaEncoding.Subtitles { using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) { - if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) + if (!IsCachedSubtitleFresh(outputPath, subtitleStream.Path)) { await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); } } } + // ffmpeg codec names don't always match the file extensions the subtitle parser is keyed on. + private static string NormalizeCodecToParserExtension(string codecOrExtension) + { + return codecOrExtension switch + { + "subrip" => "srt", + "webvtt" => "vtt", + _ => codecOrExtension + }; + } + + // Records "this cache was built from this exact source revision" in a sidecar file next to the cache: ":" + private static string GetCacheMetaPath(string cachePath) => cachePath + ".meta"; + + private static string FormatCacheMeta(long length, DateTime lastWriteUtc) + => string.Create(CultureInfo.InvariantCulture, $"{length}:{lastWriteUtc.Ticks}"); + + private bool IsCachedSubtitleFresh(string cachePath, string? sourcePath) + { + if (!File.Exists(cachePath)) + { + return false; + } + + var cacheInfo = _fileSystem.GetFileInfo(cachePath); + if (cacheInfo.Length == 0) + { + return false; + } + + if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) + { + return true; + } + + var metaPath = GetCacheMetaPath(cachePath); + if (!File.Exists(metaPath)) + { + // Pre-existing cache from before metadata tracking - regenerate so we can record the source state. + return false; + } + + try + { + var sourceInfo = _fileSystem.GetFileInfo(sourcePath); + var expected = FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc); + var actual = File.ReadAllText(metaPath); + return string.Equals(expected, actual, StringComparison.Ordinal); + } + catch (IOException) + { + return false; + } + } + + private void WriteCacheMeta(string cachePath, string? sourcePath) + { + if (string.IsNullOrEmpty(sourcePath)) + { + return; + } + + try + { + var sourceInfo = _fileSystem.GetFileInfo(sourcePath); + if (!sourceInfo.Exists) + { + return; + } + + File.WriteAllText(GetCacheMetaPath(cachePath), FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc)); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to record subtitle cache metadata for {CachePath}", cachePath); + } + } + /// /// Converts the text subtitle to SRT internal. /// @@ -375,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles CreateNoWindow = true, UseShellExecute = false, FileName = _mediaEncoder.EncoderPath, - Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), + Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, @@ -455,6 +534,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); + WriteCacheMeta(outputPath, inputPath); + _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } @@ -531,7 +612,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); - if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0) + var sourcePath = string.IsNullOrEmpty(subtitleStream.Path) ? mediaSource.Path : subtitleStream.Path; + if (IsCachedSubtitleFresh(outputPath, sourcePath)) { releaser.Dispose(); continue; @@ -588,7 +670,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0}", + "-y -i {0}", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -628,6 +710,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); + + foreach (var outputPath in outputPaths) + { + WriteCacheMeta(outputPath, mksFile); + } } } @@ -683,6 +770,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (outputPaths.Count > 0) { await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); + + foreach (var outputPath in outputPaths) + { + WriteCacheMeta(outputPath, mediaSource.Path); + } } } -- cgit v1.2.3