using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Floatplane.Api.Models;
using Jellyfin.Plugin.Floatplane.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Floatplane.Api;
///
/// Thin typed client for Floatplane's (unofficial) REST API. See
/// doc/floatplane-api-notes.md for which endpoints are public and which need the
/// session cookie.
///
public class FloatplaneApiClient
{
public const string BaseUrl = "https://www.floatplane.com";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger _logger;
private readonly Func _config;
private readonly FloatplaneAuthService? _auth;
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger, FloatplaneAuthService auth)
: this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
{
_auth = auth;
}
/// Constructor for tests: configuration is supplied explicitly, no OAuth.
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger, Func config)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_config = config;
}
/// True when a session (cookie or OAuth refresh token) is configured — not necessarily valid.
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie) || !string.IsNullOrEmpty(_config().OAuthRefreshToken);
/// "oauth", "cookie" or "none".
public string SessionKind => !string.IsNullOrEmpty(_config().OAuthRefreshToken) ? "oauth" : !string.IsNullOrWhiteSpace(_config().SessionCookie) ? "cookie" : "none";
// ───────────────────────────── auth / user ─────────────────────────────
/// GET /api/v3/user/self — validates the cookie. Throws if invalid.
public Task GetSelfAsync(CancellationToken ct, string? cookieOverride = null)
=> GetAsync("/api/v3/user/self", null, ct, cookieOverride);
/// GET /api/v3/user/subscriptions (login required).
public Task> GetSubscriptionsAsync(CancellationToken ct, string? cookieOverride = null)
=> GetAsync>("/api/v3/user/subscriptions", new() { ["active"] = "true" }, ct, cookieOverride);
// ───────────────────────────── creators ─────────────────────────────
/// GET /api/v3/creator/named?creatorURL[]= (public).
public Task> GetCreatorsByUrlNameAsync(IEnumerable urlNames, CancellationToken ct)
{
var q = new Dictionary();
var i = 0;
foreach (var n in urlNames)
{
q[$"creatorURL[{i++}]"] = n;
}
return GetAsync>("/api/v3/creator/named", q, ct);
}
/// GET /api/v3/creator/info?id= (login required).
public Task GetCreatorAsync(string creatorId, CancellationToken ct)
=> GetAsync("/api/v3/creator/info", new() { ["id"] = creatorId }, ct);
/// GET /api/v3/creator/channels/list?ids[]= (public).
public Task> GetCreatorChannelsAsync(IEnumerable creatorIds, CancellationToken ct)
{
var q = new Dictionary();
var i = 0;
foreach (var id in creatorIds)
{
q[$"ids[{i++}]"] = id;
}
return GetAsync>("/api/v3/creator/channels/list", q, ct);
}
/// GET /api/v3/content/tags?creatorIds[]= (public) → tag → count.
public Task> GetTagsAsync(string creatorId, CancellationToken ct)
=> GetAsync>("/api/v3/content/tags", new() { ["creatorIds[0]"] = creatorId }, ct);
// ───────────────────────────── content ─────────────────────────────
///
/// GET /api/v3/content/creator (public). FetchAfter is an offset
/// (multiples of Limit); max limit is 20. Returns an empty list at the end.
///
public Task> ListPostsAsync(PostQuery query, CancellationToken ct)
{
var q = new Dictionary
{
["id"] = query.CreatorId,
["limit"] = Math.Clamp(query.Limit, 1, 20).ToString(CultureInfo.InvariantCulture),
["fetchAfter"] = query.FetchAfter.ToString(CultureInfo.InvariantCulture),
["sort"] = query.SortAscending ? "ASC" : "DESC"
};
if (!string.IsNullOrEmpty(query.ChannelId))
{
q["channel"] = query.ChannelId;
}
if (!string.IsNullOrEmpty(query.Search))
{
q["search"] = query.Search;
}
if (query.HasVideo)
{
q["hasVideo"] = "true";
}
if (query.Tags != null)
{
var i = 0;
foreach (var t in query.Tags)
{
q[$"tags[{i++}]"] = t;
}
}
if (query.FromDate.HasValue)
{
q["fromDate"] = query.FromDate.Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
if (query.ToDate.HasValue)
{
q["toDate"] = query.ToDate.Value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
}
if (query.FromDurationSeconds.HasValue)
{
q["fromDuration"] = query.FromDurationSeconds.Value.ToString(CultureInfo.InvariantCulture);
}
if (query.ToDurationSeconds.HasValue)
{
q["toDuration"] = query.ToDurationSeconds.Value.ToString(CultureInfo.InvariantCulture);
}
return GetAsync>("/api/v3/content/creator", q, ct);
}
/// GET /api/v3/content/post?id= (login required) — expands attachments.
public Task GetPostAsync(string postId, CancellationToken ct)
=> GetAsync("/api/v3/content/post", new() { ["id"] = postId }, ct);
/// GET /api/v3/content/video?id= (login required).
public Task GetVideoAsync(string videoId, CancellationToken ct)
=> GetAsync("/api/v3/content/video", new() { ["id"] = videoId }, ct);
/// GET /api/v3/content/progress?ids[]=&contentType=blogPost (login) → percentage per post.
public Task> GetProgressAsync(IEnumerable postIds, CancellationToken ct)
{
var q = new Dictionary { ["contentType"] = "blogPost" };
var i = 0;
foreach (var id in postIds)
{
q[$"ids[{i++}]"] = id;
}
return GetAsync>("/api/v3/content/progress", q, ct);
}
/// POST /api/v3/content/progress {id, contentType:"video", progress:seconds} (login).
public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct)
{
var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds });
using var request = await BuildRequestAsync(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null, ct).ConfigureAwait(false);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
}
// ───────────────────────────── delivery ─────────────────────────────
/// GET /api/v3/delivery/info?scenario=&entityId= (login; rate-limited ≈2/min).
public Task GetDeliveryInfoAsync(string scenario, string entityId, CancellationToken ct)
=> GetAsync("/api/v3/delivery/info", new() { ["scenario"] = scenario, ["entityId"] = entityId }, ct);
/// Fetches a (sub)playlist from the CDN. No cookie — the signed token is enough.
public async Task GetPlaylistAsync(string url, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Referrer = new Uri(BaseUrl + "/");
using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
///
/// Fetches the HLS AES-128 key (/api/video/watchKey?token=). This is the one
/// playback request that needs the session cookie.
///
public async Task GetWatchKeyAsync(string keyUrl, CancellationToken ct)
{
using var request = await BuildRequestAsync(HttpMethod.Get, keyUrl, null, null, ct).ConfigureAwait(false);
request.Headers.Referrer = new Uri(BaseUrl + "/");
using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
/// Opens an upstream media request (MP4 relay) with optional Range; caller disposes.
public async Task OpenMediaAsync(HttpMethod method, string url, RangeHeaderValue? range, CancellationToken ct)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Referrer = new Uri(BaseUrl + "/");
if (range != null)
{
request.Headers.Range = range;
}
var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName);
return await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
}
// ───────────────────────────── plumbing ─────────────────────────────
/// Builds a request with auth: explicit cookie > OAuth bearer > configured cookie.
internal async Task BuildRequestAsync(HttpMethod method, string url, Dictionary? query, string? cookieOverride, CancellationToken ct)
{
var request = BuildRequest(method, url, query, cookieOverride);
if (cookieOverride == null && _auth != null && !string.IsNullOrEmpty(_config().OAuthRefreshToken))
{
var token = await _auth.GetAccessTokenAsync(ct).ConfigureAwait(false);
if (!string.IsNullOrEmpty(token))
{
request.Headers.Remove("Cookie");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
return request;
}
internal HttpRequestMessage BuildRequest(HttpMethod method, string url, Dictionary? query, string? cookieOverride)
{
if (query is { Count: > 0 })
{
var sb = new StringBuilder(url);
sb.Append(url.Contains('?', StringComparison.Ordinal) ? '&' : '?');
var first = true;
foreach (var (k, v) in query)
{
if (!first)
{
sb.Append('&');
}
first = false;
sb.Append(Uri.EscapeDataString(k)).Append('=').Append(Uri.EscapeDataString(v));
}
url = sb.ToString();
}
var request = new HttpRequestMessage(method, url);
var cookie = cookieOverride ?? _config().SessionCookie;
if (!string.IsNullOrWhiteSpace(cookie))
{
request.Headers.TryAddWithoutValidation("Cookie", "sails.sid=" + cookie.Trim());
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}
private async Task GetAsync(string path, Dictionary? query, CancellationToken ct, string? cookieOverride = null)
{
using var request = await BuildRequestAsync(HttpMethod.Get, BaseUrl + path, query, cookieOverride, ct).ConfigureAwait(false);
using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
_logger.LogDebug("GET {Url} -> {Status}, {Length} bytes", request.RequestUri, (int)response.StatusCode, json.Length);
T? result;
try
{
result = JsonSerializer.Deserialize(json, FloatplaneJson.Options);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Could not parse Floatplane response from {Path}: {Head}", path, json.Length > 300 ? json[..300] : json);
throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected response from " + path);
}
return result ?? throw new FloatplaneApiException(response.StatusCode, "emptyResponse", "Empty response from " + path);
}
private Task SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName);
return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
{
return;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retry = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60);
_logger.LogWarning("Floatplane rate limit hit on {Url}; retry after {Retry}", response.RequestMessage?.RequestUri, retry);
throw new FloatplaneRateLimitException(retry, "Floatplane rate limit (429)");
}
string body = string.Empty;
try
{
body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// ignore – we only need the body for the error name
}
ApiErrorModel? error = null;
if (body.StartsWith('{'))
{
try
{
error = JsonSerializer.Deserialize(body, FloatplaneJson.Options);
}
catch (JsonException)
{
// not an envelope
}
}
var name = error?.Errors.FirstOrDefault()?.Name;
var message = error?.Message ?? error?.Errors.FirstOrDefault()?.Message ?? $"HTTP {(int)response.StatusCode} from Floatplane";
var url = response.RequestMessage?.RequestUri?.GetLeftPart(UriPartial.Path);
_logger.LogDebug("Floatplane error {Status} {Name} on {Url}: {Message}", (int)response.StatusCode, name, url, message);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
|| string.Equals(name, "notLoggedInError", StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "notSubscribedError", StringComparison.OrdinalIgnoreCase))
{
throw new FloatplaneAuthException(response.StatusCode, name, message);
}
throw new FloatplaneApiException(response.StatusCode, name, message);
}
}
/// Parameters of GET /api/v3/content/creator.
public class PostQuery
{
public string CreatorId { get; set; } = string.Empty;
public string? ChannelId { get; set; }
public int Limit { get; set; } = 20;
public int FetchAfter { get; set; }
public string? Search { get; set; }
public bool HasVideo { get; set; }
public bool SortAscending { get; set; }
public IEnumerable? Tags { get; set; }
public DateTimeOffset? FromDate { get; set; }
public DateTimeOffset? ToDate { get; set; }
public int? FromDurationSeconds { get; set; }
public int? ToDurationSeconds { get; set; }
}