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

12 KiB
Raw Blame History

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 ".

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