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; /// /// Calls /api/v3/delivery/info 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). /// 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 _logger; private readonly Func _config; private readonly ConcurrentDictionary _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 logger) : this(api, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) { } public DeliveryResolver(FloatplaneApiClient api, ILogger logger, Func config) { _api = api; _logger = logger; _config = config; } public int CachedEntries => _cache.Count; public DateTimeOffset LastCall => _lastCall; /// Variants for an attachment, from cache when the token is still valid. public async Task> 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(); } } /// /// 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. /// public static ResolvedVariant? SelectVariant(IEnumerable 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(); } /// Reads exp from the JWT in a ?token= query parameter, if any. 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 Variants, DateTimeOffset ExpiresAt); }