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