aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPiotr Niełacny <piotr.nielacny@gmail.com>2026-03-21 21:57:58 +0100
committerPiotr Niełacny <piotr.nielacny@gmail.com>2026-05-19 13:03:07 +0200
commit2a689f268bc88ee7ab7e25121a6d43f71c1f8a5f (patch)
tree2a41965b8cc2a88de16f9f0e54a5f83917ed8db6
parent2c66447f08f740193c4dd4f340691d2cdb07ea49 (diff)
Embed external subtitles into MKV when transcoding
Allow external subtitle files (SRT, ASS, PGS, etc.) to be muxed into MKV output containers when the device profile requests Embed delivery. Previously, the IsExternal guard in GetSubtitleProfile excluded external subtitles from Embed consideration entirely, forcing them to be served as separate sidecar files even when the output container supports embedding. Changes: - Extract CanConsiderEmbedSubtitle in StreamBuilder to allow external subs through when transcoding to MKV - Add external subtitle file as FFmpeg input (-i) for Embed delivery - Map external embedded subs from the correct FFmpeg input index - Fix external audio map index to account for the new subtitle input - Extract NeedsExternalSubtitleMuxing in EncodingHelper to deduplicate the external subtitle input check Fixes #16403
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs58
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs15
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs54
3 files changed, 104 insertions, 23 deletions
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 65f6b79656..1fdb5fd4bd 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -1267,22 +1267,23 @@ namespace MediaBrowser.Controller.MediaEncoding
.Append(_mediaEncoder.GetInputPathArgument(state));
}
- // sub2video for external graphical subtitles
- if (state.SubtitleStream is not null
- && ShouldEncodeSubtitle(state)
- && !state.SubtitleStream.IsTextSubtitleStream
- && state.SubtitleStream.IsExternal)
+ if (NeedsExternalSubtitleMuxing(state))
{
var subtitlePath = state.SubtitleStream.Path;
- var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan());
+ var isGraphicalBurnIn = ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream;
- // dvdsub/vobsub graphical subtitles use .sub+.idx pairs
- if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
+ if (isGraphicalBurnIn)
{
- var idxFile = Path.ChangeExtension(subtitlePath, ".idx");
- if (File.Exists(idxFile))
+ var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan());
+
+ // dvdsub/vobsub graphical subtitles use .sub+.idx pairs
+ if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
- subtitlePath = idxFile;
+ var idxFile = Path.ChangeExtension(subtitlePath, ".idx");
+ if (File.Exists(idxFile))
+ {
+ subtitlePath = idxFile;
+ }
}
}
@@ -1307,7 +1308,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekSubParam);
}
- if (!string.IsNullOrEmpty(canvasArgs))
+ if (isGraphicalBurnIn && !string.IsNullOrEmpty(canvasArgs))
{
arg.Append(canvasArgs);
}
@@ -3072,11 +3073,8 @@ namespace MediaBrowser.Controller.MediaEncoding
int audioStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.AudioStream);
if (state.AudioStream.IsExternal)
{
- bool hasExternalGraphicsSubs = state.SubtitleStream is not null
- && ShouldEncodeSubtitle(state)
- && state.SubtitleStream.IsExternal
- && !state.SubtitleStream.IsTextSubtitleStream;
- int externalAudioMapIndex = hasExternalGraphicsSubs ? 2 : 1;
+ bool hasExternalSubAsInput = NeedsExternalSubtitleMuxing(state);
+ int externalAudioMapIndex = hasExternalSubAsInput ? 2 : 1;
args += string.Format(
CultureInfo.InvariantCulture,
@@ -3104,12 +3102,20 @@ namespace MediaBrowser.Controller.MediaEncoding
}
else if (subtitleMethod == SubtitleDeliveryMethod.Embed)
{
- int subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
+ if (state.SubtitleStream.IsExternal)
+ {
+ // External subtitle file is added as second FFmpeg input
+ args += " -map 1:0";
+ }
+ else
+ {
+ int subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
- args += string.Format(
- CultureInfo.InvariantCulture,
- " -map 0:{0}",
- subtitleStreamIndex);
+ args += string.Format(
+ CultureInfo.InvariantCulture,
+ " -map 0:{0}",
+ subtitleStreamIndex);
+ }
}
else if (state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)
{
@@ -7886,6 +7892,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|| (state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding && !IsCopyCodec(state.OutputVideoCodec));
}
+ private static bool NeedsExternalSubtitleMuxing(EncodingJobInfo state)
+ {
+ return state.SubtitleStream is not null
+ && state.SubtitleStream.IsExternal
+ && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed
+ || (ShouldEncodeSubtitle(state) && !state.SubtitleStream.IsTextSubtitleStream));
+ }
+
public static string GetVideoSyncOption(string videoSync, Version encoderVersion)
{
if (string.IsNullOrEmpty(videoSync))
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index 44697837ca..2ccd2a6c28 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -1451,7 +1451,7 @@ namespace MediaBrowser.Model.Dlna
string? outputContainer,
MediaStreamProtocol? transcodingSubProtocol)
{
- if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || transcodingSubProtocol != MediaStreamProtocol.hls))
+ if (CanConsiderEmbedSubtitle(subtitleStream, playMethod, transcodingSubProtocol, outputContainer))
{
// Look for supported embedded subs of the same format
foreach (var profile in subtitleProfiles)
@@ -1540,6 +1540,19 @@ namespace MediaBrowser.Model.Dlna
return false;
}
+ private static bool CanConsiderEmbedSubtitle(MediaStream subtitleStream, PlayMethod playMethod, MediaStreamProtocol? transcodingSubProtocol, string? outputContainer)
+ {
+ if (subtitleStream.IsExternal)
+ {
+ return playMethod == PlayMethod.Transcode
+ && transcodingSubProtocol != MediaStreamProtocol.hls
+ && IsSubtitleEmbedSupported(outputContainer);
+ }
+
+ return playMethod != PlayMethod.Transcode
+ || transcodingSubProtocol != MediaStreamProtocol.hls;
+ }
+
private static SubtitleProfile? GetExternalSubtitleProfile(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, bool allowConversion)
{
foreach (var profile in subtitleProfiles)
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 0b103debad..16c586bcda 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -675,5 +675,59 @@ namespace Jellyfin.Model.Tests
Assert.Equal(expectedMethod, result.Method);
}
+
+ [Theory]
+ // External text subs embedded into MKV when transcoding (#16403)
+ [InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ [InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ // External graphical subs embedded into MKV when transcoding
+ [InlineData("pgssub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ [InlineData("dvdsub", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ // External subs remain external when transcoding to non-MKV containers
+ [InlineData("srt", true, PlayMethod.Transcode, "mp4", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)]
+ [InlineData("srt", true, PlayMethod.Transcode, "ts", MediaStreamProtocol.hls, SubtitleDeliveryMethod.External)]
+ // External subs remain external during DirectPlay even with MKV
+ [InlineData("srt", true, PlayMethod.DirectPlay, "mkv", null, SubtitleDeliveryMethod.External)]
+ // Internal subs still embedded into MKV when transcoding (existing behavior)
+ [InlineData("srt", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ [InlineData("pgssub", false, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
+ public void GetSubtitleProfile_ReturnsExpectedDeliveryMethod(
+ string codec,
+ bool isExternal,
+ PlayMethod playMethod,
+ string outputContainer,
+ MediaStreamProtocol? transcodingSubProtocol,
+ SubtitleDeliveryMethod expectedMethod)
+ {
+ var mediaSource = new MediaSourceInfo();
+ var subtitleStream = new MediaStream
+ {
+ Codec = codec,
+ Language = "eng",
+ IsExternal = isExternal,
+ Type = MediaStreamType.Subtitle,
+ SupportsExternalStream = true
+ };
+
+ var subtitleProfiles = new[]
+ {
+ new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.Embed },
+ new SubtitleProfile { Format = codec, Method = SubtitleDeliveryMethod.External }
+ };
+
+ var transcoderSupport = new Mock<ITranscoderSupport>();
+ transcoderSupport.Setup(x => x.CanExtractSubtitles(It.IsAny<string>())).Returns(true);
+
+ var result = StreamBuilder.GetSubtitleProfile(
+ mediaSource,
+ subtitleStream,
+ subtitleProfiles,
+ playMethod,
+ transcoderSupport.Object,
+ outputContainer,
+ transcodingSubProtocol);
+
+ Assert.Equal(expectedMethod, result.Method);
+ }
}
}