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,
|
value.Id,
|
||||||
_originalItemId);
|
_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 authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId);
|
||||||
|
var metadata = _proxyService.GetStreamMetadata(_originalItemId);
|
||||||
if (authenticatedUrl != null)
|
if (authenticatedUrl != null)
|
||||||
{
|
{
|
||||||
// Register the same stream URL with the transcoding session ID
|
// Register the same stream URL with the transcoding session ID, preserving metadata
|
||||||
_proxyService.RegisterStream(value.Id, authenticatedUrl);
|
var urn = metadata?.Urn;
|
||||||
_logger.LogInformation("Registered stream for transcoding session ID: {LiveStreamId}", value.Id);
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@ -177,18 +177,23 @@ public class SRFMediaProvider : IMediaSourceProvider
|
|||||||
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
|
_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
|
// Register stream with proxy service
|
||||||
var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes
|
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
|
// Get the server URL for proxy - prefer configured public URL for remote clients
|
||||||
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
|
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
|
||||||
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
|
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
|
||||||
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
|
: _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)
|
// Generate an open token for this media source (used to track transcoding sessions)
|
||||||
var openToken = Guid.NewGuid().ToString("N");
|
var openToken = Guid.NewGuid().ToString("N");
|
||||||
_openTokenToItemId[openToken] = itemIdStr;
|
_openTokenToItemId[openToken] = itemIdStr;
|
||||||
|
|||||||
@ -6,6 +6,8 @@ using System.Text.RegularExpressions;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||||
@ -16,6 +18,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
|||||||
public class StreamProxyService : IDisposable
|
public class StreamProxyService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ILogger<StreamProxyService> _logger;
|
private readonly ILogger<StreamProxyService> _logger;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly StreamUrlResolver _streamResolver;
|
private readonly StreamUrlResolver _streamResolver;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
|
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
|
||||||
@ -25,10 +28,12 @@ public class StreamProxyService : IDisposable
|
|||||||
/// Initializes a new instance of the <see cref="StreamProxyService"/> class.
|
/// Initializes a new instance of the <see cref="StreamProxyService"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <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>
|
/// <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;
|
_logger = logger;
|
||||||
|
_loggerFactory = loggerFactory;
|
||||||
_streamResolver = streamResolver;
|
_streamResolver = streamResolver;
|
||||||
_httpClient = new HttpClient
|
_httpClient = new HttpClient
|
||||||
{
|
{
|
||||||
@ -42,7 +47,9 @@ public class StreamProxyService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="itemId">The item ID.</param>
|
/// <param name="itemId">The item ID.</param>
|
||||||
/// <param name="authenticatedUrl">The authenticated stream URL.</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 tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
|
||||||
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
|
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
|
||||||
@ -52,7 +59,9 @@ public class StreamProxyService : IDisposable
|
|||||||
AuthenticatedUrl = authenticatedUrl,
|
AuthenticatedUrl = authenticatedUrl,
|
||||||
UnauthenticatedUrl = unauthenticatedUrl,
|
UnauthenticatedUrl = unauthenticatedUrl,
|
||||||
RegisteredAt = DateTime.UtcNow,
|
RegisteredAt = DateTime.UtcNow,
|
||||||
TokenExpiresAt = tokenExpiry
|
TokenExpiresAt = tokenExpiry,
|
||||||
|
Urn = urn,
|
||||||
|
IsLiveStream = isLiveStream
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register with the provided item ID
|
// 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>
|
/// <summary>
|
||||||
/// Gets the authenticated URL for an item.
|
/// Gets the authenticated URL for an item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -191,14 +230,43 @@ public class StreamProxyService : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo)
|
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)
|
if (streamInfo.TokenExpiresAt.HasValue)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
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(
|
_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,
|
itemId,
|
||||||
streamInfo.TokenExpiresAt.Value,
|
streamInfo.TokenExpiresAt.Value,
|
||||||
now);
|
now);
|
||||||
@ -217,15 +285,72 @@ public class StreamProxyService : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug(
|
_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,
|
itemId,
|
||||||
streamInfo.TokenExpiresAt.Value,
|
streamInfo.TokenExpiresAt.Value,
|
||||||
streamInfo.TokenExpiresAt.Value - now);
|
timeUntilExpiry.TotalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return streamInfo.AuthenticatedUrl;
|
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>
|
/// <summary>
|
||||||
/// Attempts to refresh an expired token.
|
/// Attempts to refresh an expired token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -557,5 +682,16 @@ public class StreamProxyService : IDisposable
|
|||||||
public DateTime RegisteredAt { get; set; }
|
public DateTime RegisteredAt { get; set; }
|
||||||
|
|
||||||
public DateTime? TokenExpiresAt { 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