diff options
| author | Shadowghost <Shadowghost@users.noreply.github.com> | 2026-09-27 16:30:56 -0400 |
|---|---|---|
| committer | Cody Robibero <cody@robibe.ro> | 2026-09-27 16:30:56 -0400 |
| commit | 8bce631afa58e553183435b9568e5bac293c1353 (patch) | |
| tree | 62df9055e076dea7015dddfdbc868db448761859 | |
| parent | bfb7af28971c890d4108902ef1d2134de6e5a35b (diff) | |
Backport pull request #18168 from jellyfin/release-12.z
Serve graphical subtitles as raw files with range support
Original-merge: 4acfd6eb2e19cb8d258b240b01c766229cb1314c
Merged-by: crobibero <cody@robibe.ro>
Backported-by: Cody Robibero <cody@robibe.ro>
| -rw-r--r-- | Jellyfin.Api/Controllers/SubtitleController.cs | 61 | ||||
| -rw-r--r-- | tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs | 137 |
2 files changed, 187 insertions, 11 deletions
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index c4851091c1..6721cd6c7e 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -233,18 +233,17 @@ public class SubtitleController : BaseJellyfinApiController format = "json"; } - if (string.IsNullOrEmpty(format)) + // Graphical subtitles cannot be parsed or rewritten, so they are always delivered as the raw + // stream for the client to render. Serve them straight from disk instead of piping them + // through the encoder, so that range requests keep working: clients fetch these tracks + // incrementally while rendering them, and they are far too large to hand over in one piece. + if (MediaStream.IsPgsFormat(format) || MediaStream.IsVobSubFormat(format)) { - var item = _libraryManager.GetItemById<Video>(itemId.Value); - - var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); - var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) - .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); - - var subtitleStream = mediaSource.MediaStreams - .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); - - return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); + var subtitlePath = await GetGraphicalSubtitlePath(itemId.Value, mediaSourceId, index.Value).ConfigureAwait(false); + if (!string.IsNullOrEmpty(subtitlePath)) + { + return PhysicalFile(subtitlePath, MimeTypes.GetMimeType(subtitlePath), true); + } } if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) @@ -456,6 +455,46 @@ public class SubtitleController : BaseJellyfinApiController } } + private async Task<string?> GetGraphicalSubtitlePath(Guid id, string? mediaSourceId, int index) + { + var item = _libraryManager.GetItemById<BaseItem>(id); + if (item is null) + { + return null; + } + + var idString = id.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .FirstOrDefault(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.OrdinalIgnoreCase)); + + var subtitleStream = mediaSource?.MediaStreams + .FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + + if (mediaSource is null || subtitleStream is null + || !(subtitleStream.IsPgsSubtitleStream || subtitleStream.IsVobSubSubtitleStream)) + { + return null; + } + + var path = await _subtitleEncoder.GetSubtitleFilePath(subtitleStream, mediaSource, CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrEmpty(path)) + { + return null; + } + + // Only a raw PGS payload or an extracted .mks can be handed over untouched. Anything else the + // encoder resolved to (e.g. a VobSub .idx/.sub pair, which it converts) is not a passthrough. + var extension = Path.GetExtension(path.AsSpan()).TrimStart('.').ToString(); + if (!MediaStream.IsPgsFormat(extension) + && !string.Equals(extension, "mks", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + // Remotely hosted external subtitles have no local file to serve; those fall back to the encoder. + return _fileSystem.FileExists(path) ? path : null; + } + /// <summary> /// Encodes a subtitle in the specified format. /// </summary> diff --git a/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs new file mode 100644 index 0000000000..77321f0b05 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class SubtitleControllerTests +{ + private const int SubtitleIndex = 2; + + private static readonly Guid _itemId = new("6e1f0a3b7c8d4e5f9a0b1c2d3e4f5a6b"); + + [Theory] + // Graphical subtitle tracks are negotiated as SubtitleDeliveryMethod.External and must be handed + // to the client untouched, because neither format can be parsed or rewritten. + [InlineData("pgssub", "pgssub", "/cache/subs/sub.sup")] + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub is exposed as .mks + // while the negotiated profile format stays "vobsub". + [InlineData("vobsub", "dvdsub", "/cache/subs/sub.mks")] + public async Task GetSubtitle_GraphicalStream_ReturnsRawFileWithRangeProcessing(string format, string codec, string path) + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController(codec, path, encoder, out var fileSystem); + fileSystem.Setup(f => f.FileExists(path)).Returns(true); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, format, null, null, null, null, null); + + var fileResult = Assert.IsType<PhysicalFileResult>(result); + Assert.Equal(path, fileResult.FileName); + Assert.True(fileResult.EnableRangeProcessing); + encoder.Verify( + e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string>(), It.IsAny<long>(), It.IsAny<long>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + public async Task GetSubtitle_RemotelyHostedGraphicalStream_FallsBackToEncoder() + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("pgssub", "https://example.com/sub.sup", encoder, out var fileSystem); + fileSystem.Setup(f => f.FileExists(It.IsAny<string>())).Returns(false); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "pgssub", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "pgssub", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + [Fact] + public async Task GetSubtitle_VobSubIdxPair_FallsBackToEncoder() + { + // A .idx/.sub pair has no raw payload the client could render: the encoder converts it. + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("dvdsub", "/media/sub.idx", encoder, out _); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vobsub", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vobsub", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + [Fact] + public async Task GetSubtitle_TextStream_IsEncoded() + { + var encoder = new Mock<ISubtitleEncoder>(MockBehavior.Strict); + var controller = CreateController("subrip", "/media/sub.srt", encoder, out _); + encoder.Setup(e => e.GetSubtitles(It.IsAny<BaseItem>(), It.IsAny<string>(), SubtitleIndex, "vtt", 0, 0, false, It.IsAny<CancellationToken>())) + .ReturnsAsync(new MemoryStream([1, 2, 3])); + + var result = await controller.GetSubtitle(_itemId, _itemId.ToString("N"), SubtitleIndex, "vtt", null, null, null, null, null); + + Assert.IsType<FileStreamResult>(result); + } + + private static SubtitleController CreateController( + string codec, + string subtitlePath, + Mock<ISubtitleEncoder> subtitleEncoder, + out Mock<IFileSystem> fileSystem) + { + var item = new Movie { Id = _itemId }; + var subtitleStream = new MediaStream + { + Type = MediaStreamType.Subtitle, + Index = SubtitleIndex, + Codec = codec, + Path = subtitlePath, + IsExternal = true + }; + + var mediaSource = new MediaSourceInfo + { + Id = _itemId.ToString("N"), + MediaStreams = new List<MediaStream> { subtitleStream } + }; + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.GetItemById<BaseItem>(_itemId)).Returns(item); + + var mediaSourceManager = new Mock<IMediaSourceManager>(); + mediaSourceManager.Setup(m => m.GetStaticMediaSources(item, false, null)).Returns([mediaSource]); + + subtitleEncoder.Setup(e => e.GetSubtitleFilePath(subtitleStream, mediaSource, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtitlePath); + + fileSystem = new Mock<IFileSystem>(); + + return new SubtitleController( + Mock.Of<IServerConfigurationManager>(), + libraryManager.Object, + Mock.Of<ISubtitleManager>(), + subtitleEncoder.Object, + mediaSourceManager.Object, + Mock.Of<IProviderManager>(), + fileSystem.Object, + NullLogger<SubtitleController>.Instance); + } +} |
