Files
jellyfin-plugin-floatplane/doc/floatplane-api-notes.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

240 lines
12 KiB
Markdown
Raw Permalink 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.

# 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`.