Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9db0aa09c | ||
| 7c81ef94ca | |||
|
|
f715130302 |
@ -75,6 +75,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
EnableCategoryFolders = true;
|
EnableCategoryFolders = true;
|
||||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||||
GenerateTitleCards = true;
|
GenerateTitleCards = true;
|
||||||
|
LiveStartSegmentsBack = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -162,4 +163,14 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// Gets or sets the output directory for sport livestream recordings.
|
/// Gets or sets the output directory for sport livestream recordings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
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" />
|
<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 class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||||
</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 />
|
<br />
|
||||||
<h2>Network Settings</h2>
|
<h2>Network Settings</h2>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
@ -148,6 +153,7 @@
|
|||||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||||
|
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
// Load recordings UI
|
// Load recordings UI
|
||||||
@ -173,6 +179,7 @@
|
|||||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||||
|
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
||||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase
|
|||||||
queryParams = string.Empty;
|
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)
|
// Set cache headers based on stream type (live vs VOD)
|
||||||
// Variant manifests use stricter no-cache for live streams
|
// 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="manifestContent">The variant manifest content.</param>
|
||||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</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="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>
|
/// <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>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// 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="manifestContent">The variant manifest content.</param>
|
||||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</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="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>
|
/// <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)
|
string RewriteUrl(string url)
|
||||||
{
|
{
|
||||||
if (url.Contains("://", StringComparison.Ordinal))
|
if (url.Contains("://", StringComparison.Ordinal))
|
||||||
@ -953,6 +959,82 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
return result.ToString();
|
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>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -8,6 +8,14 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"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",
|
"version": "1.0.20260627.185",
|
||||||
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
||||||
|
|||||||
@ -8,6 +8,14 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"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",
|
"version": "0.0.0.0",
|
||||||
"changelog": "Latest Build",
|
"changelog": "Latest Build",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user