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
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:
258
Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs
Normal file
258
Jellyfin.Plugin.Floatplane/Api/FloatplaneStreamController.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user