# Floatplane API — what the captured session shows Source: `doc/har/www.floatplane.com.har` (Chrome export, 2026-10-25, web client `4.5.25`). The HAR is **gitignored** because it contains the login POST body (plain-text password + captcha token), the account e-mail and user-bound signed CDN tokens. Everything below is sanitised. The capture covers: a video already playing → logout → login (with Cloudflare Turnstile) → load the LinusTechTips creator page → open a post → play its 1080p video. ## Hosts | Host | Role | Needs session? | |------|------|----------------| | `www.floatplane.com` | REST API (`/api/v3/...`) + HLS **key server** (`/api/video/watchKey`) | yes for most | | `cdn-vod-drm2.floatplane.com` | VOD HLS playlists + `.ts` segments (Cloudflare-cached) | no — signed URL token | | `pbs.floatplane.com` | thumbnails, cover images, timeline sprites | no | | `*.r2.cloudflarestorage.com` | subtitle `.vtt` tracks (S3-presigned, 15 min) | no — presigned | | `apm.floatplane.com`, `playback-slurp.floatplane.com` | telemetry (ignore) | — | Verified 2026-10-25 with plain `curl` (custom User-Agent, no cookie): - `GET /api/v3/creator/named`, `GET /api/v3/content/creator` → **200, public** (post metadata, thumbnails, titles, durations — no login needed). - `GET /api/v3/delivery/info` → `notLoggedInError` (needs `sails.sid`). - `POST /api/v3/auth/login` **without** `captchaToken` → `invalidCaptchaTokenError`, **regardless of User-Agent** (tested Hydravion/Wasserflug/CFNetwork/Android style UAs). Server-side username+password login is therefore not possible without a Turnstile token → the plugin must accept a pasted session cookie (see "Auth" below). - Cloudflare does **not** block plain non-browser requests to the API. ## Auth ``` GET /api/v3/auth/captcha/info → site keys; the web client uses turnstile.variants.managed.siteKey POST /api/v3/auth/login {"username","password","captchaToken"} 200 → {"user":{id,username,email,profileImage,...},"needs2FA":false} (if needs2FA → POST /api/v3/auth/checkFor2faLogin {"token":"123456"}) POST /api/v3/auth/logout → 200 "OK" GET /api/v3/user/self → current user (good "is the cookie alive?" probe) ``` Session = cookie `sails.sid` (HttpOnly). Chrome strips cookies from HAR exports, so the actual value is not in the capture. Plugin plan: the user copies `sails.sid` from the browser (DevTools → Application → Cookies) into the plugin settings; the plugin validates it with `/api/v3/user/self` and reports "logged in as ". ## Discovery ``` GET /api/v3/user/subscriptions?active=true → [{plan:{id,title,price,...}, creator:"", startDate, endDate, interval}] GET /api/v3/creator/list?ids[0]= → [Creator] GET /api/v3/creator/named?creatorURL[0]=linustechtips → [Creator] GET /api/v3/creator/info?id= → Creator GET /api/v3/creator/stats?id= ``` `Creator` keys: `id, title, urlname, description, about, category, cover, icon, card, channels[], defaultChannel, liveStream, subscriptionPlans[], subscriberCountDisplay, socialLinks, discordServers, owner, visibility`. `channels[]` = sub-channels (`{id, title, urlname, icon, cover, card}`), e.g. LTT has 11: `main`, `techlinked`, `shortcircuit`, `techquickie`, `fpexclusive`, `tajm`, `gamelinked`, `channelsuperfun`, `macaddress`, `psucircuit`, `ltxexpo`. Every post carries a `channel` object → natural folder structure in Jellyfin: **Creator → Channel → Posts**. `liveStream` = `{id, title, streamPath, thumbnail, ...}`; `streamPath` looks like `/api/video/v1/..channel..m3u8`. ## Content feed ``` POST /api/v3/content/creator/list {"ids":["",...],"limit":20} → {"blogPosts":[Post...], "lastElements":[{creatorId, blogPostId, moreFetchable}]} (pagination: send fetchAfter=[{creatorId, blogPostId, moreFetchable}] back) GET /api/v3/content/creator?id=&limit=20&fetchAfter=0&sort=DESC &search=&hasVideo=false&hasAudio=false&hasPicture=false&hasText=false → [Post...] (fetchAfter = offset; add &channel= to filter) GET /api/v3/content/post?id= → Post (full, with videoAttachments[]) GET /api/v3/content/video?id= → Video GET /api/v3/content/related?id= GET /api/v3/content/progress?ids[0]=&...&contentType=blogPost → [{id, progress}] POST /api/v3/content/progress {"id":"","contentType":"video","progress":} ``` `Post` (trimmed): ```json { "id": "SSvhppI28t", "guid": "SSvhppI28t", "title": "How Board Games Come ALIVE...- Board", "text": "

…html…

", "textMarkdown": "…", "type": "blogPost", "tags": [], "attachmentOrder": ["dcm2jUIg1J"], "releaseDate": "2026-10-24T18:24:00.036Z", "likes": 59, "dislikes": 0, "score": 59, "comments": 6, "isAccessible": true, "metadata": { "hasVideo": true, "videoCount": 1, "videoDuration": 1104, "hasAudio": false, "hasPicture": false, "hasGallery": false, "isFeatured": false, "displayDuration": 1104 }, "thumbnail": { "width": 1920, "height": 1080, "path": "https://pbs.floatplane.com/video_thumbnails//_.jpeg", "childImages": [ {400x225}, {1200x675}, {1920x1080} ] }, "creator": { "id": "59f94c0bdd241b70349eb72b", "title": "LinusTechTips", "urlname": "linustechtips", "...": "…" }, "channel": { "id": "64135da7ce81077a8480c679", "title": "ShortCircuit", "urlname": "shortcircuit", "...": "…" }, "videoAttachments": [ Video ], // only in /content/post "audioAttachments": [], "pictureAttachments": [], "galleryAttachments": [] } ``` In the list endpoints `videoAttachments` are **ids only**; `/content/post` expands them. `isAccessible:false` = locked behind a higher tier. `Video` (trimmed): ```json { "id": "dcm2jUIg1J", "title": "…", "type": "video", "duration": 1104, "creator": "", "primaryBlogPost": "SSvhppI28t", "isProcessing": false, "levels": [ {"name":"360","width":640,"height":360,"label":"360p","order":0}, {"name":"1080",…}, {"name":"2160","label":"4K","order":7} ], "textTracks": [ {"id","src":"https://.r2.cloudflarestorage.com/…//….vtt?X-Amz-…&X-Amz-Expires=900", "kind":"captions","language":"en","generated":true} ], "thumbnail": { … "content_thumbnails//…" }, "timelineSprite": { "width": 3840, "height": 2070, "path": "https://pbs.floatplane.com/timeline_sprite//….jpeg" } } ``` ## Playback (VOD) ``` GET /api/v3/delivery/info?scenario=onDemand&entityId= ``` ```json { "groups": [ { "origins": [ { "url": "https://cdn-vod-drm2.floatplane.com" } ], "variants": [ { "name": "1080-avc1", "label": "1080p", "enabled": true, "hidden": false, "url": "/Videos//1080.mp4/chunk.m3u8?token=", "mimeType": "application/x-mpegURL", "order": 111745216, "meta": { "video": { "codec": "avc1.640028", "width": 1920, "height": 1080, "fps": 29.97, "isHdr": false, "bitrate": {"average": 1661360}, "mimeType": "video/MP2T" }, "audio": { "codec": "mp4a.40.2", "channelCount": 2, "samplerate": 48000, "bitrate": {"average": 151115} } } }, { "name": "2160-avc1", "label": "4K", "enabled": false, "url": "", "meta": { "common": { "access": { "deniedReason": "isMissingPermission", "deniedMessage": "You lack permission to access this media." } } } } ] } ] } ``` - Final playlist URL = `origins[0].url + variants[i].url`. - The `token` is a **JWT** (HS256) with `resourcePath`, `userId`, `iat`, `exp` → **valid 6 h** (`exp - iat = 21600`). Variants above the user's tier come back `enabled:false` with an empty url (4K here). - `chunk.m3u8` (HLS v3, `#EXT-X-ENDLIST`, ~181 segments of 4–12 s for an 18-minute video) lists segments as `.ts?token=` and starts with ``` #EXT-X-KEY:METHOD=AES-128,URI="https://www.floatplane.com/api/video/watchKey?token=" ``` - **The AES-128 key request is the only playback call that needs the session cookie** (`cache-control: no-cache, private`, 16-byte body). Segments and playlists are served by Cloudflare with `access-control-allow-origin: *` and `cache-control: public, max-age=86400` and need no cookie, only their token. - Consequence for Jellyfin: whoever plays the stream (ffmpeg on the server, or hls.js in the browser on direct play) must be able to fetch the key **with** `sails.sid`. Browsers cannot attach a third-party cookie, so the plugin should **serve a rewritten playlist** from its own API controller where the `#EXT-X-KEY` URI points back at the plugin (`/Floatplane/Key//`), which fetches the key server-side. Segment URLs can stay on the CDN (CORS is open), or be proxied too. - Other `scenario` values documented by the community: `download` (progressive mp4, used by floatplane-downloader) and `live`. Not in the capture — test once a cookie is available; a plain mp4 would let Jellyfin direct-play with range requests and skip the HLS/key dance. ## Search & filters on `GET /api/v3/content/creator` (mapped 2026-10-25, public) All verified with plain curl, no cookie: | Param | Effect | Note | |-------|--------|------| | `id` | creator id (required) | | | `channel` | sub-channel id | combine freely with the rest | | `search` | free-text, matches titles (and apparently body text) | `search=framework` → Framework laptop videos across channels | | `hasVideo=true` | only posts with a video | `hasAudio`/`hasPicture`/`hasText` likewise (one at a time) | | `tags[0]=wan` | tag filter | matched WAN-show VODs even though their `tags` array is empty — server-side tags differ from the exposed array | | `fromDate`/`toDate` | ISO 8601 window on `releaseDate` | | | `fromDuration`/`toDuration` | seconds | `0..300` returned shorts and 0-s text posts | | `sort` | `DESC` (default) / `ASC` | | | `limit` | **max 20** (`paramValidationError "limit" must be less than or equal to 20`) | | | `fetchAfter` | offset in posts, multiples of `limit` | empty array = end | Related public endpoints: `GET /api/v3/content/tags?creatorIds[0]=` → `{tag: count}` (136 tags for LTT), `GET /api/v3/creator/channels/list?ids[0]=`, `GET /api/v3/creator/named?creatorURL[0]=`. **Login required** (verified `notLoggedInError` without cookie): `creator/info?id`, `creator/list` (with or without `ids[]`/`search`), `POST content/creator/list` (multi-creator feed), `content/post`, `content/video`, `delivery/info`, `user/self`, `user/subscriptions`. **Anonymous listings report every post as `isAccessible: false`** and `videoAttachments: null` — the flag only means something once logged in. The plugin therefore only applies its "locked" filter when a session exists. Jellyfin has no channel-search hook in 10.10, so Floatplane search is exposed as an admin endpoint (`GET /Floatplane/Search?creatorId=&q=&channelId=`) plus the "Search test" box on the plugin settings page. Items that have been listed once are searchable through Jellyfin's normal library search. ## Live `creator.liveStream.streamPath` + `GET /api/v3/delivery/info?scenario=live&entityId=` (not in capture). ## Subtitles `video.textTracks[].src` is an S3-presigned URL that expires after 900 s — fetch at play time and hand to Jellyfin as an external subtitle stream (or cache the `.vtt` on disk). ## Images All `pbs.floatplane.com` paths are public and cacheable (verified without cookie). Use `thumbnail.childImages` `1200x675` for primary art, `creator.cover`/`card` for the creator folder. ## IDs seen (public) - LinusTechTips creator id `59f94c0bdd241b70349eb72b`, channel ShortCircuit `64135da7ce81077a8480c679`, sample post `SSvhppI28t`, video `dcm2jUIg1J`.