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
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:
280
Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs
Normal file
280
Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user