aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api/Controllers/SubtitleController.cs
diff options
context:
space:
mode:
authorSpeenah <speenah@pm.me>2026-10-13 13:43:53 -0400
committerGitHub <noreply@github.com>2026-10-13 19:43:53 +0200
commite53a6d3e6ae29cb05c55c7b8edf2d237a1f75c16 (patch)
treedbb20b5519d0ba5954dfc02b03473c849d0eff1e /Jellyfin.Api/Controllers/SubtitleController.cs
parent34768404f7df0bd881ec853c8e5eb4489891688d (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.cs58
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>