Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
256 lines
10 KiB
C#
256 lines
10 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.Chaosflix.Api.Models;
|
|
using Jellyfin.Plugin.Chaosflix.Configuration;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Chaosflix.Api;
|
|
|
|
/// <summary>
|
|
/// API controller that acts as a transparent reverse proxy for CCC recordings.
|
|
/// Proxies CDN content with full HTTP Range request support, enabling seeking.
|
|
/// By serving content through a local endpoint, the Jellyfin server treats it
|
|
/// as a non-remote source and can use DirectStream instead of forced transcoding.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class ChaosflixStreamController : ControllerBase
|
|
{
|
|
private readonly CccApiClient _apiClient;
|
|
private readonly ILogger<ChaosflixStreamController> _logger;
|
|
private static readonly HttpClient _proxyClient = new HttpClient { Timeout = TimeSpan.FromMinutes(60) };
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ChaosflixStreamController"/> class.
|
|
/// </summary>
|
|
public ChaosflixStreamController(CccApiClient apiClient, ILogger<ChaosflixStreamController> logger)
|
|
{
|
|
_apiClient = apiClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Proxies a CCC recording from the CDN with full Range request support.
|
|
/// This enables seeking in both browser and Android clients.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The second route is what subtitles use. Their signature and filename sit in
|
|
/// the path so the url ends in <c>.srt</c> or <c>.vtt</c>: Jellyfin and its
|
|
/// clients derive a subtitle's format from the extension of the url they are
|
|
/// handed, and a query string would end up in <c>Path.GetExtension</c>.
|
|
/// </remarks>
|
|
[HttpGet("proxy/{eventGuid}")]
|
|
[HttpHead("proxy/{eventGuid}")]
|
|
[HttpGet("proxy/{eventGuid}/{signature}/{filename}")]
|
|
[HttpHead("proxy/{eventGuid}/{signature}/{filename}")]
|
|
public async Task ProxyStream(
|
|
[FromRoute] string eventGuid,
|
|
[FromRoute] string? signature = null,
|
|
[FromRoute] string? filename = null,
|
|
[FromQuery] string? recordingFolder = null,
|
|
[FromQuery] string? language = null,
|
|
[FromQuery(Name = ProxySignature.QueryParameter)] string? t = null)
|
|
{
|
|
// The endpoint is anonymous because the server's own ffmpeg fetches this
|
|
// url; the signature is what keeps it from being a general purpose relay.
|
|
if (!ProxySignature.Verify(eventGuid, recordingFolder, language, signature ?? t, filename))
|
|
{
|
|
_logger.LogWarning("Rejected unsigned proxy request for {EventGuid}", eventGuid);
|
|
Response.StatusCode = 401;
|
|
return;
|
|
}
|
|
|
|
var cancellationToken = HttpContext.RequestAborted;
|
|
|
|
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
|
|
if (cccEvent?.Recordings == null)
|
|
{
|
|
Response.StatusCode = 404;
|
|
return;
|
|
}
|
|
|
|
CccRecording? recording;
|
|
|
|
if (filename != null)
|
|
{
|
|
// Subtitles share the folder "" across every language and format, so the
|
|
// filename is the only thing that identifies one; it is signed like the rest.
|
|
recording = cccEvent.Recordings
|
|
.FirstOrDefault(r => r.Filename.Equals(filename, StringComparison.Ordinal));
|
|
}
|
|
else
|
|
{
|
|
var videoRecordings = cccEvent.Recordings
|
|
.Where(r => r.MimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
if (videoRecordings.Count == 0)
|
|
{
|
|
Response.StatusCode = 404;
|
|
return;
|
|
}
|
|
|
|
recording = videoRecordings.FirstOrDefault(r =>
|
|
(recordingFolder == null || r.Folder.Equals(recordingFolder, StringComparison.OrdinalIgnoreCase)) &&
|
|
(language == null || r.Language.Equals(language, StringComparison.OrdinalIgnoreCase)));
|
|
|
|
recording ??= videoRecordings
|
|
.OrderByDescending(r => r.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
|
|
.ThenByDescending(r => r.HighQuality ? 1 : 0)
|
|
.First();
|
|
}
|
|
|
|
if (recording == null)
|
|
{
|
|
Response.StatusCode = 404;
|
|
return;
|
|
}
|
|
|
|
var originalUrl = recording.RecordingUrl;
|
|
var resolvedUrl = await _apiClient.ResolveRedirectAsync(originalUrl, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
_logger.LogDebug("Proxying {EventGuid} from {Url}", eventGuid, resolvedUrl);
|
|
|
|
RangeHeaderValue? range = null;
|
|
if (Request.Headers.TryGetValue("Range", out var rangeHeader)
|
|
&& !RangeHeaderValue.TryParse(rangeHeader.ToString(), out range))
|
|
{
|
|
// RFC 9110: a Range header with invalid syntax is ignored, not rejected.
|
|
_logger.LogDebug("Ignoring malformed Range header {Range}", rangeHeader.ToString());
|
|
range = null;
|
|
}
|
|
|
|
var method = HttpContext.Request.Method == "HEAD" ? HttpMethod.Head : HttpMethod.Get;
|
|
|
|
var upstreamResponse = await SendUpstreamAsync(method, resolvedUrl, range, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// A cached mirror can die long before its two-hour cache entry expires; drop it
|
|
// and give the CDN one chance to hand out a mirror that still has the recording.
|
|
if (upstreamResponse?.IsSuccessStatusCode != true
|
|
&& !string.Equals(resolvedUrl, originalUrl, StringComparison.Ordinal))
|
|
{
|
|
_logger.LogWarning(
|
|
"Mirror {Url} failed for {EventGuid} ({Status}), retrying via {Original}",
|
|
resolvedUrl,
|
|
eventGuid,
|
|
upstreamResponse?.StatusCode.ToString() ?? "no response",
|
|
originalUrl);
|
|
|
|
upstreamResponse?.Dispose();
|
|
_apiClient.InvalidateRedirect(originalUrl);
|
|
upstreamResponse = await SendUpstreamAsync(method, originalUrl, range, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (upstreamResponse == null)
|
|
{
|
|
Response.StatusCode = 502;
|
|
return;
|
|
}
|
|
|
|
using var finalResponse = upstreamResponse;
|
|
|
|
// Set response status (200 or 206)
|
|
Response.StatusCode = (int)finalResponse.StatusCode;
|
|
|
|
if (!finalResponse.IsSuccessStatusCode)
|
|
{
|
|
// An error body is not media, so it gets neither Accept-Ranges nor a video type.
|
|
// The unsatisfied-range of a 416 is the exception: RFC 9110 has clients read the
|
|
// real size off it and ask again for a range that fits (#104).
|
|
if (finalResponse.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable
|
|
&& finalResponse.Content.Headers.ContentRange != null)
|
|
{
|
|
Response.Headers["Content-Range"] = finalResponse.Content.Headers.ContentRange.ToString();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Forward relevant headers
|
|
if (finalResponse.Content.Headers.ContentLength.HasValue)
|
|
{
|
|
Response.ContentLength = finalResponse.Content.Headers.ContentLength.Value;
|
|
}
|
|
|
|
Response.Headers["Accept-Ranges"] = "bytes";
|
|
|
|
if (filename != null)
|
|
{
|
|
// The subtitle mirrors hand out .srt as application/octet-stream, which
|
|
// no client renders as a caption track; the API's own mime type is the
|
|
// one that describes the bytes.
|
|
Response.ContentType = recording.MimeType;
|
|
}
|
|
else if (finalResponse.Content.Headers.ContentType != null)
|
|
{
|
|
Response.ContentType = finalResponse.Content.Headers.ContentType.ToString();
|
|
}
|
|
else
|
|
{
|
|
Response.ContentType = recording.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase)
|
|
? "video/mp4" : "video/webm";
|
|
}
|
|
|
|
if (finalResponse.Content.Headers.ContentRange != null)
|
|
{
|
|
Response.Headers["Content-Range"] = finalResponse.Content.Headers.ContentRange.ToString();
|
|
}
|
|
|
|
// Stream body (skip for HEAD requests)
|
|
if (HttpContext.Request.Method != "HEAD")
|
|
{
|
|
try
|
|
{
|
|
await using var upstreamStream = await finalResponse.Content.ReadAsStreamAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
await upstreamStream.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// ffmpeg being killed at the end of a transcode, a player seeking or a
|
|
// browser tab closing all hang up mid-file: that is how a proxied media
|
|
// response normally ends. Letting it unwind would reach Jellyfin's
|
|
// exception middleware with the response already on the wire, which can
|
|
// only log a warning about it (#58).
|
|
_logger.LogDebug("Client aborted the stream for {EventGuid}", eventGuid);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<HttpResponseMessage?> SendUpstreamAsync(
|
|
HttpMethod method,
|
|
string url,
|
|
RangeHeaderValue? range,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(method, url);
|
|
if (range != null)
|
|
{
|
|
request.Headers.Range = range;
|
|
}
|
|
|
|
try
|
|
{
|
|
return await _proxyClient
|
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Upstream request to {Url} failed", url);
|
|
return null;
|
|
}
|
|
}
|
|
}
|