Clone
3
Home
claude edited this page 2026-10-25 17:32:20 +01:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

jellyfin-plugin-floatplane

A Jellyfin channel plugin that shows your Floatplane subscriptions (creators → sub-channels → videos) inside Jellyfin and every Jellyfin client, and plays them through Jellyfin's normal player. No client-side changes.

  • Code + plan: https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane (doc/plan.md, doc/floatplane-api-notes.md, doc/research/)
  • Install: Dashboard → Plugins → Repositories → add https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json, or run install-pi5.sh from the release.
  • Built for Jellyfin 10.10.x (.NET 8, targetAbi 10.10.0.0). One AnyCPU DLL for x64 and arm64 (Pi5).

Tutorials

What's new (2026-10-25, v0.2)

  • "All videos" folder per creator: every sub-channel's videos in release order (all:<creatorId>, items keep the canonical post:<id> ids). Sub-channel folders list the same posts under cpost:<channelId>:<postId> because Jellyfin allows one parent per item id; UserDataMirror (an IHostedService) copies played/resume state between the two copies on every UserDataSaved event.
  • Release order by default in every client: SortNameUpdater sets each video's ForcedSortName to (10^13 - 1 - releaseUnixMs) zero-padded, so the clients' default "Name ascending" sort shows newest first. Runs ~10 s after a listing (the DB entities exist only after Jellyfin synced them) and during the scheduled refresh.
  • Official Floatplane icon (from frontend.floatplane.com/favicons) as channel Primary image (16:9 card, Resources/logo.png) and plugin thumbnail (thumb.png in the zip, imagePath in meta.json, imageUrl in manifest.json). The channel offers only a Primary image: a channel Thumb is inherited by video cards in "Continue watching" and would hide the video thumbnails. Existing installs get the new image once via FloatplaneSyncTask.ChannelImageVersion (explicit SaveImage + delete of Thumb/Backdrop; a metadata refresh does not replace a stored Primary).
  • OAuth device-flow plumbing (FloatplaneAuthService, Floatplane/OAuth/* endpoints, settings page section): works as soon as Floatplane issues a client_id; unregistered ids get invalid_client. Username/password inside Jellyfin is impossible: Floatplane's Turnstile site key is domain-bound (error 110200 on other origins, verified).
  • Generic install.sh (auto-detects linuxserver / official image / native layout), doc/usage.md with screenshots.

Architecture

Jellyfin clients (web, Android, TV, JMP)
        │  normal channel browsing + playback APIs
        ▼
Jellyfin server ──► FloatplaneChannel (IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia)
                        │ folders/items                 │ media sources
                        ▼                               ▼
                   CatalogService                 proxy URLs → FloatplaneStreamController (anonymous, HMAC-signed)
                   (creators, channels,                     │ delivery/info (cached, throttled) via DeliveryResolver
                    newest posts; cached)                   │ HLS playlist rewrite (HlsRewriter)
                        │                                   │ AES-128 key fetched WITH the session cookie
                        ▼                                   ▼
                   FloatplaneApiClient ──────────► www.floatplane.com (REST + key server)
                                                   cdn-vod-drm2.floatplane.com (playlists + segments, signed token only)
                                                   pbs.floatplane.com (thumbnails, public)
FloatplaneSyncTask (IScheduledTask) refreshes the catalogue (startup + every N hours) and bumps the cache key.
FloatplaneAdminController (admin only) backs the settings page: validate cookie, status, refresh, search test, tags.

Folder / item id scheme

Level ChannelItemInfo.Id Source
root – creators = subscriptions (needs cookie) ∪ "extra creators" (public url-names)
creator creator:<creatorId> creator.channels[] (public)
sub-channel channel:<creatorId>:<channelId> GET /api/v3/content/creator?id&channel&hasVideo=true paged 20 at a time, newest first, up to Videos listed per channel
video post:<postId> one item per post; a post only ever lives under its own channel (Jellyfin allows one parent per item)

The channel Name ("Floatplane") must never change: Jellyfin derives the channel's library id from it.

Playback flow

  1. Jellyfin asks GetChannelItemMediaInfo("post:<id>") → plugin fetches GET /api/v3/content/post (login required, cached 1 h) → one MediaSourceInfo per video attachment × quality level (preferred level first), Path = <proxy base>/Floatplane/Stream/<post>/<video>/<level>/index.m3u8?t=<hmac>, IsRemote=false, SupportsProbing=false, declared h264/aac streams so Jellyfin stream-copies instead of transcoding.
  2. ffmpeg (Jellyfin's transcode/remux path) requests the playlist. The controller verifies the signature, calls GET /api/v3/delivery/info?scenario=onDemand&entityId=<video> (via DeliveryResolver: cached until the JWT in the URL expires, ≈6 h, throttled to Delivery lookups per minute, default 2), picks the variant (exact level → best ≤ level → best), downloads chunk.m3u8 from the CDN (no cookie needed) and rewrites it:
    • #EXT-X-KEY … URI="https://www.floatplane.com/api/video/watchKey?token=…" → …/Floatplane/Stream/<video>/Key?u=<b64url>&t=<hmac> (the plugin fetches the 16-byte key with the session cookie, which Jellyfin itself cannot forward — it only passes User-Agent/Referer to ffmpeg).
    • segment URIs → absolute CDN URLs (signed per-segment token, CORS open, no cookie).
  3. Optional experimental MP4 version: …/video.mp4 relays Floatplane's scenario=download URL with Range passthrough.

Why a proxy at all: Jellyfin forwards no cookies/Authorization to ffmpeg or its static relay, and browsers cannot attach a third-party cookie, so the key request has to come from the server. Why signed URLs: the stream endpoints are anonymous (ffmpeg has no Jellyfin token); the HMAC (process-lifetime secret) limits them to what the plugin itself handed out.

Session model

  • Floatplane's password login requires a Cloudflare Turnstile token regardless of User-Agent (verified 2026-10-25), and the OAuth device flow (Keycloak auth.floatplane.com) needs a client_id registered by Floatplane (invalid_client otherwise). So v1 uses a pasted sails.sid cookie (settings page → "Validate cookie" calls /api/v3/user/self + /user/subscriptions).
  • Browsing (creator pages, channel lists, post listings, search, thumbnails) is public; content/post, delivery/info and the key need the cookie. An expired cookie shows up as SessionState = invalid on the settings page and as a warning in the log.
  • Rate limits: delivery/info ≈2/min (429 + Retry-After, Cloudflare 1015 if hammered). Listing endpoints are not noticeably limited; limit max is 20.

Jellyfin version note

The Pi5 runs 10.10.7; lscr.io/linuxserver/jellyfin:latest is already 12.3 (.NET 10, new plugin ABI). A 10.10 plugin is NotSupported on 12.x and vice versa. Pin the image tag (10.10.7ubu2404-ls80) until the upgrade is done deliberately; the known 12.x deltas for this code are the TaskTriggerInfo.Type enum and the NuGet package/TFM bump.

Caches (to know when "nothing updates")

What Where TTL / invalidation
Jellyfin's own channel listing cache <cache>/channels/<id>/…json 3 h, bypassed when the plugin's cache key (generation counter) changes → "Refresh now" or the scheduled task
Jellyfin's media-info cache memory 5 min per item
Plugin creators + post lists memory Listing cache (minutes), default 30
Plugin post details memory 1 h
Plugin delivery variants memory until token exp − 5 min