Dynamically fetch livestream info, resolves bug where stale data caused playback to fail.
This commit is contained in:
parent
89c41842a7
commit
0fea57a4f9
@ -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
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user