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,194 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Floatplane.Api;
using Jellyfin.Plugin.Floatplane.Api.Models;
using Jellyfin.Plugin.Floatplane.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Floatplane.Playback;
/// <summary>
/// Calls <c>/api/v3/delivery/info</c> sparingly: results are cached until the signed
/// token expires and calls are throttled to the configured budget (Floatplane allows
/// roughly two per minute and answers 429 / Cloudflare 1015 beyond that).
/// </summary>
public class DeliveryResolver
{
public const string ScenarioOnDemand = "onDemand";
public const string ScenarioDownload = "download";
public const string ScenarioLive = "live";
private readonly FloatplaneApiClient _api;
private readonly ILogger<DeliveryResolver> _logger;
private readonly Func<PluginConfiguration> _config;
private readonly ConcurrentDictionary<string, CachedDelivery> _cache = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _throttle = new(1, 1);
private DateTimeOffset _lastCall = DateTimeOffset.MinValue;
private DateTimeOffset _blockedUntil = DateTimeOffset.MinValue;
public DeliveryResolver(FloatplaneApiClient api, ILogger<DeliveryResolver> logger)
: this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
{
}
public DeliveryResolver(FloatplaneApiClient api, ILogger<DeliveryResolver> logger, Func<PluginConfiguration> config)
{
_api = api;
_logger = logger;
_config = config;
}
public int CachedEntries => _cache.Count;
public DateTimeOffset LastCall => _lastCall;
/// <summary>Variants for an attachment, from cache when the token is still valid.</summary>
public async Task<IReadOnlyList<ResolvedVariant>> GetVariantsAsync(string scenario, string entityId, CancellationToken ct)
{
var key = scenario + "|" + entityId;
if (_cache.TryGetValue(key, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.Variants;
}
await _throttle.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_cache.TryGetValue(key, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.Variants;
}
var now = DateTimeOffset.UtcNow;
if (_blockedUntil > now)
{
throw new FloatplaneRateLimitException(_blockedUntil - now, "Floatplane delivery endpoint is rate-limited; retry later");
}
var perMinute = Math.Max(1, _config().DeliveryCallsPerMinute);
var minGap = TimeSpan.FromSeconds(60.0 / perMinute);
var wait = _lastCall + minGap - now;
if (wait > TimeSpan.Zero)
{
_logger.LogDebug("Throttling delivery/info for {Wait}", wait);
await Task.Delay(wait, ct).ConfigureAwait(false);
}
DeliveryInfoModel info;
try
{
_lastCall = DateTimeOffset.UtcNow;
info = await _api.GetDeliveryInfoAsync(scenario, entityId, ct).ConfigureAwait(false);
}
catch (FloatplaneRateLimitException ex)
{
_blockedUntil = DateTimeOffset.UtcNow + ex.RetryAfter;
throw;
}
var variants = info.Resolve().ToList();
var expires = variants
.Select(v => TokenExpiry(v.Url))
.Where(e => e.HasValue)
.Select(e => e!.Value)
.DefaultIfEmpty(DateTimeOffset.UtcNow.AddHours(1))
.Min()
.AddMinutes(-5);
_cache[key] = new CachedDelivery(variants, expires);
_logger.LogInformation(
"delivery/info {Scenario} {Entity}: {Count} variants ({Usable} usable), valid until {Expires:u}",
scenario,
entityId,
variants.Count,
variants.Count(v => v.IsUsable),
expires);
return variants;
}
finally
{
_throttle.Release();
}
}
/// <summary>
/// Picks the variant for a requested level name ("1080"): exact level, else the best
/// usable one not taller than requested, else the best usable one at all.
/// </summary>
public static ResolvedVariant? SelectVariant(IEnumerable<ResolvedVariant> variants, string requestedLevel)
{
var usable = variants.Where(v => v.IsUsable).ToList();
if (usable.Count == 0)
{
return null;
}
var wanted = DeliveryVariant.ParseHeight(requestedLevel);
var exact = usable.FirstOrDefault(v => string.Equals(v.Name, requestedLevel, StringComparison.OrdinalIgnoreCase)
|| DeliveryVariant.ParseHeight(v.Name) == wanted);
if (exact != null)
{
return exact;
}
var notTaller = usable.Where(v => v.Height <= wanted).OrderByDescending(v => v.Height).ThenByDescending(v => v.Variant.Order).FirstOrDefault();
return notTaller ?? usable.OrderByDescending(v => v.Height).ThenByDescending(v => v.Variant.Order).First();
}
/// <summary>Reads <c>exp</c> from the JWT in a <c>?token=</c> query parameter, if any.</summary>
public static DateTimeOffset? TokenExpiry(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return null;
}
var query = uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries);
var token = query.Select(p => p.Split('=', 2)).FirstOrDefault(p => p[0] == "token" && p.Length == 2)?[1];
if (string.IsNullOrEmpty(token))
{
return null;
}
var parts = Uri.UnescapeDataString(token).Split('.');
if (parts.Length < 2)
{
return null;
}
try
{
var payload = Base64UrlDecode(parts[1]);
using var doc = JsonDocument.Parse(payload);
if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var seconds))
{
return DateTimeOffset.FromUnixTimeSeconds(seconds);
}
}
catch (Exception ex) when (ex is FormatException or JsonException)
{
return null;
}
return null;
}
private static byte[] Base64UrlDecode(string s)
{
var padded = s.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private sealed record CachedDelivery(IReadOnlyList<ResolvedVariant> Variants, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace Jellyfin.Plugin.Floatplane.Playback;
/// <summary>
/// Rewrites an HLS playlist so that (a) every media/segment URI is absolute and
/// (b) the AES-128 key URI (which needs the Floatplane session cookie) and any nested
/// playlist URIs point back at the plugin's proxy.
/// </summary>
public static partial class HlsRewriter
{
[GeneratedRegex("URI=\"([^\"]*)\"", RegexOptions.CultureInvariant)]
private static partial Regex UriAttribute();
/// <param name="playlist">Playlist text as served by the CDN.</param>
/// <param name="playlistUrl">Absolute URL the playlist was fetched from (base for relative URIs).</param>
/// <param name="mapKeyUri">Maps the absolute key URI to the proxy key URL.</param>
/// <param name="mapPlaylistUri">Maps a nested playlist URI (master playlists) to a proxy URL; null = leave absolute.</param>
/// <param name="mapSegmentUri">Maps a segment URI; null = leave absolute (segments need no cookie).</param>
public static string Rewrite(
string playlist,
Uri playlistUrl,
Func<string, string> mapKeyUri,
Func<string, string>? mapPlaylistUri = null,
Func<string, string>? mapSegmentUri = null)
{
var sb = new StringBuilder(playlist.Length + 512);
var isMaster = playlist.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal);
var nextIsVariantPlaylist = false;
foreach (var rawLine in playlist.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
if (line.Length == 0)
{
sb.Append('\n');
continue;
}
if (line.StartsWith('#'))
{
if (line.StartsWith("#EXT-X-KEY", StringComparison.Ordinal) || line.StartsWith("#EXT-X-SESSION-KEY", StringComparison.Ordinal))
{
line = UriAttribute().Replace(line, m => "URI=\"" + mapKeyUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1);
}
else if (line.StartsWith("#EXT-X-MAP", StringComparison.Ordinal))
{
line = UriAttribute().Replace(line, m => "URI=\"" + MapSegment(Absolute(playlistUrl, m.Groups[1].Value), mapSegmentUri) + "\"", 1);
}
else if (line.StartsWith("#EXT-X-MEDIA", StringComparison.Ordinal) && mapPlaylistUri != null)
{
line = UriAttribute().Replace(line, m => "URI=\"" + mapPlaylistUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1);
}
else if (line.StartsWith("#EXT-X-STREAM-INF", StringComparison.Ordinal))
{
nextIsVariantPlaylist = true;
}
sb.Append(line).Append('\n');
continue;
}
var absolute = Absolute(playlistUrl, line);
if (isMaster && nextIsVariantPlaylist)
{
nextIsVariantPlaylist = false;
sb.Append(mapPlaylistUri != null ? mapPlaylistUri(absolute) : absolute).Append('\n');
}
else
{
sb.Append(MapSegment(absolute, mapSegmentUri)).Append('\n');
}
}
return sb.ToString().TrimEnd('\n') + "\n";
}
private static string MapSegment(string absolute, Func<string, string>? mapSegmentUri)
=> mapSegmentUri != null ? mapSegmentUri(absolute) : absolute;
/// <summary>Resolves <paramref name="reference"/> against <paramref name="baseUrl"/> (RFC 3986).</summary>
public static string Absolute(Uri baseUrl, string reference)
{
// Only http(s) is "absolute" here: on Linux Uri.TryCreate("/x.ts", Absolute) yields file:///x.ts.
if (Api.Models.DeliveryInfoModel.IsHttpUrl(reference))
{
return reference;
}
return new Uri(baseUrl, reference).ToString();
}
}