Files
jellyfin-plugin-floatplane/doc/research/floatplane-api-research.md
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

39 KiB
Raw Permalink Blame History

Floatplane unofficial API — research report for a Jellyfin plugin (as of 2026-10-25)

Legend: [V] = verified from docs/spec/source code or a live fetch today; [I] = inferred / not directly verified. Local copies of everything I read are in /tmp/claude-1000/-home-brasse/8a10aa19-62bd-490c-b689-b51638220e46/scratchpad/src/ (e.g. spec_full.json, spec_dump.txt, oidc.json, fpts_*.ts, fpdl_*.ts, hydra_*.brs, wf_*.swift, floaty_*.dart, fn_*.kt, fn_ios_*.swift, ytdlp_floatplane.py, bz_*.mjs).

0. Executive summary — what changed and what it means for a Jellyfin plugin

  1. Username/password login is legacy. Every actively maintained third-party client (floatplane-downloader 5.19.x, Hydravion Roku, Wasserflug tvOS, Floaty, FloatNative) has migrated in 2025–2026 to OAuth 2.0 / OIDC on a Keycloak server at https://auth.floatplane.com/realms/floatplane, using the device-code grant (TV/CLI) or auth-code+PKCE (mobile/desktop), and then call the REST API with Authorization: Bearer <access_token> instead of the sails.sid cookie. [V] (sources: floatplaneAPI.ts src/index.ts, Hydravion oauthDeviceTask.brs, Wasserflug Wasserflug_tvOSApp.swift, Floaty oauth2_service.dart, FloatNative OAuthApi.kt, live OIDC discovery doc).
  2. You need an OAuth client_id registered by Floatplane. The floatplane npm README says clientId: "example-client", // Has to be registered on FloatPlane. Known registered public clients: hydravion, wasserflug, floatplane-downloader, floaty, floatnative, and Floatplane's own fp-tv-app. There is no public self-service registration page; the maintainers evidently obtained client IDs from Floatplane staff (the community Discord https://discord.gg/4xKDGz5M5B is where Hydravion/Wasserflug devs hang out). [V] for the IDs, [I] for the "ask staff" process. Using another app's client ID would work technically but is not yours to use; the bmlzootown canary code explicitly says "Do not invent alternate clients".
  3. Some clients require DPoP (RFC 9449). fp-tv-app and floatnative are configured with usesExtendedSecurity → PKCE S256 on the device-auth request and a DPoP proof (ES256/P-256) on every token-endpoint and REST request (Authorization: DPoP <token>). hydravion, wasserflug, floatplane-downloader, floaty use plain Bearer without DPoP. So whether your plugin needs DPoP depends on how Floatplane configures your client. [V]
  4. The CDN/HLS side needs auth too (for VOD). Hydravion sets Authorization: Bearer on the Roku video node for all playlist/segment/key requests; Floaty passes Authorization + Referer/Origin headers to mpv; Wasserflug intercepts AVPlayer loads and adds Bearer; FloatNative fetches the m3u8 manifest and the AES-128 #EXT-X-KEY with DPoP, but rewrites segment URIs to plain absolute https ("the backend is designed around this split: keys require session-bound auth, segments don't"). Live (IVS) streams carry the token only in the URL and are played natively without headers. [V] For Jellyfin this means you cannot just hand the HLS URL to the client; the plugin must proxy the playlist+key (or feed ffmpeg -headers), see §6.6.
  5. /api/v3/delivery/info is rate-limited hard (community consensus ≈ 2 requests/minute; Floaty budgets 2 per 5 min; 429 + Retry-After, Cloudflare error 1015). Cache delivery responses per attachment until the embedded token expires. [V]
  6. The community OpenAPI spec (jamamp/FloatplaneAPI, spec version 3.10.0-c, frontend tracked to 4.0.13) lags the live site (frontend is 4.5.25 today) and still documents only CookieAuth; the bmlzootown fork README states "OpenAPI may still list only CookieAuth — explorer REST auth is Bearer ahead of that docs lag" and that sails.sid is "chat/Socket.IO-only". [V]

1. Community API documentation

1.1 Repositories and docs sites

  • Spec repo (renamed): https://github.com/jamamp/FloatplaneAPI (formerly Jman012/FloatplaneAPI; old URL redirects). Files: src/floatplane-openapi-specification.json (full, 606 KB, OpenAPI 3.0.3, title "Floatplane REST API", version 3.10.0-c), trimmed spec generated by tools/trim.js (drops operations whose description starts with "TODO"), AsyncAPI specs for chat (chat.floatplane.com) and frontend sockets. MIT. [V]
  • Rendered docs: https://jamamp.github.io/FloatplaneAPIDocs/ (Swagger UI, Redoc, RapiDoc, ReSlate, Postman v2.1 collection; trimmed and full variants). The old jman012.github.io/FloatplaneAPIDocs now 404s. [V]
  • Trimmed spec download: https://jamamp.github.io/FloatplaneAPIDocs/floatplane-openapi-specification-trimmed.json (raw GitHub path for the trimmed file 404s; the full one is https://raw.githubusercontent.com/jamamp/FloatplaneAPI/main/src/floatplane-openapi-specification.json). [V]
  • Fork with extras: https://github.com/bmlzootown/FloatplaneAPI — static API explorer (static/explorer/, PR #6), frontend-change watcher, and an authenticated "API shape canary" (PR #7 Phase 3.0, PR #11/#12 Phase 3.1) that implements the device flow + DPoP in Node (tools/fp-api-shape-canary/lib/{device-flow,dpop,auth-refresh,constants}.mjs) and commits measured token lifetimes (artifacts/api-shape/refresh-lifetime-evidence.json). [V]
  • README guidance (jamamp): "Please set a custom User-Agent header ... that uniquely identifies what app the requests are coming from, and what version ... This is very helpful to the Floatplane team". Integration tests authenticate with env var sails.sid. The spec's info.description has an "API Flow" section (as of FP 4.0.13): login → /api/v3/user/subscriptions → /api/v3/creator/info → /api/v3/content/creator/list (home) → /api/v3/content/creator (creator page) → /api/v3/content/post → /api/v3/content/{video|audio|picture} → /api/v3/delivery/info; livestream via delivery/info + wss://chat.floatplane.com/sails.io/. [V]
  • Rate limiting text in the spec: "Rate-limited requests will respond with an HTTP 429 ... will also have a Retry-After header ... Clients are expected to 1) prevent too many requests ... particularly for the /api/v2/cdn/delivery and /api/v3/delivery/info endpoints, and 2) properly handle rate-limited responses". [V]
  • LTT forum thread announcing the docs: https://linustechtips.com/topic/1384044-floatplane-unofficial-floatplane-api-documentation/ (Cloudflare-blocked for fetch tools; not read). [I]

1.2 Authentication

  • POST /api/v2/auth/login (also /api/v3/auth/login) body {"username","password","captchaToken"?}; 200 → {"user": {id, username, profileImage}, "needs2FA": bool}; sets cookie sails.sid (Sails.js session). captchaToken description: "The Google Recaptcha v2/v3 token ... On web browsers, this is required. For mobile or TV applications, this is not required only if the User-[Agent …]" (text truncated in my dump; the point is a non-browser User-Agent exempts you). 401 → ErrorModel {id, errors[{id,name,message?,data?}], message?}.
  • POST /api/v2/auth/checkFor2faLogin (also v3) body {"token": "<TOTP>"} → same login response, completes the session; POST /api/v2/auth/logout → "OK".
  • GET /api/v3/auth/captcha/info → {"v2":{"variants":{"android":{siteKey},"checkbox":{siteKey},"invisible":{siteKey}}},"v3":{"variants":{"invisible":{siteKey}}}} (Google reCAPTCHA keys).
  • Cookie lifetime: not documented in the spec; Wasserflug persisted it with the Expires from Set-Cookie and rewrote the domain to .floatplane.com. No verified number. [I]
  • How clients handled captcha: Wasserflug's source comment: "Set custom user agent for network requests. This is particularly required in order to pass the login phase with bypassing the captcha" (UA "Wasserflug tvOS App version X, CFNetwork"); the spec's own test UA also ends in , CFNetwork; Hydravion's dev wrote (LTT forum, via search snippet) that the Roku store build logs in without a reCAPTCHA token via "a semi-shady workaround that won't be made public". [V] for the code comments, [I] for how robust that still is.
  • yt-dlp (master) still uses this path: it requires a sails.sid cookie exported from a browser (_real_initialize raises login-required otherwise) and sends Origin/Referer: https://www.floatplane.com/. [V]

B. Current OAuth2/OIDC login (what you should implement) [V]

Live discovery document fetched today: https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration

issuer                        https://auth.floatplane.com/realms/floatplane
authorization_endpoint        …/protocol/openid-connect/auth
token_endpoint                …/protocol/openid-connect/token
device_authorization_endpoint …/protocol/openid-connect/auth/device
revocation_endpoint           …/protocol/openid-connect/revoke
end_session_endpoint          …/protocol/openid-connect/logout
grant_types_supported         authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code, … (also password, client_credentials, token-exchange, ciba)
scopes_supported              openid, profile, email, offline_access, videos:watch, videos:download, account:read, account:write, …
code_challenge_methods        plain, S256
dpop_signing_alg_values       ES256, RS256, PS256, EdDSA, …

Pre-prod realm/host used by devs: realm floatplane-pp with API base https://pp.floatplane.com (floatplaneAPI.ts sanity.ts, Hydravion apiConfig.brs). [V]

Device-code flow as implemented by Hydravion (oauthDeviceTask.brs), floatplaneAPI.ts (deviceLogin, via openid-client) and the canary:

  1. POST https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device, form client_id=<yours>&scope=openid profile email offline_access (+ code_challenge_method=S256&code_challenge=… when the client has extended security). Response: {device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}. Hydravion shows floatplane.com/link as the friendly URL (the website bundle has linkShortUrl:"floatplane.com/link") and renders verification_uri_complete as a QR code.
  2. Poll POST …/openid-connect/token with grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=…&device_code=… (+ code_verifier, + DPoP header for DPoP clients) every interval s; 400 {"error":"authorization_pending"} → keep polling; slow_down → +5 s; expired_token/access_denied → abort.
  3. Success: {access_token, expires_in, refresh_token, refresh_expires_in, token_type ("Bearer" or "DPoP"), id_token?, scope, session_state, not-before-policy}.
  4. Refresh: POST …/token form grant_type=refresh_token&client_id=…&refresh_token=… (DPoP proof again for DPoP clients). Refresh before expiry (floatplaneAPI.ts refreshes when <60 s left; Hydravion when <300 s). Don't refresh on every request: floatplane-downloader 5.19.5 had a bug refreshing before every call and after ~40 refreshes in seconds auth.floatplane.com returned Cloudflare 429 / error 1015 with a 600 s retry (issue #290).
  5. Call https://www.floatplane.com/api/... with Authorization: Bearer <access_token> (floatplaneAPI.ts adds it in a got beforeRequest hook only for URLs under the base URL; Hydravion/Floaty add it to every API request; Floaty keeps a cookie jar too but relies on Bearer).

Measured lifetimes (bmlzootown canary, live 2026-10-11, client fp-tv-app, requested openid offline_access, granted openid profile email offline_access): access token expires_in=1800 s (30 min); refresh token refresh_expires_in=7776000 s (90 days), rotated on every refresh, sliding window ("Each refresh returned full refresh_expires_in=7776000 from that moment"). Hydravion also handles refresh_expires_in=0 by assuming a 30-day idle limit. [V]

DPoP details (FloatNative DPoPManager.kt/.swift, canary dpop.mjs): EC P-256 key stored per device; proof JWT header {"typ":"dpop+jwt","alg":"ES256","jwk":{kty,crv,x,y}}, payload {iat, jti, htm, htu (URL without query), ath = b64url(sha256(access_token)) on resource requests}; headers DPoP: <proof> and Authorization: DPoP <token>; honour DPoP-Nonce response header and the use_dpop_nonce / invalid_dpop_proof errors by retrying with the nonce; FloatNative auto-corrects clock skew from the Date header on a DPoP 400. [V]

Website's own auth modes (from frontend.floatplane.com/user/4.5.25-340-92712da/js/index-DaEmRTqt.js): enum cookie | token | oauth-basic | oauth-dpop; TV config {baseUrl:"https://auth.floatplane.com", realm:"floatplane", clientId:"fp-tv-app", usesExtendedSecurity:true, linkShortUrl:"floatplane.com/link"}, token_endpoint_auth_method:"none" (public client). [V]

C. GET /api/v3/user/self [V]

{"id":"0123456789abcdef01234567","username":"my_username",
 "profileImage":{"width":512,"height":512,"path":"https://pbs.floatplane.com/profile_images/default/user12.png","childImages":[{"width":250,"height":250,"path":"…_250x250.png"}]},
 "email":"…","displayName":"…","creators":[],"scheduledDeletionDate":null}

Used by every client as the "am I logged in" probe (Hydravion uses /api/v3/user/subscriptions for that; a 401 → clear tokens). Wasserflug calls GET /api/v3/status?platform=web&version=… first (returns selfUser or 403).

1.3 Subscriptions — GET /api/v3/user/subscriptions [V]

[{"startDate":"2020-09-25T07:35:04.273Z","endDate":"2021-09-25T07:35:04.273Z","paymentID":12345,"interval":"year","paymentCancelled":false,
  "plan":{"id":"5d48d0306825b5780db93d07","title":"LTT Supporter (1080p)","description":"…","price":"5.00","priceYearly":"50.00","currency":"usd","logo":null,"featured":true,"allowGrandfatheredAccess":false,"discordServers":[],"discordRoles":[]},
  "creator":"59f94c0bdd241b70349eb72b"}]

creator is the creator GUID (24 hex). Duplicates are possible (Wasserflug de-dupes with a Set). Wasserflug shows an error if the list is empty.

1.4 Creators [V]

  • GET /api/v3/creator/info?id=<guid> → CreatorModelV3 (single creator).
  • GET /api/v3/creator/named?creatorURL[0]=linustechtips → [CreatorModelV3] (yt-dlp uses it to resolve /channel/<urlname>/home/<channel> URLs).
  • GET /api/v3/creator/list?search= → [CreatorModelV3] (discovery; FloatNative also calls it with ids[]= to batch-fetch creators).
  • GET /api/v3/creator/channels/list?ids[0]=<guid> → [ChannelModel] (floatplane-downloader uses this per subscription).
  • v2: GET /api/v2/creator/info?creatorGUID[0]=…&creatorGUID[1]=… → [CreatorModelV2] (multi-creator in one call; Wasserflug still uses it for livestream refresh). CreatorModelV3 (trimmed):
{"id":"59f94c0bdd241b70349eb72b","owner":{"id":"…","username":"Linus"},"title":"LinusTechTips","urlname":"linustechtips",
 "description":"…","about":"# We're LinusTechTips\n…","category":{"id":"…","title":"Technology"},
 "cover":{"width":1990,"height":519,"path":"https://pbs.floatplane.com/cover_images/…jpeg","childImages":[…]},
 "icon":{"width":600,"height":600,"path":"https://pbs.floatplane.com/creator_icons/…jpeg","childImages":[…]},
 "liveStream":{"id":"5c13f3c006f1be15e08e05c0","title":"…","description":"<p>…</p>","thumbnail":{…},"owner":"…","channel":"<channelId>","streamPath":"…","offline":{"title":null,"description":null,"thumbnail":null}},
 "subscriptionPlans":[…],"discoverable":true,"subscriberCountDisplay":"total","incomeDisplay":false,
 "defaultChannel":"63fe42c309e691e4e36de93d",
 "channels":[{"id":"63fe42c309e691e4e36de93d","creator":"59f94c0b…","title":"Linus Tech Tips","urlname":"main","about":"…","order":0,"cover":{…},"card":{…},"icon":{…},"socialLinks":{}}, …],
 "socialLinks":{},"card":{…}}

1.5 Content listing [V]

GET /api/v3/content/creator — params: id* (creator GUID), channel (channel id), limit (clients use 20), fetchAfter (integer offset = number of posts to skip, "usually a multiple of limit"), search, tags[0]=…, hasVideo, hasAudio, hasPicture, hasText (mutually exclusive with the others), sort=ASC|DESC, fromDuration/toDuration (seconds, imply hasVideo), fromDate/toDate (ISO). Returns [BlogPostModelV3]; empty array = end. floatplaneAPI.ts blogPostsIterable simply loops fetchAfter += 20 until an empty page; yt-dlp uses fetchAfter = page * 20.

BlogPostModelV3 (trimmed):

{"id":"Dw2ms0AgL8","guid":"Dw2ms0AgL8","title":"…","text":"<p>…</p>","type":"blogPost",
 "channel":{…ChannelModel…} /* or just a channel id string in some responses */,
 "tags":["test"],"attachmentOrder":["TViGzkuIic"],
 "metadata":{"hasVideo":true,"videoCount":1,"videoDuration":5689,"hasAudio":false,"audioCount":0,"audioDuration":0,"hasPicture":false,"pictureCount":0,"hasGallery":false,"galleryCount":0,"isFeatured":false},
 "releaseDate":"2021-10-09T09:29:00.039Z","likes":41,"dislikes":0,"score":41,"comments":28,
 "creator":{"id":"…","owner":{…},"title":"…","urlname":"…","icon":{…},"liveStream":{…}|null,"defaultChannel":"…","channels":["…"]},
 "wasReleasedSilently":false,
 "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/blogPost_thumbnails/<postId>/<n>_<ts>.jpeg","childImages":[{"width":400,"height":225,"path":"…_400x225.jpeg"},…]},
 "isAccessible":true,
 "videoAttachments":["TViGzkuIic"],"audioAttachments":[],"pictureAttachments":[],"galleryAttachments":[]}

Important recent change: in list responses the attachment arrays are now omitted/empty — Hydravion's current code comments: "List responses omit attachment ids. Keep them when an older payload still sends them." and "Delivery and playback use the attachment id. The creator list no longer includes it." So always fetch GET /api/v3/content/post?id= to get attachment IDs before playback. [V]

GET /api/v3/content/creator/list (multi-creator home feed): ids[0]=…&ids[1]=…&limit=20 plus pagination objects fetchAfter[0][creatorId]=…&fetchAfter[0][blogPostId]=…&fetchAfter[0][moreFetchable]=true (one per creator, taken verbatim from the previous response's lastElements). Response {"blogPosts":[BlogPostModelV3…],"lastElements":[{"creatorId":"…","blogPostId":"…"|null,"moreFetchable":true}]}. Wasserflug filters out lastElements entries with blogPostId == null because "Floatplane doesn't like when we send one of these without a blogPostId". [V]

GET /api/v3/content/post?id=<postId> → ContentPostV3Response: same as above but channel is a full ChannelModel, creator is CreatorModelV2, userInteraction[], and the attachments are full objects:

"videoAttachments":[{"id":"TViGzkuIic","guid":"TViGzkuIic","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…","likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8","thumbnail":{…},"isAccessible":true}],
"audioAttachments":[{…,"duration":114,"waveform":{"dataSetLength":…,"highestValue":…,"lowestValue":…,"data":[…]}}]

Order attachments by attachmentOrder; a post may contain several videos (yt-dlp returns a playlist then). 403 for posts behind a plan you don't have (isAccessible:false).

GET /api/v3/content/video?id=<attachmentId> → ContentVideoV3Response:

{"id":"TViGzkuIic","guid":"…","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…",
 "likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8",
 "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/content_thumbnails/TViGzkuIic/…jpeg","childImages":[]},
 "isAccessible":true,"blogPosts":["Dw2ms0AgL8"],
 "timelineSprite":{"width":4960,"height":2610,"path":"https://pbs.floatplane.com/timeline_sprite/…jpeg"},
 "progress":123 /* seconds, optional */,"userInteraction":[],
 "levels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…],
 "textTracks":[{"id":"…","src":"https://<r2>.r2.cloudflarestorage.com/prod-texttrack/text_tracks/…vtt?X-Amz-…&X-Amz-Expires=900…","kind":"caption","language":"en","generated":true,"processing":false}]}

Caption URLs are pre-signed (900 s) — fetch promptly. floatplane-downloader skips videos whose levels is empty. [V]

1.6 Delivery [V]

GET /api/v3/delivery/info?scenario=onDemand|download|live&entityId=<attachmentId or liveStream.id>[&outputKind=hls.mpegts|hls.fmp4|dash.mpegts|dash.m4s|flat][&entityKind=video|audio|livestream|auto] (entityKind is in the website's client but not in the community spec). Floaty always sends outputKind=hls.mpegts for streaming; Wasserflug sends none (gets HLS TS by default). Response:

{"groups":[{"origins":[{"url":"https://cdn-vod-drm2.floatplane.com"}],
  "variants":[
   {"name":"1080-avc1","label":"1080p","url":"/Videos/Lug14XXgLx/1080.mp4/chunk.m3u8?token=<JWT>","mimeType":"application/x-mpegURL","order":99293376,"hidden":false,"enabled":true,
    "meta":{"video":{"codec":"avc1.64002a","codecSimple":"avc1","bitrate":{"average":1448573},"width":1920,"height":960,"isHdr":false,"fps":29.97,"mimeType":"video/MP2T"},
            "audio":{"codec":"mp4a.40.2","bitrate":{"average":93340,"maximum":93340},"channelCount":2,"samplerate":48000,"mimeType":"video/MP2T"}}},
   {"name":"2160-avc1","label":"4K","url":"/Videos/Lug14XXgLx/2160.mp4/chunk.m3u8?token=<JWT>", …}]}]}
  • download scenario: origins are edge servers (https://edge01-na.floatplane.com, queryUrl, datacenter{latitude,longitude,countryCode,regionCode}) and variant URLs are plain MP4s /Videos/<id>/1080.mp4?token=<JWT> with mimeType: video/mp4.
  • live scenario: {"origins":[{"url":"https://<id>.us-east-1.playback.live-video.net"}],"variants":[{"name":"live-abr","label":"Auto","url":"/api/video/v1/us-east-1.<acct>.channel.<id>.m3u8?allow_source=false&token=<token>","mimeType":"application/x-mpegURL","meta":{"live":{"lowLatencyExtension":"ivshls"}}}]} (Amazon IVS).
  • meta.common.access.deniedReason ∈ isMissingPermission | isProcessing | isBroken with deniedMessage when a variant is not available to you.
  • Building the final URL: if variant.url is absolute use it; else origin.url (trailing slash trimmed) + variant.url; prefer variant.origins over group.origins (Wasserflug DeliveryHelper.getBestUrl, random origin); floatplane-downloader round-robins origins for downloads; Hydravion/yt-dlp just take groups[0].origins[0].url + variant.url. The spec says v3 URLs contain no template placeholders (that was v2).
  • Quality selection in practice: filter enabled && !hidden; sort by order (bigger = better) or meta.video.height; match the user's preference on label ("1080p", "4K") or name ("1080-avc1"); Wasserflug drops variants taller than the screen (×1.15 tolerance; note LTT 2:1 videos are 2160×1080 and 4320×2160).
  • Token: the ?token= is a JWT. The spec's v2 example decodes to {"ressourcePath":"/Videos/TViGzkuIic/360.mp4/chunk.m3u8","userId":"…","iat":1633797311,"exp":1633818911} → 6-hour lifetime in that example (older format); Floaty checks an expires epoch query param on download URLs and re-requests delivery info one minute before expiry. Treat tokens as short-lived (hours) and per-user/per-resource. [V] for the example, [I] for current lifetime.
  • Does the CDN need the cookie/bearer or just the signed URL? Verified behaviour of clients: all current clients send Authorization (Bearer/DPoP) to the CDN as well; FloatNative documents that the master playlist and the AES-128 key request require session-bound auth, media segments do not; yt-dlp pulls the key URL from #EXT-X-KEY:METHOD=AES-128,URI="https://…" and fetches it with the same cookies/headers. Whether a download-scenario MP4 URL plays with the token alone is not verified (Floaty's downloader also goes through its Bearer-adding HTTP client). [V]/[I] as marked. Test both.
  • Older endpoint GET /api/v2/cdn/delivery?type=vod|aod|live|download&guid=<attachment> (or &creator=<guid> for live): {"cdn":"https://cdn-vod-drm2.floatplane.com","strategy":"cdn","resource":{"uri":"/Videos/TViGzkuIic/{qualityLevels}.mp4/chunk.m3u8?token={qualityLevelParams.token}","data":{"qualityLevels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…],"qualityLevelParams":{"360":{"token":"<JWT>"},…}}}} — substitute {qualityLevels} with qualityLevels[].name and {qualityLevelParams.X} with qualityLevelParams[name][X]; later revisions used numeric keys ({qualityLevelParams.1} file, {qualityLevelParams.2}/.4 token — Floaty and old yt-dlp). yt-dlp's old code: path.replace(f'{{qualityLevelParams.{i}}}', val) for each param then urljoin(stream['cdn'], path). yt-dlp switched to v3 on 2025-11-10 (PR #14984) because the v2 shape broke. Treat v2 as deprecated. [V]
  • Floaty's download trick: take the HLS variant URL, strip /chunk.m3u8, replace /Videos/ with /, append &attachment=true&filename=<name> to get a browser-style download. [V] (behaviour), [I] (stability).
  • Rate limit evidence: spec test test_Flow.py sleeps 35 s between delivery calls ("Current rate limit is 2 req per min"); floatplane-downloader uses a semaphore of 2 with a 65 s hold ("only call the delivery endpoint twice a minute at most"); Floaty budgets 2 per 5 minutes and parses Retry-After (default 300 s) and body text error code: 1015. [V]

1.7 Live streams [V]

  • Find the stream via creator.liveStream (CreatorModelV2/V3, also on posts' creator). liveStream.id → GET /api/v3/delivery/info?scenario=live&entityId=<liveStream.id> → IVS HLS URL with token (see above). Old way: /api/v2/cdn/delivery?type=live&creator=<creatorId>.
  • Live/offline detection: Hydravion (streamCheckTask.brs) and Wasserflug (LivestreamViewModel) simply GET the m3u8 URL every ~3–5 s; HTTP 200 = live, anything else = offline. liveStream.offline{title,description,thumbnail} holds the offline card. Channels (sub-channels) don't have their own livestreams ("Individual channels do not currently support livestreams" — Hydravion). FloatNative plays live URLs natively without DPoP/headers (token in URL). The AsyncAPI spec covers chat on wss://chat.floatplane.com/sails.io/ (needs the sails.sid cookie — the one remaining use of the cookie per bmlzootown).

1.8 Watch progress [V]

  • Batch read: POST /api/v3/content/get/progress body {"ids":["<blogPostId>",…],"contentType":"blogPost"} → [{"id":"3qZrz5ugsa","progress":0},{"id":"mhhrOfndeF","progress":100}] — percentage 0–100 of the first video/audio attachment; don't use it to seek.
  • Write: POST /api/v3/content/progress body {"id":"<video attachment id>","contentType":"video"|"audio","progress":<seconds>} → "OK". Wasserflug posts periodically during playback and reads back ContentVideoV3Response.progress (seconds) to resume.
  • Undocumented but present in the website client: GET /api/v3/content/progress?ids=&contentType=, POST /api/v3/content/progress/clear, POST /api/v3/content/progress/delete, GET /api/v3/content/history?limit=&creatorIds= (FloatNative uses offset). [V] (strings in bundle), shapes [I].

1.9 User-Agent, rate limiting, Cloudflare [V]

  • Set a descriptive UA with app name + version (spec README). Observed: Floatplane-Downloader/<ver> (Inrix, +https://github.com/Inrixia/Floatplane-Downloader), CFNetwork, Hydravion (Roku) v<ver>, Wasserflug tvOS App version <ver>, CFNetwork, FloatNative/1.0 (Android), FloatplaneAPI-Watch/3.1 (api-shape-canary; read-only). The , CFNetwork suffix was a captcha-bypass hint for the legacy login; harmless to keep.
  • Floatplane's HTML pages sit behind a Cloudflare JS challenge (my curl of /legal/terms returned the challenge shell), but the JSON API and auth.floatplane.com respond to plain HTTP clients (yt-dlp sets impersonate=None for floatplane.com; the Node canary uses plain fetch). Cloudflare 1015 appears only when you hammer the token endpoint.
  • Handle 429 + Retry-After everywhere; serialize delivery/info calls.

2. Open-source clients to learn from (how each resolves a playable URL)

Client Lang / URL Auth Video resolution path
floatplane-downloader + floatplane npm (floatplaneAPI.ts 5.2.8) TS — https://github.com/Inrixia/floatplane-downloader, https://github.com/Inrixia/floatplaneAPI.ts deviceLogin() via openid-client (device grant, scope openid profile email offline_access, clientId:"floatplane-downloader", token persisted to db/auth.json, auto-refresh in a got hook, Bearer header) user.subscriptions() → creator.channels([id]) → creator.blogPostsIterable(creatorId) (offset 20) → content.post(id) → content.video(attachmentId) (skip if levels empty) → cdn.delivery("download", attachmentId) (semaphore 2/65 s) → round-robin origins, variants with non-empty url sorted by order desc, pick label.includes("1080") else best → got.stream(origin.url + variant.url) → mux with ffmpeg. Channel sorting via post.channel.id.
Wasserflug-tvOS Swift — https://github.com/jamamp/Wasserflug-tvOS OAuthKit device flow, clientID:"wasserflug", QR code of verification_uri_complete; Bearer injected via generated client Configuration.apiWrapper; legacy cookie code kept but commented out getDeliveryInfo(scenario:.ondemand, entityId: attachment.guid) + getVideoContent; DeliveryHelper.getBestUrl (absolute → variant.origins → group.origins → fallback https://www.floatplane.com); filters enabled/!hidden/height ≤ screen; AVURLAsset + HLSResourceLoaderDelegate that adds Authorization: Bearer to intercepted (key) requests; live uses AVURLAssetHTTPCookiesKey. Progress via /content/progress and /content/get/progress. Generated client from the trimmed OpenAPI spec (openapi-generator swift5 --library vapor).
Hydravion (Roku) BrightScript — https://github.com/bmlzootown/Hydravion (Android TV sibling Hydravion-AndroidTV, Java) Hand-rolled device flow (client_id=hydravion, no PKCE/DPoP), tokens in registry, refresh with 300 s buffer, validates with /api/v3/user/subscriptions /api/v3/content/creator?id=&channel= per channel, /api/v3/content/post for attachment ids, /api/v3/delivery/info?scenario=onDemand&entityId= → groups[0].origins[0].url + variant.url, match label against chosen resolution; Roku Video node gets AddHeader("Authorization","Bearer …") + EnableCookies(); live: scenario=live → downloads m3u8 to tmp:/live.m3u8 or plays URL directly.
Floaty Flutter/Dart — https://github.com/floatyfp/floaty Auth-code + PKCE (clientId:"floaty", redirect uk.bw86.floaty://oauth/callback or http://localhost:36479/oauth/callback), refresh with lock, revoke endpoint; cookie mode kept behind a flag v3/delivery/info?scenario=onDemand&entityId=&outputKind=hls.mpegts; mpv/media_kit opened with headers User-Agent, Referer, Origin, Authorization: Bearer; download helper derives MP4 URL (see §1.6) and tracks expires; FPRateLimitManager 2 req/5 min.
FloatNative Kotlin (Android/TV/Fire TV) + SwiftUI (iOS/tvOS) — https://github.com/coulterpeterson/FloatNative Device flow + PKCE + DPoP (clientId:"floatnative", scope openid offline_access), nonce handling, legacy sails.sid captured only for chat VideoResourceLoader.swift: custom scheme → fetch m3u8 with DPoP, rewrite #EXT-X-KEY URI back through the loader (DPoP) and segment URIs to absolute https (no auth); live streams bypass the loader. Also uses /api/v3/content/history, /api/v3/creator/list?ids[]=.
yt-dlp Floatplane extractor Python — https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/floatplane.py (fix PR https://github.com/yt-dlp/yt-dlp/pull/14984, 2025-11-10) Browser sails.sid cookie /api/v3/content/post → for each video/audio attachment /api/v3/delivery/info?scenario=onDemand&entityId= + `/api/v3/content/{video
Kodi plugin.video.floatplaneclub Python — https://github.com/JWolvers/plugin.video.floatplaneclub (archived 2022-02-09) 2017 scraper of the old "Floatplane Club" site; obsolete.
lucacasonato/floatplaneapi old unofficial wrapper (https://github.com/lucacasonato/floatplaneapi), pre-v3; obsolete.

.NET / C#: no Floatplane client exists on NuGet or GitHub (searched). The intended route is to generate one from the trimmed OpenAPI spec (openapi-generator -g csharp / NSwag / Kiota) — the spec repo exists for exactly that — and add the OAuth part yourself (plain HttpClient form posts; for DPoP, Microsoft.IdentityModel.Tokens + ECDsa P-256 is enough). [V] for absence, [I] for tooling advice. Watch out: generated models will be strict about the schema and the live API drifts (e.g. attachment arrays missing from lists); use lenient deserialization.


3. Terms of service / API-usage considerations (brief)

  • The API is unofficial and reverse-engineered from frontend.floatplane.com/{version}/*.js; the spec repo is "for purposes of tinkering and creating custom clients for Floatplane". Floatplane visibly tolerates third-party clients: Hydravion is in the Roku Channel Store, Wasserflug and FloatNative in Apple's/Google's stores, and Floatplane has issued Keycloak client IDs to these projects (strong signal of cooperation). [V] for the facts, [I] for "tolerates".
  • I could not retrieve the ToS text itself (https://www.floatplane.com/legal/terms is a Cloudflare-challenged SPA and the ToS isn't in the JS bundle) — read it in a browser before publishing a plugin. [V]
  • Practical rules all clients follow: personal use with your own paid subscription; unique User-Agent; respect 429/Retry-After and the delivery-endpoint budget; don't share tokens; don't redistribute content. FloatNative's footer wording is the standard disclaimer ("unofficial third-party client and is not affiliated with Floatplane Media Inc.").

4. Other relevant details

  • Channels (sub-channels, introduced 2023): CreatorModelV3.channels[] (ChannelModel {id, creator, title, urlname, about, order, cover, card, icon, socialLinks}), creator.defaultChannel, posts carry channel (object or id). Filter with /api/v3/content/creator?id=<creator>&channel=<channelId>. Web URLs: https://www.floatplane.com/channel/<creator.urlname>/home/<channel.urlname>, posts https://www.floatplane.com/post/<postId>. Known LTT channel ids used by clients: main 63fe42c309e691e4e36de93d, TechLinked 6413534d88c13c181c3e2809, FP Exclusives 6413623f5b12cca228a28e78, They're Just Movies 64135f82fc76ab7f9fbdc876, GameLinked 649dbade3540dbc3945eeda7. [V]
  • IDs: creators/channels/users = 24-hex Mongo ids; posts and attachments = 10-char alphanumeric (Dw2ms0AgL8, TViGzkuIic); guid == id in practice.
  • Thumbnails/images: ImageModel {width,height,path,childImages[{width,height,path}]} on pbs.floatplane.com (blogPost_thumbnails/<postId>/…, video_thumbnails/<attId>/…, content_thumbnails/, creator_icons/, cover_images/, creator_card/, stream_thumbnails/); child sizes are suffixed _WxH.jpeg. Some picture imageFiles[].path are pre-signed (AWSAccessKeyId/Expires/Signature). Hydravion fetches images with the Bearer header too, but pbs thumbnails are generally public. [V]/[I]
  • Post metadata: metadata{hasVideo, videoCount, videoDuration(s), hasAudio, audioCount, audioDuration, hasPicture, pictureCount, hasGallery, galleryCount, isFeatured}; isAccessible:false = locked (show as unavailable); wasReleasedSilently; text is HTML (yt-dlp clean_htmls it).
  • Pagination semantics: /content/creator → fetchAfter is a plain skip count (page N = N*limit), stop on empty page; /content/creator/list → opaque per-creator cursors in lastElements (moreFetchable:false when a creator is exhausted; drop entries with null blogPostId). Both default to 20 per page in clients.
  • Versions: spec 3.10.0-c tracks frontend ≤ 4.0.13; live frontend today 4.5.25-340-92712da. The bmlzootown fork's canary (baselines for user/self, user/subscriptions, content/creator, content/post) reported "no structural drift" as of 2026-10-02/11.
  • Pre-prod environment for testing exists (pp.floatplane.com + realm floatplane-pp) but needs a pre-prod account/client.

5. Suggested shape for the Jellyfin plugin (inferred from the above) [I]

  1. Config page runs the device flow: get a client ID from Floatplane first; POST …/auth/device → show verification_uri_complete + QR + user_code; poll …/token; store refresh_token (+ DPoP key if your client has extended security) in the plugin config; refresh proactively (<60 s left), never per-request; re-login when refresh fails with invalid_grant.
  2. Library/Channel provider: user/subscriptions → creator/info (+ channels) → content/creator?id&channel&limit=20&fetchAfter (filter metadata.hasVideo) → content/post for attachment objects → one Jellyfin item per video attachment (title from attachment when a post has several; releaseDate, thumbnail, videoDuration, text as overview).
  3. Playback: on request call delivery/info?scenario=onDemand&entityId=<attachmentId> (cache per attachment until the JWT exp; throttle to ~2/min globally), pick a variant, then serve the HLS through the plugin (an authenticated proxy endpoint that fetches the master/media playlist and the #EXT-X-KEY with Authorization, rewrites segment URIs to absolute CDN URLs) or hand ffmpeg the URL with -headers "Authorization: Bearer …"; alternatively try scenario=download MP4 URLs and verify whether the token alone is accepted. Live: scenario=live URL plays as-is.
  4. Progress sync: POST /content/progress every ~30 s with seconds; POST /content/get/progress to mark watched (100) in listings; content/video.progress for resume position.
  5. Send User-Agent: Jellyfin-Floatplane/<version> (+<repo url>), honour 429/Retry-After.

Sources: https://github.com/jamamp/FloatplaneAPI · https://jamamp.github.io/FloatplaneAPIDocs/ · https://github.com/bmlzootown/FloatplaneAPI (PRs #6, #7, #11, #12) · https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration · https://github.com/Inrixia/floatplaneAPI.ts (incl. PR #15) · https://github.com/Inrixia/floatplane-downloader (issue #290) · https://github.com/NigelVanHattum/Homelab-Helm-charts/pull/89 · https://github.com/jamamp/Wasserflug-tvOS · https://github.com/bmlzootown/Hydravion · https://github.com/bmlzootown/Hydravion-AndroidTV · https://github.com/floatyfp/floaty · https://github.com/coulterpeterson/FloatNative · https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/floatplane.py · https://github.com/yt-dlp/yt-dlp/pull/14984 · https://github.com/JWolvers/plugin.video.floatplaneclub · https://github.com/lucacasonato/floatplaneapi · https://frontend.floatplane.com/user/4.5.25-340-92712da/js/index-DaEmRTqt.js · https://linustechtips.com/topic/1052617-floatplane-unofficial-rokutvosandroidtv-channel/ and https://linustechtips.com/topic/1626139-hydravion-androidtv-login-issues/ (search snippets only; pages blocked).