mixed refactor
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -65,7 +66,8 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
RegisteredAt = DateTime.UtcNow,
|
||||
TokenExpiresAt = tokenExpiry,
|
||||
Urn = urn,
|
||||
IsLiveStream = isLiveStream
|
||||
IsLiveStream = isLiveStream,
|
||||
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
|
||||
};
|
||||
|
||||
// Register with the provided item ID
|
||||
@@ -106,6 +108,55 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
};
|
||||
|
||||
// Register with the provided item ID
|
||||
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
|
||||
|
||||
// Also register with alternative GUID formats
|
||||
if (Guid.TryParse(itemId, out var guid))
|
||||
{
|
||||
var formats = new[]
|
||||
{
|
||||
guid.ToString("N"),
|
||||
guid.ToString("D"),
|
||||
guid.ToString("B"),
|
||||
};
|
||||
|
||||
foreach (var format in formats)
|
||||
{
|
||||
if (format != itemId)
|
||||
{
|
||||
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => 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.
|
||||
@@ -148,7 +199,36 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
// Try direct lookup first
|
||||
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
|
||||
{
|
||||
_logger.LogInformation("✅ Found stream by direct lookup for itemId: {ItemId}", itemId);
|
||||
// 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 ValidateAndReturnStream(itemId, freshStream.Value.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return ValidateAndReturnStream(itemId, streamInfo);
|
||||
}
|
||||
|
||||
@@ -194,10 +274,14 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
|
||||
if (activeStreams.Count == 1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback",
|
||||
_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 ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value);
|
||||
}
|
||||
|
||||
@@ -212,11 +296,15 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
// This indicates it's likely the stream currently being set up for transcoding
|
||||
if (age.TotalSeconds < 30)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No exact match for {RequestedId}, but using most recently registered stream {RegisteredId} (registered {Seconds}s ago) as fallback",
|
||||
_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 ValidateAndReturnStream(mostRecent.Key, mostRecent.Value);
|
||||
}
|
||||
}
|
||||
@@ -234,13 +322,52 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
/// </summary>
|
||||
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo)
|
||||
{
|
||||
// For livestreams, always fetch fresh URL from API to avoid stale CDN paths
|
||||
if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn))
|
||||
// Handle deferred authentication (first playback after browsing)
|
||||
if (streamInfo.NeedsAuthentication)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Livestream detected for item {ItemId} (URN: {Urn}) - fetching fresh stream URL from API",
|
||||
"First playback for item {ItemId} - authenticating stream on-demand",
|
||||
itemId);
|
||||
|
||||
var authenticatedUrl = AuthenticateOnDemand(itemId, streamInfo);
|
||||
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,
|
||||
streamInfo.Urn);
|
||||
tokenTimeLeft,
|
||||
timeSinceLastFetch);
|
||||
|
||||
var freshUrl = FetchFreshStreamUrl(itemId, streamInfo);
|
||||
if (freshUrl != null)
|
||||
@@ -339,6 +466,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
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})",
|
||||
@@ -397,6 +525,54 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a stream on-demand (first playback after browsing).
|
||||
/// </summary>
|
||||
private string? AuthenticateOnDemand(string itemId, StreamInfo streamInfo)
|
||||
{
|
||||
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 = _streamResolver.GetAuthenticatedStreamUrlAsync(
|
||||
streamInfo.UnauthenticatedUrl,
|
||||
CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
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>
|
||||
@@ -446,6 +622,41 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
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>
|
||||
@@ -466,13 +677,15 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl);
|
||||
_logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl);
|
||||
var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("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("Successfully rewrote manifest for item {ItemId}", itemId);
|
||||
_logger.LogInformation("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
|
||||
return rewrittenContent;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -512,7 +725,12 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
// Build full segment URL
|
||||
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
|
||||
|
||||
_logger.LogDebug("Fetching segment: {SegmentUrl}", segmentUrl);
|
||||
_logger.LogInformation(
|
||||
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}",
|
||||
authenticatedUrl,
|
||||
baseUrl,
|
||||
segmentPath,
|
||||
segmentUrl);
|
||||
var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
|
||||
@@ -547,24 +765,51 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
_logger.LogDebug("Extracted query parameters from proxy URL: {QueryParams}", queryParams);
|
||||
}
|
||||
|
||||
// Pattern to match .m3u8 and .ts/.mp4 segment references
|
||||
var pattern = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)";
|
||||
|
||||
var rewritten = Regex.Replace(manifestContent, pattern, match =>
|
||||
// Helper function to rewrite a URL to proxy
|
||||
string RewriteUrl(string url)
|
||||
{
|
||||
var url = match.Groups[1].Value.Trim();
|
||||
|
||||
// Skip if it's already an absolute URL
|
||||
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
// Try to parse as absolute URL
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
// Rewrite absolute URLs to proxy
|
||||
var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal);
|
||||
return $"\n{proxyBaseUrl}/{relativePath}{queryParams}";
|
||||
// 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 - rewrite to proxy
|
||||
return $"\n{proxyBaseUrl}/{url}{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;
|
||||
@@ -697,5 +942,17 @@ public class StreamProxyService : IStreamProxyService, IDisposable
|
||||
/// 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; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user