Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
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>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Jellyfin.Plugin.Floatplane.Api.Models;
using Jellyfin.Plugin.Floatplane.Channel;
using Jellyfin.Plugin.Floatplane.Playback;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Floatplane.Api;
/// <summary>
/// Anonymous, signature-protected endpoints that Jellyfin's ffmpeg (or a client) plays
/// from. Floatplane's HLS is AES-128 encrypted and the key request needs the session
/// cookie, which Jellyfin cannot forward — so the playlist is served from here with the
/// key URI pointing back at <c>…/Key</c>. Segments stay on the CDN (signed token, CORS open).
/// </summary>
[ApiController]
[Route("Floatplane/Stream")]
public class FloatplaneStreamController : ControllerBase
{
private readonly FloatplaneApiClient _api;
private readonly CatalogService _catalog;
private readonly DeliveryResolver _delivery;
private readonly ILogger<FloatplaneStreamController> _logger;
public FloatplaneStreamController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneStreamController> logger)
{
_api = api;
_catalog = catalog;
_delivery = delivery;
_logger = logger;
}
/// <summary>HLS media playlist for one quality level, rewritten for the proxy.</summary>
[HttpGet("{postId}/{videoId}/{level}/index.m3u8")]
[Produces("application/vnd.apple.mpegurl")]
public async Task<IActionResult> GetPlaylist(
[FromRoute] string postId,
[FromRoute] string videoId,
[FromRoute] string level,
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
{
if (!ProxySignature.Verify(t, "hls", postId, videoId, level))
{
_logger.LogWarning("Rejected unsigned playlist request for {Post}/{Video}", postId, videoId);
return Unauthorized();
}
var ct = HttpContext.RequestAborted;
try
{
var variants = await _delivery.GetVariantsAsync(DeliveryResolver.ScenarioOnDemand, videoId, ct).ConfigureAwait(false);
var variant = DeliveryResolver.SelectVariant(variants, level);
if (variant == null)
{
var denied = variants.Select(v => v.Variant.Meta?.Common?.Access?.DeniedMessage).FirstOrDefault(m => m != null);
_logger.LogWarning("No usable delivery variant for {Video}: {Reason}", videoId, denied ?? "none enabled");
return StatusCode(403, denied ?? "No playable variant");
}
if (!string.Equals(variant.Name, level, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Level {Level} not available for {Video}; serving {Actual}", level, videoId, variant.Name);
}
var playlistUrl = new Uri(variant.Url);
var text = await _api.GetPlaylistAsync(variant.Url, ct).ConfigureAwait(false);
var rewritten = HlsRewriter.Rewrite(
text,
playlistUrl,
keyUri => ProxyUrl("Key", videoId, keyUri),
playlistUri => ProxyUrl("Playlist", videoId, playlistUri));
Response.Headers.CacheControl = "no-store";
return Content(rewritten, "application/vnd.apple.mpegurl");
}
catch (FloatplaneAuthException ex)
{
_logger.LogWarning("Floatplane refused delivery for {Video}: {Message}", videoId, ex.Message);
return StatusCode(403, ex.Message);
}
catch (FloatplaneRateLimitException ex)
{
Response.Headers.RetryAfter = ((int)ex.RetryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
return StatusCode(429, ex.Message);
}
catch (FloatplaneApiException ex)
{
_logger.LogError(ex, "Delivery lookup failed for {Video}", videoId);
return StatusCode(502, ex.Message);
}
}
/// <summary>Nested playlist (master → media) proxy; same rewriting.</summary>
[HttpGet("{videoId}/Playlist")]
public async Task<IActionResult> GetNestedPlaylist(
[FromRoute] string videoId,
[FromQuery] string? u,
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
{
var url = ProxySignature.DecodeUrl(u);
if (url == null || !ProxySignature.Verify(t, "Playlist", videoId, url))
{
return Unauthorized();
}
var text = await _api.GetPlaylistAsync(url, HttpContext.RequestAborted).ConfigureAwait(false);
var rewritten = HlsRewriter.Rewrite(
text,
new Uri(url),
keyUri => ProxyUrl("Key", videoId, keyUri),
playlistUri => ProxyUrl("Playlist", videoId, playlistUri));
Response.Headers.CacheControl = "no-store";
return Content(rewritten, "application/vnd.apple.mpegurl");
}
/// <summary>AES-128 key, fetched from Floatplane with the session cookie.</summary>
[HttpGet("{videoId}/Key")]
public async Task<IActionResult> GetKey(
[FromRoute] string videoId,
[FromQuery] string? u,
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
{
var url = ProxySignature.DecodeUrl(u);
if (url == null || !ProxySignature.Verify(t, "Key", videoId, url))
{
return Unauthorized();
}
if (!url.StartsWith(FloatplaneApiClient.BaseUrl + "/", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Refusing key fetch from unexpected host: {Url}", url);
return BadRequest();
}
try
{
var key = await _api.GetWatchKeyAsync(url, HttpContext.RequestAborted).ConfigureAwait(false);
Response.Headers.CacheControl = "no-store";
return File(key, "application/octet-stream");
}
catch (FloatplaneAuthException ex)
{
_logger.LogWarning("Key fetch refused for {Video}: {Message} (session cookie expired?)", videoId, ex.Message);
return StatusCode(403, ex.Message);
}
}
/// <summary>
/// Progressive MP4 relay (Floatplane "download" delivery) with Range passthrough.
/// Experimental until verified that the token alone is accepted by the edge servers.
/// </summary>
[HttpGet("{postId}/{videoId}/{level}/video.mp4")]
[HttpHead("{postId}/{videoId}/{level}/video.mp4")]
public async Task GetMp4(
[FromRoute] string postId,
[FromRoute] string videoId,
[FromRoute] string level,
[FromQuery(Name = ProxySignature.QueryParameter)] string? t)
{
if (!ProxySignature.Verify(t, "mp4", postId, videoId, level))
{
Response.StatusCode = 401;
return;
}
var ct = HttpContext.RequestAborted;
ResolvedVariant? variant;
try
{
var variants = await _delivery.GetVariantsAsync(DeliveryResolver.ScenarioDownload, videoId, ct).ConfigureAwait(false);
variant = DeliveryResolver.SelectVariant(variants, level);
}
catch (FloatplaneApiException ex)
{
_logger.LogWarning(ex, "download delivery failed for {Video}", videoId);
Response.StatusCode = ex is FloatplaneRateLimitException ? 429 : 502;
return;
}
if (variant == null)
{
Response.StatusCode = 404;
return;
}
RangeHeaderValue? range = null;
if (Request.Headers.TryGetValue("Range", out var rangeHeader) && !RangeHeaderValue.TryParse(rangeHeader.ToString(), out range))
{
range = null;
}
var method = HttpMethods.IsHead(Request.Method) ? HttpMethod.Head : HttpMethod.Get;
HttpResponseMessage upstream;
try
{
upstream = await _api.OpenMediaAsync(method, variant.Url, range, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Upstream MP4 request failed for {Video}", videoId);
Response.StatusCode = 502;
return;
}
using var response = upstream;
Response.StatusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && response.Content.Headers.ContentRange != null)
{
Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString();
}
return;
}
if (response.Content.Headers.ContentLength.HasValue)
{
Response.ContentLength = response.Content.Headers.ContentLength.Value;
}
Response.Headers.AcceptRanges = "bytes";
Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "video/mp4";
if (response.Content.Headers.ContentRange != null)
{
Response.Headers.ContentRange = response.Content.Headers.ContentRange.ToString();
}
if (method == HttpMethod.Head)
{
return;
}
try
{
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await stream.CopyToAsync(Response.Body, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogDebug("Client aborted MP4 stream for {Video}", videoId);
}
}
private string ProxyUrl(string kind, string videoId, string upstreamUrl)
{
var signature = ProxySignature.Create(kind, videoId, upstreamUrl);
var encoded = ProxySignature.EncodeUrl(upstreamUrl);
var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
return $"{baseUrl}/Floatplane/Stream/{Uri.EscapeDataString(videoId)}/{kind}?u={encoded}&{ProxySignature.QueryParameter}={signature}";
}
}