Remove 9 dead methods, 6 unused constants, and redundant ReaderWriterLockSlim from MetadataCache. Consolidate repeated patterns into HasChapters, IsPlayable, and ToLowerString helpers. Extract shared API methods in SRFApiClient. Move variant manifest rewriting from controller to StreamProxyService. Make Auto quality distinct from HD. Update README architecture section.
997 lines
40 KiB
C#
997 lines
40 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
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 Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
using MediaBrowser.Common.Net;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.SRFPlay.Services;
|
|
|
|
/// <summary>
|
|
/// Service for proxying SRF Play streams and managing authentication.
|
|
/// </summary>
|
|
public class StreamProxyService : IStreamProxyService
|
|
{
|
|
private readonly ILogger<StreamProxyService> _logger;
|
|
private readonly IStreamUrlResolver _streamResolver;
|
|
private readonly IMediaCompositionFetcher _compositionFetcher;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="StreamProxyService"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">The logger.</param>
|
|
/// <param name="streamResolver">The stream URL resolver.</param>
|
|
/// <param name="compositionFetcher">The media composition fetcher.</param>
|
|
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
|
public StreamProxyService(
|
|
ILogger<StreamProxyService> logger,
|
|
IStreamUrlResolver streamResolver,
|
|
IMediaCompositionFetcher compositionFetcher,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_streamResolver = streamResolver;
|
|
_compositionFetcher = compositionFetcher;
|
|
_httpClientFactory = httpClientFactory;
|
|
_streamMappings = new ConcurrentDictionary<string, StreamInfo>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a stream for deferred authentication (authenticates on first playback request).
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="unauthenticatedUrl">The unauthenticated stream URL.</param>
|
|
/// <param name="urn">The SRF URN for this content.</param>
|
|
/// <param name="isLiveStream">Whether this is a livestream.</param>
|
|
public void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false)
|
|
{
|
|
var streamInfo = new StreamInfo
|
|
{
|
|
AuthenticatedUrl = string.Empty, // Will be populated on first access
|
|
UnauthenticatedUrl = unauthenticatedUrl,
|
|
RegisteredAt = DateTime.UtcNow,
|
|
TokenExpiresAt = null,
|
|
Urn = urn,
|
|
IsLiveStream = isLiveStream,
|
|
LastLivestreamFetchAt = null,
|
|
NeedsAuthentication = true
|
|
};
|
|
|
|
RegisterWithGuidFormats(itemId, streamInfo);
|
|
|
|
_logger.LogDebug(
|
|
"Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)",
|
|
itemId,
|
|
urn ?? "null");
|
|
}
|
|
|
|
/// <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>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The authenticated URL, or null if not found or expired.</returns>
|
|
public async Task<string?> GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default)
|
|
{
|
|
_logger.LogInformation("GetAuthenticatedUrlAsync called for itemId: {ItemId}", itemId);
|
|
|
|
// Try direct lookup first
|
|
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
|
|
{
|
|
// Log detailed StreamInfo state to diagnose stale alias issues
|
|
var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue
|
|
? (streamInfo.TokenExpiresAt.Value - DateTime.UtcNow).TotalSeconds
|
|
: -1;
|
|
_logger.LogInformation(
|
|
"Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}",
|
|
itemId,
|
|
streamInfo.NeedsAuthentication,
|
|
streamInfo.IsLiveStream,
|
|
string.IsNullOrEmpty(streamInfo.Urn) ? "(empty)" : "set",
|
|
tokenTimeLeft,
|
|
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
|
|
|
|
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
|
|
// Don't replace a valid token (>5s left) with a new deferred registration
|
|
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
|
|
{
|
|
var freshStream = FindFreshestStream();
|
|
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
|
|
{
|
|
_logger.LogWarning(
|
|
"Token expiring soon ({TokenLeft:F0}s), switching to fresher deferred stream {ItemId} -> {FreshKey}",
|
|
tokenTimeLeft,
|
|
itemId,
|
|
freshStream.Value.Key);
|
|
_streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value);
|
|
return await ValidateAndReturnStreamAsync(itemId, freshStream.Value.Value, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
return await ValidateAndReturnStreamAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
_logger.LogWarning("No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count);
|
|
|
|
// 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 = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: Live TV channelId_guid format lookup
|
|
// For Live TV streams, itemId format is "channelId_urnGuid"
|
|
// Try matching by suffix (urnGuid part) or prefix (channelId part)
|
|
if (itemId.Contains('_', StringComparison.Ordinal))
|
|
{
|
|
var parts = itemId.Split('_', 2);
|
|
var prefix = parts[0]; // channelId part
|
|
var suffix = parts.Length > 1 ? parts[1] : null; // urnGuid part
|
|
|
|
foreach (var kvp in _streamMappings)
|
|
{
|
|
// Check if registered key contains the same suffix (urnGuid)
|
|
if (suffix != null && kvp.Key.Contains(suffix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogInformation(
|
|
"Found stream by Live TV suffix match - Requested: {RequestedId}, Registered: {RegisteredId}",
|
|
itemId,
|
|
kvp.Key);
|
|
var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
|
|
if (url != null)
|
|
{
|
|
// Also register with the requested itemId for future lookups
|
|
_streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
|
|
return url;
|
|
}
|
|
}
|
|
|
|
// Check if registered key starts with the same prefix (channelId)
|
|
if (kvp.Key.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogInformation(
|
|
"Found stream by Live TV prefix match - Requested: {RequestedId}, Registered: {RegisteredId}",
|
|
itemId,
|
|
kvp.Key);
|
|
var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
|
|
if (url != null)
|
|
{
|
|
_streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
|
|
return url;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.LogInformation(
|
|
"Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (single active stream)",
|
|
itemId,
|
|
activeStreams[0].Key);
|
|
|
|
// Register the transcoding session ID as an alias (update if stale alias exists)
|
|
_streamMappings.AddOrUpdate(itemId, activeStreams[0].Value, (key, old) => activeStreams[0].Value);
|
|
|
|
return await ValidateAndReturnStreamAsync(activeStreams[0].Key, activeStreams[0].Value, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// 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.LogInformation(
|
|
"Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (registered {Seconds:F1}s ago)",
|
|
itemId,
|
|
mostRecent.Key,
|
|
age.TotalSeconds);
|
|
|
|
// Register the transcoding session ID as an alias (update if stale alias exists)
|
|
_streamMappings.AddOrUpdate(itemId, mostRecent.Value, (key, old) => mostRecent.Value);
|
|
|
|
return await ValidateAndReturnStreamAsync(mostRecent.Key, mostRecent.Value, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"No stream mapping found for item {ItemId}. Active streams: {Count}. Registered IDs: {RegisteredIds}",
|
|
itemId,
|
|
activeStreams.Count,
|
|
string.Join(", ", _streamMappings.Keys));
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates a stream and returns its URL if valid.
|
|
/// </summary>
|
|
private async Task<string?> ValidateAndReturnStreamAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
|
|
{
|
|
// Handle deferred authentication (first playback after browsing)
|
|
if (streamInfo.NeedsAuthentication)
|
|
{
|
|
_logger.LogInformation(
|
|
"First playback for item {ItemId} - authenticating stream on-demand",
|
|
itemId);
|
|
|
|
var authenticatedUrl = await AuthenticateOnDemandAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
|
|
if (authenticatedUrl != null)
|
|
{
|
|
return authenticatedUrl;
|
|
}
|
|
|
|
_logger.LogWarning("Failed to authenticate stream on-demand for item {ItemId}", itemId);
|
|
return null;
|
|
}
|
|
|
|
// For livestreams, use smart caching to avoid hammering the API
|
|
// Only fetch fresh if token is expiring soon or hasn't been fetched recently
|
|
if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn))
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue
|
|
? (streamInfo.TokenExpiresAt.Value - now).TotalSeconds
|
|
: 30; // Assume 30s if no expiry
|
|
|
|
var timeSinceLastFetch = streamInfo.LastLivestreamFetchAt.HasValue
|
|
? (now - streamInfo.LastLivestreamFetchAt.Value).TotalSeconds
|
|
: double.MaxValue;
|
|
|
|
// Use cached URL if: token has >10s left AND we fetched within last 15 seconds
|
|
if (tokenTimeLeft > 10 && timeSinceLastFetch < 15)
|
|
{
|
|
_logger.LogDebug(
|
|
"Livestream {ItemId}: Using cached URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)",
|
|
itemId,
|
|
tokenTimeLeft,
|
|
timeSinceLastFetch);
|
|
return streamInfo.AuthenticatedUrl;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Livestream {ItemId}: Fetching fresh URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)",
|
|
itemId,
|
|
tokenTimeLeft,
|
|
timeSinceLastFetch);
|
|
|
|
var freshUrl = await FetchFreshStreamUrlAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
|
|
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;
|
|
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 {Reason} for item {ItemId} (expires at {ExpiresAt}, now is {Now}) - attempting to refresh",
|
|
reason,
|
|
itemId,
|
|
streamInfo.TokenExpiresAt.Value,
|
|
now);
|
|
|
|
// Try to refresh the token
|
|
var refreshedUrl = await RefreshTokenAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
|
|
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:F1}s remaining)",
|
|
itemId,
|
|
streamInfo.TokenExpiresAt.Value,
|
|
timeUntilExpiry.TotalSeconds);
|
|
}
|
|
|
|
return streamInfo.AuthenticatedUrl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches a fresh stream URL from the SRF API for livestreams.
|
|
/// </summary>
|
|
private async Task<string?> FetchFreshStreamUrlAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrEmpty(streamInfo.Urn))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Use short cache duration (5 min) for livestreams
|
|
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);
|
|
|
|
if (mediaComposition?.HasChapters != true)
|
|
{
|
|
_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 = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Update the stored stream info with the fresh data
|
|
var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl);
|
|
streamInfo.AuthenticatedUrl = authenticatedUrl;
|
|
streamInfo.UnauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
|
|
streamInfo.TokenExpiresAt = newTokenExpiry;
|
|
streamInfo.LastLivestreamFetchAt = DateTime.UtcNow;
|
|
|
|
_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>
|
|
private async Task<string?> RefreshTokenAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
|
|
{
|
|
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
|
|
var newAuthenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(
|
|
streamInfo.UnauthenticatedUrl,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates a stream on-demand (first playback after browsing).
|
|
/// </summary>
|
|
private async Task<string?> AuthenticateOnDemandAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
|
|
{
|
|
_logger.LogWarning("Cannot authenticate on-demand for {ItemId} - no unauthenticated URL stored", itemId);
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Authenticate the stream URL
|
|
var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(
|
|
streamInfo.UnauthenticatedUrl,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (string.IsNullOrEmpty(authenticatedUrl))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Update the stream info - no longer needs authentication
|
|
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
|
|
streamInfo.AuthenticatedUrl = authenticatedUrl;
|
|
streamInfo.TokenExpiresAt = tokenExpiry;
|
|
streamInfo.NeedsAuthentication = false;
|
|
|
|
if (streamInfo.IsLiveStream)
|
|
{
|
|
streamInfo.LastLivestreamFetchAt = DateTime.UtcNow;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Authenticated stream on-demand for item {ItemId} (expires at {ExpiresAt} UTC)",
|
|
itemId,
|
|
tokenExpiry);
|
|
|
|
return authenticatedUrl;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error authenticating stream on-demand for item {ItemId}", itemId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Strips authentication parameters from a URL to get the base unauthenticated URL.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a GUID string to a consistent format for comparison.
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
|
|
/// </summary>
|
|
/// <returns>The freshest stream entry, or null if none found.</returns>
|
|
private KeyValuePair<string, StreamInfo>? FindFreshestStream()
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Find streams that either need authentication (fresh deferred registration)
|
|
// or have tokens that aren't expired yet
|
|
var candidates = _streamMappings.Where(kvp =>
|
|
{
|
|
if (kvp.Value.NeedsAuthentication)
|
|
{
|
|
return true; // Fresh deferred registration
|
|
}
|
|
|
|
if (!kvp.Value.TokenExpiresAt.HasValue)
|
|
{
|
|
return true; // No expiry
|
|
}
|
|
|
|
// Token not expired yet
|
|
return now < kvp.Value.TokenExpiresAt.Value;
|
|
}).ToList();
|
|
|
|
if (candidates.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Prefer the most recently registered stream
|
|
return candidates.OrderByDescending(kvp => kvp.Value.RegisteredAt).First();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches and rewrites an HLS manifest to use proxy URLs.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="baseProxyUrl">The base proxy URL (e.g., https://jellyfin-server/Plugins/SRFPlay/Proxy/{itemId}).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The rewritten manifest content.</returns>
|
|
public async Task<string?> GetRewrittenManifestAsync(
|
|
string itemId,
|
|
string baseProxyUrl,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
|
|
if (authenticatedUrl == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
_logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl);
|
|
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
|
var manifestContent = await httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogDebug("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent);
|
|
|
|
// Rewrite the manifest to replace Akamai URLs with proxy URLs
|
|
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
|
|
|
|
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
|
|
return rewrittenContent;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fetch manifest for item {ItemId} from {Url}", itemId, authenticatedUrl);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches a segment from the original source.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="segmentPath">The segment path.</param>
|
|
/// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The segment content as bytes.</returns>
|
|
public async Task<byte[]?> GetSegmentAsync(
|
|
string itemId,
|
|
string segmentPath,
|
|
string? queryString = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
|
|
if (authenticatedUrl == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Build the full segment URL by combining the base URL with the segment path
|
|
var baseUri = new Uri(authenticatedUrl);
|
|
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
|
|
|
|
// Use the original query string from the request (preserves segment-specific params like ?m=timestamp)
|
|
// If no query string is provided, check if we need to add auth params from the master manifest
|
|
var queryParams = string.Empty;
|
|
if (!string.IsNullOrEmpty(queryString))
|
|
{
|
|
// Use the original query string from the segment request
|
|
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
|
|
}
|
|
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Only append master manifest query params if segment doesn't have path-based auth
|
|
queryParams = baseUri.Query;
|
|
}
|
|
|
|
// Build full segment URL
|
|
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
|
|
|
|
_logger.LogDebug(
|
|
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, QueryString: {QueryString}, FullUrl: {FullUrl}",
|
|
authenticatedUrl,
|
|
baseUrl,
|
|
segmentPath,
|
|
queryString ?? "(none)",
|
|
segmentUrl);
|
|
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
|
var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
|
|
|
_logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
|
|
return segmentData;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fetch segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrites URLs in HLS manifest to point to proxy.
|
|
/// </summary>
|
|
/// <param name="manifestContent">The original manifest content.</param>
|
|
/// <param name="originalBaseUrl">The original base URL.</param>
|
|
/// <param name="proxyBaseUrl">The proxy base URL.</param>
|
|
/// <returns>The rewritten manifest.</returns>
|
|
private string RewriteManifestUrls(string manifestContent, string originalBaseUrl, string proxyBaseUrl)
|
|
{
|
|
var baseUri = new Uri(originalBaseUrl);
|
|
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
|
|
|
|
// Extract query parameters from proxyBaseUrl to propagate them
|
|
var queryParams = string.Empty;
|
|
var queryStart = proxyBaseUrl.IndexOf('?', StringComparison.Ordinal);
|
|
if (queryStart >= 0)
|
|
{
|
|
queryParams = proxyBaseUrl[queryStart..];
|
|
proxyBaseUrl = proxyBaseUrl[..queryStart]; // Remove query from base URL
|
|
_logger.LogDebug("Extracted query parameters from proxy URL: {QueryParams}", queryParams);
|
|
}
|
|
|
|
// Helper function to rewrite a URL to proxy
|
|
string RewriteUrl(string url)
|
|
{
|
|
// Try to parse as absolute URL
|
|
if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri))
|
|
{
|
|
// Check if it's from the same CDN host
|
|
if (!absoluteUri.Host.Equals(baseUri.Host, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// External URL (e.g., subtitles from different domain) - leave as-is
|
|
_logger.LogDebug("Leaving external URL unchanged: {Url}", url);
|
|
return url;
|
|
}
|
|
|
|
// Same host - extract just the filename (last path segment)
|
|
var segments = absoluteUri.AbsolutePath.Split('/');
|
|
var filename = segments[^1];
|
|
return $"{proxyBaseUrl}/{filename}{queryParams}";
|
|
}
|
|
|
|
// Relative URL - extract just the path without query params
|
|
var path = url;
|
|
var queryIndex = path.IndexOf('?', StringComparison.Ordinal);
|
|
if (queryIndex >= 0)
|
|
{
|
|
path = path[..queryIndex];
|
|
}
|
|
|
|
return $"{proxyBaseUrl}/{path}{queryParams}";
|
|
}
|
|
|
|
// Pattern 1: Standalone URL lines (non-# lines ending with media extensions)
|
|
var pattern1 = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)";
|
|
var rewritten = Regex.Replace(manifestContent, pattern1, match =>
|
|
{
|
|
var url = match.Groups[1].Value.Trim();
|
|
return $"\n{RewriteUrl(url)}";
|
|
});
|
|
|
|
// Pattern 2: URI="..." attributes in HLS tags (e.g., #EXT-X-MEDIA, #EXT-X-I-FRAME-STREAM-INF)
|
|
var pattern2 = @"URI=""([^""]+)""";
|
|
rewritten = Regex.Replace(rewritten, pattern2, match =>
|
|
{
|
|
var url = match.Groups[1].Value;
|
|
return $"URI=\"{RewriteUrl(url)}\"";
|
|
});
|
|
|
|
return rewritten;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the token expiry time from a stream URL with hdnts parameter.
|
|
/// </summary>
|
|
/// <param name="url">The authenticated stream URL.</param>
|
|
/// <returns>The expiry time, or null if not found.</returns>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
|
|
/// </summary>
|
|
/// <param name="manifestContent">The variant manifest content.</param>
|
|
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
|
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
|
/// <returns>The rewritten manifest content.</returns>
|
|
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
|
|
{
|
|
string RewriteUrl(string url)
|
|
{
|
|
if (url.Contains("://", StringComparison.Ordinal))
|
|
{
|
|
// Absolute URL - extract filename and rewrite
|
|
var uri = new Uri(url.Trim());
|
|
var segments = uri.AbsolutePath.Split('/');
|
|
var fileName = segments[^1];
|
|
return $"{baseProxyUrl}/{fileName}{queryParams}";
|
|
}
|
|
|
|
// Relative URL - rewrite to proxy
|
|
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
|
|
}
|
|
|
|
var lines = manifestContent.Split('\n');
|
|
var result = new System.Text.StringBuilder();
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
result.AppendLine(line);
|
|
}
|
|
else if (line.StartsWith('#'))
|
|
{
|
|
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
|
|
if (line.Contains("URI=\"", StringComparison.Ordinal))
|
|
{
|
|
var rewrittenLine = Regex.Replace(
|
|
line,
|
|
@"URI=""([^""]+)""",
|
|
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
|
|
result.AppendLine(rewrittenLine);
|
|
}
|
|
else
|
|
{
|
|
result.AppendLine(line);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Non-tag line with URL - rewrite it
|
|
result.AppendLine(RewriteUrl(line));
|
|
}
|
|
}
|
|
|
|
return result.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans up old and expired stream mappings.
|
|
/// </summary>
|
|
public void CleanupOldMappings()
|
|
{
|
|
var cutoff = DateTime.UtcNow.AddHours(-24);
|
|
var now = DateTime.UtcNow;
|
|
var keysToRemove = new System.Collections.Generic.List<string>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
foreach (var key in keysToRemove)
|
|
{
|
|
_streamMappings.TryRemove(key, out _);
|
|
}
|
|
|
|
if (keysToRemove.Count > 0)
|
|
{
|
|
_logger.LogInformation("Cleaned up {Count} old/expired stream mappings", keysToRemove.Count);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a stream with multiple GUID format variations to handle Jellyfin's ID transformations.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="streamInfo">The stream information to register.</param>
|
|
private void RegisterWithGuidFormats(string itemId, StreamInfo streamInfo)
|
|
{
|
|
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stream information.
|
|
/// </summary>
|
|
private sealed class StreamInfo
|
|
{
|
|
public string AuthenticatedUrl { get; set; } = string.Empty;
|
|
|
|
public string UnauthenticatedUrl { get; set; } = string.Empty;
|
|
|
|
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; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets when this livestream URL was last fetched from the API.
|
|
/// Used to prevent rapid-fire API calls from clients like Android TV.
|
|
/// </summary>
|
|
public DateTime? LastLivestreamFetchAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether this stream needs authentication on first access.
|
|
/// True when registered via RegisterStreamDeferred (authentication deferred until playback).
|
|
/// </summary>
|
|
public bool NeedsAuthentication { get; set; }
|
|
}
|
|
}
|