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.
///