Files
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- 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>
2026-10-25 09:42:27 +01:00

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;
}
}
}