v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs
Some checks failed
release / build-release (push) Has been cancelled

- 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>
This commit is contained in:
2026-10-25 16:36:32 +01:00
parent 2a883c02de
commit bf476134c2
33 changed files with 1478 additions and 155 deletions

View File

@@ -18,18 +18,90 @@ namespace Jellyfin.Plugin.Floatplane.Api;
public class FloatplaneAdminController : ControllerBase
{
private readonly FloatplaneApiClient _api;
private readonly FloatplaneAuthService _auth;
private readonly CatalogService _catalog;
private readonly DeliveryResolver _delivery;
private readonly ILogger<FloatplaneAdminController> _logger;
public FloatplaneAdminController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneAdminController> logger)
public FloatplaneAdminController(FloatplaneApiClient api, FloatplaneAuthService auth, CatalogService catalog, DeliveryResolver delivery, ILogger<FloatplaneAdminController> logger)
{
_api = api;
_auth = auth;
_catalog = catalog;
_delivery = delivery;
_logger = logger;
}
// ───────────────────────────── OAuth device flow ─────────────────────────────
/// <summary>Starts an OAuth device login with the given client id (saved to the configuration).</summary>
[HttpPost("OAuth/Start")]
public async Task<ActionResult<DeviceLoginStart>> StartOAuth([FromBody] OAuthStartRequest request)
{
var clientId = request.ClientId?.Trim() ?? string.Empty;
if (clientId.Length == 0)
{
return BadRequest("Client id is empty");
}
try
{
var start = await _auth.StartDeviceLoginAsync(clientId, HttpContext.RequestAborted).ConfigureAwait(false);
var cfg = Plugin.Instance?.Configuration;
if (cfg != null && cfg.OAuthClientId != clientId)
{
cfg.OAuthClientId = clientId;
Plugin.Instance!.SaveConfiguration();
}
start.DeviceCode = string.Empty; // never hand the device code to the browser
return start;
}
catch (FloatplaneApiException ex)
{
_logger.LogWarning("OAuth device start failed: {Error} {Message}", ex.ErrorName, ex.Message);
return StatusCode(502, $"{ex.ErrorName}: {ex.Message}");
}
}
/// <summary>Polls the pending device login once.</summary>
[HttpGet("OAuth/Poll")]
public async Task<ActionResult<DeviceLoginStatus>> PollOAuth()
{
var status = await _auth.PollDeviceLoginAsync(HttpContext.RequestAborted).ConfigureAwait(false);
if (status.State == "done")
{
_catalog.Invalidate();
try
{
var self = await _api.GetSelfAsync(HttpContext.RequestAborted).ConfigureAwait(false);
var cfg = Plugin.Instance?.Configuration;
if (cfg != null)
{
cfg.SessionUsername = self.Username;
Plugin.Instance!.SaveConfiguration();
}
status.Message = "Logged in as " + self.Username;
}
catch (FloatplaneApiException ex)
{
status.Message = "Logged in, but user lookup failed: " + ex.Message;
}
}
return status;
}
/// <summary>Drops the stored OAuth tokens.</summary>
[HttpPost("OAuth/Logout")]
public ActionResult<PluginStatus> LogoutOAuth()
{
FloatplaneAuthService.Logout();
_catalog.Invalidate();
return GetStatus();
}
/// <summary>Validates a session cookie (without saving it) and lists the subscriptions it has.</summary>
[HttpPost("Session/Validate")]
public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request)
@@ -78,6 +150,8 @@ public class FloatplaneAdminController : ControllerBase
{
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
HasSessionCookie = _api.HasSession,
SessionKind = _api.SessionKind,
OAuthClientId = Plugin.Instance?.Configuration.OAuthClientId ?? string.Empty,
SessionState = _catalog.SessionState ?? "unknown",
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
LastError = _catalog.LastError,
@@ -131,6 +205,11 @@ public class FloatplaneAdminController : ControllerBase
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
}
public class OAuthStartRequest
{
public string? ClientId { get; set; }
}
public class SessionValidationRequest
{
public string? Cookie { get; set; }
@@ -155,6 +234,11 @@ public class PluginStatus
public bool HasSessionCookie { get; set; }
/// <summary>"oauth", "cookie" or "none".</summary>
public string SessionKind { get; set; } = string.Empty;
public string OAuthClientId { get; set; } = string.Empty;
public string SessionState { get; set; } = string.Empty;
public string SessionUsername { get; set; } = string.Empty;

View File

@@ -27,13 +27,15 @@ public class FloatplaneApiClient
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)
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.</summary>
/// <summary>Constructor for tests: configuration is supplied explicitly, no OAuth.</summary>
public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
{
_httpClientFactory = httpClientFactory;
@@ -41,8 +43,11 @@ public class FloatplaneApiClient
_config = config;
}
/// <summary>True when a session cookie is configured (not necessarily valid).</summary>
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie);
/// <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 ─────────────────────────────
@@ -177,7 +182,7 @@ public class FloatplaneApiClient
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);
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);
@@ -205,7 +210,7 @@ public class FloatplaneApiClient
/// </summary>
public async Task<byte[]> GetWatchKeyAsync(string keyUrl, CancellationToken ct)
{
using var request = BuildRequest(HttpMethod.Get, keyUrl, null, null);
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);
@@ -228,6 +233,23 @@ public class FloatplaneApiClient
// ───────────────────────────── 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 })
@@ -262,7 +284,7 @@ public class FloatplaneApiClient
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 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);

View File

@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Floatplane.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Floatplane.Api;
/// <summary>
/// OAuth 2.0 device-code login against Floatplane's Keycloak
/// (<c>auth.floatplane.com</c>), the way Hydravion / Wasserflug / floatplane-downloader
/// log in. It needs a <c>client_id</c> registered by Floatplane; without one the server
/// answers <c>invalid_client</c>. Tokens are kept in the plugin configuration. DPoP-bound
/// clients are not supported (plain Bearer only).
/// </summary>
public class FloatplaneAuthService
{
public const string Issuer = "https://auth.floatplane.com/realms/floatplane";
private const string DeviceEndpoint = Issuer + "/protocol/openid-connect/auth/device";
private const string TokenEndpoint = Issuer + "/protocol/openid-connect/token";
private const string Scope = "openid profile email offline_access";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<FloatplaneAuthService> _logger;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private PendingDeviceLogin? _pending;
public FloatplaneAuthService(IHttpClientFactory httpClientFactory, ILogger<FloatplaneAuthService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>True when a refresh token is stored (OAuth login completed at some point).</summary>
public static bool HasOAuthSession => !string.IsNullOrEmpty(Config.OAuthRefreshToken);
/// <summary>Starts a device login: returns the code and URL the user must open.</summary>
public async Task<DeviceLoginStart> StartDeviceLoginAsync(string clientId, CancellationToken ct)
{
var form = new Dictionary<string, string> { ["client_id"] = clientId, ["scope"] = Scope };
using var response = await PostFormAsync(DeviceEndpoint, form, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var err = Parse<OAuthError>(json);
throw new FloatplaneApiException(response.StatusCode, err?.Error, err?.ErrorDescription ?? "Device authorization failed");
}
var start = Parse<DeviceLoginStart>(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected device authorization response");
_pending = new PendingDeviceLogin(clientId, start.DeviceCode, DateTimeOffset.UtcNow.AddSeconds(start.ExpiresIn), Math.Max(5, start.Interval));
_logger.LogInformation("Floatplane device login started; user code {Code}", start.UserCode);
return start;
}
/// <summary>One poll of the token endpoint; "pending" until the user has approved.</summary>
public async Task<DeviceLoginStatus> PollDeviceLoginAsync(CancellationToken ct)
{
var pending = _pending;
if (pending == null)
{
return new DeviceLoginStatus { State = "none", Message = "No login in progress" };
}
if (pending.ExpiresAt < DateTimeOffset.UtcNow)
{
_pending = null;
return new DeviceLoginStatus { State = "expired", Message = "The code expired; start again" };
}
var form = new Dictionary<string, string>
{
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
["client_id"] = pending.ClientId,
["device_code"] = pending.DeviceCode
};
using var response = await PostFormAsync(TokenEndpoint, form, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
var tokens = Parse<TokenResponse>(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected token response");
StoreTokens(tokens, pending.ClientId);
_pending = null;
return new DeviceLoginStatus { State = "done", Message = "Logged in" };
}
var err = Parse<OAuthError>(json);
switch (err?.Error)
{
case "authorization_pending":
return new DeviceLoginStatus { State = "pending", Message = "Waiting for approval…", Interval = pending.Interval };
case "slow_down":
pending.Interval += 5;
return new DeviceLoginStatus { State = "pending", Message = "Waiting (slower)…", Interval = pending.Interval };
default:
_pending = null;
return new DeviceLoginStatus { State = "error", Message = err?.ErrorDescription ?? err?.Error ?? "Login failed" };
}
}
/// <summary>Forgets the OAuth tokens (local only; Floatplane keeps the session until it expires).</summary>
public static void Logout()
{
var cfg = Config;
cfg.OAuthAccessToken = string.Empty;
cfg.OAuthRefreshToken = string.Empty;
cfg.OAuthAccessTokenExpiry = 0;
Plugin.Instance?.SaveConfiguration();
}
/// <summary>
/// Access token for API calls, refreshed when it has less than a minute left. Never
/// refresh per request: ~40 refreshes in a row trip Cloudflare's 429 on auth.floatplane.com.
/// </summary>
public async Task<string?> GetAccessTokenAsync(CancellationToken ct)
{
var cfg = Config;
if (string.IsNullOrEmpty(cfg.OAuthRefreshToken))
{
return null;
}
var expiry = DateTimeOffset.FromUnixTimeSeconds(cfg.OAuthAccessTokenExpiry);
if (!string.IsNullOrEmpty(cfg.OAuthAccessToken) && expiry > DateTimeOffset.UtcNow.AddSeconds(60))
{
return cfg.OAuthAccessToken;
}
await _refreshLock.WaitAsync(ct).ConfigureAwait(false);
try
{
cfg = Config;
expiry = DateTimeOffset.FromUnixTimeSeconds(cfg.OAuthAccessTokenExpiry);
if (!string.IsNullOrEmpty(cfg.OAuthAccessToken) && expiry > DateTimeOffset.UtcNow.AddSeconds(60))
{
return cfg.OAuthAccessToken;
}
var form = new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["client_id"] = cfg.OAuthClientId,
["refresh_token"] = cfg.OAuthRefreshToken
};
using var response = await PostFormAsync(TokenEndpoint, form, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var err = Parse<OAuthError>(json);
_logger.LogWarning("Floatplane token refresh failed: {Error} {Description}", err?.Error, err?.ErrorDescription);
if (err?.Error == "invalid_grant")
{
Logout();
}
throw new FloatplaneAuthException(response.StatusCode, err?.Error ?? "refreshFailed", err?.ErrorDescription ?? "Token refresh failed");
}
var tokens = Parse<TokenResponse>(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected token response");
StoreTokens(tokens, cfg.OAuthClientId);
return tokens.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
private static void StoreTokens(TokenResponse tokens, string clientId)
{
var cfg = Config;
cfg.OAuthClientId = clientId;
cfg.OAuthAccessToken = tokens.AccessToken ?? string.Empty;
if (!string.IsNullOrEmpty(tokens.RefreshToken))
{
cfg.OAuthRefreshToken = tokens.RefreshToken; // rotated on every refresh
}
cfg.OAuthAccessTokenExpiry = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, tokens.ExpiresIn)).ToUnixTimeSeconds();
Plugin.Instance?.SaveConfiguration();
}
private async Task<HttpResponseMessage> PostFormAsync(string url, Dictionary<string, string> form, CancellationToken ct)
{
var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(form) };
return await client.SendAsync(request, ct).ConfigureAwait(false);
}
private static T? Parse<T>(string json)
{
try
{
return JsonSerializer.Deserialize<T>(json);
}
catch (JsonException)
{
return default;
}
}
private sealed class PendingDeviceLogin
{
public PendingDeviceLogin(string clientId, string deviceCode, DateTimeOffset expiresAt, int interval)
{
ClientId = clientId;
DeviceCode = deviceCode;
ExpiresAt = expiresAt;
Interval = interval;
}
public string ClientId { get; }
public string DeviceCode { get; }
public DateTimeOffset ExpiresAt { get; }
public int Interval { get; set; }
}
}
public class DeviceLoginStart
{
[JsonPropertyName("device_code")]
public string DeviceCode { get; set; } = string.Empty;
[JsonPropertyName("user_code")]
public string UserCode { get; set; } = string.Empty;
[JsonPropertyName("verification_uri")]
public string VerificationUri { get; set; } = string.Empty;
[JsonPropertyName("verification_uri_complete")]
public string? VerificationUriComplete { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; } = 600;
[JsonPropertyName("interval")]
public int Interval { get; set; } = 5;
}
public class DeviceLoginStatus
{
public string State { get; set; } = string.Empty;
public string? Message { get; set; }
public int Interval { get; set; } = 5;
}
public class TokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
}
public class OAuthError
{
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
}