Remove 9 dead methods, 6 unused constants, and redundant ReaderWriterLockSlim from MetadataCache. Consolidate repeated patterns into HasChapters, IsPlayable, and ToLowerString helpers. Extract shared API methods in SRFApiClient. Move variant manifest rewriting from controller to StreamProxyService. Make Auto quality distinct from HD. Update README architecture section.
443 lines
18 KiB
C#
443 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Controller for proxying SRF Play streams.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("Plugins/SRFPlay/Proxy")]
|
|
public class StreamProxyController : ControllerBase
|
|
{
|
|
private readonly ILogger<StreamProxyController> _logger;
|
|
private readonly IStreamProxyService _proxyService;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">The logger.</param>
|
|
/// <param name="proxyService">The proxy service.</param>
|
|
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
|
public StreamProxyController(
|
|
ILogger<StreamProxyController> logger,
|
|
IStreamProxyService proxyService,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_proxyService = proxyService;
|
|
_httpClientFactory = httpClientFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds CORS headers to allow cross-origin requests from hls.js in browsers.
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds Cache-Control headers appropriate for the stream type.
|
|
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID to check.</param>
|
|
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";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles CORS preflight OPTIONS requests for all proxy endpoints.
|
|
/// </summary>
|
|
/// <returns>Empty response with CORS headers.</returns>
|
|
[HttpOptions("{itemId}/master.m3u8")]
|
|
[HttpOptions("{itemId}/{manifestPath}.m3u8")]
|
|
[HttpOptions("{itemId}/{*segmentPath}")]
|
|
[AllowAnonymous]
|
|
public IActionResult HandleOptions()
|
|
{
|
|
AddCorsHeaders();
|
|
return Ok();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proxies HLS master manifest requests.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID from URL path.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The HLS manifest with rewritten URLs.</returns>
|
|
[HttpGet("{itemId}/master.m3u8")]
|
|
[AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proxies HLS variant manifest requests.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="manifestPath">The manifest path (e.g., "index_0_av").</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The HLS manifest with rewritten segment URLs.</returns>
|
|
[HttpGet("{itemId}/{manifestPath}.m3u8")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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 queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
|
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, queryString, 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}";
|
|
|
|
// Extract query parameters from the current request to propagate them
|
|
string queryParams;
|
|
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
|
|
{
|
|
queryParams = $"?token={tokenVal}";
|
|
}
|
|
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
|
|
{
|
|
queryParams = $"?itemId={itemIdVal}";
|
|
}
|
|
else
|
|
{
|
|
queryParams = string.Empty;
|
|
}
|
|
|
|
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files).
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="segmentPath">The segment path.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The segment data.</returns>
|
|
[HttpGet("{itemId}/{*segmentPath}")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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
|
|
{
|
|
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
|
|
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
|
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the correct scheme for proxy URLs (https if public URL is configured with https).
|
|
/// </summary>
|
|
/// <returns>The scheme to use (http or https).</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the actual item ID from the request.
|
|
/// </summary>
|
|
/// <param name="pathItemId">The item ID from the URL path.</param>
|
|
/// <returns>The resolved item ID.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
|
|
/// </summary>
|
|
/// <param name="url">The original image URL (base64 encoded).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The image data with correct Content-Type.</returns>
|
|
[HttpGet("Image")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a placeholder image with the given text centered.
|
|
/// </summary>
|
|
/// <param name="text">The text to display (base64 encoded).</param>
|
|
/// <returns>A PNG image with the text centered.</returns>
|
|
[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");
|
|
}
|
|
}
|