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; /// /// Service for proxying SRF Play streams and managing authentication. /// public class StreamProxyService : IStreamProxyService { private readonly ILogger _logger; private readonly IStreamUrlResolver _streamResolver; private readonly IMediaCompositionFetcher _compositionFetcher; private readonly IHttpClientFactory _httpClientFactory; private readonly ConcurrentDictionary _streamMappings; /// /// Initializes a new instance of the class. /// /// The logger. /// The stream URL resolver. /// The media composition fetcher. /// The HTTP client factory. public StreamProxyService( ILogger logger, IStreamUrlResolver streamResolver, IMediaCompositionFetcher compositionFetcher, IHttpClientFactory httpClientFactory) { _logger = logger; _streamResolver = streamResolver; _compositionFetcher = compositionFetcher; _httpClientFactory = httpClientFactory; _streamMappings = new ConcurrentDictionary(); } /// /// Registers a stream for proxying. /// /// The item ID. /// The authenticated stream URL. /// The SRF URN for this content (used for re-fetching fresh URLs). /// Whether this is a livestream (livestreams always fetch fresh URLs). public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false) { var tokenExpiry = ExtractTokenExpiry(authenticatedUrl); var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl); var streamInfo = new StreamInfo { AuthenticatedUrl = authenticatedUrl, UnauthenticatedUrl = unauthenticatedUrl, RegisteredAt = DateTime.UtcNow, TokenExpiresAt = tokenExpiry, Urn = urn, IsLiveStream = isLiveStream, LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null }; RegisterWithGuidFormats(itemId, streamInfo); if (tokenExpiry.HasValue) { _logger.LogDebug( "Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}", itemId, tokenExpiry.Value, authenticatedUrl); } else { _logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); } } /// /// Registers a stream for deferred authentication (authenticates on first playback request). /// /// The item ID. /// The unauthenticated stream URL. /// The SRF URN for this content. /// Whether this is a livestream. 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"); } /// /// Gets stream metadata for an item (URN and isLiveStream flag). /// Used when propagating stream registration to transcoding sessions. /// /// The item ID. /// A tuple of (URN, IsLiveStream), or null if not found. 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; } /// /// Gets the authenticated URL for an item. /// /// The item ID. /// Cancellation token. /// The authenticated URL, or null if not found or expired. public async Task 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; } /// /// Validates a stream and returns its URL if valid. /// private async Task 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; } /// /// Fetches a fresh stream URL from the SRF API for livestreams. /// private async Task 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?.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 = 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; } } /// /// Attempts to refresh an expired token. /// private async Task 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; } } /// /// Authenticates a stream on-demand (first playback after browsing). /// private async Task 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; } } /// /// Strips authentication parameters from a URL to get the base unauthenticated URL. /// 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; } } /// /// Normalizes a GUID string to a consistent format for comparison. /// 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; } /// /// Finds the freshest (most recently registered) stream that needs authentication or has a valid token. /// /// The freshest stream entry, or null if none found. private KeyValuePair? 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(); } /// /// Fetches and rewrites an HLS manifest to use proxy URLs. /// /// The item ID. /// The base proxy URL (e.g., https://jellyfin-server/Plugins/SRFPlay/Proxy/{itemId}). /// Cancellation token. /// The rewritten manifest content. public async Task 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; } } /// /// Fetches a segment from the original source. /// /// The item ID. /// The segment path. /// Cancellation token. /// The segment content as bytes. public async Task GetSegmentAsync( string itemId, string segmentPath, 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])}"; // Extract query parameters (auth tokens) from authenticated URL var queryParams = baseUri.Query; // Build full segment URL var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}"; _logger.LogDebug( "Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", authenticatedUrl, baseUrl, segmentPath, 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; } } /// /// Rewrites URLs in HLS manifest to point to proxy. /// /// The original manifest content. /// The original base URL. /// The proxy base URL. /// The rewritten manifest. 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; } /// /// Extracts the token expiry time from a stream URL with hdnts parameter. /// /// The authenticated stream URL. /// The expiry time, or null if not found. 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; } } /// /// Cleans up old and expired stream mappings. /// public void CleanupOldMappings() { var cutoff = DateTime.UtcNow.AddHours(-24); var now = DateTime.UtcNow; var keysToRemove = new System.Collections.Generic.List(); 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); } } /// /// Registers a stream with multiple GUID format variations to handle Jellyfin's ID transformations. /// /// The item ID. /// The stream information to register. 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); } } /// /// Stream information. /// 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; } /// /// Gets or sets the SRF URN for this stream (used for re-fetching fresh URLs). /// public string? Urn { get; set; } /// /// Gets or sets a value indicating whether this is a livestream. /// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths. /// public bool IsLiveStream { get; set; } /// /// 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. /// public DateTime? LastLivestreamFetchAt { get; set; } /// /// Gets or sets a value indicating whether this stream needs authentication on first access. /// True when registered via RegisterStreamDeferred (authentication deferred until playback). /// public bool NeedsAuthentication { get; set; } } }