Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
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>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

239
doc/floatplane-api-notes.md Normal file
View File

@@ -0,0 +1,239 @@
# 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`.