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 runinstall-pi5.shfrom the release. - Built for Jellyfin 10.10.x (.NET 8,
targetAbi 10.10.0.0). One AnyCPU DLL for x64 and arm64 (Pi5).
Tutorials
- Install tutorial — add the repository, install from the catalogue, restart, paste the session cookie (with screenshots)
- Update tutorial — automatic updates, updating through the catalogue,
install.sh - Floatplane API notes
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 canonicalpost:<id>ids). Sub-channel folders list the same posts undercpost:<channelId>:<postId>because Jellyfin allows one parent per item id;UserDataMirror(anIHostedService) copies played/resume state between the two copies on everyUserDataSavedevent. - Release order by default in every client:
SortNameUpdatersets each video'sForcedSortNameto(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.pngin the zip,imagePathin meta.json,imageUrlin 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 viaFloatplaneSyncTask.ChannelImageVersion(explicitSaveImage+ 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 aclient_id; unregistered ids getinvalid_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.mdwith 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
- Jellyfin asks
GetChannelItemMediaInfo("post:<id>")→ plugin fetchesGET /api/v3/content/post(login required, cached 1 h) → oneMediaSourceInfoper video attachment × quality level (preferred level first),Path = <proxy base>/Floatplane/Stream/<post>/<video>/<level>/index.m3u8?t=<hmac>,IsRemote=false,SupportsProbing=false, declaredh264/aacstreams so Jellyfin stream-copies instead of transcoding. - ffmpeg (Jellyfin's transcode/remux path) requests the playlist. The controller verifies the signature, calls
GET /api/v3/delivery/info?scenario=onDemand&entityId=<video>(viaDeliveryResolver: 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), downloadschunk.m3u8from 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).
- Optional experimental MP4 version:
…/video.mp4relays Floatplane'sscenario=downloadURL 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 aclient_idregistered by Floatplane (invalid_clientotherwise). So v1 uses a pastedsails.sidcookie (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/infoand the key need the cookie. An expired cookie shows up asSessionState = invalidon 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;limitmax 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 |