From bf476134c23befc115097ff083f3b8f5b615c2b2 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 25 Oct 2026 16:36:32 +0100 Subject: [PATCH] v0.2: All videos folder, shared watched state, release-order sorting, official icon, OAuth plumbing, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/release.yaml | 10 +- .../AllVideosTests.cs | 55 ++++ .../Api/FloatplaneAdminController.cs | 86 +++++- .../Api/FloatplaneApiClient.cs | 36 ++- .../Api/FloatplaneAuthService.cs | 280 +++++++++++++++++ .../Channel/CatalogService.cs | 30 +- .../Channel/FloatplaneChannel.cs | 86 +++++- .../Channel/SortNameUpdater.cs | 120 ++++++++ .../Channel/UserDataMirror.cs | 156 ++++++++++ .../Configuration/PluginConfiguration.cs | 23 +- .../Configuration/configPage.html | 80 ++++- .../Jellyfin.Plugin.Floatplane.csproj | 2 + .../PluginServiceRegistrator.cs | 6 + Jellyfin.Plugin.Floatplane/Resources/logo.png | Bin 8024 -> 50454 bytes .../Resources/thumb.png | Bin 0 -> 54128 bytes .../Tasks/FloatplaneSyncTask.cs | 72 ++++- README.md | 230 ++++++++------ build.yaml | 4 +- doc/img/all-videos.png | Bin 0 -> 265230 bytes doc/img/channel-root.png | Bin 0 -> 19852 bytes doc/img/creator-folder.png | Bin 0 -> 94312 bytes doc/img/dashboard-plugins.png | Bin 0 -> 47050 bytes doc/img/home.png | Bin 0 -> 68501 bytes doc/img/plugin-catalog-floatplane.png | Bin 0 -> 40718 bytes doc/img/plugin-catalog.png | Bin 0 -> 81599 bytes doc/img/plugin-repositories.png | Bin 0 -> 22129 bytes doc/img/scheduled-tasks.png | Bin 0 -> 32839 bytes doc/img/settings.png | Bin 0 -> 129976 bytes doc/img/video-details.png | Bin 0 -> 83126 bytes doc/plan.md | 12 +- doc/usage.md | 281 ++++++++++++++++++ install-pi5.sh => install.sh | 55 +++- scripts/package.sh | 9 +- 33 files changed, 1478 insertions(+), 155 deletions(-) create mode 100644 Jellyfin.Plugin.Floatplane.Tests/AllVideosTests.cs create mode 100644 Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs create mode 100644 Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs create mode 100644 Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs create mode 100644 Jellyfin.Plugin.Floatplane/Resources/thumb.png create mode 100644 doc/img/all-videos.png create mode 100644 doc/img/channel-root.png create mode 100644 doc/img/creator-folder.png create mode 100644 doc/img/dashboard-plugins.png create mode 100644 doc/img/home.png create mode 100644 doc/img/plugin-catalog-floatplane.png create mode 100644 doc/img/plugin-catalog.png create mode 100644 doc/img/plugin-repositories.png create mode 100644 doc/img/scheduled-tasks.png create mode 100644 doc/img/settings.png create mode 100644 doc/img/video-details.png create mode 100644 doc/usage.md rename install-pi5.sh => install.sh (57%) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 9c9f3bb..7113dc7 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -6,7 +6,7 @@ name: release # - tagg vX.Y.Z -> versionerad release (plugin-version X.Y.Z.0) # - workflow_dispatch -> valfri tag (tom = latest) # Assets: floatplane_.zip (+.md5), manifest.json (Jellyfins plugin- -# repository-format) och install-pi5.sh. manifest.json laddas ALLTID upp +# repository-format) och install.sh. manifest.json laddas ALLTID upp # till "latest" sa att URL:en nedan alltid pekar pa nyaste versionen: # https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json # @@ -124,7 +124,7 @@ jobs: Sedan Plugins -> Catalog -> Floatplane -> Install, starta om Jellyfin. Manuell installation pa en host med linuxserver/jellyfin-containern: - curl -fsSL $REPO_URL/releases/download/$TAG/install-pi5.sh | sh + curl -fsSL $REPO_URL/releases/download/$TAG/install.sh | sh (TAG=... JELLYFIN_CONFIG_DIR=... JELLYFIN_CONTAINER=... och --dry-run stods, se skriptet.) Efter installation: Dashboard -> Plugins -> Floatplane -> klistra in sails.sid-cookien." @@ -159,7 +159,8 @@ jobs: upload "$rid" "$ZIP" "build/$ZIP" upload "$rid" "$ZIP.md5" "build/$ZIP.md5" upload "$rid" "manifest.json" "build/manifest.json" - upload "$rid" "install-pi5.sh" "install-pi5.sh" + upload "$rid" "install.sh" "install.sh" + upload "$rid" "thumb.png" "build/thumb.png" echo "klart: https://gitea.brasse-pc.eu/$REPO/releases/tag/$TAG" # --- manifest.json aven till "latest" vid taggad release -------------- @@ -175,7 +176,8 @@ jobs: fi if [ -n "$lid" ] && [ "$lid" != "null" ]; then upload "$lid" "manifest.json" "build/manifest.json.keep" - upload "$lid" "install-pi5.sh" "install-pi5.sh" + upload "$lid" "install.sh" "install.sh" + upload "$lid" "thumb.png" "build/thumb.png" echo "manifest.json uppdaterat pa latest: $REPO_URL/releases/download/latest/manifest.json" else echo "::warning::kunde inte hitta/skapa latest-releasen for manifest.json" diff --git a/Jellyfin.Plugin.Floatplane.Tests/AllVideosTests.cs b/Jellyfin.Plugin.Floatplane.Tests/AllVideosTests.cs new file mode 100644 index 0000000..c75244c --- /dev/null +++ b/Jellyfin.Plugin.Floatplane.Tests/AllVideosTests.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Text.Json; +using Jellyfin.Plugin.Floatplane.Api.Models; +using Jellyfin.Plugin.Floatplane.Channel; +using Xunit; + +namespace Jellyfin.Plugin.Floatplane.Tests; + +public class AllVideosTests +{ + [Theory] + [InlineData("post:SSvhppI28t", "SSvhppI28t")] + [InlineData("cpost:64135da7ce81077a8480c679:SSvhppI28t", "SSvhppI28t")] + [InlineData("creator:59f94c0bdd241b70349eb72b", null)] + [InlineData("all:59f94c0bdd241b70349eb72b", null)] + [InlineData("channel:a:b", null)] + [InlineData("cpost:nochannel", null)] + [InlineData("", null)] + [InlineData(null, null)] + public void PostIdOf_ParsesBothItemIdForms(string? externalId, string? expected) + { + Assert.Equal(expected, FloatplaneChannel.PostIdOf(externalId)); + } + + [Fact] + public void ToItem_UsesCanonicalIdByDefault_AndChannelPrefixWhenAsked() + { + var posts = Fixture.Json>("content-creator.json"); + var post = posts[0]; + + var canonical = FloatplaneChannel.ToItem(post, null); + var inChannel = FloatplaneChannel.ToItem(post, null, true, "cpost:" + post.Channel!.Id + ":"); + + Assert.Equal("post:" + post.Id, canonical.Id); + Assert.Equal("cpost:" + post.Channel.Id + ":" + post.Id, inChannel.Id); + Assert.Equal(canonical.Name, inChannel.Name); + Assert.Equal(post.Id, FloatplaneChannel.PostIdOf(canonical.Id)); + Assert.Equal(post.Id, FloatplaneChannel.PostIdOf(inChannel.Id)); + Assert.NotEqual(FloatplaneChannel.DeterministicGuid(canonical.Id), FloatplaneChannel.DeterministicGuid(inChannel.Id)); + } +} + +public class SortNameTests +{ + [Fact] + public void NewerReleaseSortsFirstUnderAscendingNameOrder() + { + var older = SortNameUpdater.SortNameFor(new System.DateTime(2026, 10, 1, 0, 0, 0, System.DateTimeKind.Utc)); + var newer = SortNameUpdater.SortNameFor(new System.DateTime(2026, 10, 24, 18, 24, 0, System.DateTimeKind.Utc)); + Assert.Equal(13, older.Length); + Assert.Equal(13, newer.Length); + Assert.True(string.CompareOrdinal(newer, older) < 0, "newer must sort before older"); + Assert.Equal(13, SortNameUpdater.SortNameFor(null).Length); + } +} diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs index 7028051..f7c3d2e 100644 --- a/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneAdminController.cs @@ -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 _logger; - public FloatplaneAdminController(FloatplaneApiClient api, CatalogService catalog, DeliveryResolver delivery, ILogger logger) + public FloatplaneAdminController(FloatplaneApiClient api, FloatplaneAuthService auth, CatalogService catalog, DeliveryResolver delivery, ILogger logger) { _api = api; + _auth = auth; _catalog = catalog; _delivery = delivery; _logger = logger; } + // ───────────────────────────── OAuth device flow ───────────────────────────── + + /// Starts an OAuth device login with the given client id (saved to the configuration). + [HttpPost("OAuth/Start")] + public async Task> 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}"); + } + } + + /// Polls the pending device login once. + [HttpGet("OAuth/Poll")] + public async Task> 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; + } + + /// Drops the stored OAuth tokens. + [HttpPost("OAuth/Logout")] + public ActionResult LogoutOAuth() + { + FloatplaneAuthService.Logout(); + _catalog.Invalidate(); + return GetStatus(); + } + /// Validates a session cookie (without saving it) and lists the subscriptions it has. [HttpPost("Session/Validate")] public async Task> 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; } + /// "oauth", "cookie" or "none". + 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; diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs index 8f7a883..96fc89f 100644 --- a/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneApiClient.cs @@ -27,13 +27,15 @@ public class FloatplaneApiClient private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly Func _config; + private readonly FloatplaneAuthService? _auth; - public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger) + public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger, FloatplaneAuthService auth) : this(httpClientFactory, logger, () => Plugin.Instance?.Configuration ?? new PluginConfiguration()) { + _auth = auth; } - /// Constructor for tests: configuration is supplied explicitly. + /// Constructor for tests: configuration is supplied explicitly, no OAuth. public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger logger, Func config) { _httpClientFactory = httpClientFactory; @@ -41,8 +43,11 @@ public class FloatplaneApiClient _config = config; } - /// True when a session cookie is configured (not necessarily valid). - public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie); + /// True when a session (cookie or OAuth refresh token) is configured — not necessarily valid. + public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie) || !string.IsNullOrEmpty(_config().OAuthRefreshToken); + + /// "oauth", "cookie" or "none". + 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 /// public async Task 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 ───────────────────────────── + /// Builds a request with auth: explicit cookie > OAuth bearer > configured cookie. + internal async Task BuildRequestAsync(HttpMethod method, string url, Dictionary? 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? query, string? cookieOverride) { if (query is { Count: > 0 }) @@ -262,7 +284,7 @@ public class FloatplaneApiClient private async Task GetAsync(string path, Dictionary? 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); diff --git a/Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs b/Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs new file mode 100644 index 0000000..dfe0735 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Api/FloatplaneAuthService.cs @@ -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; + +/// +/// OAuth 2.0 device-code login against Floatplane's Keycloak +/// (auth.floatplane.com), the way Hydravion / Wasserflug / floatplane-downloader +/// log in. It needs a client_id registered by Floatplane; without one the server +/// answers invalid_client. Tokens are kept in the plugin configuration. DPoP-bound +/// clients are not supported (plain Bearer only). +/// +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 _logger; + private readonly SemaphoreSlim _refreshLock = new(1, 1); + private PendingDeviceLogin? _pending; + + public FloatplaneAuthService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration(); + + /// True when a refresh token is stored (OAuth login completed at some point). + public static bool HasOAuthSession => !string.IsNullOrEmpty(Config.OAuthRefreshToken); + + /// Starts a device login: returns the code and URL the user must open. + public async Task StartDeviceLoginAsync(string clientId, CancellationToken ct) + { + var form = new Dictionary { ["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(json); + throw new FloatplaneApiException(response.StatusCode, err?.Error, err?.ErrorDescription ?? "Device authorization failed"); + } + + var start = Parse(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; + } + + /// One poll of the token endpoint; "pending" until the user has approved. + public async Task 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 + { + ["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(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(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" }; + } + } + + /// Forgets the OAuth tokens (local only; Floatplane keeps the session until it expires). + public static void Logout() + { + var cfg = Config; + cfg.OAuthAccessToken = string.Empty; + cfg.OAuthRefreshToken = string.Empty; + cfg.OAuthAccessTokenExpiry = 0; + Plugin.Instance?.SaveConfiguration(); + } + + /// + /// 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. + /// + public async Task 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 + { + ["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(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(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 PostFormAsync(string url, Dictionary 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(string json) + { + try + { + return JsonSerializer.Deserialize(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; } +} diff --git a/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs b/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs index da02fcf..9d28261 100644 --- a/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs +++ b/Jellyfin.Plugin.Floatplane/Channel/CatalogService.cs @@ -50,6 +50,9 @@ public class CatalogService public DateTimeOffset LastRefresh => _creatorsFetched; + /// "oauth", "cookie" or "none". + public string SessionKind => _api.SessionKind; + /// Session state as last observed (null = not checked yet). public string? SessionState { get; private set; } @@ -83,7 +86,16 @@ public class CatalogService var done = 0; foreach (var creator in creators) { - var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List { string.Empty }; + var channelIds = creator.Channels.Select(c => c.Id).ToList(); + try + { + await GetAllPostsAsync(creator.Id, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Could not warm all-videos list for {Creator}", creator.Urlname); + } + foreach (var channelId in channelIds) { ct.ThrowIfCancellationRequested(); @@ -240,6 +252,22 @@ public class CatalogService return posts; } + /// Newest video posts across all of a creator's channels (the "All videos" folder). + public async Task> GetAllPostsAsync(string creatorId, CancellationToken ct) + { + var key = creatorId + "|*"; + var ttl = TimeSpan.FromMinutes(Math.Max(1, _config().CatalogCacheMinutes)); + if (_posts.TryGetValue(key, out var cached) && DateTimeOffset.UtcNow - cached.Fetched < ttl) + { + return cached.Posts; + } + + var max = Math.Clamp(_config().MaxPostsAllVideos, 20, 5000); + var posts = await FetchPostsAsync(creatorId, string.Empty, max, ct).ConfigureAwait(false); + _posts[key] = new CachedPosts(posts, DateTimeOffset.UtcNow); + return posts; + } + /// Pages through /content/creator until posts or the end. public async Task> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null) { diff --git a/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs b/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs index 8f2367e..e997ade 100644 --- a/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs +++ b/Jellyfin.Plugin.Floatplane/Channel/FloatplaneChannel.cs @@ -32,18 +32,22 @@ namespace Jellyfin.Plugin.Floatplane.Channel; public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia { private const string CreatorPrefix = "creator:"; + private const string AllPrefix = "all:"; private const string ChannelPrefix = "channel:"; private const string PostPrefix = "post:"; + private const string ChannelPostPrefix = "cpost:"; private readonly CatalogService _catalog; private readonly IServerApplicationHost _appHost; + private readonly SortNameUpdater _sortNames; private readonly ILogger _logger; private readonly Func _config; - public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger logger) + public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, SortNameUpdater sortNames, ILogger logger) { _catalog = catalog; _appHost = appHost; + _sortNames = sortNames; _logger = logger; _config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration(); } @@ -82,7 +86,9 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I }; /// - public IEnumerable GetSupportedChannelImages() => [ImageType.Primary, ImageType.Thumb]; + // Primary only: a channel Thumb would be inherited by every video card in "Continue + // watching" (the web client prefers parent thumbs there), hiding the video thumbnails. + public IEnumerable GetSupportedChannelImages() => [ImageType.Primary]; /// public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) @@ -111,6 +117,10 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I { items = await GetCreatorItemsAsync(folderId[CreatorPrefix.Length..], cancellationToken).ConfigureAwait(false); } + else if (folderId.StartsWith(AllPrefix, StringComparison.Ordinal)) + { + items = await GetAllVideoItemsAsync(folderId[AllPrefix.Length..], cancellationToken).ConfigureAwait(false); + } else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal)) { var rest = folderId[ChannelPrefix.Length..]; @@ -125,6 +135,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I items = new List(); } + if (items.Any(i => i.Type == ChannelItemType.Media)) + { + // Jellyfin creates the DB entities after this returns; fix their sort names then. + _sortNames.ScheduleSoon(); + } + items = ApplySort(items, query); var total = items.Count; if (query.StartIndex.HasValue && query.StartIndex.Value > 0) @@ -164,12 +180,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I /// public async Task> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken) { - if (!id.StartsWith(PostPrefix, StringComparison.Ordinal)) + var postId = PostIdOf(id); + if (postId is null) { return Array.Empty(); } - var postId = id[PostPrefix.Length..]; PostModel post; try { @@ -259,10 +275,23 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I if (creator.Channels.Count == 0) { - return await GetPostItemsAsync(creator.Id, string.Empty, ct).ConfigureAwait(false); + return await GetAllVideoItemsAsync(creator.Id, ct).ConfigureAwait(false); } - return creator.Channels.Select(ch => new ChannelItemInfo + var items = new List + { + new() + { + Id = AllPrefix + creator.Id, + Name = "All videos", + Type = ChannelItemType.Folder, + FolderType = ChannelFolderType.Container, + Overview = "Every video from " + creator.Title + " across all its channels, newest first.", + ImageUrl = creator.Card?.BestPath(1200) ?? creator.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600), + HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home" + } + }; + items.AddRange(creator.Channels.Select(ch => new ChannelItemInfo { Id = ChannelPrefix + creator.Id + ":" + ch.Id, Name = ch.Title, @@ -271,25 +300,62 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I Overview = ch.About, ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600), HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname - }).ToList(); + })); + return items; } + /// "All videos": every video post of the creator, newest first (canonical post: ids). + private async Task> GetAllVideoItemsAsync(string creatorId, CancellationToken ct) + { + var creator = _catalog.FindCreator(creatorId); + var posts = await _catalog.GetAllPostsAsync(creatorId, ct).ConfigureAwait(false); + var markLocked = _catalog.HasSession; + return posts.Select(p => ToItem(p, creator, markLocked)).ToList(); + } + + /// Sub-channel folder: the same posts under cpost: ids (one parent per item in Jellyfin). private async Task> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct) { var creator = _catalog.FindCreator(creatorId); var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false); // Anonymous listings flag everything as inaccessible; only mark locks when logged in. var markLocked = _catalog.HasSession; - return posts.Select(p => ToItem(p, creator, markLocked)).ToList(); + return posts.Select(p => ToItem(p, creator, markLocked, ChannelPostPrefix + channelId + ":")).ToList(); + } + + /// + /// Extracts the Floatplane post id from a channel item id (post:<id> or + /// cpost:<channelId>:<id>); null for folders / foreign ids. + /// + internal static string? PostIdOf(string? externalId) + { + if (string.IsNullOrEmpty(externalId)) + { + return null; + } + + if (externalId.StartsWith(PostPrefix, StringComparison.Ordinal)) + { + return externalId[PostPrefix.Length..]; + } + + if (externalId.StartsWith(ChannelPostPrefix, StringComparison.Ordinal)) + { + var rest = externalId[ChannelPostPrefix.Length..]; + var sep = rest.IndexOf(':', StringComparison.Ordinal); + return sep > 0 && sep < rest.Length - 1 ? rest[(sep + 1)..] : null; + } + + return null; } /// Maps a post to a channel item. Internal for tests. - internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true) + internal static ChannelItemInfo ToItem(PostModel post, CreatorModel? creator, bool markLocked = true, string idPrefix = PostPrefix) { var name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title; var item = new ChannelItemInfo { - Id = PostPrefix + post.Id, + Id = idPrefix + post.Id, Name = name, Type = ChannelItemType.Media, MediaType = ChannelMediaType.Video, diff --git a/Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs b/Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs new file mode 100644 index 0000000..c3c049c --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Channel/SortNameUpdater.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Channel; + +/// +/// Every Jellyfin client lists a channel folder sorted by name by default, and the web +/// client stores a sort choice per folder. To get release order without touching every +/// folder, each video's ForcedSortName is set to its release time reversed +/// ((10^13 - unixMs), zero-padded), so "Name ascending" = newest first. +/// Runs a few seconds after a listing (the items only exist in the database after +/// Jellyfin has synced them) and during the scheduled refresh. +/// +public class SortNameUpdater +{ + private const long Epoch = 10_000_000_000_000; // > any unix ms this side of year 2286 + + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private int _scheduled; + + public SortNameUpdater(ILibraryManager libraryManager, ILogger logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + /// Sort name for a release date: newest first under ascending name order. + public static string SortNameFor(DateTime? releaseDate) + { + var ms = releaseDate.HasValue ? new DateTimeOffset(DateTime.SpecifyKind(releaseDate.Value, DateTimeKind.Utc)).ToUnixTimeMilliseconds() : 0; + return (Epoch - 1 - Math.Clamp(ms, 0, Epoch - 1)).ToString("D13", CultureInfo.InvariantCulture); + } + + /// Schedules one pass shortly after a listing (coalesces bursts of calls). + public void ScheduleSoon() + { + if (Interlocked.CompareExchange(ref _scheduled, 1, 0) != 0) + { + return; + } + + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Interlocked.Exchange(ref _scheduled, 0); + await UpdateAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + Interlocked.Exchange(ref _scheduled, 0); + _logger.LogWarning(ex, "Sort-name update failed"); + } + }); + } + + /// Sets the forced sort name on every Floatplane video item that lacks the right one. + public async Task UpdateAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + var channelIds = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Channel], + Name = "Floatplane" + }).Select(c => c.Id).ToArray(); + if (channelIds.Length == 0) + { + return 0; + } + + var items = _libraryManager.GetItemList(new InternalItemsQuery + { + ChannelIds = channelIds, + IncludeItemTypes = [BaseItemKind.Video], + Recursive = true + }); + + var changed = new List(); + foreach (var item in items) + { + if (FloatplaneChannel.PostIdOf(item.ExternalId) is null) + { + continue; + } + + var wanted = SortNameFor(item.PremiereDate ?? item.DateCreated); + if (!string.Equals(item.ForcedSortName, wanted, StringComparison.Ordinal)) + { + item.ForcedSortName = wanted; + changed.Add(item); + } + } + + if (changed.Count > 0) + { + await _libraryManager.UpdateItemsAsync(changed, changed[0].GetParent(), ItemUpdateType.MetadataEdit, ct).ConfigureAwait(false); + _logger.LogInformation("Set release-order sort names on {Count} Floatplane videos", changed.Count); + } + + return changed.Count; + } + finally + { + _gate.Release(); + } + } +} diff --git a/Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs b/Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs new file mode 100644 index 0000000..f555178 --- /dev/null +++ b/Jellyfin.Plugin.Floatplane/Channel/UserDataMirror.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Floatplane.Channel; + +/// +/// A post is listed twice — under "All videos" (post:…) and under its sub-channel +/// (cpost:…) — because Jellyfin gives a channel item exactly one parent. Jellyfin +/// therefore keeps two sets of user data; this service copies played / resume state from +/// one copy to the other whenever it changes (pattern borrowed from chaosflix-jellyfin). +/// +public sealed class UserDataMirror : IHostedService +{ + private readonly IUserDataManager _userDataManager; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly ILogger _logger; + + // UserDataSaved is raised from inside SaveUserData, so our own mirrored save comes + // back as an event; the copies being written are tracked here while it runs. + private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new(); + + public UserDataMirror(IUserDataManager userDataManager, ILibraryManager libraryManager, IUserManager userManager, ILogger logger) + { + _userDataManager = userDataManager; + _libraryManager = libraryManager; + _userManager = userManager; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved += OnUserDataSaved; + _logger.LogDebug("Floatplane user-data mirror active"); + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _userDataManager.UserDataSaved -= OnUserDataSaved; + return Task.CompletedTask; + } + + private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) + { + try + { + Mirror(e); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not mirror watch state of {Item}", e.Item?.Name); + } + } + + private void Mirror(UserDataSaveEventArgs e) + { + var item = e.Item; + var source = e.UserData; + if (item is null || source is null || !IsWatchState(e.SaveReason)) + { + return; + } + + var postId = FloatplaneChannel.PostIdOf(item.ExternalId); + if (postId is null || _mirroring.ContainsKey((e.UserId, item.Id))) + { + return; + } + + var user = _userManager.GetUserById(e.UserId); + if (user is null) + { + return; + } + + foreach (var sibling in Siblings(item, postId)) + { + var target = _userDataManager.GetUserData(user, sibling); + if (target is null || !Differs(source, target)) + { + continue; + } + + target.Played = source.Played; + target.PlaybackPositionTicks = source.PlaybackPositionTicks; + target.PlayCount = source.PlayCount; + target.LastPlayedDate = source.LastPlayedDate; + + if (!_mirroring.TryAdd((e.UserId, sibling.Id), 0)) + { + continue; + } + + try + { + _userDataManager.SaveUserData(user, sibling, target, UserDataSaveReason.UpdateUserData, CancellationToken.None); + _logger.LogDebug("Mirrored watch state of {Post} to its other copy", postId); + } + finally + { + _mirroring.TryRemove((e.UserId, sibling.Id), out _); + } + } + } + + private static bool IsWatchState(UserDataSaveReason reason) => reason is + UserDataSaveReason.PlaybackStart or + UserDataSaveReason.PlaybackProgress or + UserDataSaveReason.PlaybackFinished or + UserDataSaveReason.TogglePlayed or + UserDataSaveReason.UpdateUserData; + + private static bool Differs(UserItemData source, UserItemData target) => + source.Played != target.Played || + source.PlaybackPositionTicks != target.PlaybackPositionTicks || + source.PlayCount != target.PlayCount || + source.LastPlayedDate != target.LastPlayedDate; + + private IEnumerable Siblings(BaseItem item, string postId) + { + var query = new InternalItemsQuery + { + Name = item.Name, + ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty() : new[] { item.ChannelId } + }; + + IReadOnlyList candidates; + try + { + candidates = _libraryManager.GetItemList(query); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not look up the other copy of {Item}", item.Name); + return Array.Empty(); + } + + return candidates + .Where(c => !c.Id.Equals(item.Id) && string.Equals(FloatplaneChannel.PostIdOf(c.ExternalId), postId, StringComparison.Ordinal)) + // Through the library cache so the instance the server serves gets the new data too. + .Select(c => _libraryManager.GetItemById(c.Id) ?? c) + .ToList(); + } +} diff --git a/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs index a5ed3d0..6dbd188 100644 --- a/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Floatplane/Configuration/PluginConfiguration.cs @@ -32,9 +32,27 @@ public class PluginConfiguration : BasePluginConfiguration /// Highest level offered as a version at all (keeps 4K out of the list by default). public int MaxHeight { get; set; } = 1080; - /// How many posts (newest first) are listed per Floatplane channel. + /// How many posts (newest first) are listed per Floatplane sub-channel. public int MaxPostsPerChannel { get; set; } = 100; + /// How many posts (newest first) the per-creator "All videos" folder lists. + public int MaxPostsAllVideos { get; set; } = 200; + + /// + /// OAuth client id registered with Floatplane for this plugin. Empty = OAuth login + /// unavailable (use the cookie). See the README for how to obtain one. + /// + public string OAuthClientId { get; set; } = string.Empty; + + /// OAuth access token (short-lived, refreshed automatically). + public string OAuthAccessToken { get; set; } = string.Empty; + + /// OAuth refresh token (≈90 days, rotated on refresh). Presence = logged in via OAuth. + public string OAuthRefreshToken { get; set; } = string.Empty; + + /// Unix time (seconds) when the access token expires. + public long OAuthAccessTokenExpiry { get; set; } + /// Show posts the account cannot access (higher tier) as locked items. public bool ShowLockedPosts { get; set; } @@ -59,6 +77,9 @@ public class PluginConfiguration : BasePluginConfiguration /// Hours between automatic catalogue refreshes (scheduled task). public int SyncIntervalHours { get; set; } = 6; + /// Version of the channel logo Jellyfin last stored (internal; drives a one-time image refresh). + public int ChannelImageVersion { get; set; } + /// Report watch progress back to Floatplane (not implemented yet). public bool SyncProgressToFloatplane { get; set; } } diff --git a/Jellyfin.Plugin.Floatplane/Configuration/configPage.html b/Jellyfin.Plugin.Floatplane/Configuration/configPage.html index f8ea473..58873fa 100644 --- a/Jellyfin.Plugin.Floatplane/Configuration/configPage.html +++ b/Jellyfin.Plugin.Floatplane/Configuration/configPage.html @@ -30,6 +30,23 @@ + +

Login with Floatplane (OAuth, optional)

+
+ Floatplane's own apps and third-party clients log in through an OAuth device flow instead of a cookie. + That needs a client id issued by Floatplane for this plugin — there is no self-service registration. + If you have one, enter it and press "Login with Floatplane"; the OAuth session then replaces the cookie + (refresh tokens last about 90 days and renew themselves). +
+
+ + +
+
+ + +
+
@@ -40,9 +57,14 @@
Shown in addition to your subscriptions — or instead of them when no cookie is set (public listings only, no playback).
- + + +
Each creator gets an "All videos" folder with every channel's videos in release order, newest first. Floatplane pages 20 at a time, so 200 = 10 requests.
+
+
+ -
Newest first. Floatplane pages 20 at a time, so 100 = 5 requests per channel.
+
Newest first, per sub-channel folder (TechLinked, ShortCircuit, …).