From 0fea57a4f91f13a2a7d0fa91c51582d51aed78e4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 6 Dec 2025 16:35:36 +0100 Subject: [PATCH] Dynamically fetch livestream info, resolves bug where stale data caused playback to fail. --- .../Providers/SRFLiveStream.cs | 15 +- .../Providers/SRFMediaProvider.cs | 13 +- .../Services/StreamProxyService.cs | 152 +++++++++++++++++- 3 files changed, 164 insertions(+), 16 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs index ffe2ff5..01846db 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs @@ -77,13 +77,20 @@ internal sealed class SRFLiveStream : ILiveStream value.Id, _originalItemId); - // Get the authenticated URL from the original registration + // Get the authenticated URL and metadata from the original registration var authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId); + var metadata = _proxyService.GetStreamMetadata(_originalItemId); if (authenticatedUrl != null) { - // Register the same stream URL with the transcoding session ID - _proxyService.RegisterStream(value.Id, authenticatedUrl); - _logger.LogInformation("Registered stream for transcoding session ID: {LiveStreamId}", value.Id); + // Register the same stream URL with the transcoding session ID, preserving metadata + var urn = metadata?.Urn; + var isLiveStream = metadata?.IsLiveStream ?? false; + _proxyService.RegisterStream(value.Id, authenticatedUrl, urn, isLiveStream); + _logger.LogInformation( + "Registered stream for transcoding session ID: {LiveStreamId} (URN: {Urn}, IsLiveStream: {IsLiveStream})", + value.Id, + urn ?? "null", + isLiveStream); } else { diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index 6136a70..8c6d552 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -177,18 +177,23 @@ public class SRFMediaProvider : IMediaSourceProvider _logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn); } + // Detect if this is a live stream + var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); + _logger.LogInformation( + "Livestream detection - ChapterType: {ChapterType}, URN: {Urn}, IsLiveStream: {IsLiveStream}", + chapter.Type, + urn, + isLiveStream); + // Register stream with proxy service var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes - _proxyService.RegisterStream(itemIdStr, streamUrl); + _proxyService.RegisterStream(itemIdStr, streamUrl, urn, isLiveStream); // Get the server URL for proxy - prefer configured public URL for remote clients var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl) ? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients) : _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution - // Detect if this is a live stream - var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); - // Generate an open token for this media source (used to track transcoding sessions) var openToken = Guid.NewGuid().ToString("N"); _openTokenToItemId[openToken] = itemIdStr; diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index 94f00e7..c461d97 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -6,6 +6,8 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Web; +using Jellyfin.Plugin.SRFPlay.Api; +using Jellyfin.Plugin.SRFPlay.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -16,6 +18,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services; public class StreamProxyService : IDisposable { private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; private readonly StreamUrlResolver _streamResolver; private readonly HttpClient _httpClient; private readonly ConcurrentDictionary _streamMappings; @@ -25,10 +28,12 @@ public class StreamProxyService : IDisposable /// Initializes a new instance of the class. /// /// The logger. + /// The logger factory (for creating API clients). /// The stream URL resolver. - public StreamProxyService(ILogger logger, StreamUrlResolver streamResolver) + public StreamProxyService(ILogger logger, ILoggerFactory loggerFactory, StreamUrlResolver streamResolver) { _logger = logger; + _loggerFactory = loggerFactory; _streamResolver = streamResolver; _httpClient = new HttpClient { @@ -42,7 +47,9 @@ public class StreamProxyService : IDisposable /// /// The item ID. /// The authenticated stream URL. - public void RegisterStream(string itemId, string authenticatedUrl) + /// The SRF URN for this content (used for re-fetching fresh URLs). + /// Whether this is a livestream (livestreams always fetch fresh URLs). + public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false) { var tokenExpiry = ExtractTokenExpiry(authenticatedUrl); var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl); @@ -52,7 +59,9 @@ public class StreamProxyService : IDisposable AuthenticatedUrl = authenticatedUrl, UnauthenticatedUrl = unauthenticatedUrl, RegisteredAt = DateTime.UtcNow, - TokenExpiresAt = tokenExpiry + TokenExpiresAt = tokenExpiry, + Urn = urn, + IsLiveStream = isLiveStream }; // Register with the provided item ID @@ -93,6 +102,36 @@ public class StreamProxyService : IDisposable } } + /// + /// Gets stream metadata for an item (URN and isLiveStream flag). + /// Used when propagating stream registration to transcoding sessions. + /// + /// The item ID. + /// A tuple of (URN, IsLiveStream), or null if not found. + public (string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId) + { + if (_streamMappings.TryGetValue(itemId, out var streamInfo)) + { + return (streamInfo.Urn, streamInfo.IsLiveStream); + } + + // Try GUID normalization + var normalizedId = NormalizeGuid(itemId); + if (normalizedId != null) + { + foreach (var kvp in _streamMappings) + { + var normalizedKey = NormalizeGuid(kvp.Key); + if (normalizedKey != null && normalizedKey == normalizedId) + { + return (kvp.Value.Urn, kvp.Value.IsLiveStream); + } + } + } + + return null; + } + /// /// Gets the authenticated URL for an item. /// @@ -191,14 +230,43 @@ public class StreamProxyService : IDisposable /// private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo) { - // Check if token has expired + // For livestreams, always fetch fresh URL from API to avoid stale CDN paths + if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn)) + { + _logger.LogInformation( + "Livestream detected for item {ItemId} (URN: {Urn}) - fetching fresh stream URL from API", + itemId, + streamInfo.Urn); + + var freshUrl = FetchFreshStreamUrl(itemId, streamInfo); + if (freshUrl != null) + { + return freshUrl; + } + + _logger.LogWarning("Failed to fetch fresh URL for livestream {ItemId}, falling back to cached URL", itemId); + // Fall through to use cached URL as fallback + } + + // Check if token has expired or is about to expire if (streamInfo.TokenExpiresAt.HasValue) { var now = DateTime.UtcNow; - if (now >= streamInfo.TokenExpiresAt.Value) + var timeUntilExpiry = streamInfo.TokenExpiresAt.Value - now; + + // Proactive refresh: refresh if token has expired OR will expire within 5 seconds + // This prevents race conditions during rapid segment fetching in transcoding + var shouldRefresh = now >= streamInfo.TokenExpiresAt.Value || timeUntilExpiry.TotalSeconds <= 5; + + if (shouldRefresh) { + var reason = now >= streamInfo.TokenExpiresAt.Value + ? "expired" + : $"expiring in {timeUntilExpiry.TotalSeconds:F1}s"; + _logger.LogWarning( - "Token expired for item {ItemId} (expired at {ExpiresAt}, now is {Now}) - attempting to refresh", + "Token {Reason} for item {ItemId} (expires at {ExpiresAt}, now is {Now}) - attempting to refresh", + reason, itemId, streamInfo.TokenExpiresAt.Value, now); @@ -217,15 +285,72 @@ public class StreamProxyService : IDisposable } _logger.LogDebug( - "Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft} remaining)", + "Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft:F1}s remaining)", itemId, streamInfo.TokenExpiresAt.Value, - streamInfo.TokenExpiresAt.Value - now); + timeUntilExpiry.TotalSeconds); } return streamInfo.AuthenticatedUrl; } + /// + /// Fetches a fresh stream URL from the SRF API for livestreams. + /// + private string? FetchFreshStreamUrl(string itemId, StreamInfo streamInfo) + { + if (string.IsNullOrEmpty(streamInfo.Urn)) + { + return null; + } + + try + { + using var apiClient = new SRFApiClient(_loggerFactory); + var mediaComposition = apiClient.GetMediaCompositionByUrnAsync(streamInfo.Urn, CancellationToken.None) + .GetAwaiter().GetResult(); + + if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) + { + _logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn); + return null; + } + + var chapter = mediaComposition.ChapterList[0]; + var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); + var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); + + if (string.IsNullOrEmpty(streamUrl)) + { + _logger.LogWarning("No stream URL found when refreshing livestream for URN: {Urn}", streamInfo.Urn); + return null; + } + + // Authenticate the fresh URL + var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, CancellationToken.None) + .GetAwaiter().GetResult(); + + // Update the stored stream info with the fresh data + var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl); + streamInfo.AuthenticatedUrl = authenticatedUrl; + streamInfo.UnauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl); + streamInfo.TokenExpiresAt = newTokenExpiry; + + _logger.LogInformation( + "Fetched fresh livestream URL for item {ItemId} (URN: {Urn}, new expiry: {Expiry})", + itemId, + streamInfo.Urn, + newTokenExpiry); + + return authenticatedUrl; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching fresh stream URL for livestream {ItemId} (URN: {Urn})", itemId, streamInfo.Urn); + return null; + } + } + /// /// Attempts to refresh an expired token. /// @@ -557,5 +682,16 @@ public class StreamProxyService : IDisposable public DateTime RegisteredAt { get; set; } public DateTime? TokenExpiresAt { get; set; } + + /// + /// Gets or sets the SRF URN for this stream (used for re-fetching fresh URLs). + /// + public string? Urn { get; set; } + + /// + /// Gets or sets a value indicating whether this is a livestream. + /// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths. + /// + public bool IsLiveStream { get; set; } } }