- 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>
39 KiB
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
- 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 withAuthorization: Bearer <access_token>instead of thesails.sidcookie. [V] (sources: floatplaneAPI.tssrc/index.ts, HydravionoauthDeviceTask.brs, WasserflugWasserflug_tvOSApp.swift, Floatyoauth2_service.dart, FloatNativeOAuthApi.kt, live OIDC discovery doc). - You need an OAuth
client_idregistered by Floatplane. Thefloatplanenpm README saysclientId: "example-client", // Has to be registered on FloatPlane. Known registered public clients:hydravion,wasserflug,floatplane-downloader,floaty,floatnative, and Floatplane's ownfp-tv-app. There is no public self-service registration page; the maintainers evidently obtained client IDs from Floatplane staff (the community Discordhttps://discord.gg/4xKDGz5M5Bis 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". - Some clients require DPoP (RFC 9449).
fp-tv-appandfloatnativeare configured withusesExtendedSecurity→ 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,floatyuse plainBearerwithout DPoP. So whether your plugin needs DPoP depends on how Floatplane configures your client. [V] - The CDN/HLS side needs auth too (for VOD). Hydravion sets
Authorization: Beareron the Roku video node for all playlist/segment/key requests; Floaty passesAuthorization+Referer/Originheaders to mpv; Wasserflug intercepts AVPlayer loads and adds Bearer; FloatNative fetches the m3u8 manifest and the AES-128#EXT-X-KEYwith 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. /api/v3/delivery/infois 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]- The community OpenAPI spec (jamamp/FloatplaneAPI, spec version
3.10.0-c, frontend tracked to4.0.13) lags the live site (frontend is4.5.25today) and still documents onlyCookieAuth; the bmlzootown fork README states "OpenAPI may still list only CookieAuth — explorer REST auth is Bearer ahead of that docs lag" and thatsails.sidis "chat/Socket.IO-only". [V]
1. Community API documentation
1.1 Repositories and docs sites
- Spec repo (renamed):
https://github.com/jamamp/FloatplaneAPI(formerlyJman012/FloatplaneAPI; old URL redirects). Files:src/floatplane-openapi-specification.json(full, 606 KB, OpenAPI 3.0.3, title "Floatplane REST API", version3.10.0-c), trimmed spec generated bytools/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 oldjman012.github.io/FloatplaneAPIDocsnow 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 ishttps://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-Agentheader ... 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 varsails.sid. The spec'sinfo.descriptionhas 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 viadelivery/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-Afterheader ... Clients are expected to 1) prevent too many requests ... particularly for the/api/v2/cdn/deliveryand/api/v3/delivery/infoendpoints, 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
A. Legacy cookie login (documented in the spec; still exists server-side; the website bundle still contains /api/v3/auth/login, /api/v3/auth/checkFor2faLogin, /api/v3/auth/captcha/info, /api/v3/auth/logout) [V]
POST /api/v2/auth/login(also/api/v3/auth/login) body{"username","password","captchaToken"?}; 200 →{"user": {id, username, profileImage}, "needs2FA": bool}; sets cookiesails.sid(Sails.js session).captchaTokendescription: "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
ExpiresfromSet-Cookieand 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.sidcookie exported from a browser (_real_initializeraises login-required otherwise) and sendsOrigin/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:
POST https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device, formclient_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 showsfloatplane.com/linkas the friendly URL (the website bundle haslinkShortUrl:"floatplane.com/link") and rendersverification_uri_completeas a QR code.- Poll
POST …/openid-connect/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=…&device_code=…(+code_verifier, +DPoPheader for DPoP clients) everyintervals; 400{"error":"authorization_pending"}→ keep polling;slow_down→ +5 s;expired_token/access_denied→ abort. - Success:
{access_token, expires_in, refresh_token, refresh_expires_in, token_type ("Bearer" or "DPoP"), id_token?, scope, session_state, not-before-policy}. - Refresh:
POST …/tokenformgrant_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 secondsauth.floatplane.comreturned Cloudflare 429 / error 1015 with a 600 s retry (issue #290). - Call
https://www.floatplane.com/api/...withAuthorization: Bearer <access_token>(floatplaneAPI.ts adds it in agotbeforeRequesthook 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 withids[]=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>", …}]}]}
downloadscenario: 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>withmimeType: video/mp4.livescenario:{"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 | isBrokenwithdeniedMessagewhen a variant is not available to you.- Building the final URL: if
variant.urlis absolute use it; elseorigin.url(trailing slash trimmed) +variant.url; prefervariant.originsovergroup.origins(WasserflugDeliveryHelper.getBestUrl, random origin); floatplane-downloader round-robins origins for downloads; Hydravion/yt-dlp just takegroups[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 byorder(bigger = better) ormeta.video.height; match the user's preference onlabel("1080p", "4K") orname("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 anexpiresepoch 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 adownload-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}withqualityLevels[].nameand{qualityLevelParams.X}withqualityLevelParams[name][X]; later revisions used numeric keys ({qualityLevelParams.1}file,{qualityLevelParams.2}/.4token — Floaty and old yt-dlp). yt-dlp's old code:path.replace(f'{{qualityLevelParams.{i}}}', val)for each param thenurljoin(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.pysleeps 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 parsesRetry-After(default 300 s) and body texterror 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 withtoken(see above). Old way:/api/v2/cdn/delivery?type=live&creator=<creatorId>. - Live/offline detection: Hydravion (
streamCheckTask.brs) and Wasserflug (LivestreamViewModel) simplyGETthe 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 onwss://chat.floatplane.com/sails.io/(needs thesails.sidcookie — the one remaining use of the cookie per bmlzootown).
1.8 Watch progress [V]
- Batch read:
POST /api/v3/content/get/progressbody{"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/progressbody{"id":"<video attachment id>","contentType":"video"|"audio","progress":<seconds>}→"OK". Wasserflug posts periodically during playback and reads backContentVideoV3Response.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 usesoffset). [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, CFNetworksuffix was a captcha-bypass hint for the legacy login; harmless to keep. - Floatplane's HTML pages sit behind a Cloudflare JS challenge (my
curlof/legal/termsreturned the challenge shell), but the JSON API andauth.floatplane.comrespond to plain HTTP clients (yt-dlp setsimpersonate=Nonefor floatplane.com; the Node canary uses plainfetch). Cloudflare 1015 appears only when you hammer the token endpoint. - Handle 429 +
Retry-Aftereverywhere; 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/termsis 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-Afterand 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 carrychannel(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>, postshttps://www.floatplane.com/post/<postId>. Known LTT channel ids used by clients: main63fe42c309e691e4e36de93d, TechLinked6413534d88c13c181c3e2809, FP Exclusives6413623f5b12cca228a28e78, They're Just Movies64135f82fc76ab7f9fbdc876, GameLinked649dbade3540dbc3945eeda7. [V] - IDs: creators/channels/users = 24-hex Mongo ids; posts and attachments = 10-char alphanumeric (
Dw2ms0AgL8,TViGzkuIic);guid == idin practice. - Thumbnails/images:
ImageModel {width,height,path,childImages[{width,height,path}]}onpbs.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 pictureimageFiles[].pathare 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;textis HTML (yt-dlpclean_htmls it). - Pagination semantics:
/content/creator→fetchAfteris a plain skip count (page N =N*limit), stop on empty page;/content/creator/list→ opaque per-creator cursors inlastElements(moreFetchable:falsewhen a creator is exhausted; drop entries with nullblogPostId). Both default to 20 per page in clients. - Versions: spec
3.10.0-ctracks frontend ≤4.0.13; live frontend today4.5.25-340-92712da. The bmlzootown fork's canary (baselines foruser/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+ realmfloatplane-pp) but needs a pre-prod account/client.
5. Suggested shape for the Jellyfin plugin (inferred from the above) [I]
- Config page runs the device flow: get a client ID from Floatplane first;
POST …/auth/device→ showverification_uri_complete+ QR +user_code; poll…/token; storerefresh_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 withinvalid_grant. - Library/Channel provider:
user/subscriptions→creator/info(+channels) →content/creator?id&channel&limit=20&fetchAfter(filtermetadata.hasVideo) →content/postfor attachment objects → one Jellyfin item per video attachment (title from attachment when a post has several;releaseDate,thumbnail,videoDuration,textas overview). - Playback: on request call
delivery/info?scenario=onDemand&entityId=<attachmentId>(cache per attachment until the JWTexp; 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-KEYwithAuthorization, rewrites segment URIs to absolute CDN URLs) or hand ffmpeg the URL with-headers "Authorization: Bearer …"; alternatively tryscenario=downloadMP4 URLs and verify whether the token alone is accepted. Live:scenario=liveURL plays as-is. - Progress sync:
POST /content/progressevery ~30 s with seconds;POST /content/get/progressto mark watched (100) in listings;content/video.progressfor resume position. - 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).