Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9db0aa09c | ||
| 7c81ef94ca | |||
|
|
f715130302 |
@ -75,6 +75,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
EnableCategoryFolders = true;
|
||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||
GenerateTitleCards = true;
|
||||
LiveStartSegmentsBack = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -162,4 +163,14 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// Gets or sets the output directory for sport livestream recordings.
|
||||
/// </summary>
|
||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many segments back from the live edge livestream playback should start.
|
||||
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> 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.
|
||||
/// </summary>
|
||||
public int LiveStartSegmentsBack { get; set; }
|
||||
}
|
||||
|
||||
@ -82,6 +82,11 @@
|
||||
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
|
||||
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
|
||||
<div class="fieldDescription">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.</div>
|
||||
</div>
|
||||
<br />
|
||||
<h2>Network Settings</h2>
|
||||
<div class="inputContainer">
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -71,8 +71,9 @@ public interface IStreamProxyService
|
||||
/// <param name="manifestContent">The variant manifest content.</param>
|
||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
||||
/// <returns>The rewritten manifest content.</returns>
|
||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
|
||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
|
||||
@ -900,9 +900,15 @@ public class StreamProxyService : IStreamProxyService
|
||||
/// <param name="manifestContent">The variant manifest content.</param>
|
||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
||||
/// <returns>The rewritten manifest content.</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects an <c>#EXT-X-START:TIME-OFFSET</c> 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 <c>EXT-X-TARGETDURATION</c> to stay
|
||||
/// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
|
||||
/// </summary>
|
||||
/// <param name="manifestContent">The media playlist content.</param>
|
||||
/// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
|
||||
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)..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
/// </summary>
|
||||
|
||||
@ -8,6 +8,14 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.20260627.189",
|
||||
"changelog": "Nightly build 1.0.20260627.189 (7c81ef9)",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.189.zip",
|
||||
"checksum": "553ac3f806bce24e5aae3b2c8d17395b",
|
||||
"timestamp": "2026-06-27T13:46:17Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260627.185",
|
||||
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
||||
|
||||
@ -8,6 +8,14 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"changelog": "Release 1.1.0",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.0/srfplay_1.1.0.0.zip",
|
||||
"checksum": "2094b00b29f79e7625fcd3ebf046674f",
|
||||
"timestamp": "2026-06-27T09:33:04Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user