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

222 lines
39 KiB
Markdown
Raw Blame History

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.

<!-- Research report produced 2026-10-25 by a Claude research agent (web sources). [V]=verified from docs/code/live fetch, [I]=inferred. -->
# 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
#### 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 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]**
```json
{"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]**
```json
[{"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):
```json
{"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):
```json
{"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**:
```json
"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`:
```json
{"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:
```json
{"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|audio}` → `urljoin(groups[0].origins[*].url, variant.url)`; formats from `variant.meta`; HLS AES key via `#EXT-X-KEY` URI; channel pages via `/api/v3/creator/named?creatorURL[0]=` and `/api/v3/content/creator?id=&limit=20&fetchAfter=page*20[&channel=]`. Cleanest reference for the v3 shapes. |
| 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_html`s 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).