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:
194
Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs
Normal file
194
Jellyfin.Plugin.Floatplane/Playback/DeliveryResolver.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user