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; /// /// 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 …/Key. Segments stay on the CDN (signed token, CORS open). /// [ApiController] [Route("Floatplane/Stream")] public class FloatplaneStreamController : ControllerBase { private readonly FloatplaneApiClient _api; private readonly CatalogService _catalog; private readonly DeliveryResolver _delivery; private readonly ILogger _logger; public FloatplaneStreamController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger logger) { _api = api; _catalog = catalog; _delivery = delivery; _logger = logger; } /// HLS media playlist for one quality level, rewritten for the proxy. [HttpGet("{postId}/{videoId}/{level}/index.m3u8")] [Produces("application/vnd.apple.mpegurl")] public async Task 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); } } /// Nested playlist (master → media) proxy; same rewriting. [HttpGet("{videoId}/Playlist")] public async Task 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"); } /// AES-128 key, fetched from Floatplane with the session cookie. [HttpGet("{videoId}/Key")] public async Task 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); } } /// /// Progressive MP4 relay (Floatplane "download" delivery) with Range passthrough. /// Experimental until verified that the token alone is accepted by the edge servers. /// [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}"; } }