Files
jellyfin-plugin-floatplane/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

370 lines
16 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;
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger)
: this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration())
{
}
/// <summary>Constructor for tests: configuration is supplied explicitly.</summary>
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_config = config;
}
/// <summary>True when a session cookie is configured (not necessarily valid).</summary>
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie);
// ───────────────────────────── 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 = 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 ─────────────────────────────
/// <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 = 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);
}
/// <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 ─────────────────────────────
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 = 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<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; }
}