From 7c81ef94cadaec2ea0f95b88b09fcb3679025aa7 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 27 Jun 2026 15:44:50 +0200 Subject: [PATCH] fix Start stream 3 seconds behind edge --- .../Configuration/PluginConfiguration.cs | 11 +++ .../Configuration/configPage.html | 7 ++ .../Controllers/StreamProxyController.cs | 3 +- .../Interfaces/IStreamProxyService.cs | 3 +- .../Services/StreamProxyService.cs | 84 ++++++++++++++++++- 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index 519becb..9352ad5 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -75,6 +75,7 @@ public class PluginConfiguration : BasePluginConfiguration EnableCategoryFolders = true; EnabledTopics = new System.Collections.Generic.List(); GenerateTitleCards = true; + LiveStartSegmentsBack = 3; } /// @@ -162,4 +163,14 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets the output directory for sport livestream recordings. /// public string RecordingOutputPath { get; set; } = string.Empty; + + /// + /// Gets or sets how many segments back from the live edge livestream playback should start. + /// Injected as #EXT-X-START:TIME-OFFSET=-(N * targetDuration) into the live media + /// playlist. This keeps the start point out of the volatile live edge, which on Android TV + /// (ExoPlayer) otherwise causes stalling/jumping until a manual skip. RFC 8216 requires the + /// offset to stay at least 3 target durations from the edge, so values below 3 are clamped. + /// Set to 0 to disable injection entirely. + /// + public int LiveStartSegmentsBack { get; set; } } diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 1efa49f..1e0499c 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -82,6 +82,11 @@
Directory where sport livestream recordings will be saved (requires ffmpeg)
+
+ + +
How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.
+

Network Settings

@@ -148,6 +153,7 @@ document.querySelector('#ProxyPassword').value = config.ProxyPassword || ''; document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || ''; document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || ''; + document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3; Dashboard.hideLoadingMsg(); // Load recordings UI @@ -173,6 +179,7 @@ config.ProxyPassword = document.querySelector('#ProxyPassword').value; config.PublicServerUrl = document.querySelector('#PublicServerUrl').value; config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value; + config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0; ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); }); diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index db5bceb..46a93a1 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase queryParams = string.Empty; } - var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams); + var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false; + var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams, isLiveStream); // Set cache headers based on stream type (live vs VOD) // Variant manifests use stricter no-cache for live streams diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs index 47fb811..a2e2f57 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs @@ -71,8 +71,9 @@ public interface IStreamProxyService /// The variant manifest content. /// The base proxy URL (without query params). /// Query parameters to append to rewritten URLs (e.g., "?token=abc"). + /// Whether this is a livestream; if so an EXT-X-START offset is injected. /// The rewritten manifest content. - string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams); + string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false); /// /// Cleans up old and expired stream mappings. diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index f8c50c4..00b3681 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -900,9 +900,15 @@ public class StreamProxyService : IStreamProxyService /// The variant manifest content. /// The base proxy URL (without query params). /// Query parameters to append to rewritten URLs (e.g., "?token=abc"). + /// Whether this is a livestream; if so an EXT-X-START offset is injected. /// The rewritten manifest content. - public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams) + public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false) { + if (isLiveStream) + { + manifestContent = InjectLiveStartOffset(manifestContent); + } + string RewriteUrl(string url) { if (url.Contains("://", StringComparison.Ordinal)) @@ -953,6 +959,82 @@ public class StreamProxyService : IStreamProxyService return result.ToString(); } + /// + /// Injects an #EXT-X-START:TIME-OFFSET tag into a live media playlist so that players + /// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge + /// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip. + /// The offset is sized relative to the playlist's own EXT-X-TARGETDURATION to stay + /// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists). + /// + /// The media playlist content. + /// The playlist with the tag injected, or unchanged if it is not applicable. + private string InjectLiveStartOffset(string manifestContent) + { + var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3; + if (segmentsBack <= 0) + { + return manifestContent; // Disabled by config. + } + + // Only a media playlist (has segments, no variant list) should carry EXT-X-START. + // A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched. + if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal) + || manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal) + || manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal)) + { + return manifestContent; + } + + // Don't add a second tag if the source already specified a start point. + if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal)) + { + _logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is"); + return manifestContent; + } + + var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)"); + if (!targetDurationMatch.Success + || !double.TryParse( + targetDurationMatch.Groups[1].Value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var targetDuration) + || targetDuration <= 0) + { + _logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection"); + return manifestContent; + } + + // RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge. + var clampedSegments = Math.Max(3, segmentsBack); + var offsetSeconds = clampedSegments * targetDuration; + var startTag = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES", + offsetSeconds); + + _logger.LogInformation( + "Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})", + startTag, + targetDuration, + clampedSegments); + + // Insert after the #EXTM3U header so it sits with the other top-level tags. + var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal); + if (headerIndex < 0) + { + return startTag + "\n" + manifestContent; + } + + var lineEnd = manifestContent.IndexOf('\n', headerIndex); + if (lineEnd < 0) + { + return manifestContent + "\n" + startTag; + } + + return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..]; + } + /// /// Cleans up old and expired stream mappings. ///