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>
195 lines
7.0 KiB
C#
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);
|
|
}
|