using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services; 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 StreamProxyService _proxyService; /// /// Initializes a new instance of the class. /// /// The logger. /// The proxy service. public StreamProxyController(ILogger logger, StreamProxyService proxyService) { _logger = logger; _proxyService = proxyService; } /// /// 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) { _logger.LogInformation("Proxy request for master manifest - ItemId: {ItemId}", itemId); // Try to resolve the actual item ID (path ID might be a session ID during transcoding) var actualItemId = ResolveItemId(itemId); try { // Build the base proxy URL for this item (use original itemId from path to maintain URL structure) // Always include the actualItemId as a query parameter to ensure proper resolution during transcoding var baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}"; if (actualItemId != itemId) { _logger.LogDebug("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId); } var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false); if (manifestContent == null) { _logger.LogWarning("Manifest not found for item {ItemId}", itemId); return NotFound(); } _logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length); return Content(manifestContent, "application/vnd.apple.mpegurl"); } 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) { 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 baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl); _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); return Content(rewrittenContent, "application/vnd.apple.mpegurl"); } 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) { _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 = GetContentType(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); } } /// /// 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 if there's an itemId query parameter (fallback for transcoding sessions) if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId)) { _logger.LogDebug("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId); return queryItemId.ToString(); } // If path ID and query ID don't match, it's likely a transcoding session // Try to use the proxy service fallback to find the correct stream _logger.LogDebug("No itemId query parameter found, 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 the itemId query parameter from the current request to propagate it var itemIdParam = Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId) ? $"?itemId={itemId}" : string.Empty; var lines = manifestContent.Split('\n'); var result = new System.Text.StringBuilder(); foreach (var line in lines) { if (line.StartsWith('#') || string.IsNullOrWhiteSpace(line)) { // Keep metadata and blank lines as-is result.AppendLine(line); } else if (line.Contains("://", StringComparison.Ordinal)) { // Absolute URL - extract the path and rewrite var uri = new Uri(line.Trim()); var segments = uri.AbsolutePath.Split('/'); var fileName = segments[^1]; result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{fileName}{itemIdParam}"); } else { // Relative URL - rewrite to proxy result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}{itemIdParam}"); } } return result.ToString(); } /// /// Gets the content type for a segment based on file extension. /// /// The segment path. /// The MIME content type. private static string GetContentType(string path) { if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) { return "video/MP2T"; } if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase)) { return "video/mp4"; } if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase)) { return "audio/aac"; } return "application/octet-stream"; } }