Files
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- 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>
2026-10-25 09:42:27 +01:00

195 lines
7.0 KiB
C#

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);
}