Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- 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>
This commit is contained in:
369
Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs
Normal file
369
Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs
Normal file
@@ -0,0 +1,369 @@
|
||||
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[]=&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=&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; }
|
||||
}
|
||||
Reference in New Issue
Block a user