From d75e69249b72c7c70b5f13f1ea328e7688573848 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 28 Feb 2026 11:55:05 +0100 Subject: [PATCH] Live-stream starts 6 seconds from edge not at oldest segment. --- .../Controllers/StreamProxyController.cs | 43 ++++++++--- .../Interfaces/IStreamProxyService.cs | 13 ++++ .../Services/StreamProxyService.cs | 76 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index 4dba2cc..2033143 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -58,16 +58,28 @@ public class StreamProxyController : ControllerBase /// Livestreams need frequent manifest refresh, VOD can be cached longer. /// /// The item ID to check. - private void AddManifestCacheHeaders(string itemId) + /// Whether this is a variant (sub) manifest with segment lists. + private void AddManifestCacheHeaders(string itemId, bool isVariantManifest = false) { var metadata = _proxyService.GetStreamMetadata(itemId); var isLiveStream = metadata?.IsLiveStream ?? false; if (isLiveStream) { - // Livestreams need frequent manifest refresh (segments rotate every ~6-10s) - Response.Headers["Cache-Control"] = "max-age=2, must-revalidate"; - _logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId); + if (isVariantManifest) + { + // Variant manifests contain the segment list which changes every target duration. + // Use no-cache to ensure the player always gets the freshest segment list, + // preventing requests for segments that have been rotated out of the sliding window. + Response.Headers["Cache-Control"] = "no-cache, no-store"; + } + else + { + // Master manifest is relatively stable (just lists quality variants) + Response.Headers["Cache-Control"] = "max-age=2, must-revalidate"; + } + + _logger.LogDebug("Setting livestream cache headers for {ItemId} (variant={IsVariant})", itemId, isVariantManifest); } else { @@ -215,7 +227,8 @@ public class StreamProxyController : ControllerBase var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams); // Set cache headers based on stream type (live vs VOD) - AddManifestCacheHeaders(actualItemId); + // Variant manifests use stricter no-cache for live streams + AddManifestCacheHeaders(actualItemId, isVariantManifest: true); _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8"); @@ -253,9 +266,12 @@ public class StreamProxyController : ControllerBase { // Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp) var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null; - var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false); - if (segmentData == null) + // Use streaming proxy: starts forwarding data to the client before the full segment + // is downloaded from the CDN, reducing time-to-first-byte for live streams + using var upstreamResponse = await _proxyService.GetSegmentStreamAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false); + + if (upstreamResponse == null) { _logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); return NotFound(); @@ -263,9 +279,18 @@ public class StreamProxyController : ControllerBase // Determine content type based on file extension var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath); + Response.ContentType = contentType; - _logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType); - return File(segmentData, contentType); + if (upstreamResponse.Content.Headers.ContentLength.HasValue) + { + Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value; + } + + // Stream directly from CDN to client without buffering the entire segment in memory + await upstreamResponse.Content.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType); + return new EmptyResult(); } catch (Exception ex) { diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs index d669a96..47fb811 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -52,6 +53,18 @@ public interface IStreamProxyService /// The segment content as bytes. Task GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default); + /// + /// Fetches a segment from the original source as a streaming response. + /// Returns the HttpResponseMessage for streaming directly to the client, reducing TTFB. + /// The caller is responsible for disposing the response. + /// + /// The item ID. + /// The segment path. + /// The original query string from the request. + /// Cancellation token. + /// The HTTP response for streaming, or null if not found. + Task GetSegmentStreamAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default); + /// /// Rewrites URLs in a variant (sub) manifest to point to the proxy. /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index a2b7544..b531089 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -647,6 +647,21 @@ public class StreamProxyService : IStreamProxyService // Rewrite the manifest to replace Akamai URLs with proxy URLs var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl); + // For live streams, inject #EXT-X-START to tell the player to start near the live edge + // Without this, players may start at the beginning of the sliding window and stutter + // as old segments get rotated out by the CDN + if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream) + { + if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal)) + { + rewrittenContent = rewrittenContent.Replace( + "#EXTM3U", + "#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO", + StringComparison.Ordinal); + _logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId); + } + } + _logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); return rewrittenContent; } @@ -720,6 +735,67 @@ public class StreamProxyService : IStreamProxyService } } + /// + /// Fetches a segment as a streaming response for direct forwarding to the client. + /// Uses HttpCompletionOption.ResponseHeadersRead to start streaming before the full + /// segment is downloaded, reducing time-to-first-byte for live streams. + /// + /// The item ID. + /// The segment path. + /// The original query string from the request. + /// Cancellation token. + /// The HTTP response for streaming, or null if not found. + public async Task GetSegmentStreamAsync( + string itemId, + string segmentPath, + string? queryString = null, + CancellationToken cancellationToken = default) + { + var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false); + if (authenticatedUrl == null) + { + return null; + } + + try + { + var baseUri = new Uri(authenticatedUrl); + var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}"; + + var queryParams = string.Empty; + if (!string.IsNullOrEmpty(queryString)) + { + queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}"; + } + else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase)) + { + queryParams = baseUri.Query; + } + + var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}"; + + _logger.LogDebug("Streaming segment - SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", segmentPath, segmentUrl); + + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + var request = new HttpRequestMessage(HttpMethod.Get, segmentUrl); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Segment stream request failed with {StatusCode} for {SegmentPath}", response.StatusCode, segmentPath); + response.Dispose(); + return null; + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stream segment {SegmentPath} for item {ItemId}", segmentPath, itemId); + return null; + } + } + /// /// Rewrites URLs in HLS manifest to point to proxy. ///