Files
jellyfin-plugin-floatplane/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs
claude bf476134c2
Some checks failed
release / build-release (push) Has been cancelled
v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs
- per-creator "All videos" folder (release order across sub-channels); sub-channel copies use
  cpost: ids and UserDataMirror keeps played/resume state in sync
- SortNameUpdater forces release-order sort names so every client lists newest first by default
- official Floatplane icon as channel image (Primary only — a channel Thumb hid video thumbnails
  in Continue Watching) and as 16:9 plugin thumbnail; one-time image replacement on upgrade
- OAuth device-flow login (FloatplaneAuthService + settings UI); needs a Floatplane client id;
  Turnstile verified domain-bound so password login inside Jellyfin is impossible
- generic install.sh (linuxserver / official image / native), thumb.png in package + manifest
- README rewritten for a general audience, doc/usage.md with screenshots, 60 tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 16:36:32 +01:00

392 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Thin typed client for Floatplane's (unofficial) REST API. See
/// <c>doc/floatplane-api-notes.md</c> for which endpoints are public and which need the
/// session cookie.
/// </summary>
public class FloatplaneApiClient
{
public const string BaseUrl = "https://www.floatplane.com";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<FloatplaneApiClient> _logger;
private readonly Func<PluginConfiguration> _config;
private readonly FloatplaneAuthService? _auth;
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, FloatplaneAuthService auth)
: this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
{
_auth = auth;
}
/// <summary>Constructor for tests: configuration is supplied explicitly, no OAuth.</summary>
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_config = config;
}
/// <summary>True when a session (cookie or OAuth refresh token) is configured — not necessarily valid.</summary>
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie) || !string.IsNullOrEmpty(_config().OAuthRefreshToken);
/// <summary>"oauth", "cookie" or "none".</summary>
public string SessionKind => !string.IsNullOrEmpty(_config().OAuthRefreshToken) ? "oauth" : !string.IsNullOrWhiteSpace(_config().SessionCookie) ? "cookie" : "none";
// ───────────────────────────── auth / user ─────────────────────────────
/// <summary>GET /api/v3/user/self — validates the cookie. Throws <see cref="FloatplaneAuthException"/> if invalid.</summary>
public Task<UserSelfModel> GetSelfAsync(CancellationToken ct, string? cookieOverride = null)
=> GetAsync<UserSelfModel>("/api/v3/user/self", null, ct, cookieOverride);
/// <summary>GET /api/v3/user/subscriptions (login required).</summary>
public Task<List<SubscriptionModel>> GetSubscriptionsAsync(CancellationToken ct, string? cookieOverride = null)
=> GetAsync<List<SubscriptionModel>>("/api/v3/user/subscriptions", new() { ["active"] = "true" }, ct, cookieOverride);
// ───────────────────────────── creators ─────────────────────────────
/// <summary>GET /api/v3/creator/named?creatorURL[]= (public).</summary>
public Task<List<CreatorModel>> GetCreatorsByUrlNameAsync(IEnumerable<string> urlNames, CancellationToken ct)
{
var q = new Dictionary<string, string>();
var i = 0;
foreach (var n in urlNames)
{
q[$"creatorURL[{i++}]"] = n;
}
return GetAsync<List<CreatorModel>>("/api/v3/creator/named", q, ct);
}
/// <summary>GET /api/v3/creator/info?id= (login required).</summary>
public Task<CreatorModel> GetCreatorAsync(string creatorId, CancellationToken ct)
=> GetAsync<CreatorModel>("/api/v3/creator/info", new() { ["id"] = creatorId }, ct);
/// <summary>GET /api/v3/creator/channels/list?ids[]= (public).</summary>
public Task<List<ChannelModel>> GetCreatorChannelsAsync(IEnumerable<string> creatorIds, CancellationToken ct)
{
var q = new Dictionary<string, string>();
var i = 0;
foreach (var id in creatorIds)
{
q[$"ids[{i++}]"] = id;
}
return GetAsync<List<ChannelModel>>("/api/v3/creator/channels/list", q, ct);
}
/// <summary>GET /api/v3/content/tags?creatorIds[]= (public) → tag → count.</summary>
public Task<Dictionary<string, int>> GetTagsAsync(string creatorId, CancellationToken ct)
=> GetAsync<Dictionary<string, int>>("/api/v3/content/tags", new() { ["creatorIds[0]"] = creatorId }, ct);
// ───────────────────────────── content ─────────────────────────────
/// <summary>
/// GET /api/v3/content/creator (public). <c>FetchAfter</c> is an offset
/// (multiples of <c>Limit</c>); max limit is 20. Returns an empty list at the end.
/// </summary>
public Task<List<PostModel>> ListPostsAsync(PostQuery query, CancellationToken ct)
{
var q = new Dictionary<string, string>
{
["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<List<PostModel>>("/api/v3/content/creator", q, ct);
}
/// <summary>GET /api/v3/content/post?id= (login required) — expands attachments.</summary>
public Task<PostModel> GetPostAsync(string postId, CancellationToken ct)
=> GetAsync<PostModel>("/api/v3/content/post", new() { ["id"] = postId }, ct);
/// <summary>GET /api/v3/content/video?id= (login required).</summary>
public Task<VideoModel> GetVideoAsync(string videoId, CancellationToken ct)
=> GetAsync<VideoModel>("/api/v3/content/video", new() { ["id"] = videoId }, ct);
/// <summary>GET /api/v3/content/progress?ids[]=&amp;contentType=blogPost (login) → percentage per post.</summary>
public Task<List<ProgressModel>> GetProgressAsync(IEnumerable<string> postIds, CancellationToken ct)
{
var q = new Dictionary<string, string> { ["contentType"] = "blogPost" };
var i = 0;
foreach (var id in postIds)
{
q[$"ids[{i++}]"] = id;
}
return GetAsync<List<ProgressModel>>("/api/v3/content/progress", q, ct);
}
/// <summary>POST /api/v3/content/progress {id, contentType:"video", progress:seconds} (login).</summary>
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 ─────────────────────────────
/// <summary>GET /api/v3/delivery/info?scenario=&amp;entityId= (login; rate-limited ≈2/min).</summary>
public Task<DeliveryInfoModel> GetDeliveryInfoAsync(string scenario, string entityId, CancellationToken ct)
=> GetAsync<DeliveryInfoModel>("/api/v3/delivery/info", new() { ["scenario"] = scenario, ["entityId"] = entityId }, ct);
/// <summary>Fetches a (sub)playlist from the CDN. No cookie — the signed token is enough.</summary>
public async Task<string> 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);
}
/// <summary>
/// Fetches the HLS AES-128 key (<c>/api/video/watchKey?token=</c>). This is the one
/// playback request that needs the session cookie.
/// </summary>
public async Task<byte[]> 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);
}
/// <summary>Opens an upstream media request (MP4 relay) with optional Range; caller disposes.</summary>
public async Task<HttpResponseMessage> 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 ─────────────────────────────
/// <summary>Builds a request with auth: explicit cookie > OAuth bearer > configured cookie.</summary>
internal async Task<HttpRequestMessage> BuildRequestAsync(HttpMethod method, string url, Dictionary<string, string>? 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<string, string>? 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<T> GetAsync<T>(string path, Dictionary<string, string>? 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<T>(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<HttpResponseMessage> 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<ApiErrorModel>(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);
}
}
/// <summary>Parameters of GET /api/v3/content/creator.</summary>
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<string>? Tags { get; set; }
public DateTimeOffset? FromDate { get; set; }
public DateTimeOffset? ToDate { get; set; }
public int? FromDurationSeconds { get; set; }
public int? ToDurationSeconds { get; set; }
}