Some checks failed
release / build-release (push) Has been cancelled
- 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>
240 lines
12 KiB
Markdown
240 lines
12 KiB
Markdown
# 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 <username>".
|
||
|
||
## 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):
|
||
|
||
```json
|
||
{
|
||
"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):
|
||
|
||
```json
|
||
{
|
||
"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>
|
||
```
|
||
|
||
```json
|
||
{ "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 `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
|
||
`<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 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/<videoId>/<level>`), 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]=<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 ShortCircuit
|
||
`64135da7ce81077a8480c679`, sample post `SSvhppI28t`, video `dcm2jUIg1J`.
|