- 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>
12 KiB
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(needssails.sid).POST /api/v3/auth/loginwithoutcaptchaToken→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:"<creatorId>", startDate, endDate, interval}]
GET /api/v3/creator/list?ids[0]=<creatorId> → [Creator]
GET /api/v3/creator/named?creatorURL[0]=linustechtips → [Creator]
GET /api/v3/creator/info?id=<creatorId> → Creator
GET /api/v3/creator/stats?id=<creatorId>
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/<region>.<acct>.channel.<id>.m3u8.
Content feed
POST /api/v3/content/creator/list {"ids":["<creatorId>",...],"limit":20}
→ {"blogPosts":[Post...], "lastElements":[{creatorId, blogPostId, moreFetchable}]}
(pagination: send fetchAfter=[{creatorId, blogPostId, moreFetchable}] back)
GET /api/v3/content/creator?id=<creatorId>&limit=20&fetchAfter=0&sort=DESC
&search=&hasVideo=false&hasAudio=false&hasPicture=false&hasText=false
→ [Post...] (fetchAfter = offset; add &channel=<channelId> to filter)
GET /api/v3/content/post?id=<postId> → Post (full, with videoAttachments[])
GET /api/v3/content/video?id=<videoId> → Video
GET /api/v3/content/related?id=<postId>
GET /api/v3/content/progress?ids[0]=<postId>&...&contentType=blogPost → [{id, progress}]
POST /api/v3/content/progress {"id":"<videoId>","contentType":"video","progress":<seconds>}
Post (trimmed):
{
"id": "SSvhppI28t", "guid": "SSvhppI28t",
"title": "How Board Games Come ALIVE...- Board",
"text": "<p>…html…</p>", "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/<postId>/<n>_<ts>.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):
{
"id": "dcm2jUIg1J", "title": "…", "type": "video", "duration": 1104,
"creator": "<creatorId>", "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>.r2.cloudflarestorage.com/…/<videoId>/….vtt?X-Amz-…&X-Amz-Expires=900",
"kind":"captions","language":"en","generated":true} ],
"thumbnail": { … "content_thumbnails/<videoId>/…" },
"timelineSprite": { "width": 3840, "height": 2070, "path": "https://pbs.floatplane.com/timeline_sprite/<videoId>/….jpeg" }
}
Playback (VOD)
GET /api/v3/delivery/info?scenario=onDemand&entityId=<videoId>
{ "groups": [ {
"origins": [ { "url": "https://cdn-vod-drm2.floatplane.com" } ],
"variants": [
{ "name": "1080-avc1", "label": "1080p", "enabled": true, "hidden": false,
"url": "/Videos/<videoId>/1080.mp4/chunk.m3u8?token=<JWT>",
"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
tokenis a JWT (HS256) withresourcePath,userId,iat,exp→ valid 6 h (exp - iat = 21600). Variants above the user's tier come backenabled:falsewith 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<base64json>.ts?token=<per-segment JWT, also 6 h>and starts with#EXT-X-KEY:METHOD=AES-128,URI="https://www.floatplane.com/api/video/watchKey?token=<base64({path,timestamp})>" -
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 withaccess-control-allow-origin: *andcache-control: public, max-age=86400and 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-KEYURI points back at the plugin (/Floatplane/Key/<videoId>/<level>), which fetches the key server-side. Segment URLs can stay on the CDN (CORS is open), or be proxied too. -
Other
scenariovalues documented by the community:download(progressive mp4, used by floatplane-downloader) andlive. 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]=<id> → {tag: count} (136 tags for LTT),
GET /api/v3/creator/channels/list?ids[0]=<id>, GET /api/v3/creator/named?creatorURL[0]=<urlname>.
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=<liveStreamId>
(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 ShortCircuit64135da7ce81077a8480c679, sample postSSvhppI28t, videodcm2jUIg1J.