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

@@ -6,7 +6,7 @@ name: release
# - tagg vX.Y.Z -> versionerad release (plugin-version X.Y.Z.0) # - tagg vX.Y.Z -> versionerad release (plugin-version X.Y.Z.0)
# - workflow_dispatch -> valfri tag (tom = latest) # - workflow_dispatch -> valfri tag (tom = latest)
# Assets: floatplane_<ver>.zip (+.md5), manifest.json (Jellyfins plugin- # Assets: floatplane_<ver>.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: # 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 # 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. Sedan Plugins -> Catalog -> Floatplane -> Install, starta om Jellyfin.
Manuell installation pa en host med linuxserver/jellyfin-containern: 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.) (TAG=... JELLYFIN_CONFIG_DIR=... JELLYFIN_CONTAINER=... och --dry-run stods, se skriptet.)
Efter installation: Dashboard -> Plugins -> Floatplane -> klistra in sails.sid-cookien." Efter installation: Dashboard -> Plugins -> Floatplane -> klistra in sails.sid-cookien."
@@ -159,7 +159,8 @@ jobs:
upload "$rid" "$ZIP" "build/$ZIP" upload "$rid" "$ZIP" "build/$ZIP"
upload "$rid" "$ZIP.md5" "build/$ZIP.md5" upload "$rid" "$ZIP.md5" "build/$ZIP.md5"
upload "$rid" "manifest.json" "build/manifest.json" 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" echo "klart: https://gitea.brasse-pc.eu/$REPO/releases/tag/$TAG"
# --- manifest.json aven till "latest" vid taggad release -------------- # --- manifest.json aven till "latest" vid taggad release --------------
@@ -175,7 +176,8 @@ jobs:
fi fi
if [ -n "$lid" ] && [ "$lid" != "null" ]; then if [ -n "$lid" ] && [ "$lid" != "null" ]; then
upload "$lid" "manifest.json" "build/manifest.json.keep" 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" echo "manifest.json uppdaterat pa latest: $REPO_URL/releases/download/latest/manifest.json"
else else
echo "::warning::kunde inte hitta/skapa latest-releasen for manifest.json" echo "::warning::kunde inte hitta/skapa latest-releasen for manifest.json"

View File

@@ -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<List<PostModel>>("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);
}
}

View File

@@ -18,18 +18,90 @@ namespace Jellyfin.Plugin.Floatplane.Api;
public class FloatplaneAdminController : ControllerBase public class FloatplaneAdminController : ControllerBase
{ {
private readonly FloatplaneApiClient _api; private readonly FloatplaneApiClient _api;
private readonly FloatplaneAuthService _auth;
private readonly CatalogService _catalog; private readonly CatalogService _catalog;
private readonly DeliveryResolver _delivery; private readonly DeliveryResolver _delivery;
private readonly ILogger<FloatplaneAdminController> _logger; 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; _api = api;
_auth = auth;
_catalog = catalog; _catalog = catalog;
_delivery = delivery; _delivery = delivery;
_logger = logger; _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> /// <summary>Validates a session cookie (without saving it) and lists the subscriptions it has.</summary>
[HttpPost("Session/Validate")] [HttpPost("Session/Validate")]
public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request) public async Task<ActionResult<SessionValidationResult>> ValidateSession([FromBody] SessionValidationRequest request)
@@ -78,6 +150,8 @@ public class FloatplaneAdminController : ControllerBase
{ {
Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?", Version = typeof(Plugin).Assembly.GetName().Version?.ToString() ?? "?",
HasSessionCookie = _api.HasSession, HasSessionCookie = _api.HasSession,
SessionKind = _api.SessionKind,
OAuthClientId = Plugin.Instance?.Configuration.OAuthClientId ?? string.Empty,
SessionState = _catalog.SessionState ?? "unknown", SessionState = _catalog.SessionState ?? "unknown",
SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty, SessionUsername = Plugin.Instance?.Configuration.SessionUsername ?? string.Empty,
LastError = _catalog.LastError, LastError = _catalog.LastError,
@@ -131,6 +205,11 @@ public class FloatplaneAdminController : ControllerBase
=> _api.GetTagsAsync(creatorId, HttpContext.RequestAborted); => _api.GetTagsAsync(creatorId, HttpContext.RequestAborted);
} }
public class OAuthStartRequest
{
public string? ClientId { get; set; }
}
public class SessionValidationRequest public class SessionValidationRequest
{ {
public string? Cookie { get; set; } public string? Cookie { get; set; }
@@ -155,6 +234,11 @@ public class PluginStatus
public bool HasSessionCookie { get; set; } 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 SessionState { get; set; } = string.Empty;
public string SessionUsername { 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 IHttpClientFactory _httpClientFactory;
private readonly ILogger<FloatplaneApiClient> _logger; private readonly ILogger<FloatplaneApiClient> _logger;
private readonly Func<PluginConfiguration> _config; 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()) : 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) public FloatplaneApiClient(IHttpClientFactory httpClientFactory, ILogger<FloatplaneApiClient> logger, Func<PluginConfiguration> config)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
@@ -41,8 +43,11 @@ public class FloatplaneApiClient
_config = config; _config = config;
} }
/// <summary>True when a session cookie is configured (not necessarily valid).</summary> /// <summary>True when a session (cookie or OAuth refresh token) is configured — not necessarily valid.</summary>
public bool HasSession => !string.IsNullOrWhiteSpace(_config().SessionCookie); 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 ───────────────────────────── // ───────────────────────────── auth / user ─────────────────────────────
@@ -177,7 +182,7 @@ public class FloatplaneApiClient
public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct) public async Task PostProgressAsync(string videoId, int seconds, CancellationToken ct)
{ {
var body = JsonSerializer.Serialize(new { id = videoId, contentType = "video", progress = seconds }); 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"); request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await SendAsync(request, ct).ConfigureAwait(false); using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false); await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
@@ -205,7 +210,7 @@ public class FloatplaneApiClient
/// </summary> /// </summary>
public async Task<byte[]> GetWatchKeyAsync(string keyUrl, CancellationToken ct) 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 + "/"); request.Headers.Referrer = new Uri(BaseUrl + "/");
using var response = await SendAsync(request, ct).ConfigureAwait(false); using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false); await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
@@ -228,6 +233,23 @@ public class FloatplaneApiClient
// ───────────────────────────── plumbing ───────────────────────────── // ───────────────────────────── 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) internal HttpRequestMessage BuildRequest(HttpMethod method, string url, Dictionary<string, string>? query, string? cookieOverride)
{ {
if (query is { Count: > 0 }) 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) 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); using var response = await SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false); await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(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; }
}

View File

@@ -50,6 +50,9 @@ public class CatalogService
public DateTimeOffset LastRefresh => _creatorsFetched; public DateTimeOffset LastRefresh => _creatorsFetched;
/// <summary>"oauth", "cookie" or "none".</summary>
public string SessionKind => _api.SessionKind;
/// <summary>Session state as last observed (null = not checked yet).</summary> /// <summary>Session state as last observed (null = not checked yet).</summary>
public string? SessionState { get; private set; } public string? SessionState { get; private set; }
@@ -83,7 +86,16 @@ public class CatalogService
var done = 0; var done = 0;
foreach (var creator in creators) foreach (var creator in creators)
{ {
var channelIds = creator.Channels.Count > 0 ? creator.Channels.Select(c => c.Id).ToList() : new List<string> { 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) foreach (var channelId in channelIds)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -240,6 +252,22 @@ public class CatalogService
return posts; return posts;
} }
/// <summary>Newest video posts across all of a creator's channels (the "All videos" folder).</summary>
public async Task<IReadOnlyList<PostModel>> 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;
}
/// <summary>Pages through /content/creator until <paramref name="max"/> posts or the end.</summary> /// <summary>Pages through /content/creator until <paramref name="max"/> posts or the end.</summary>
public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null) public async Task<IReadOnlyList<PostModel>> FetchPostsAsync(string creatorId, string channelId, int max, CancellationToken ct, string? search = null)
{ {

View File

@@ -32,18 +32,22 @@ namespace Jellyfin.Plugin.Floatplane.Channel;
public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia
{ {
private const string CreatorPrefix = "creator:"; private const string CreatorPrefix = "creator:";
private const string AllPrefix = "all:";
private const string ChannelPrefix = "channel:"; private const string ChannelPrefix = "channel:";
private const string PostPrefix = "post:"; private const string PostPrefix = "post:";
private const string ChannelPostPrefix = "cpost:";
private readonly CatalogService _catalog; private readonly CatalogService _catalog;
private readonly IServerApplicationHost _appHost; private readonly IServerApplicationHost _appHost;
private readonly SortNameUpdater _sortNames;
private readonly ILogger<FloatplaneChannel> _logger; private readonly ILogger<FloatplaneChannel> _logger;
private readonly Func<PluginConfiguration> _config; private readonly Func<PluginConfiguration> _config;
public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, ILogger<FloatplaneChannel> logger) public FloatplaneChannel(CatalogService catalog, IServerApplicationHost appHost, SortNameUpdater sortNames, ILogger<FloatplaneChannel> logger)
{ {
_catalog = catalog; _catalog = catalog;
_appHost = appHost; _appHost = appHost;
_sortNames = sortNames;
_logger = logger; _logger = logger;
_config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration(); _config = () => Plugin.Instance?.Configuration ?? new PluginConfiguration();
} }
@@ -82,7 +86,9 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
}; };
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<ImageType> 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<ImageType> GetSupportedChannelImages() => [ImageType.Primary];
/// <inheritdoc /> /// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken) public Task<DynamicImageResponse> 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); 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)) else if (folderId.StartsWith(ChannelPrefix, StringComparison.Ordinal))
{ {
var rest = folderId[ChannelPrefix.Length..]; var rest = folderId[ChannelPrefix.Length..];
@@ -125,6 +135,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
items = new List<ChannelItemInfo>(); items = new List<ChannelItemInfo>();
} }
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); items = ApplySort(items, query);
var total = items.Count; var total = items.Count;
if (query.StartIndex.HasValue && query.StartIndex.Value > 0) if (query.StartIndex.HasValue && query.StartIndex.Value > 0)
@@ -164,12 +180,12 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
/// <inheritdoc /> /// <inheritdoc />
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken) public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
{ {
if (!id.StartsWith(PostPrefix, StringComparison.Ordinal)) var postId = PostIdOf(id);
if (postId is null)
{ {
return Array.Empty<MediaSourceInfo>(); return Array.Empty<MediaSourceInfo>();
} }
var postId = id[PostPrefix.Length..];
PostModel post; PostModel post;
try try
{ {
@@ -259,10 +275,23 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
if (creator.Channels.Count == 0) 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<ChannelItemInfo>
{
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, Id = ChannelPrefix + creator.Id + ":" + ch.Id,
Name = ch.Title, Name = ch.Title,
@@ -271,25 +300,62 @@ public partial class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, I
Overview = ch.About, Overview = ch.About,
ImageUrl = ch.Icon?.BestPath(600) ?? ch.Card?.BestPath(1200) ?? ch.Cover?.BestPath(1200) ?? creator.Icon?.BestPath(600), 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 HomePageUrl = "https://www.floatplane.com/channel/" + creator.Urlname + "/home/" + ch.Urlname
}).ToList(); }));
return items;
} }
/// <summary>"All videos": every video post of the creator, newest first (canonical <c>post:</c> ids).</summary>
private async Task<List<ChannelItemInfo>> 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();
}
/// <summary>Sub-channel folder: the same posts under <c>cpost:</c> ids (one parent per item in Jellyfin).</summary>
private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct) private async Task<List<ChannelItemInfo>> GetPostItemsAsync(string creatorId, string channelId, CancellationToken ct)
{ {
var creator = _catalog.FindCreator(creatorId); var creator = _catalog.FindCreator(creatorId);
var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false); var posts = await _catalog.GetPostsAsync(creatorId, channelId, ct).ConfigureAwait(false);
// Anonymous listings flag everything as inaccessible; only mark locks when logged in. // Anonymous listings flag everything as inaccessible; only mark locks when logged in.
var markLocked = _catalog.HasSession; var markLocked = _catalog.HasSession;
return posts.Select(p => ToItem(p, creator, markLocked)).ToList(); return posts.Select(p => ToItem(p, creator, markLocked, ChannelPostPrefix + channelId + ":")).ToList();
}
/// <summary>
/// Extracts the Floatplane post id from a channel item id (<c>post:&lt;id&gt;</c> or
/// <c>cpost:&lt;channelId&gt;:&lt;id&gt;</c>); null for folders / foreign ids.
/// </summary>
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;
} }
/// <summary>Maps a post to a channel item. Internal for tests.</summary> /// <summary>Maps a post to a channel item. Internal for tests.</summary>
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 name = post.IsAccessible || !markLocked ? post.Title : "🔒 " + post.Title;
var item = new ChannelItemInfo var item = new ChannelItemInfo
{ {
Id = PostPrefix + post.Id, Id = idPrefix + post.Id,
Name = name, Name = name,
Type = ChannelItemType.Media, Type = ChannelItemType.Media,
MediaType = ChannelMediaType.Video, MediaType = ChannelMediaType.Video,

View File

@@ -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;
/// <summary>
/// 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 <c>ForcedSortName</c> is set to its release time reversed
/// (<c>(10^13 - unixMs)</c>, 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.
/// </summary>
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<SortNameUpdater> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private int _scheduled;
public SortNameUpdater(ILibraryManager libraryManager, ILogger<SortNameUpdater> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>Sort name for a release date: newest first under ascending name order.</summary>
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);
}
/// <summary>Schedules one pass shortly after a listing (coalesces bursts of calls).</summary>
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");
}
});
}
/// <summary>Sets the forced sort name on every Floatplane video item that lacks the right one.</summary>
public async Task<int> 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<BaseItem>();
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();
}
}
}

View File

@@ -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;
/// <summary>
/// A post is listed twice — under "All videos" (<c>post:…</c>) and under its sub-channel
/// (<c>cpost:…</c>) — 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).
/// </summary>
public sealed class UserDataMirror : IHostedService
{
private readonly IUserDataManager _userDataManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly ILogger<UserDataMirror> _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<UserDataMirror> logger)
{
_userDataManager = userDataManager;
_libraryManager = libraryManager;
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved += OnUserDataSaved;
_logger.LogDebug("Floatplane user-data mirror active");
return Task.CompletedTask;
}
/// <inheritdoc />
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<BaseItem> Siblings(BaseItem item, string postId)
{
var query = new InternalItemsQuery
{
Name = item.Name,
ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty<Guid>() : new[] { item.ChannelId }
};
IReadOnlyList<BaseItem> 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<BaseItem>();
}
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();
}
}

View File

@@ -32,9 +32,27 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary>Highest level offered as a version at all (keeps 4K out of the list by default).</summary> /// <summary>Highest level offered as a version at all (keeps 4K out of the list by default).</summary>
public int MaxHeight { get; set; } = 1080; public int MaxHeight { get; set; } = 1080;
/// <summary>How many posts (newest first) are listed per Floatplane channel.</summary> /// <summary>How many posts (newest first) are listed per Floatplane sub-channel.</summary>
public int MaxPostsPerChannel { get; set; } = 100; public int MaxPostsPerChannel { get; set; } = 100;
/// <summary>How many posts (newest first) the per-creator "All videos" folder lists.</summary>
public int MaxPostsAllVideos { get; set; } = 200;
/// <summary>
/// OAuth client id registered with Floatplane for this plugin. Empty = OAuth login
/// unavailable (use the cookie). See the README for how to obtain one.
/// </summary>
public string OAuthClientId { get; set; } = string.Empty;
/// <summary>OAuth access token (short-lived, refreshed automatically).</summary>
public string OAuthAccessToken { get; set; } = string.Empty;
/// <summary>OAuth refresh token (≈90 days, rotated on refresh). Presence = logged in via OAuth.</summary>
public string OAuthRefreshToken { get; set; } = string.Empty;
/// <summary>Unix time (seconds) when the access token expires.</summary>
public long OAuthAccessTokenExpiry { get; set; }
/// <summary>Show posts the account cannot access (higher tier) as locked items.</summary> /// <summary>Show posts the account cannot access (higher tier) as locked items.</summary>
public bool ShowLockedPosts { get; set; } public bool ShowLockedPosts { get; set; }
@@ -59,6 +77,9 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary>Hours between automatic catalogue refreshes (scheduled task).</summary> /// <summary>Hours between automatic catalogue refreshes (scheduled task).</summary>
public int SyncIntervalHours { get; set; } = 6; public int SyncIntervalHours { get; set; } = 6;
/// <summary>Version of the channel logo Jellyfin last stored (internal; drives a one-time image refresh).</summary>
public int ChannelImageVersion { get; set; }
/// <summary>Report watch progress back to Floatplane (not implemented yet).</summary> /// <summary>Report watch progress back to Floatplane (not implemented yet).</summary>
public bool SyncProgressToFloatplane { get; set; } public bool SyncProgressToFloatplane { get; set; }
} }

View File

@@ -30,6 +30,23 @@
<button is="emby-button" type="button" id="btnValidate" class="raised"><span>Validate cookie</span></button> <button is="emby-button" type="button" id="btnValidate" class="raised"><span>Validate cookie</span></button>
<span id="validateResult" class="fieldDescription" style="margin-left:1em"></span> <span id="validateResult" class="fieldDescription" style="margin-left:1em"></span>
</div> </div>
<h3 class="sectionTitle" style="margin-top:1.5em">Login with Floatplane (OAuth, optional)</h3>
<div class="fieldDescription" style="margin-bottom:0.5em">
Floatplane's own apps and third-party clients log in through an OAuth device flow instead of a cookie.
That needs a <b>client id</b> 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).
</div>
<div class="inputContainer">
<label class="inputLabel" for="OAuthClientId">OAuth client id</label>
<input is="emby-input" type="text" id="OAuthClientId" placeholder="(none)" />
</div>
<div>
<button is="emby-button" type="button" id="btnOAuth" class="raised"><span>Login with Floatplane</span></button>
<button is="emby-button" type="button" id="btnOAuthLogout" class="raised" hidden><span>Forget OAuth session</span></button>
<div id="oauthResult" class="fieldDescription" style="margin-top:0.5em"></div>
</div>
</div> </div>
<div class="verticalSection"> <div class="verticalSection">
@@ -40,9 +57,14 @@
<div class="fieldDescription">Shown in addition to your subscriptions — or instead of them when no cookie is set (public listings only, no playback).</div> <div class="fieldDescription">Shown in addition to your subscriptions — or instead of them when no cookie is set (public listings only, no playback).</div>
</div> </div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel" for="MaxPostsPerChannel">Videos listed per channel</label> <label class="inputLabel" for="MaxPostsAllVideos">Videos listed in "All videos"</label>
<input is="emby-input" type="number" id="MaxPostsAllVideos" min="20" max="5000" step="20" />
<div class="fieldDescription">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.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="MaxPostsPerChannel">Videos listed per sub-channel</label>
<input is="emby-input" type="number" id="MaxPostsPerChannel" min="20" max="2000" step="20" /> <input is="emby-input" type="number" id="MaxPostsPerChannel" min="20" max="2000" step="20" />
<div class="fieldDescription">Newest first. Floatplane pages 20 at a time, so 100 = 5 requests per channel.</div> <div class="fieldDescription">Newest first, per sub-channel folder (TechLinked, ShortCircuit, …).</div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
@@ -126,8 +148,8 @@
<script type="text/javascript"> <script type="text/javascript">
(function () { (function () {
var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b"; var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "CatalogCacheMinutes", "SyncIntervalHours", var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "MaxPostsAllVideos", "CatalogCacheMinutes", "SyncIntervalHours",
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl"]; "PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl", "OAuthClientId"];
var checks = ["ShowLockedPosts", "EnableMp4Sources"]; var checks = ["ShowLockedPosts", "EnableMp4Sources"];
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
@@ -138,15 +160,17 @@
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) { ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) {
var lines = []; var lines = [];
lines.push("Plugin " + esc(s.Version)); lines.push("Plugin " + esc(s.Version));
var kind = s.SessionKind === "oauth" ? "OAuth" : "cookie";
if (!s.HasSessionCookie) { if (!s.HasSessionCookie) {
lines.push("Session: <b>no cookie</b> — browsing only."); lines.push("Session: <b>none</b> — browsing only (public listings); paste a cookie or log in to play.");
} else if (s.SessionState === "ok") { } else if (s.SessionState === "ok") {
lines.push("Session: <b>ok</b>" + (s.SessionUsername ? " (" + esc(s.SessionUsername) + ")" : "")); lines.push("Session: <b>ok</b> via " + kind + (s.SessionUsername ? " (" + esc(s.SessionUsername) + ")" : ""));
} else if (s.SessionState === "invalid") { } else if (s.SessionState === "invalid") {
lines.push("Session: <b style='color:#e55'>rejected</b> — paste a fresh cookie. " + esc(s.LastError || "")); lines.push("Session (" + kind + "): <b style='color:#e55'>rejected</b> — " + (s.SessionKind === "oauth" ? "log in again." : "paste a fresh cookie. ") + esc(s.LastError || ""));
} else { } else {
lines.push("Session: cookie set, not checked yet."); lines.push("Session: " + kind + " configured, not checked yet.");
} }
el("btnOAuthLogout").hidden = s.SessionKind !== "oauth";
lines.push("Creators: " + s.Creators.length + (s.LastRefresh ? " (refreshed " + new Date(s.LastRefresh).toLocaleString() + ")" : " (not loaded yet)")); lines.push("Creators: " + s.Creators.length + (s.LastRefresh ? " (refreshed " + new Date(s.LastRefresh).toLocaleString() + ")" : " (not loaded yet)"));
s.Creators.forEach(function (c) { lines.push("&nbsp;&nbsp;• " + esc(c.Title) + " — " + esc(c.Channels.join(", "))); }); s.Creators.forEach(function (c) { lines.push("&nbsp;&nbsp;• " + esc(c.Title) + " — " + esc(c.Channels.join(", "))); });
lines.push("Cached post lists: " + s.CachedPostLists + ", cached deliveries: " + s.CachedDeliveries + ", generation " + s.Generation); lines.push("Cached post lists: " + s.CachedPostLists + ", cached deliveries: " + s.CachedDeliveries + ", generation " + s.Generation);
@@ -213,6 +237,44 @@
}); });
} }
var oauthTimer = null;
function oauthStart() {
var out = el("oauthResult");
var clientId = el("OAuthClientId").value.trim();
if (!clientId) { out.textContent = "Enter the client id Floatplane issued for this plugin first."; return; }
out.textContent = "Contacting auth.floatplane.com…";
ApiClient.ajax({
type: "POST", url: ApiClient.getUrl("Floatplane/OAuth/Start"),
data: JSON.stringify({ ClientId: clientId }), contentType: "application/json", dataType: "json"
}).then(function (r) {
var link = r.VerificationUriComplete || r.VerificationUri;
out.innerHTML = "Open <a href='" + esc(link) + "' target='_blank' rel='noopener'>" + esc(link) + "</a> and enter code <b style='font-size:1.3em'>" + esc(r.UserCode) + "</b> (valid " + Math.round(r.ExpiresIn / 60) + " min). Waiting…";
var interval = (r.Interval || 5) * 1000;
clearInterval(oauthTimer);
oauthTimer = setInterval(function () {
ApiClient.getJSON(ApiClient.getUrl("Floatplane/OAuth/Poll")).then(function (s) {
if (s.State === "pending") { return; }
clearInterval(oauthTimer);
out.textContent = s.Message || s.State;
out.style.color = s.State === "done" ? "#4c4" : "#e55";
if (s.State === "done") { loadStatus(); }
}).catch(function () { clearInterval(oauthTimer); out.textContent = "Polling failed."; });
}, interval);
}).catch(function (err) {
var msg = "Start failed";
try { msg += ": " + (err.responseText || ("HTTP " + err.status)); } catch (e) { /* ignore */ }
out.textContent = msg + " — usually the client id is not registered with Floatplane (invalid_client).";
out.style.color = "#e55";
});
}
function oauthLogout() {
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/OAuth/Logout") }).then(function () {
el("oauthResult").textContent = "OAuth session forgotten.";
loadStatus();
});
}
function refresh() { function refresh() {
el("fpStatus").textContent = "Refreshing…"; el("fpStatus").textContent = "Refreshing…";
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus); ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
@@ -238,6 +300,8 @@
el("FloatplaneConfigForm").addEventListener("submit", saveConfig); el("FloatplaneConfigForm").addEventListener("submit", saveConfig);
el("btnValidate").addEventListener("click", validate); el("btnValidate").addEventListener("click", validate);
el("btnRefresh").addEventListener("click", refresh); el("btnRefresh").addEventListener("click", refresh);
el("btnOAuth").addEventListener("click", oauthStart);
el("btnOAuthLogout").addEventListener("click", oauthLogout);
el("btnSearch").addEventListener("click", search); el("btnSearch").addEventListener("click", search);
})(); })();
</script> </script>

View File

@@ -32,6 +32,8 @@
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Resources\logo.png" /> <None Remove="Resources\logo.png" />
<EmbeddedResource Include="Resources\logo.png" /> <EmbeddedResource Include="Resources\logo.png" />
<None Remove="Resources\thumb.png" />
<EmbeddedResource Include="Resources\thumb.png" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -38,13 +38,19 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
AutomaticDecompression = System.Net.DecompressionMethods.All AutomaticDecompression = System.Net.DecompressionMethods.All
}); });
serviceCollection.AddSingleton<FloatplaneAuthService>();
serviceCollection.AddSingleton<FloatplaneApiClient>(); serviceCollection.AddSingleton<FloatplaneApiClient>();
serviceCollection.AddSingleton<CatalogService>(); serviceCollection.AddSingleton<CatalogService>();
serviceCollection.AddSingleton<DeliveryResolver>(); serviceCollection.AddSingleton<DeliveryResolver>();
// One instance: the status endpoint and the sync task must see the same caches // One instance: the status endpoint and the sync task must see the same caches
// as the channel Jellyfin serves from. // as the channel Jellyfin serves from.
serviceCollection.AddSingleton<SortNameUpdater>();
serviceCollection.AddSingleton<FloatplaneChannel>(); serviceCollection.AddSingleton<FloatplaneChannel>();
serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>()); serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>());
// The same post appears under "All videos" and under its sub-channel (Jellyfin
// allows one parent per item id); this keeps watched/resume state in sync.
serviceCollection.AddHostedService<UserDataMirror>();
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -2,7 +2,14 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.Floatplane.Channel; using Jellyfin.Plugin.Floatplane.Channel;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,12 +21,23 @@ namespace Jellyfin.Plugin.Floatplane.Tasks;
/// </summary> /// </summary>
public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
{ {
/// <summary>Bump when Resources/logo.png changes so existing installs get the new channel image.</summary>
public const int ChannelImageVersion = 3;
private readonly CatalogService _catalog; private readonly CatalogService _catalog;
private readonly SortNameUpdater _sortNames;
private readonly ILibraryManager _libraryManager;
private readonly IProviderManager _providerManager;
private readonly IFileSystem _fileSystem;
private readonly ILogger<FloatplaneSyncTask> _logger; private readonly ILogger<FloatplaneSyncTask> _logger;
public FloatplaneSyncTask(CatalogService catalog, ILogger<FloatplaneSyncTask> logger) public FloatplaneSyncTask(CatalogService catalog, SortNameUpdater sortNames, ILibraryManager libraryManager, IProviderManager providerManager, IFileSystem fileSystem, ILogger<FloatplaneSyncTask> logger)
{ {
_catalog = catalog; _catalog = catalog;
_sortNames = sortNames;
_libraryManager = libraryManager;
_providerManager = providerManager;
_fileSystem = fileSystem;
_logger = logger; _logger = logger;
} }
@@ -50,6 +68,58 @@ public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
_logger.LogInformation("Floatplane sync started"); _logger.LogInformation("Floatplane sync started");
await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false); await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation); _logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation);
await UpdateChannelImageIfNeededAsync(cancellationToken).ConfigureAwait(false);
await _sortNames.UpdateAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Jellyfin stores the channel's image once, so when the plugin ships a new logo the
/// stored Primary is replaced explicitly (a metadata refresh does not replace it), and
/// Thumb/Backdrop images from earlier versions are removed because video cards inherit
/// the parent thumb in "Continue watching". Runs once per <see cref="ChannelImageVersion"/>.
/// </summary>
private async Task UpdateChannelImageIfNeededAsync(CancellationToken ct)
{
var cfg = Plugin.Instance?.Configuration;
if (cfg == null || cfg.ChannelImageVersion >= ChannelImageVersion)
{
return;
}
try
{
var channels = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Channel],
Name = "Floatplane"
});
foreach (var channel in channels)
{
foreach (var type in new[] { ImageType.Thumb, ImageType.Backdrop, ImageType.Logo })
{
while (channel.HasImage(type))
{
await channel.DeleteImageAsync(type, 0).ConfigureAwait(false);
}
}
using var stream = typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Floatplane.Resources.logo.png");
if (stream != null)
{
await _providerManager.SaveImage(channel, stream, "image/png", ImageType.Primary, null, ct).ConfigureAwait(false);
await _libraryManager.UpdateItemAsync(channel, channel.GetParent(), ItemUpdateType.ImageUpdate, ct).ConfigureAwait(false);
}
_logger.LogInformation("Updated channel image for {Name} (logo v{Version})", channel.Name, ChannelImageVersion);
}
cfg.ChannelImageVersion = ChannelImageVersion;
Plugin.Instance!.SaveConfiguration();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not update the channel image");
}
} }
/// <inheritdoc /> /// <inheritdoc />

230
README.md
View File

@@ -1,116 +1,150 @@
# jellyfin-plugin-floatplane # Floatplane for Jellyfin
A Jellyfin server plugin that exposes your [Floatplane](https://www.floatplane.com) A [Jellyfin](https://jellyfin.org) server plugin that brings your
subscriptions (creators → channels → videos, plus live streams) inside [Floatplane](https://www.floatplane.com) subscriptions into Jellyfin.
Jellyfin and all its clients, so Floatplane content shows up next to the Creators, their sub-channels and every video appear as a **Floatplane**
rest of the library on the Pi5 server and plays through the normal channel under *My Media* in the web client and in all Jellyfin apps, and
Jellyfin players. play through the normal Jellyfin player — with watched state, resume,
"Continue Watching", "Recently Added" and search, like any other library.
**Status (2026-10-25):** browsing works (verified against a local Jellyfin ![Home screen with the Floatplane tile, Continue Watching and Recently Added](doc/img/home.png)
10.10.7); playback proxy is implemented but still needs a real session
cookie to be verified. See [`doc/plan.md`](doc/plan.md) for milestones and
[`doc/floatplane-api-notes.md`](doc/floatplane-api-notes.md) for the API.
The plugin is a pure managed .NET assembly (AnyCPU IL), so one build runs ## Features
on both `linux-x64` (dev box) and `linux-arm64` (the Pi5).
## Prerequisites - **Creators → All videos → sub-channels.** Each creator you subscribe to
gets an *All videos* folder (every sub-channel in release order, newest
first) plus one folder per sub-channel (TechLinked, ShortCircuit, …).
- **Real metadata**: thumbnail, description, duration, release date,
sub-channel as *Genre*, creator as *Studio*.
- **Newest first by default** in every Jellyfin client, without touching
the sort menu.
- **Watched/resume state is shared** between the *All videos* copy and the
sub-channel copy of a video.
- **Low-CPU playback**: the server remuxes Floatplane's H.264/AAC stream
(stream copy, no re-encode). Quality levels show up as *Versions*.
- **Home screen integration**: "Recently Added in Floatplane" and
"Continue Watching" work out of the box.
- **Browse without logging in**: public creators (e.g. `linustechtips`)
can be listed by url-name; playback needs a logged-in session.
- **Search test tool** for Floatplane's own search API on the settings
page, and a scheduled task that refreshes the catalogue.
| Target | What to install | ![Creator folder: All videos first, then the sub-channels](doc/img/creator-folder.png)
|--------|-----------------|
| Arch / Garuda (dev box) | `sudo pacman -S dotnet-sdk-8.0` (Jellyfin 10.10.x = .NET 8) — for Jellyfin 12.x use `dotnet-sdk` (.NET 10) |
| Debian / Ubuntu | `apt install dotnet-sdk-8.0` from the Microsoft repo |
| Pi5 (runtime only) | nothing — the plugin runs inside the existing `linuxserver/jellyfin` container |
Optional, for local testing: Docker (to run a throw-away Jellyfin of the ## Requirements
same version as the Pi5 with the plugin folder mounted).
## Build | | |
|---|---|
| Jellyfin | **10.10.x** (plugin targetAbi `10.10.0.0`, .NET 8). Jellyfin 12.x needs a separate build (not available yet). |
| Architecture | one AnyCPU DLL — runs unchanged on x64 and arm64 (Raspberry Pi 4/5 etc.). |
| Floatplane | an account with an active subscription for playback. |
## Install
**From the plugin repository (recommended)**
1. Dashboard → Plugins → **Repositories** → **+**. Name it anything and use
this URL:
```
https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
```
2. Dashboard → Plugins → **Catalog** → section *Channels* → **Floatplane**
→ **Install**.
3. Restart Jellyfin. The plugin shows as *Active* under *My Plugins*.
Updates then appear in the catalog like any other plugin.
**With the install script** (Docker — linuxserver.io or official image — or
a native install; detects the right plugin directory, verifies the checksum,
restarts Jellyfin):
```bash
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh -s -- --dry-run
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh
```
Environment overrides: `TAG` (`latest` or `vX.Y.Z`), `JELLYFIN_CONTAINER`
(default `jellyfin`, empty = native), `JELLYFIN_CONFIG_DIR`, `PLUGINS_DIR`,
`PUID`/`PGID`.
**Manually**: download `floatplane_<version>.zip` from the
[releases](https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases),
unzip it into `<jellyfin data>/plugins/Floatplane_<version>/` and restart.
## Log in
Floatplane's password login requires a Cloudflare captcha that only works on
floatplane.com, so the plugin cannot ask for your username and password.
Two options, both on **Dashboard → Plugins → Floatplane**:
1. **Session cookie** (works today): log in at floatplane.com in a browser,
copy the value of the `sails.sid` cookie (DevTools → Application/Storage
→ Cookies), paste it into the settings page, press **Validate cookie**
(shows your username and subscriptions) and **Save**.
2. **Login with Floatplane** (OAuth device flow — the way Floatplane's own
apps and clients like Hydravion or Wasserflug log in). This needs a
client id issued by Floatplane; without one the button reports
`invalid_client`.
Step-by-step instructions, every setting explained and troubleshooting:
**[doc/usage.md](doc/usage.md)**.
## How it works (short)
- The channel lists creators → *All videos* / sub-channels → posts from
Floatplane's public listing API (ids `creator:…`, `all:…`, `channel:…`,
`post:…`, `cpost:…`).
- On play, the plugin fetches the post details with your session and hands
Jellyfin signed URLs to its own `Floatplane/Stream/…` endpoints.
- The stream endpoint asks Floatplane for the stream info (cached 6 h,
throttled to the ~2/min the API allows), downloads the HLS playlist and
rewrites it: the AES-128 key is fetched through the plugin with your
session, the video segments go straight to Floatplane's CDN. Jellyfin's
ffmpeg then stream-copies the result to the client.
- A scheduled task refreshes the catalogue and bumps the channel cache key
so Jellyfin re-lists the folders.
## Development
```bash ```bash
git clone https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane.git git clone https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane.git
cd jellyfin-plugin-floatplane cd jellyfin-plugin-floatplane
scripts/package.sh # → build/Floatplane_<ver>/ (DLL + meta.json), build/floatplane_<ver>.zip (+ .md5) dotnet test # 59 unit tests (models, HLS rewriter, API client, catalogue)
scripts/package.sh # → build/Floatplane_<ver>/ (DLL, meta.json, thumb.png), build/floatplane_<ver>.zip (+ .md5)
scripts/package.sh 1.2.3.0 https://example.com/dl # versioned package + build/manifest.json
``` ```
The VS Code `build` task (`Ctrl+Shift+B`) runs the same script. Plain - Needs the .NET 8 SDK; the test host also needs the ASP.NET Core 8 shared
`dotnet build` works too (`jellyfin-plugin-floatplane.sln`). runtime (Arch: `sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0`).
- The VS Code `build` task (`Ctrl+Shift+B`) runs `scripts/package.sh`.
- Local end-to-end test against a throw-away Jellyfin of the right version:
## Install ```bash
mkdir -p .jellyfin-test/config/data/plugins
cp -r build/Floatplane_0.2.0.0 .jellyfin-test/config/data/plugins/ # writable copy
docker run --rm -p 8096:8096 -v "$PWD/.jellyfin-test/config:/config" \
lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80
# logs: .jellyfin-test/config/log/log_*.log — plugin status: Dashboard → Plugins
```
**From the plugin repository (recommended):** Dashboard → Plugins → - Every push to `master` runs the tests and publishes the rolling `latest`
Repositories → add release (zip, md5, `manifest.json`, `install.sh`); a `vX.Y.Z` tag publishes
a versioned release.
``` More: [`doc/plan.md`](doc/plan.md) (milestones, roadmap),
https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json [`doc/floatplane-api-notes.md`](doc/floatplane-api-notes.md) (the API as
``` observed), [`doc/research/`](doc/research/) (Jellyfin plugin + Floatplane
API research and reference sources), and the
[project wiki](https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/wiki).
then Catalog → Channels → Floatplane → Install → restart Jellyfin. ## Credits & disclaimer
**Manual, on the Pi5 (linuxserver image):** Unofficial, not affiliated with or endorsed by Floatplane Media Inc.
The plugin uses Floatplane's unofficial API documented by the community
```bash ([jamamp/FloatplaneAPI](https://github.com/jamamp/FloatplaneAPI)) and
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install-pi5.sh | sh -s -- --dry-run borrows the channel + signed-proxy pattern from
# then without --dry-run; it installs into /srv/docker/jellyfin/data/plugins/ and restarts the container [chaosflix-jellyfin](https://github.com/praetorianer777/chaosflix-jellyfin).
``` Use it with your own subscription, for personal viewing. The Floatplane
name and logo belong to Floatplane Media Inc.
**Manual, from a local build:**
```bash
# linuxserver image keeps data under /config/data (container path);
# host path on the Pi5: /srv/docker/jellyfin/data/plugins/ (infra-Doc services/media-stack.md)
cp -r build/Floatplane_0.1.0.0 /srv/docker/jellyfin/data/plugins/
docker restart jellyfin
```
> The Pi5 runs Jellyfin **10.10.7** but `lscr.io/linuxserver/jellyfin:latest`
> is already **12.3** (.NET 10). Pin the tag (`10.10.7ubu2404-ls80`) before
> any `docker pull`, or the plugin becomes `NotSupported` overnight.
Then Dashboard → Plugins → Floatplane → paste your `sails.sid` cookie
(the page explains where to find it) → Validate → Save. A "Floatplane"
channel appears under My Media: creators → sub-channels → videos.
Without a cookie you can still browse creators listed under
"Extra creators" (public data); playback needs the cookie.
## How it works (short)
- `FloatplaneChannel` (`IChannel`) lists creators → sub-channels → posts
from Floatplane's public listing API; ids are `creator:…`, `channel:…`,
`post:…`.
- On play, `IRequiresMediaInfoCallback` fetches the post (login) and hands
Jellyfin signed URLs to the plugin's own `Floatplane/Stream/…` endpoints.
- The stream endpoint calls `delivery/info` (cached, throttled to ~2/min),
fetches the HLS playlist, and rewrites it: the AES-128 key URI points
back at the plugin (which fetches the key with the cookie), segments go
straight to the CDN. ffmpeg then remuxes (stream copy) for the client.
- A scheduled task refreshes the catalogue and bumps the channel cache key.
## Test
```bash
dotnet test # unit tests (models, HLS rewriter, API client with fixtures from the HAR)
```
The test host needs the ASP.NET Core 8 shared runtime (the plugin references
Jellyfin's controllers): on Arch `sudo pacman -S aspnet-runtime-8.0`.
Local end-to-end test with a disposable Jellyfin matching the Pi5 version:
```bash
mkdir -p .jellyfin-test/config/data/plugins
cp -r build .jellyfin-test/config/data/plugins/Floatplane_0.1.0.0 # writable copy, not :ro
docker run --rm -p 8096:8096 -v $PWD/.jellyfin-test/config:/config \
lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80
# logs: .jellyfin-test/config/log/log_*.log ; plugin status: Dashboard → Plugins
```
## Docs
- `doc/plan.md` — goal, stack, milestones, roadmap
- `doc/floatplane-api-notes.md` — endpoints, auth, HLS/AES-128 playback flow
- `doc/research/jellyfin-plugin-research.md` — plugin API research (10.10 vs 12.x, channels, proxy pattern, packaging, paths)
- `doc/research/floatplane-api-research.md` — community API docs, OAuth device flow, DPoP, rate limits, other clients
- `doc/research/reference-sources/` — snapshots of other clients' source for study (chaosflix is the template to follow)
- `doc/har/` — the captured browser session (**gitignored, contains credentials**)
- Gitea wiki (once the repo exists) — architecture and design decisions

View File

@@ -15,4 +15,6 @@ owner: "brasse"
artifacts: artifacts:
- "Jellyfin.Plugin.Floatplane.dll" - "Jellyfin.Plugin.Floatplane.dll"
changelog: > changelog: >
Initial release: channel browsing, HLS playback proxy, cookie session, search test. "All videos" folder per creator (release order across sub-channels) with shared
watched state, official Floatplane icon, OAuth device-flow login (needs a client id),
generic install script, documentation with screenshots.

BIN
doc/img/all-videos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

BIN
doc/img/channel-root.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
doc/img/creator-folder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
doc/img/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
doc/img/plugin-catalog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
doc/img/scheduled-tasks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
doc/img/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
doc/img/video-details.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -103,11 +103,13 @@ Jellyfin clients ──► Jellyfin server (Pi5)
| # | Increment | Done when | | # | Increment | Done when |
|---|-----------|-----------| |---|-----------|-----------|
| M0 | Scaffold: csproj (net8/10.10.7), Plugin.cs, config page, `meta.json`, build task, unit-test project, local docker Jellyfin 10.10.7 | **done 2026-10-25** — Active in a local 10.10.7 (LSIO image) | | M0 | Scaffold: csproj (net8/10.10.7), Plugin.cs, config page, `meta.json`, build task, unit-test project, local docker Jellyfin 10.10.7 | **done 2026-10-25** — Active in a local 10.10.7 (LSIO image) |
| M1 | `FloatplaneApiClient`: cookie session, `user/self`, subscriptions, creator/info, content listing with pagination + search/filters, post details; fixtures from the HAR | **code done** — config page validates the cookie; unit tests in `Jellyfin.Plugin.Floatplane.Tests` | | M1 | `FloatplaneApiClient`: cookie session, `user/self`, subscriptions, creator/info, content listing with pagination + search/filters, post details; fixtures from the HAR | **done** — config page validates the cookie; 59 unit tests in `Jellyfin.Plugin.Floatplane.Tests` |
| M2 | Channel browse: root → creators → sub-channels → videos with thumbnails, overview, duration, release date; sync task + cache key bump | **done locally** (web API verified: folders, paging, sorting, latest, images); Android app not yet checked | | M2 | Channel browse: root → creators → *All videos* + sub-channels → videos with thumbnails, overview, duration, release date; sync task + cache key bump | **done 2026-10-25** — verified in the web client (folders, paging, newest-first default sort, latest, images); *All videos* per creator added, watched state mirrored between the two copies of a video; official Floatplane logo as channel image and plugin thumbnail; Android app not yet checked |
| M3 | Playback via proxy controller: delivery/info cache + throttle, HLS playlist rewrite with key proxy (+ optional MP4 relay), `MediaStreams` filled so Jellyfin remuxes instead of transcoding | **code done, untested** — needs a real session cookie (Bjorn, on the Pi5 or locally) | | M3 | Playback via proxy controller: delivery/info cache + throttle, HLS playlist rewrite with key proxy (+ optional MP4 relay), `MediaStreams` filled so Jellyfin remuxes instead of transcoding | **code done, still unverified** — needs a real session cookie on a running server |
| M4 | Packaging + CI: Gitea Actions build → zip + md5 + `manifest.json` → releases; install on Pi5 from the repository URL or `install-pi5.sh` | **code done** — first CI run pending the initial push | | M4 | Packaging + CI: Gitea Actions build → zip + md5 + `manifest.json` + `thumb.png` → releases; install from the repository URL or `install.sh` (generic: LSIO / official image / native) | **done** — CI green, install from the manifest verified locally |
| M5 | Polish: watched/resume sync to Floatplane progress API, subtitles (VTT text tracks), locked-tier handling, cookie-expiry warning, Android/TV app check | next | | M5 | Login without cookie copying: OAuth device flow (`Login with Floatplane`) | **plumbing done 2026-10-25** — works up to `invalid_client`; waits for a client id from Floatplane. Password login inside Jellyfin is impossible (Turnstile is domain-bound, error 110200 verified) |
| M6 | Documentation for a general audience: README, `doc/usage.md` with screenshots, settings reference, troubleshooting | **done 2026-10-25** |
| M7 | Polish: watched/resume sync to Floatplane's progress API, subtitles (VTT text tracks), Android/TV app check, Jellyfin 12.x build | next |
## Roadmap / expansions (to agree) ## Roadmap / expansions (to agree)

281
doc/usage.md Normal file
View File

@@ -0,0 +1,281 @@
# Floatplane for Jellyfin — user guide
Everything below refers to the Jellyfin web client; the Android, Android
TV, iOS and desktop apps show the same channel and folders.
Contents: [Install](#install) · [Log in](#log-in) · [Browse and play](#browse-and-play) ·
[Settings reference](#settings-reference) · [Scheduled task](#scheduled-task) ·
[Troubleshooting](#troubleshooting) · [Privacy and security](#privacy-and-security)
## Install
### From the plugin repository
1. Open **Dashboard → Plugins → Repositories** and press **+**.
Give it any name and enter the URL
```
https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
```
![Repositories page with the Floatplane manifest added](img/plugin-repositories.png)
2. Go to **Dashboard → Plugins → Catalog**, scroll to the *Channels* section
and open **Floatplane**, then press **Install**.
![Catalog, Channels section, Floatplane card](img/plugin-catalog-floatplane.png)
3. Restart Jellyfin. **Dashboard → Plugins → My Plugins** now lists Floatplane
as *Active*. Click the card (or the ⋮ menu → Settings) to open the
settings page.
![My Plugins with the Floatplane card](img/dashboard-plugins.png)
New versions show up in the catalog like for any other plugin.
### With the install script
The script downloads the release, verifies the MD5 checksum, puts the plugin
in the right folder and restarts Jellyfin. It understands the
linuxserver.io image (`<config>/data/plugins`), the official image
(`<config>/plugins`) and native installs (`/var/lib/jellyfin/plugins`).
Run it as a user that may write there and restart Jellyfin (usually with
`sudo`). `--dry-run` only prints what would happen.
```bash
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh -s -- --dry-run
curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh
```
| Variable | Meaning | Default |
|----------|---------|---------|
| `TAG` | release to install | `latest` (or `vX.Y.Z`) |
| `JELLYFIN_CONTAINER` | Docker container to inspect and restart; empty = native install | `jellyfin` |
| `JELLYFIN_CONFIG_DIR` | host path of the container's `/config` (or the data dir of a native install) | read from `docker inspect` |
| `PLUGINS_DIR` | plugin directory, overrides the detection | detected |
| `PUID` / `PGID` | owner of the plugin files | from the container, else the directory owner |
### Manually
Download `floatplane_<version>.zip` from the
[releases page](https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases),
unzip it into `<jellyfin data>/plugins/Floatplane_<version>/` (the folder
must contain `Jellyfin.Plugin.Floatplane.dll` and `meta.json`) and restart
Jellyfin.
### Requirements
- Jellyfin **10.10.x**. The plugin targets ABI `10.10.0.0` and .NET 8;
Jellyfin 12.x uses a different ABI and needs a separate build (not yet
available).
- Any CPU architecture Jellyfin runs on: the plugin is one managed DLL
(x64 and arm64 verified).
- A Floatplane account with an active subscription for playback. Browsing
public creators works without logging in.
## Log in
Floatplane's username/password login is protected by a Cloudflare Turnstile
captcha that is bound to floatplane.com (any other site gets error 110200),
so the plugin cannot log you in with your password. Instead, open
**Dashboard → Plugins → Floatplane** and use one of the two methods below.
The status block at the top of the page always tells you which session is
in use and whether Floatplane still accepts it.
![The settings page](img/settings.png)
### Option 1 — paste the session cookie (works today)
1. Log in at [floatplane.com](https://www.floatplane.com) in a browser.
2. Open the browser's developer tools and find the cookie `sails.sid` for
`https://www.floatplane.com`:
- **Chrome / Edge**: F12 → *Application* → *Storage* → *Cookies* →
`https://www.floatplane.com`.
- **Firefox**: F12 → *Storage* → *Cookies* → `https://www.floatplane.com`.
- **Safari**: enable the Develop menu, then *Develop → Show Web Inspector*
→ *Storage* → *Cookies*.
3. Copy the **Value** of `sails.sid` (a long string; `sails.sid=` in front
is fine, the plugin strips it).
4. Paste it into **sails.sid cookie** and press **Validate cookie**. The page
shows your username and active subscriptions.
5. Press **Save**. The creators you subscribe to appear in the channel after
the automatic refresh that follows.
When the cookie expires, the status line changes to *rejected*. Log in at
floatplane.com again, copy the new value and repeat.
### Option 2 — Login with Floatplane (OAuth device flow)
Floatplane's own apps and third-party clients (Hydravion, Wasserflug,
floatplane-downloader) log in through an OAuth 2.0 device flow: you open a
link, enter a short code, and the plugin receives tokens that renew
themselves for about 90 days.
This requires a **client id issued by Floatplane** for this plugin. There is
no self-service registration; ask Floatplane (for example through their
community Discord). Without a registered id the button reports
`invalid_client`.
If you have one: enter it under **OAuth client id**, press **Login with
Floatplane**, open the link shown (or go to `floatplane.com/link`) and enter
the code. The page polls until Floatplane confirms and then shows *Logged in
as …*. The OAuth session replaces the cookie. **Forget OAuth session** drops
the tokens again. Client ids that require DPoP-bound tokens are not
supported.
## Browse and play
The **Floatplane** tile appears under *My Media*. It opens a folder per
creator — your subscriptions plus anything listed under *Extra creators*.
![The Floatplane channel: one tile per creator](img/channel-root.png)
Each creator contains:
- **All videos** — every video from all of the creator's sub-channels in
release order, newest first (up to the number set in the settings).
- **One folder per sub-channel** (for example Linus Tech Tips, TechLinked,
ShortCircuit) with that channel's newest videos.
![Creator folder: All videos first, then the sub-channels](img/creator-folder.png)
![All videos, newest first](img/all-videos.png)
Videos carry the Floatplane thumbnail, the post text as description, the
duration and release date, the sub-channel as *Genre* and the creator as
*Studio*. Press **Play** to watch, or use the tick to mark a video as
played.
![Video page](img/video-details.png)
Good to know:
- **Order.** The plugin gives every video a sort name derived from its
release time, so the default *Name* sort of all Jellyfin clients lists
newest first — no need to change the sort in every folder. Switching the
sort direction flips it to oldest first. (The A–Z jump bar on the right of
the web client therefore does nothing useful in these folders.)
- **Watched state.** A video exists twice — in *All videos* and in its
sub-channel folder. The plugin mirrors played/resume state between the two
copies automatically.
- **Home screen.** *Recently Added in Floatplane* shows the newest videos
across creators; *Continue Watching* shows partially watched videos with
their thumbnails.
- **Search.** Jellyfin's search finds every video the plugin has listed at
least once. Floatplane's own search can be tried on the settings page
(*Search test*).
- **Locked posts.** Posts above your subscription tier are hidden by
default; with *Show posts above my tier* they appear with a 🔒 in the
name and will not play.
- **Playback and quality.** On play, the plugin asks Floatplane for the
stream (this endpoint is rate-limited to about two requests per minute;
the answer is cached for six hours) and serves a rewritten HLS playlist
from your Jellyfin server: the encryption key is fetched with your session,
the video segments come straight from Floatplane's CDN, and Jellyfin's
ffmpeg copies the H.264/AAC stream to the client without re-encoding.
The available quality levels appear as *Versions* in the player, preferred
quality first, up to *Highest quality offered*. 4K needs the matching
Floatplane tier. *Progressive MP4 versions* is an experimental alternative
that has not been verified yet.
## Settings reference
Dashboard → Plugins → Floatplane, in page order.
**Status block** — plugin version, which session is configured (cookie or
OAuth) and whether Floatplane accepted it, the creators and sub-channels
currently loaded, cache counters and the last refresh time.
### Session
| Setting | What it does |
|---------|--------------|
| **sails.sid cookie** | The Floatplane session cookie (see [Option 1](#option-1--paste-the-session-cookie-works-today)). Needed for subscriptions and playback. |
| **Validate cookie** | Checks the pasted value against Floatplane without saving it and shows username and subscriptions. |
| **OAuth client id** | Client id issued by Floatplane for this plugin (see [Option 2](#option-2--login-with-floatplane-oauth-device-flow)). |
| **Login with Floatplane** | Starts the device flow with that client id and shows the link and code to enter. |
| **Forget OAuth session** | Removes the stored OAuth tokens (shown only while an OAuth session exists). |
### Catalogue
| Setting | Default | What it does |
|---------|---------|--------------|
| **Extra creators** | empty | Comma-separated Floatplane url-names (the part after `floatplane.com/channel/`, e.g. `linustechtips`) to list in addition to your subscriptions — or instead of them when you are not logged in. Listings are public; playback still needs a session. |
| **Videos listed in "All videos"** | 200 | How many of the newest videos the *All videos* folder contains. Floatplane returns 20 per request, so 200 = 10 requests per creator at refresh. |
| **Videos listed per sub-channel** | 100 | Same, per sub-channel folder. |
| **Show posts above my tier (locked)** | off | Include posts your subscription cannot access, marked with 🔒. |
| **Listing cache (minutes)** | 30 | How long the plugin keeps a folder listing before asking Floatplane again. |
| **Automatic refresh (hours)** | 6 | Interval of the *Refresh Floatplane catalogue* scheduled task. |
| **Refresh now** | — | Drops the caches, reloads creators and sub-channels and makes Jellyfin re-list the folders. Also happens after every **Save**. |
### Playback
| Setting | Default | What it does |
|---------|---------|--------------|
| **Preferred quality** | 1080p | The version listed first and played by default. |
| **Highest quality offered (pixels)** | 1080 | Versions taller than this are not offered at all (keeps 4K out of the list unless you have that tier). |
| **Delivery lookups per minute** | 2 | Budget for Floatplane's stream-info endpoint. Raise it only if Floatplane changes its rate limit. |
| **Proxy base URL** | empty | How Jellyfin's own ffmpeg reaches the plugin's stream endpoints, e.g. `http://127.0.0.1:8096`. Empty uses Jellyfin's local address, which is right for almost every setup. |
| **Also offer progressive MP4 versions (experimental)** | off | Adds an MP4 version per video using Floatplane's download delivery. Untested; may not play. |
**Save** writes everything and triggers a refresh.
### Search test
Pick a creator, type a query and press **Search** to run the query through
Floatplane's search API and see the matching videos (date, sub-channel,
title, length). This is a diagnostic tool; it does not change anything.
## Scheduled task
The plugin registers **Refresh Floatplane catalogue** under **Dashboard →
Scheduled Tasks**. It runs at server start and every *Automatic refresh*
hours, reloads creators and sub-channels, pre-fetches the newest videos and
tells Jellyfin to re-list the channel. You can run it by hand from that page,
or press *Refresh now* on the settings page.
![Scheduled Tasks with Refresh Floatplane catalogue](img/scheduled-tasks.png)
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| Playing a video fails with *NoCompatibleStream* or the player never starts; the log says *Cannot play Floatplane post … You must be logged-in* | No session or an expired one. Paste a fresh cookie (or log in with Floatplane) and Save. |
| Playback fails right after several quick starts; log mentions 429 or *rate-limited* | Floatplane's stream-info endpoint allows about two lookups per minute. Wait a minute and try again; results are cached afterwards. |
| New videos do not show up | Press *Refresh now* (or run the scheduled task). The plugin caches listings for *Listing cache* minutes and Jellyfin caches channel listings for three hours; a refresh invalidates both. |
| Creators missing although the cookie is valid | Press *Refresh now*; check the status block for an error message. Creators without a video post yet are skipped. |
| Everything is marked 🔒 | You are browsing without a session, or the session is rejected: without login Floatplane reports every post as locked. |
| *Login with Floatplane* says `invalid_client` | The client id is not registered with Floatplane. Use the cookie method until you have one. |
| Plugin shows *NotSupported* or *Malfunctioned* | Jellyfin version mismatch (this build is for 10.10.x) or a broken plugin folder. Check the server log. |
**Logs.** Everything the plugin writes is tagged `Jellyfin.Plugin.Floatplane`
in Jellyfin's server log (Dashboard → Logs, or `log/log_*.log` in the data
directory — `/config/log/` in the linuxserver.io image). For more detail,
copy `logging.default.json` to `logging.json` in the configuration
directory and add
```json
"Jellyfin.Plugin.Floatplane": "Debug"
```
under `Serilog → MinimumLevel → Override`, then restart.
**Paths (linuxserver.io image).** Plugins live in `/config/data/plugins/`,
the plugin configuration in `/config/data/plugins/configurations/Floatplane.xml`,
logs in `/config/log/`. The official image uses `/config/plugins/` and
`/config/log/`.
## Privacy and security
- The session cookie or OAuth tokens are stored in plain XML in Jellyfin's
plugin configuration folder, readable by Jellyfin administrators and
anyone with access to that directory. Treat it like a password.
- Stream URLs handed to the player are signed with a per-process HMAC
secret, so the plugin's stream endpoints cannot be used as a general
relay; they only serve what the plugin itself selected.
- Requests to Floatplane carry a `Jellyfin-Floatplane/<version>` User-Agent
so Floatplane can identify the client. The plugin keeps to the
documented rate limits.
- This is an unofficial client built on Floatplane's community-documented
API and is not affiliated with or endorsed by Floatplane Media Inc. Use it
for personal viewing with your own subscription.

View File

@@ -1,26 +1,52 @@
#!/bin/sh #!/bin/sh
# Manual install/upgrade of the Floatplane plugin on a host that runs the # Install or upgrade the Floatplane plugin from the Gitea release.
# linuxserver/jellyfin container (plugins live in /config/data/plugins/ inside it). # Works for a Jellyfin that runs in Docker (official image or linuxserver.io image) or
# natively (systemd). Nothing is changed before the summary is printed; --dry-run stops there.
# #
# curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install-pi5.sh | sh # curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install.sh | sh
# sh install-pi5.sh --dry-run # only print what would happen # curl -fsSL .../install.sh | sh -s -- --dry-run
# #
# Environment (all optional): # Environment (all optional):
# TAG release to install: latest (default) or vX.Y.Z # TAG release to install: latest (default) or vX.Y.Z
# JELLYFIN_CONFIG_DIR host path mounted as the container's /config (default /srv/docker/jellyfin, # JELLYFIN_CONTAINER docker container name (default: jellyfin; empty = native install)
# i.e. ${DOCKER_PATH}/jellyfin on the Pi5) # JELLYFIN_CONFIG_DIR host path of the container's /config (default: read from `docker inspect`),
# JELLYFIN_CONTAINER container name to restart (default jellyfin); empty = don't restart # or for native installs the data dir (default /var/lib/jellyfin)
# PUID / PGID owner for the plugin files (default: read from the container, else 1000:1000) # PLUGINS_DIR override the plugins directory outright (skips the detection below)
# PUID / PGID owner for the plugin files (default: from the container's env, else the dir owner)
# #
# NOTE (Pi5): this restarts the jellyfin container and writes under /srv/docker, so run it # Plugin directory detection:
# as a user that may do that (Bjorn, typically with sudo). Nothing is changed before the # linuxserver/jellyfin -> <config>/data/plugins (JELLYFIN_DATA_DIR=/config/data)
# summary below is printed; pass --dry-run to stop there. # jellyfin/jellyfin -> <config>/plugins (JELLYFIN_DATA_DIR=/config)
# native -> /var/lib/jellyfin/plugins
#
# Needs: curl, unzip, md5sum (or md5), and jq if available (sed fallback otherwise).
# Run it as a user that may write to the plugin directory and restart Jellyfin (sudo, typically).
set -eu set -eu
REPO_URL="https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane" REPO_URL="https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane"
TAG="${TAG:-latest}" TAG="${TAG:-latest}"
JELLYFIN_CONFIG_DIR="${JELLYFIN_CONFIG_DIR:-/srv/docker/jellyfin}"
JELLYFIN_CONTAINER="${JELLYFIN_CONTAINER-jellyfin}" JELLYFIN_CONTAINER="${JELLYFIN_CONTAINER-jellyfin}"
JELLYFIN_CONFIG_DIR="${JELLYFIN_CONFIG_DIR:-}"
PLUGINS_DIR="${PLUGINS_DIR:-}"
# --- locate the plugins directory -------------------------------------------------
if [ -z "$PLUGINS_DIR" ]; then
if [ -n "$JELLYFIN_CONTAINER" ] && command -v docker >/dev/null 2>&1 && docker inspect "$JELLYFIN_CONTAINER" >/dev/null 2>&1; then
if [ -z "$JELLYFIN_CONFIG_DIR" ]; then
JELLYFIN_CONFIG_DIR=$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/config"}}{{.Source}}{{end}}{{end}}' "$JELLYFIN_CONTAINER" 2>/dev/null || true)
fi
[ -n "$JELLYFIN_CONFIG_DIR" ] || { echo "error: cannot find the /config mount of container $JELLYFIN_CONTAINER; set JELLYFIN_CONFIG_DIR or PLUGINS_DIR" >&2; exit 1; }
DATA_DIR=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$JELLYFIN_CONTAINER" 2>/dev/null | sed -n 's/^JELLYFIN_DATA_DIR=//p' | head -n1)
case "$DATA_DIR" in
/config/*) PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/${DATA_DIR#/config/}/plugins" ;; # linuxserver: /config/data
*) if [ -d "$JELLYFIN_CONFIG_DIR/data/plugins" ]; then PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/data/plugins"; else PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/plugins"; fi ;;
esac
else
[ -n "$JELLYFIN_CONTAINER" ] && echo "note: container '$JELLYFIN_CONTAINER' not found via docker; assuming a native install (set JELLYFIN_CONTAINER= to silence)" >&2
JELLYFIN_CONTAINER=""
PLUGINS_DIR="${JELLYFIN_CONFIG_DIR:-/var/lib/jellyfin}/plugins"
fi
fi
DRY_RUN=0 DRY_RUN=0
for a in "$@"; do for a in "$@"; do
case "$a" in case "$a" in
@@ -54,7 +80,6 @@ else
fi fi
[ -n "$VERSION" ] && [ -n "$SOURCE_URL" ] || { echo "error: could not read version/sourceUrl from manifest" >&2; exit 1; } [ -n "$VERSION" ] && [ -n "$SOURCE_URL" ] || { echo "error: could not read version/sourceUrl from manifest" >&2; exit 1; }
PLUGINS_DIR="$JELLYFIN_CONFIG_DIR/data/plugins"
DEST="$PLUGINS_DIR/Floatplane_$VERSION" DEST="$PLUGINS_DIR/Floatplane_$VERSION"
# owner: the container's PUID/PGID if we can read them, else 1000:1000 # owner: the container's PUID/PGID if we can read them, else 1000:1000
@@ -75,7 +100,7 @@ echo " md5: $CHECKSUM"
echo " install: $DEST/" echo " install: $DEST/"
[ -n "$OLD" ] && echo " remove: $(echo "$OLD" | tr '\n' ' ')" [ -n "$OLD" ] && echo " remove: $(echo "$OLD" | tr '\n' ' ')"
echo " owner: $OWNER_UID:$OWNER_GID" echo " owner: $OWNER_UID:$OWNER_GID"
if [ -n "$JELLYFIN_CONTAINER" ]; then echo " restart: docker restart $JELLYFIN_CONTAINER"; else echo " restart: (skipped, JELLYFIN_CONTAINER empty)"; fi if [ -n "$JELLYFIN_CONTAINER" ]; then echo " restart: docker restart $JELLYFIN_CONTAINER"; elif command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files jellyfin.service >/dev/null 2>&1; then echo " restart: systemctl restart jellyfin"; else echo " restart: (none - restart Jellyfin yourself)"; fi
echo echo
if [ "$DRY_RUN" = 1 ]; then if [ "$DRY_RUN" = 1 ]; then
@@ -84,7 +109,7 @@ if [ "$DRY_RUN" = 1 ]; then
fi fi
if [ ! -d "$PLUGINS_DIR" ]; then if [ ! -d "$PLUGINS_DIR" ]; then
echo "error: $PLUGINS_DIR does not exist - is JELLYFIN_CONFIG_DIR right? (linuxserver image keeps data in <config>/data)" >&2 echo "error: $PLUGINS_DIR does not exist - set PLUGINS_DIR (linuxserver image: <config>/data/plugins, official image: <config>/plugins, native: /var/lib/jellyfin/plugins)" >&2
exit 1 exit 1
fi fi

View File

@@ -33,15 +33,17 @@ dotnet publish Jellyfin.Plugin.Floatplane/Jellyfin.Plugin.Floatplane.csproj \
PLUGIN_DIR="build/${NAME}_${VERSION}" PLUGIN_DIR="build/${NAME}_${VERSION}"
mkdir -p "$PLUGIN_DIR" mkdir -p "$PLUGIN_DIR"
cp build/publish/Jellyfin.Plugin.Floatplane.dll "$PLUGIN_DIR/" cp build/publish/Jellyfin.Plugin.Floatplane.dll "$PLUGIN_DIR/"
cp Jellyfin.Plugin.Floatplane/Resources/thumb.png "$PLUGIN_DIR/thumb.png" # shown in Dashboard -> Plugins
cp Jellyfin.Plugin.Floatplane/Resources/thumb.png build/thumb.png # release asset for manifest imageUrl
jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \ jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \
--arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \ --arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \
--arg abi "$ABI" --arg ts "$TS" \ --arg abi "$ABI" --arg ts "$TS" \
'{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat, '{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat,
version:$ver, changelog:$cl, targetAbi:$abi, timestamp:$ts, status:"Active", autoUpdate:true, version:$ver, changelog:$cl, targetAbi:$abi, timestamp:$ts, status:"Active", autoUpdate:true,
imagePath:"", assemblies:["Jellyfin.Plugin.Floatplane.dll"]}' > "$PLUGIN_DIR/meta.json" imagePath:"thumb.png", assemblies:["Jellyfin.Plugin.Floatplane.dll"]}' > "$PLUGIN_DIR/meta.json"
ZIP="build/${SLUG}_${VERSION}.zip" ZIP="build/${SLUG}_${VERSION}.zip"
(cd "$PLUGIN_DIR" && zip -q -j "../../$ZIP" Jellyfin.Plugin.Floatplane.dll meta.json) (cd "$PLUGIN_DIR" && zip -q -j "../../$ZIP" Jellyfin.Plugin.Floatplane.dll meta.json thumb.png)
MD5=$(md5sum "$ZIP" | cut -d' ' -f1) MD5=$(md5sum "$ZIP" | cut -d' ' -f1)
echo "$MD5 $(basename "$ZIP")" > "$ZIP.md5" echo "$MD5 $(basename "$ZIP")" > "$ZIP.md5"
@@ -49,7 +51,8 @@ if [ -n "$BASE_URL" ]; then
jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \ jq -n --arg guid "$GUID" --arg name "$NAME" --arg desc "$DESCRIPTION" --arg ov "$OVERVIEW" \
--arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \ --arg owner "$OWNER" --arg cat "$CATEGORY" --arg ver "$VERSION" --arg cl "$CHANGELOG" \
--arg abi "$ABI" --arg ts "$TS" --arg md5 "$MD5" --arg url "${BASE_URL%/}/$(basename "$ZIP")" \ --arg abi "$ABI" --arg ts "$TS" --arg md5 "$MD5" --arg url "${BASE_URL%/}/$(basename "$ZIP")" \
'[{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat, imageUrl:"", --arg img "${BASE_URL%/}/thumb.png" \
'[{guid:$guid, name:$name, description:$desc, overview:$ov, owner:$owner, category:$cat, imageUrl:$img,
versions:[{version:$ver, changelog:$cl, targetAbi:$abi, sourceUrl:$url, checksum:$md5, timestamp:$ts}]}]' \ versions:[{version:$ver, changelog:$cl, targetAbi:$abi, sourceUrl:$url, checksum:$md5, timestamp:$ts}]}]' \
> build/manifest.json > build/manifest.json
fi fi