using System; using System.Collections.Concurrent; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Web; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for proxying SRF Play streams and managing authentication. /// public class StreamProxyService : IDisposable { private readonly ILogger _logger; private readonly StreamUrlResolver _streamResolver; private readonly HttpClient _httpClient; private readonly ConcurrentDictionary _streamMappings; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The logger. /// The stream URL resolver. public StreamProxyService(ILogger logger, StreamUrlResolver streamResolver) { _logger = logger; _streamResolver = streamResolver; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; _streamMappings = new ConcurrentDictionary(); } /// /// Registers a stream for proxying. /// /// The item ID. /// The authenticated stream URL. public void RegisterStream(string itemId, string authenticatedUrl) { var tokenExpiry = ExtractTokenExpiry(authenticatedUrl); var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl); var streamInfo = new StreamInfo { AuthenticatedUrl = authenticatedUrl, UnauthenticatedUrl = unauthenticatedUrl, RegisteredAt = DateTime.UtcNow, TokenExpiresAt = tokenExpiry }; // Register with the provided item ID _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo); // Also register with alternative GUID formats to handle Jellyfin's ID transformations 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); } if (tokenExpiry.HasValue) { _logger.LogInformation( "Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}", itemId, tokenExpiry.Value, authenticatedUrl); } else { _logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); } } /// /// Gets the authenticated URL for an item. /// /// The item ID. /// The authenticated URL, or null if not found or expired. public string? GetAuthenticatedUrl(string itemId) { _logger.LogInformation("GetAuthenticatedUrl called for itemId: {ItemId}", itemId); // Try direct lookup first if (_streamMappings.TryGetValue(itemId, out var streamInfo)) { _logger.LogInformation("✅ Found stream by direct lookup for itemId: {ItemId}", itemId); return ValidateAndReturnStream(itemId, streamInfo); } _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 = ValidateAndReturnStream(kvp.Key, kvp.Value); 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 } } } // 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.LogWarning( "No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback", itemId, activeStreams[0].Key); return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value); } // 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.LogWarning( "No exact match for {RequestedId}, but using most recently registered stream {RegisteredId} (registered {Seconds}s ago) as fallback", itemId, mostRecent.Key, age.TotalSeconds); return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value); } } _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 string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo) { // Check if token has expired if (streamInfo.TokenExpiresAt.HasValue) { var now = DateTime.UtcNow; if (now >= streamInfo.TokenExpiresAt.Value) { _logger.LogWarning( "Token expired for item {ItemId} (expired at {ExpiresAt}, now is {Now}) - attempting to refresh", itemId, streamInfo.TokenExpiresAt.Value, now); // Try to refresh the token var refreshedUrl = RefreshToken(itemId, streamInfo); 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} remaining)", itemId, streamInfo.TokenExpiresAt.Value, streamInfo.TokenExpiresAt.Value - now); } return streamInfo.AuthenticatedUrl; } /// /// Attempts to refresh an expired token. /// private string? RefreshToken(string itemId, StreamInfo streamInfo) { 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 synchronously (blocking call) var newAuthenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( streamInfo.UnauthenticatedUrl, CancellationToken.None).GetAwaiter().GetResult(); 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; } } /// /// 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; } /// /// 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 = GetAuthenticatedUrl(itemId); if (authenticatedUrl == null) { return null; } try { _logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl); var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false); // 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); 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 = GetAuthenticatedUrl(itemId); 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: {SegmentUrl}", segmentUrl); 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); } // 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 => { 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)) { // Rewrite absolute URLs to proxy var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal); return $"\n{proxyBaseUrl}/{relativePath}{queryParams}"; } // Relative URL - rewrite to proxy return $"\n{proxyBaseUrl}/{url}{queryParams}"; }); 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); } } /// /// Disposes the service. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Disposes the service. /// /// True if disposing. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _httpClient?.Dispose(); } _disposed = true; } /// /// 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; } } }