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>
259 lines
10 KiB
C#
259 lines
10 KiB
C#
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}";
|
|
}
|
|
}
|