Seed wiki: architecture overview + Floatplane API summary

2026-10-25 09:38:51 +01:00
commit f970ccb0a1
2 changed files with 117 additions and 0 deletions

48
Floatplane-API.md Normal file

@@ -0,0 +1,48 @@
# Floatplane API (unofficial) — what the plugin uses
Condensed from `doc/floatplane-api-notes.md` and `doc/research/floatplane-api-research.md` in the main repo. Verified with plain `curl` on 2026-10-25 (custom User-Agent, no cookie unless stated). Base URL `https://www.floatplane.com`.
## Public (no cookie)
| Endpoint | Notes |
|----------|-------|
| `GET /api/v3/creator/named?creatorURL[0]=<urlname>` | → `[Creator]` incl. `channels[]`, `liveStream`, `subscriptionPlans` |
| `GET /api/v3/creator/channels/list?ids[0]=<creatorId>` | → `[Channel]` (`id, creator, title, urlname, order, icon, cover, card`) |
| `GET /api/v3/content/creator?id=<creatorId>&limit=20&fetchAfter=<offset>` | post listing, newest first. Filters: `channel=<channelId>`, `search=<text>` (title/body), `hasVideo=true` (also `hasAudio/hasPicture/hasText`, mutually exclusive), `tags[0]=…`, `fromDate/toDate` (ISO 8601), `fromDuration/toDuration` (seconds), `sort=ASC|DESC`. **`limit` max 20** (`paramValidationError` above), `fetchAfter` is a plain offset; empty array = end. List items carry `metadata` (hasVideo, videoDuration…) and `thumbnail` but **no attachment ids**; `isAccessible:false` = above your tier. |
| `GET /api/v3/content/tags?creatorIds[0]=<creatorId>` | → `{ "<tag>": count, … }` |
| `https://pbs.floatplane.com/...` | thumbnails / icons / covers / timeline sprites; `childImages[]` carry `_WxH` variants |
## Login required (cookie `sails.sid`)
| Endpoint | Notes |
|----------|-------|
| `GET /api/v3/user/self` | cookie probe → `{id, username, email, profileImage}` |
| `GET /api/v3/user/subscriptions?active=true` | → `[{plan{id,title,price}, creator:<creatorId>, startDate, endDate}]` |
| `GET /api/v3/creator/info?id=` · `GET /api/v3/creator/list?ids[0]=` | creator details by id |
| `POST /api/v3/content/creator/list {ids:[…], limit}` | multi-creator feed with `lastElements` cursors |
| `GET /api/v3/content/post?id=<postId>` | full post with `videoAttachments[]` objects (`id, title, duration, levels[], textTracks[]`) and `attachmentOrder` |
| `GET /api/v3/content/video?id=<videoId>` | video details, `progress` (resume seconds), `timelineSprite` |
| `GET /api/v3/delivery/info?scenario=onDemand\|download\|live&entityId=<videoId>` | `groups[].origins[].url` + `variants[]{name:"1080-avc1", label, url (+JWT token, 6 h), enabled, hidden, meta{video{codec,width,height,fps,bitrate}, audio{…}, common.access.deniedReason}}`. **Rate-limited ≈2/min.** |
| `GET /api/video/watchKey?token=…` | HLS AES-128 key (16 bytes) — the only playback request that needs the cookie |
| `GET /api/v3/content/progress?ids[0]=&contentType=blogPost` · `POST /api/v3/content/progress {id, contentType:"video", progress:<s>}` | watch progress (read = percent, write = seconds) |
## Auth facts
- `POST /api/v3/auth/login {username,password,captchaToken}` → `invalidCaptchaTokenError` without a Cloudflare Turnstile token, for every User-Agent tested (browser, Hydravion, Wasserflug/CFNetwork, Android, okhttp).
- OAuth2/OIDC: `https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration` (device-code grant, DPoP supported). Third-party clients (Hydravion, Wasserflug, floatplane-downloader, Floaty, FloatNative) have Floatplane-registered client ids; an unregistered id gets `invalid_client`. Access token 30 min, refresh token 90 days sliding (measured by the community canary).
- Session cookie lifetime: not documented; the plugin reports `invalid` and logs a warning when Floatplane rejects it.
## Playback (VOD)
```
origin = groups[0].origins[0].url e.g. https://cdn-vod-drm2.floatplane.com
playlist = origin + variant.url /Videos/<videoId>/1080.mp4/chunk.m3u8?token=<JWT>
chunk.m3u8: #EXT-X-KEY:METHOD=AES-128,URI="https://www.floatplane.com/api/video/watchKey?token=…" ← needs cookie
<base64json>.ts?token=<JWT> ← token only, CORS *
```
Variants above the account's tier come back `enabled:false, url:""` with `meta.common.access.deniedReason = isMissingPermission`. Subtitles: `video.textTracks[].src` is an S3-presigned VTT URL valid 15 minutes.
## Etiquette
Unique User-Agent (`Jellyfin-Floatplane/<version> (+repo url)`), honour `429`/`Retry-After`, cache `delivery/info` per video for the token lifetime, personal use with your own subscription.

69
Home.md Normal file

@@ -0,0 +1,69 @@
# jellyfin-plugin-floatplane
A Jellyfin **channel plugin** that shows your Floatplane subscriptions (creators → sub-channels → videos) inside Jellyfin and every Jellyfin client, and plays them through Jellyfin's normal player. No client-side changes.
- Code + plan: https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane (`doc/plan.md`, `doc/floatplane-api-notes.md`, `doc/research/`)
- Install: Dashboard → Plugins → Repositories → add `https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json`, or run `install-pi5.sh` from the release.
- Built for **Jellyfin 10.10.x** (.NET 8, `targetAbi 10.10.0.0`). One AnyCPU DLL for x64 and arm64 (Pi5).
## Architecture
```
Jellyfin clients (web, Android, TV, JMP)
│ normal channel browsing + playback APIs
▼
Jellyfin server ──► FloatplaneChannel (IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia)
│ folders/items │ media sources
▼ ▼
CatalogService proxy URLs → FloatplaneStreamController (anonymous, HMAC-signed)
(creators, channels, │ delivery/info (cached, throttled) via DeliveryResolver
newest posts; cached) │ HLS playlist rewrite (HlsRewriter)
│ │ AES-128 key fetched WITH the session cookie
▼ ▼
FloatplaneApiClient ──────────► www.floatplane.com (REST + key server)
cdn-vod-drm2.floatplane.com (playlists + segments, signed token only)
pbs.floatplane.com (thumbnails, public)
FloatplaneSyncTask (IScheduledTask) refreshes the catalogue (startup + every N hours) and bumps the cache key.
FloatplaneAdminController (admin only) backs the settings page: validate cookie, status, refresh, search test, tags.
```
### Folder / item id scheme
| Level | `ChannelItemInfo.Id` | Source |
|-------|----------------------|--------|
| root | – | creators = subscriptions (needs cookie) ∪ "extra creators" (public url-names) |
| creator | `creator:<creatorId>` | `creator.channels[]` (public) |
| sub-channel | `channel:<creatorId>:<channelId>` | `GET /api/v3/content/creator?id&channel&hasVideo=true` paged 20 at a time, newest first, up to *Videos listed per channel* |
| video | `post:<postId>` | one item per post; a post only ever lives under its own channel (Jellyfin allows one parent per item) |
The channel `Name` ("Floatplane") must never change: Jellyfin derives the channel's library id from it.
### Playback flow
1. Jellyfin asks `GetChannelItemMediaInfo("post:<id>")` → plugin fetches `GET /api/v3/content/post` (login required, cached 1 h) → one `MediaSourceInfo` per video attachment × quality level (preferred level first), `Path = <proxy base>/Floatplane/Stream/<post>/<video>/<level>/index.m3u8?t=<hmac>`, `IsRemote=false`, `SupportsProbing=false`, declared `h264`/`aac` streams so Jellyfin stream-copies instead of transcoding.
2. ffmpeg (Jellyfin's transcode/remux path) requests the playlist. The controller verifies the signature, calls `GET /api/v3/delivery/info?scenario=onDemand&entityId=<video>` (via `DeliveryResolver`: cached until the JWT in the URL expires, ≈6 h, throttled to *Delivery lookups per minute*, default 2), picks the variant (exact level → best ≤ level → best), downloads `chunk.m3u8` from the CDN (no cookie needed) and rewrites it:
- `#EXT-X-KEY … URI="https://www.floatplane.com/api/video/watchKey?token=…"` → `…/Floatplane/Stream/<video>/Key?u=<b64url>&t=<hmac>` (the plugin fetches the 16-byte key **with the session cookie**, which Jellyfin itself cannot forward — it only passes User-Agent/Referer to ffmpeg).
- segment URIs → absolute CDN URLs (signed per-segment token, CORS open, no cookie).
3. Optional experimental MP4 version: `…/video.mp4` relays Floatplane's `scenario=download` URL with Range passthrough.
Why a proxy at all: Jellyfin forwards no cookies/Authorization to ffmpeg or its static relay, and browsers cannot attach a third-party cookie, so the key request has to come from the server. Why signed URLs: the stream endpoints are anonymous (ffmpeg has no Jellyfin token); the HMAC (process-lifetime secret) limits them to what the plugin itself handed out.
### Session model
- Floatplane's password login requires a Cloudflare Turnstile token regardless of User-Agent (verified 2026-10-25), and the OAuth device flow (Keycloak `auth.floatplane.com`) needs a `client_id` registered by Floatplane (`invalid_client` otherwise). So v1 uses a **pasted `sails.sid` cookie** (settings page → "Validate cookie" calls `/api/v3/user/self` + `/user/subscriptions`).
- Browsing (creator pages, channel lists, post listings, search, thumbnails) is **public**; `content/post`, `delivery/info` and the key need the cookie. An expired cookie shows up as `SessionState = invalid` on the settings page and as a warning in the log.
- Rate limits: `delivery/info` ≈2/min (429 + `Retry-After`, Cloudflare 1015 if hammered). Listing endpoints are not noticeably limited; `limit` max is 20.
### Jellyfin version note
The Pi5 runs **10.10.7**; `lscr.io/linuxserver/jellyfin:latest` is already **12.3** (.NET 10, new plugin ABI). A 10.10 plugin is `NotSupported` on 12.x and vice versa. Pin the image tag (`10.10.7ubu2404-ls80`) until the upgrade is done deliberately; the known 12.x deltas for this code are the `TaskTriggerInfo.Type` enum and the NuGet package/TFM bump.
### Caches (to know when "nothing updates")
| What | Where | TTL / invalidation |
|------|-------|--------------------|
| Jellyfin's own channel listing cache | `<cache>/channels/<id>/…json` | 3 h, bypassed when the plugin's cache key (generation counter) changes → "Refresh now" or the scheduled task |
| Jellyfin's media-info cache | memory | 5 min per item |
| Plugin creators + post lists | memory | *Listing cache (minutes)*, default 30 |
| Plugin post details | memory | 1 h |
| Plugin delivery variants | memory | until token `exp` − 5 min |