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; public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger) : this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) { } /// Constructor for tests: configuration is supplied explicitly. public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger, Func config) { _httpClientFactory = httpClientFactory; _logger = logger; _config = config; } /// True when a session cookie is configured (not necessarily valid). public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie); // ───────────────────────────── 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 = BuildRequest(HttpMethod.Post, BaseUrl + "/api/v3/content/progress", null, null); 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 = BuildRequest(HttpMethod.Get, keyUrl, null, null); 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 ───────────────────────────── 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 = BuildRequest(HttpMethod.Get, BaseUrl + path, query, cookieOverride); 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; } }