diff options
| author | Speenah <speenah@pm.me> | 2026-10-13 13:43:53 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-10-13 19:43:53 +0200 |
| commit | e53a6d3e6ae29cb05c55c7b8edf2d237a1f75c16 (patch) | |
| tree | dbb20b5519d0ba5954dfc02b03473c849d0eff1e /Jellyfin.Api/Controllers/SubtitleController.cs | |
| parent | 34768404f7df0bd881ec853c8e5eb4489891688d (diff) | |
Merge pull request #17299 from Speenah/fix/hls-subtitle-timing
Fix fMP4 HLS subtitle timestamp map offset
Diffstat (limited to 'Jellyfin.Api/Controllers/SubtitleController.cs')
| -rw-r--r-- | Jellyfin.Api/Controllers/SubtitleController.cs | 58 |
1 files changed, 49 insertions, 9 deletions
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 6721cd6c7e..3c20bda6a6 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -50,6 +50,12 @@ public class SubtitleController : BaseJellyfinApiController private readonly ILogger<SubtitleController> _logger; /// <summary> + /// The default MPEGTS value used in the WebVTT X-TIMESTAMP-MAP header, matching the legacy MPEG-TS + /// segment container's 10 second PTS offset. + /// </summary> + internal const long DefaultVttTimestampMapMpegts = 900000; + + /// <summary> /// Initializes a new instance of the <see cref="SubtitleController"/> class. /// </summary> /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> @@ -202,6 +208,7 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="vttTimestampMapMpegts">Optional. The VTT timestamp map MPEGTS value.</param> /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> @@ -220,6 +227,7 @@ public class SubtitleController : BaseJellyfinApiController [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, [FromQuery] bool addVttTimeMap = false, + [FromQuery][Range(typeof(long), "0", "9223372036854775807")] long vttTimestampMapMpegts = DefaultVttTimestampMapMpegts, [FromQuery] long startPositionTicks = 0) { // Set parameters to route value if not provided via query. @@ -255,7 +263,7 @@ public class SubtitleController : BaseJellyfinApiController var text = await reader.ReadToEndAsync().ConfigureAwait(false); - text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); + text = text.Replace("WEBVTT", "WEBVTT\n" + GetVttTimestampMap(vttTimestampMapMpegts), StringComparison.Ordinal); return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); } @@ -289,6 +297,7 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="vttTimestampMapMpegts">Optional. The VTT timestamp map MPEGTS value.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] @@ -307,7 +316,8 @@ public class SubtitleController : BaseJellyfinApiController [FromQuery, ParameterObsolete] string? format, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, - [FromQuery] bool addVttTimeMap = false) + [FromQuery] bool addVttTimeMap = false, + [FromQuery][Range(typeof(long), "0", "9223372036854775807")] long vttTimestampMapMpegts = DefaultVttTimestampMapMpegts) { return GetSubtitle( routeItemId, @@ -321,6 +331,7 @@ public class SubtitleController : BaseJellyfinApiController endPositionTicks, copyTimestamps, addVttTimeMap, + vttTimestampMapMpegts, startPositionTicks ?? routeStartPositionTicks); } @@ -331,6 +342,7 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="index">The subtitle stream index.</param> /// <param name="mediaSourceId">The media source id.</param> /// <param name="segmentLength">The subtitle segment length.</param> + /// <param name="vttTimestampMapMpegts">Optional. The VTT timestamp map MPEGTS value.</param> /// <response code="200">Subtitle playlist retrieved.</response> /// <response code="404">Item not found.</response> /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> @@ -344,7 +356,8 @@ public class SubtitleController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] int index, [FromRoute, Required] string mediaSourceId, - [FromQuery, Required] int segmentLength) + [FromQuery, Required] int segmentLength, + [FromQuery][Range(typeof(long), "0", "9223372036854775807")] long vttTimestampMapMpegts = DefaultVttTimestampMapMpegts) { var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); if (item is null) @@ -392,12 +405,7 @@ public class SubtitleController : BaseJellyfinApiController var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); - var url = string.Format( - CultureInfo.InvariantCulture, - "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={2}", - positionTicks.ToString(CultureInfo.InvariantCulture), - endPositionTicks.ToString(CultureInfo.InvariantCulture), - accessToken); + var url = GetSubtitleSegmentUrl(positionTicks, endPositionTicks, accessToken, vttTimestampMapMpegts); builder.AppendLine(url); @@ -409,6 +417,38 @@ public class SubtitleController : BaseJellyfinApiController } /// <summary> + /// Builds the WebVTT X-TIMESTAMP-MAP header for the given MPEGTS offset. + /// </summary> + /// <param name="mpegTimestamp">The MPEGTS offset to embed.</param> + /// <returns>The X-TIMESTAMP-MAP header line.</returns> + internal static string GetVttTimestampMap(long mpegTimestamp) + { + return string.Format( + CultureInfo.InvariantCulture, + "X-TIMESTAMP-MAP=MPEGTS:{0},LOCAL:00:00:00.000", + mpegTimestamp); + } + + /// <summary> + /// Builds the relative URL for a single WebVTT subtitle segment referenced from a subtitle HLS playlist. + /// </summary> + /// <param name="positionTicks">The segment start position, in ticks.</param> + /// <param name="endPositionTicks">The segment end position, in ticks.</param> + /// <param name="accessToken">The access token to include on the segment request.</param> + /// <param name="vttTimestampMapMpegts">The MPEGTS offset to request for this segment.</param> + /// <returns>The relative stream.vtt segment URL.</returns> + internal static string GetSubtitleSegmentUrl(long positionTicks, long endPositionTicks, string? accessToken, long vttTimestampMapMpegts) + { + return string.Format( + CultureInfo.InvariantCulture, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={2}&VttTimestampMapMpegts={3}", + positionTicks.ToString(CultureInfo.InvariantCulture), + endPositionTicks.ToString(CultureInfo.InvariantCulture), + accessToken, + vttTimestampMapMpegts.ToString(CultureInfo.InvariantCulture)); + } + + /// <summary> /// Upload an external subtitle file. /// </summary> /// <param name="itemId">The item the subtitle belongs to.</param> |
