Dynamically fetch livestream info, resolves bug where stale data caused playback to fail.
All checks were successful
🏗️ Build Plugin / build (push) Successful in 3m47s
🧪 Test Plugin / test (push) Successful in 1m43s

This commit is contained in:
Duncan Tourolle 2025-12-06 16:35:36 +01:00
parent 89c41842a7
commit 0fea57a4f9
3 changed files with 164 additions and 16 deletions

View File

@ -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
{

View File

@ -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;

View File

@ -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<StreamProxyService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly StreamUrlResolver _streamResolver;
private readonly HttpClient _httpClient;
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
@ -25,10 +28,12 @@ public class StreamProxyService : IDisposable
/// Initializes a new instance of the <see cref="StreamProxyService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="loggerFactory">The logger factory (for creating API clients).</param>
/// <param name="streamResolver">The stream URL resolver.</param>
public StreamProxyService(ILogger<StreamProxyService> logger, StreamUrlResolver streamResolver)
public StreamProxyService(ILogger<StreamProxyService> logger, ILoggerFactory loggerFactory, StreamUrlResolver streamResolver)
{
_logger = logger;
_loggerFactory = loggerFactory;
_streamResolver = streamResolver;
_httpClient = new HttpClient
{
@ -42,7 +47,9 @@ public class StreamProxyService : IDisposable
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
public void RegisterStream(string itemId, string authenticatedUrl)
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
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
}
}
/// <summary>
/// Gets stream metadata for an item (URN and isLiveStream flag).
/// Used when propagating stream registration to transcoding sessions.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
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;
}
/// <summary>
/// Gets the authenticated URL for an item.
/// </summary>
@ -191,14 +230,43 @@ public class StreamProxyService : IDisposable
/// </summary>
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;
}
/// <summary>
/// Fetches a fresh stream URL from the SRF API for livestreams.
/// </summary>
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;
}
}
/// <summary>
/// Attempts to refresh an expired token.
/// </summary>
@ -557,5 +682,16 @@ public class StreamProxyService : IDisposable
public DateTime RegisteredAt { get; set; }
public DateTime? TokenExpiresAt { get; set; }
/// <summary>
/// Gets or sets the SRF URN for this stream (used for re-fetching fresh URLs).
/// </summary>
public string? Urn { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is a livestream.
/// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths.
/// </summary>
public bool IsLiveStream { get; set; }
}
}