using System; using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Controllers; /// /// Controller for proxying SRF Play streams. /// [ApiController] [Route("Plugins/SRFPlay/Proxy")] public class StreamProxyController : ControllerBase { private readonly ILogger _logger; private readonly IStreamProxyService _proxyService; private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// The logger. /// The proxy service. /// The HTTP client factory. public StreamProxyController( ILogger logger, IStreamProxyService proxyService, IHttpClientFactory httpClientFactory) { _logger = logger; _proxyService = proxyService; _httpClientFactory = httpClientFactory; } /// /// Adds CORS headers to allow cross-origin requests from hls.js in browsers. /// private void AddCorsHeaders() { Response.Headers["Access-Control-Allow-Origin"] = "*"; Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"; Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Range, Accept, Origin"; Response.Headers["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges"; Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours } /// /// Adds Cache-Control headers appropriate for the stream type. /// Livestreams need frequent manifest refresh, VOD can be cached longer. /// /// The item ID to check. private void AddManifestCacheHeaders(string itemId) { var metadata = _proxyService.GetStreamMetadata(itemId); var isLiveStream = metadata?.IsLiveStream ?? false; if (isLiveStream) { // Livestreams need frequent manifest refresh (segments rotate every ~6-10s) Response.Headers["Cache-Control"] = "max-age=2, must-revalidate"; _logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId); } else { // VOD manifests are static, can cache longer Response.Headers["Cache-Control"] = "max-age=3600"; } } /// /// Handles CORS preflight OPTIONS requests for all proxy endpoints. /// /// Empty response with CORS headers. [HttpOptions("{itemId}/master.m3u8")] [HttpOptions("{itemId}/{manifestPath}.m3u8")] [HttpOptions("{itemId}/{*segmentPath}")] [AllowAnonymous] public IActionResult HandleOptions() { AddCorsHeaders(); return Ok(); } /// /// Proxies HLS master manifest requests. /// /// The item ID from URL path. /// Cancellation token. /// The HLS manifest with rewritten URLs. [HttpGet("{itemId}/master.m3u8")] [AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetMasterManifest( [FromRoute] string itemId, CancellationToken cancellationToken) { AddCorsHeaders(); _logger.LogInformation("Proxy request for master manifest - Path ItemId: {PathItemId}, Query params: {QueryString}", itemId, Request.QueryString); // Try to resolve the actual item ID (path ID might be a session ID during transcoding) var actualItemId = ResolveItemId(itemId); try { // Get the correct scheme (https if configured, otherwise use request scheme) var scheme = GetProxyScheme(); // Build the base proxy URL for this item // Preserve query parameters (token or itemId) from the original request string baseProxyUrl; if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token)) { baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?token={token}"; _logger.LogDebug("Using token-based proxy URL with token: {Token}", token.ToString()); } else if (actualItemId != itemId) { // Legacy: If path ID differs from resolved ID, add itemId query parameter baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}"; _logger.LogInformation("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId); } else { // Simple case: no query parameters needed baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; _logger.LogDebug("Path itemId matches resolved itemId: {ItemId}", itemId); } var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false); if (manifestContent == null) { _logger.LogWarning("Manifest not found for path itemId {PathItemId}, resolved itemId {ResolvedItemId} - stream may not be registered", itemId, actualItemId); return NotFound(); } // Set cache headers based on stream type (live vs VOD) AddManifestCacheHeaders(actualItemId); _logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length); return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8"); } catch (Exception ex) { _logger.LogError(ex, "Error proxying master manifest for item {ItemId}", itemId); return StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Proxies HLS variant manifest requests. /// /// The item ID. /// The manifest path (e.g., "index_0_av"). /// Cancellation token. /// The HLS manifest with rewritten segment URLs. [HttpGet("{itemId}/{manifestPath}.m3u8")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetVariantManifest( [FromRoute] string itemId, [FromRoute] string manifestPath, CancellationToken cancellationToken) { AddCorsHeaders(); var fullPath = $"{manifestPath}.m3u8"; _logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); // Try to resolve the actual item ID var actualItemId = ResolveItemId(itemId); try { // Fetch the variant manifest as a segment var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false); if (manifestData == null) { _logger.LogWarning("Variant manifest not found - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); return NotFound(); } // Convert to string and rewrite URLs var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData); var scheme = GetProxyScheme(); var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl); // Set cache headers based on stream type (live vs VOD) AddManifestCacheHeaders(actualItemId); _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8"); } catch (Exception ex) { _logger.LogError(ex, "Error proxying variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); return StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files). /// /// The item ID. /// The segment path. /// Cancellation token. /// The segment data. [HttpGet("{itemId}/{*segmentPath}")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetSegment( [FromRoute] string itemId, [FromRoute] string segmentPath, CancellationToken cancellationToken) { AddCorsHeaders(); _logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); // Try to resolve the actual item ID var actualItemId = ResolveItemId(itemId); try { var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false); if (segmentData == null) { _logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); return NotFound(); } // Determine content type based on file extension var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath); _logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType); return File(segmentData, contentType); } catch (Exception ex) { _logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); return StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Gets the correct scheme for proxy URLs (https if public URL is configured with https). /// /// The scheme to use (http or https). private string GetProxyScheme() { // Check if PublicServerUrl is configured and uses HTTPS var config = Plugin.Instance?.Configuration; if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)) { if (config.PublicServerUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return "https"; } } // Fall back to request scheme, but prefer https if forwarded headers indicate it if (Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto)) { return forwardedProto.ToString().ToLowerInvariant(); } return Request.Scheme; } /// /// Resolves the actual item ID from the request. /// /// The item ID from the URL path. /// The resolved item ID. private string ResolveItemId(string pathItemId) { // Check for token parameter first (preferred method) if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token)) { // Try to resolve the original item ID from the token via the proxy service // We'll need to add a method to StreamProxyService to look up by token _logger.LogInformation("Found token parameter: {Token}, will use path ID {PathItemId} for lookup", token.ToString(), pathItemId); return pathItemId; // Use path ID for now; token prevents Jellyfin from rewriting the URL } // Check if there's an itemId query parameter (legacy fallback) if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId)) { _logger.LogInformation("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId); return queryItemId.ToString(); } // No query parameters - use path ID as-is (normal case for segments and transcoding sessions) _logger.LogDebug("No query parameters, using path ID as-is: {PathItemId}", pathItemId); return pathItemId; } /// /// Rewrites segment URLs in a manifest to point to proxy. /// /// The manifest content. /// The base proxy URL. /// The rewritten manifest. private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl) { // Extract query parameters from the current request to propagate them string queryParams; if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token)) { queryParams = $"?token={token}"; } else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId)) { queryParams = $"?itemId={itemId}"; } else { queryParams = string.Empty; } // Helper function to rewrite a single URL 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 = System.Text.RegularExpressions.Regex.Replace( line, @"URI=""([^""]+)""", match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\""); result.AppendLine(rewrittenLine); } else { // Keep other metadata lines as-is result.AppendLine(line); } } else { // Non-tag line with URL - rewrite it result.AppendLine(RewriteUrl(line)); } } return result.ToString(); } /// /// Proxies image requests from SRF CDN, fixing Content-Type headers. /// /// The original image URL (base64 encoded). /// Cancellation token. /// The image data with correct Content-Type. [HttpGet("Image")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetImage( [FromQuery] string url, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(url)) { return BadRequest("URL parameter is required"); } string decodedUrl; try { // Decode base64 URL var bytes = Convert.FromBase64String(url); decodedUrl = System.Text.Encoding.UTF8.GetString(bytes); } catch (FormatException) { _logger.LogWarning("Invalid base64 URL parameter: {Url}", url); return BadRequest("Invalid URL encoding"); } _logger.LogDebug("Proxying image from: {Url}", decodedUrl); try { var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); // Create request with proper headers var request = new HttpRequestMessage(HttpMethod.Get, new Uri(decodedUrl)); request.Headers.UserAgent.ParseAdd( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8"); var response = await httpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Image fetch failed with status {StatusCode} for {Url}", response.StatusCode, decodedUrl); return NotFound(); } var imageData = await response.Content.ReadAsByteArrayAsync(cancellationToken) .ConfigureAwait(false); // Determine correct content type var contentType = MimeTypeHelper.GetImageContentType(decodedUrl); _logger.LogDebug( "Returning proxied image ({Length} bytes, {ContentType})", imageData.Length, contentType); return File(imageData, contentType); } catch (HttpRequestException ex) { _logger.LogError(ex, "Error fetching image from {Url}", decodedUrl); return StatusCode(StatusCodes.Status502BadGateway); } } /// /// Generates a placeholder image with the given text centered. /// /// The text to display (base64 encoded). /// A PNG image with the text centered. [HttpGet("Placeholder")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public IActionResult GetPlaceholder([FromQuery] string text) { if (string.IsNullOrEmpty(text)) { return BadRequest("Text parameter is required"); } string decodedText; try { var bytes = Convert.FromBase64String(text); decodedText = System.Text.Encoding.UTF8.GetString(bytes); } catch (FormatException) { _logger.LogWarning("Invalid base64 text parameter: {Text}", text); return BadRequest("Invalid text encoding"); } _logger.LogDebug("Generating placeholder image for: {Text}", decodedText); var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText); return File(imageStream, "image/png"); } }