From cd0f6809815db1368f8d87c59ebe72bacd20955b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 16 Nov 2025 20:15:30 +0100 Subject: [PATCH] Improved token refresh mechanism --- .../Controllers/StreamProxyController.cs | 37 ++- .../Providers/SRFMediaProvider.cs | 22 +- .../Services/StreamProxyService.cs | 312 +++++++++++++++++- 3 files changed, 350 insertions(+), 21 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index 4a643ae..f2cf45e 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -34,7 +34,7 @@ public class StreamProxyController : ControllerBase /// /// Proxies HLS master manifest requests. /// - /// The item ID. + /// The item ID from URL path. /// Cancellation token. /// The HLS manifest with rewritten URLs. [HttpGet("{itemId}/master.m3u8")] @@ -47,12 +47,15 @@ public class StreamProxyController : ControllerBase { _logger.LogInformation("Proxy request for master manifest - ItemId: {ItemId}", itemId); + // Try to resolve the actual item ID (path ID might be a session ID during transcoding) + var actualItemId = ResolveItemId(itemId); + try { - // Build the base proxy URL for this item + // Build the base proxy URL for this item (use original itemId from path to maintain URL structure) var baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; - var manifestContent = await _proxyService.GetRewrittenManifestAsync(itemId, baseProxyUrl, cancellationToken).ConfigureAwait(false); + var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false); if (manifestContent == null) { @@ -89,10 +92,13 @@ public class StreamProxyController : ControllerBase var fullPath = $"{manifestPath}.m3u8"; _logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); + // Try to resolve the actual item ID + var actualItemId = ResolveItemId(itemId); + try { // Fetch the variant manifest as a segment - var manifestData = await _proxyService.GetSegmentAsync(itemId, fullPath, cancellationToken).ConfigureAwait(false); + var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false); if (manifestData == null) { @@ -133,9 +139,12 @@ public class StreamProxyController : ControllerBase { _logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); + // Try to resolve the actual item ID + var actualItemId = ResolveItemId(itemId); + try { - var segmentData = await _proxyService.GetSegmentAsync(itemId, segmentPath, cancellationToken).ConfigureAwait(false); + var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false); if (segmentData == null) { @@ -156,6 +165,24 @@ public class StreamProxyController : ControllerBase } } + /// + /// Resolves the actual item ID from the request. + /// + /// The item ID from the URL path. + /// The resolved item ID. + private string ResolveItemId(string pathItemId) + { + // Check if there's an itemId query parameter (fallback for transcoding sessions) + if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId)) + { + _logger.LogDebug("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId); + return queryItemId.ToString(); + } + + // Use the path item ID as-is + return pathItemId; + } + /// /// Rewrites segment URLs in a manifest to point to proxy. /// diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index cec0baa..4bf93e2 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -176,14 +176,18 @@ public class SRFMediaProvider : IMediaSourceProvider // Create proxy URL as absolute HTTP URL (required for ffmpeg) // Use localhost as Jellyfin should be able to access its own endpoints - var proxyUrl = $"http://localhost:8096/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8"; + // Include item ID as query parameter to preserve it during transcoding + var proxyUrl = $"http://localhost:8096/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8?itemId={itemIdStr}"; _logger.LogInformation("Using proxy URL for item {ItemId}: {ProxyUrl}", itemIdStr, proxyUrl); + // Detect if this is a live stream + var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); + // Create media source using proxy URL - enables DirectPlay! var mediaSource = new MediaSourceInfo { - Id = item.Id.ToString(), // Use item GUID, not URN string (required for transcoding) + Id = itemIdStr, // Must match the ID used in proxy URL registration Name = chapter.Title, Path = proxyUrl, // Proxy URL instead of direct Akamai URL Protocol = MediaProtocol.Http, @@ -195,11 +199,11 @@ public class SRFMediaProvider : IMediaSourceProvider Type = MediaSourceType.Default, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, VideoType = VideoType.VideoFile, - IsInfiniteStream = false, + IsInfiniteStream = isLiveStream, // True for live streams! RequiresOpening = false, RequiresClosing = false, SupportsProbing = false, // Disable probing for proxy URLs - ReadAtNativeFramerate = false, + ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams MediaStreams = new List { new MediaBrowser.Model.Entities.MediaStream @@ -224,20 +228,22 @@ public class SRFMediaProvider : IMediaSourceProvider sources.Add(mediaSource); _logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl); _logger.LogInformation( - "MediaSource created - Id={Id}, DirectStream={DirectStream}, DirectPlay={DirectPlay}, Probing={Probing}, Container={Container}, Protocol={Protocol}, IsRemote={IsRemote}", + "MediaSource created - Id={Id}, DirectStream={DirectStream}, DirectPlay={DirectPlay}, Probing={Probing}, Container={Container}, Protocol={Protocol}, IsRemote={IsRemote}, IsLiveStream={IsLiveStream}", mediaSource.Id, mediaSource.SupportsDirectStream, mediaSource.SupportsDirectPlay, mediaSource.SupportsProbing, mediaSource.Container, mediaSource.Protocol, - mediaSource.IsRemote); + mediaSource.IsRemote, + isLiveStream); _logger.LogInformation( - "MediaSource capabilities - SupportsTranscoding={Transcoding}, RequiresOpening={RequiresOpening}, RequiresClosing={RequiresClosing}, Type={Type}", + "MediaSource capabilities - SupportsTranscoding={Transcoding}, RequiresOpening={RequiresOpening}, RequiresClosing={RequiresClosing}, Type={Type}, IsInfiniteStream={IsInfiniteStream}", mediaSource.SupportsTranscoding, mediaSource.RequiresOpening, mediaSource.RequiresClosing, - mediaSource.Type); + mediaSource.Type, + mediaSource.IsInfiniteStream); } catch (Exception ex) { diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index e05a1c8..c5895c5 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Concurrent; +using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using System.Web; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -42,29 +44,271 @@ public class StreamProxyService : IDisposable /// The authenticated stream URL. public void RegisterStream(string itemId, string authenticatedUrl) { + var tokenExpiry = ExtractTokenExpiry(authenticatedUrl); + var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl); + var streamInfo = new StreamInfo { AuthenticatedUrl = authenticatedUrl, - RegisteredAt = DateTime.UtcNow + UnauthenticatedUrl = unauthenticatedUrl, + RegisteredAt = DateTime.UtcNow, + TokenExpiresAt = tokenExpiry }; + // Register with the provided item ID _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo); - _logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); + + // Also register with alternative GUID formats to handle Jellyfin's ID transformations + if (Guid.TryParse(itemId, out var guid)) + { + var formats = new[] + { + guid.ToString("N"), // Without dashes: 00000000000000000000000000000000 + guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000 + guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000} + }; + + foreach (var format in formats) + { + if (format != itemId) // Don't duplicate the original + { + _streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo); + } + } + + _logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length); + } + + if (tokenExpiry.HasValue) + { + _logger.LogInformation( + "Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}", + itemId, + tokenExpiry.Value, + authenticatedUrl); + } + else + { + _logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); + } } /// /// Gets the authenticated URL for an item. /// /// The item ID. - /// The authenticated URL, or null if not found. + /// The authenticated URL, or null if not found or expired. public string? GetAuthenticatedUrl(string itemId) { + // Try direct lookup first if (_streamMappings.TryGetValue(itemId, out var streamInfo)) { - return streamInfo.AuthenticatedUrl; + return ValidateAndReturnStream(itemId, streamInfo); + } + + // Fallback: Try to find by GUID variations (with/without dashes) + // This handles cases where Jellyfin uses different GUID formats + var normalizedId = NormalizeGuid(itemId); + if (normalizedId != null) + { + foreach (var kvp in _streamMappings) + { + var normalizedKey = NormalizeGuid(kvp.Key); + if (normalizedKey != null && normalizedKey == normalizedId) + { + _logger.LogInformation( + "Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}", + itemId, + kvp.Key); + var url = ValidateAndReturnStream(kvp.Key, kvp.Value); + if (url != null) + { + return url; // Found valid stream + } + + // Stream found but expired, continue to next fallback + _logger.LogDebug("GUID-normalized stream was expired, trying other fallbacks"); + break; // Exit foreach, continue to next fallback strategy + } + } + } + + // Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs) + var activeStreams = _streamMappings.Where(kvp => + { + if (!kvp.Value.TokenExpiresAt.HasValue) + { + return true; // No expiry + } + + return DateTime.UtcNow < kvp.Value.TokenExpiresAt.Value; + }).ToList(); + + if (activeStreams.Count == 1) + { + _logger.LogWarning( + "No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback", + itemId, + activeStreams[0].Key); + return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value); + } + + // If multiple active streams, use the most recently registered one (likely the one being transcoded) + // This handles cases where Jellyfin creates a random transcoding session ID seconds after registration + if (activeStreams.Count > 1) + { + var mostRecent = activeStreams.OrderByDescending(kvp => kvp.Value.RegisteredAt).First(); + var age = DateTime.UtcNow - mostRecent.Value.RegisteredAt; + + // Only use this fallback if the stream was registered very recently (within 30 seconds) + // This indicates it's likely the stream currently being set up for transcoding + if (age.TotalSeconds < 30) + { + _logger.LogWarning( + "No exact match for {RequestedId}, but using most recently registered stream {RegisteredId} (registered {Seconds}s ago) as fallback", + itemId, + mostRecent.Key, + age.TotalSeconds); + return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value); + } + } + + _logger.LogWarning( + "No stream mapping found for item {ItemId}. Active streams: {Count}. Registered IDs: {RegisteredIds}", + itemId, + activeStreams.Count, + string.Join(", ", _streamMappings.Keys)); + return null; + } + + /// + /// Validates a stream and returns its URL if valid. + /// + private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo) + { + // Check if token has expired + if (streamInfo.TokenExpiresAt.HasValue) + { + var now = DateTime.UtcNow; + if (now >= streamInfo.TokenExpiresAt.Value) + { + _logger.LogWarning( + "Token expired for item {ItemId} (expired at {ExpiresAt}, now is {Now}) - attempting to refresh", + itemId, + streamInfo.TokenExpiresAt.Value, + now); + + // Try to refresh the token + var refreshedUrl = RefreshToken(itemId, streamInfo); + if (refreshedUrl != null) + { + _logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId); + return refreshedUrl; + } + + _logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId); + _streamMappings.TryRemove(itemId, out _); + return null; + } + + _logger.LogDebug( + "Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft} remaining)", + itemId, + streamInfo.TokenExpiresAt.Value, + streamInfo.TokenExpiresAt.Value - now); + } + + return streamInfo.AuthenticatedUrl; + } + + /// + /// Attempts to refresh an expired token. + /// + private string? RefreshToken(string itemId, StreamInfo streamInfo) + { + if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl)) + { + _logger.LogWarning("Cannot refresh token for {ItemId} - no unauthenticated URL stored", itemId); + return null; + } + + try + { + // Re-authenticate the stream URL synchronously (blocking call) + var newAuthenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( + streamInfo.UnauthenticatedUrl, + CancellationToken.None).GetAwaiter().GetResult(); + + if (string.IsNullOrEmpty(newAuthenticatedUrl)) + { + return null; + } + + // Update the stream info with the new token + var newTokenExpiry = ExtractTokenExpiry(newAuthenticatedUrl); + streamInfo.AuthenticatedUrl = newAuthenticatedUrl; + streamInfo.TokenExpiresAt = newTokenExpiry; + + _logger.LogInformation( + "Refreshed token for item {ItemId} (new expiry: {ExpiresAt} UTC)", + itemId, + newTokenExpiry); + + return newAuthenticatedUrl; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing token for item {ItemId}", itemId); + return null; + } + } + + /// + /// Strips authentication parameters from a URL to get the base unauthenticated URL. + /// + private string StripAuthenticationFromUrl(string url) + { + try + { + var uri = new Uri(url); + var query = uri.Query; + + // Remove hdnts authentication parameter (Akamai token authentication) + if (query.Contains("hdnts=", StringComparison.OrdinalIgnoreCase)) + { + // Keep other parameters like caption, webvttbaseurl + var queryParams = System.Web.HttpUtility.ParseQueryString(query); + queryParams.Remove("hdnts"); + + var newQuery = queryParams.Count > 0 ? "?" + queryParams.ToString() : string.Empty; + return $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}{newQuery}"; + } + + return url; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to strip authentication from URL, using as-is"); + return url; + } + } + + /// + /// Normalizes a GUID string to a consistent format for comparison. + /// + private string? NormalizeGuid(string input) + { + if (string.IsNullOrEmpty(input)) + { + return null; + } + + // Try to parse as GUID (handles both with and without dashes) + if (Guid.TryParse(input, out var guid)) + { + return guid.ToString("N"); // Always return format without dashes } - _logger.LogWarning("No stream mapping found for item {ItemId}", itemId); return null; } @@ -183,16 +427,65 @@ public class StreamProxyService : IDisposable } /// - /// Cleans up old stream mappings. + /// Extracts the token expiry time from a stream URL with hdnts parameter. + /// + /// The authenticated stream URL. + /// The expiry time, or null if not found. + private DateTime? ExtractTokenExpiry(string url) + { + try + { + var uri = new Uri(url); + var query = uri.Query; + + // Parse the hdnts parameter (e.g., "exp=1763282021") + var match = Regex.Match(query, @"exp=(\d+)"); + if (match.Success && long.TryParse(match.Groups[1].Value, out var unixTimestamp)) + { + // Convert Unix timestamp to DateTime + var expiry = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime; + _logger.LogDebug("Extracted token expiry from URL: {Expiry} UTC (unix: {Unix})", expiry, unixTimestamp); + return expiry; + } + + _logger.LogDebug("No token expiry found in URL: {Url}", url); + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to extract token expiry from URL: {Url}", url); + return null; + } + } + + /// + /// Cleans up old and expired stream mappings. /// public void CleanupOldMappings() { var cutoff = DateTime.UtcNow.AddHours(-24); + var now = DateTime.UtcNow; var keysToRemove = new System.Collections.Generic.List(); foreach (var kvp in _streamMappings) { + var shouldRemove = false; + + // Remove if registered more than 24 hours ago if (kvp.Value.RegisteredAt < cutoff) + { + shouldRemove = true; + _logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key); + } + + // Remove if token has expired + if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now) + { + shouldRemove = true; + _logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key); + } + + if (shouldRemove) { keysToRemove.Add(kvp.Key); } @@ -201,12 +494,11 @@ public class StreamProxyService : IDisposable foreach (var key in keysToRemove) { _streamMappings.TryRemove(key, out _); - _logger.LogDebug("Removed old stream mapping for item {ItemId}", key); } if (keysToRemove.Count > 0) { - _logger.LogInformation("Cleaned up {Count} old stream mappings", keysToRemove.Count); + _logger.LogInformation("Cleaned up {Count} old/expired stream mappings", keysToRemove.Count); } } @@ -245,6 +537,10 @@ public class StreamProxyService : IDisposable { public string AuthenticatedUrl { get; set; } = string.Empty; + public string UnauthenticatedUrl { get; set; } = string.Empty; + public DateTime RegisteredAt { get; set; } + + public DateTime? TokenExpiresAt { get; set; } } }