U0q78s%B#>hplY=V6n
z)>5y6ErU0J{$HK`Pd5Mk9|}l$ww%C&xBoW$Um_(S+A;wfhf<2#mEU--KE1HVfS{Z(
zSZKV~XDnB0pwl^=8r$9E|6S@J-5BQ=V3TTgF#SdoEc&Uf8k5^D@)2%6K0GYV6Q
zwUJBuVqLef;?GjzcdTO`)Cavy94dbsx7Orep!k{GGCUeQ-ZfgWocOU~w1g8JGUwAt
zTu-po@ojxB>N_&)+1lq_Pq_g&r*S4QrBz5ptz!9v#$u61UP}S$V>Q2$4}3_$M@MY0
zv|oN4B-8ROPrcl7C5?MCob|j`pWYuJ`H-zbDjD9cpB|$>*4F0RT|M6G#_FUHMjE^7
z7i(Bds>Aw1nN!e5CX2NFAwkH{x
z9y7|6iysTDq7PPS*9Kp6m_6}wAq{<&a>nttrFHns^WlF8UeU(zJBm#NRTBp|2G?m{
z=(iH@&4>a|p7kJP?Q6NG@xPSEN74$aRZdqNH~|PM}A;Q7vMl&93V_;NU
z#Xc>{XMI<9XSkLw8M`M&Gsc^353M#ceVcyZ0DX{cVuEosdn3T?yZ;MT!GQjiY;Ju@b
zA0B_QzL|NvR>M2wSNm@hTuZul;(+wTHP?btiC#0*
zrAJAdUc-!2IQC@F3~gOYtYLci;Z2Mcxz50q<^mG1;a9Hk+dz3P&6#U&R!twsH&WgX
zxi{ONbGK$Q*(O>dO~j!gxpiY53H3^|YwnhZ#L_M*y0v*|GWM2sx&i_QfB-u9i@nLj
zvZS~nQU)24rBxL63faBl6`{X6zSNm6moQ;3N|ulF^PMUjS{xD&XxPT`j7Sfs%$53*
z3Oo(jRCMEWB3xhSTzhB368|Hh;%WO?oJhzRkeSfB89&!AEZ*Gy*dLBMSeNT}H(R@W
zuynSIW>IyO9+pc*`>#*95OxRnjz4VUM{3-D88km+a|8m(tQjd2`S>C{oFV8zYf|I<
znrFY7&`@YSPVH9niy0pC861qhbR<`Kp6$~9C>uf>US-+6d4)M>F!EJuk>V08_w7>K
zK%ueN(7wK|1}-ywxPrkfA~M47AOpr|Imi|AS=^-c%HAE4CCe*T&-_s?Y;u$Xb}6hZ
zY;OXmzeSsSwdP}Z_zy}?Qx-)`&f
z+IfA=%g6U{*9L|eQ&BTCW@11SqI5g4Hmv{mvx~0>DhZ6f9gTLN6d4-(nFNK;+2c0W
z`jLm_9{3esmbNCI*y$V^GOs=~Y$&nOPU^xXk*5?2xSL
zw*T>wwKQks5dTIuLUpm0UXU=)`9wFs)hAn3M|8AQDqMTtP8Z*`225e5(}`2@xy~AU
z7OUFc#N`wg%y;N#FRTiP*B+_pB9n4z|3u$&8}?r1kJ^qvNqxSdl8});Yq1oE?M#_$
z($1_YQNL~S2XW!gj2X2bMxV`}-=AnNsBZg^Vx+p*Wgtu$nX-0LU5s~DtCIX)t!FpP
z{}7bDu-hAq+4w&Mr<|;%uAa_4U(y)H^Zm7I0;OtUgE9K)hvJ^{>tjBKRoM%RC%m80
zHBUk|h_xZ(^vN4X0IgKUynC^K{NDcGta4Sv20pH+)AthY&3T%e`9-+aC~~9|*UT`V
z9GD!kNweL3!H%M8!kx1#8#SYgoJ%Pq%O{dJH6Qf6I1~MDEKmQv6~F&g(~&Umjnyj9
z43`@*30iwhvKWztn)XeF;pmQrl|6iP$7hx+3aguet-HeKZrg&cN1GnMT0&Xt>-9>n
z?ET$+e@@w}mxbC=pgp?Y^soVxf|%!fzMM{y8qZNDO52H(XWI)gbMr)(V_)Bj+K*XB
zTj&!B#!qJ#D|58^P7Z#JPhGQS5H>y|TMsZpdR-h|o`!5zZ>WVAVmg;Pols>rL*93F
z6tsV$5J7`uJ3P2ES8>?!YkJDji@jo`M_UQANQO!#@T4yMPh<@guOrB@Jm?fN$&
zuHAZ18-B8Y{LocD9vn6EhKX`IGfmxjhrdTCNW#=N5M6f8oLB
zJy{9p?8Js%w${81eefJ(5hIGD8H-c4YO`e<7P4H`4VhIl=Kjr7O$p=v4S8RQJI@24
zy^1l#RWkH}gKEoZ$yTBDT#-aRSF0TDTD8Wz9oicQ+TuPrgxc4w$vd
zni7BWG!HgZqpsp6^y(;0sNs|7{AxZ@MZuY1`nsOgZYQYKim_T8NaV1Bj^@%B5lJ=T
z8!1H(8QnuQzCE@dg9FEW{MQq!y$6;WHsxsKTziVzaNDya^OfiG@#P-YYbRJ~tFPq^
z+c6I|FtoC0GnE_R%#_06^Ku9%ef76bJ)!GqPvbL@Y3#Ysuzl5wEph1~4&5bTx_g&EjKf(x9lXis|{J
zum;SBaa+tyWeLL2V
zc}-pVQ%S~jNmBTUi02wbddZ^a4Ar1eiej%PICcwb*QMEwz!My1mFdBBxT#i7xAG4OE(^op!qbjMwi;P9d0
zJN=^Bc#NLM?Psq$rHp)PkU#M&KXSr2BNp^3^0dl`+3~|p>9e{^ir8THYCn2YYUg83
zX2-iuZityjHJzkUJfC~?H7!lYfDlaFIIH2Tw}+rLt+hcb9PGdx-x!W)<{TsXS(sVf
zk{yUVldE#BH&+|(UQ;U=0_AS=5-a{JyWKXEROySb?w
zV=S+It-fpr9r;eXT|fJH;8X8pA;`U(zbBy4DKqW)Um&Pb`dG@zF>`KW(?OPKi0
z*mZ@!j*jDqIheh*5g7VicEK1H`=xE8rzkGW*r;aYi+fmGL7bpgnj|tTIAU-i7-zWj
z6#L1ggr#lxj_MQ1!DiG+BU#e`-EeIYF*Ekci&ft>hbwvGZ0D7@qD!4d+UpS-AU@vk
zxl9;0EaeF
zW-~VW_eESTZX*Db9b}zs0nQ~zwv%&bdvhY)8TkT<`(hh)RhZzP$O?NNG*{W+(Ef6fIz6h-nl6jM
zK1s^R_o$@iARo}PYsgm>9(?0OwB6;-!cpv;z|1Es7dh#@O+3yAT|Atb$QabBqBW@7
zlao{;R0i{gA2KGbyPGKNn{#ixFNgMd87+Cu<{6Plj9^&9$QERUvYkL8)%+rEQ(_GkOgnZoX@%!M(jh4ux9n>S^jJ09DK!#UZr
zX{$pgU42BMeT(_XaVp8uuHo&yV~tPE${Pmt9&lc)eN7u>a3Ugn$BbMkn%vy97jGNpcX2&1-Cg;`>Ih!#lV*yQ5tDBth)_D#Ni
za@2wl4Q<@H`cwrsa5QwW6r?3Ieq@Z%HWyjaf>A9=?#JDpT1RI62&9y2?rObtA3lw*
zd(B*9C0KpX%gL_Ql!~g~m~0z!>8l(K_HOhsWPe4onQtB8XXjN5P#WNt8AB;m1|6&1
zK$%Q>mp);pup^-tkHsfhdW*-;zU6}Qp)h*S$
z5pd(0smFfV(zj<`tAsMmvh$IO9!yi}Qz?^@;0>F@SB!T8<5s@@&@%uk;?RrDMB&VK;9
CY&J>&
literal 0
HcmV?d00001
diff --git a/Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs b/Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs
new file mode 100644
index 0000000..6a01be2
--- /dev/null
+++ b/Jellyfin.Plugin.Floatplane/Tasks/FloatplaneSyncTask.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.Floatplane.Channel;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.Floatplane.Tasks;
+
+///
+/// Scheduled task: refresh creators/channels and warm the newest posts, then bump the
+/// channel cache key so Jellyfin re-lists on the next browse. Found by type scanning.
+///
+public class FloatplaneSyncTask : IScheduledTask, IConfigurableScheduledTask
+{
+ private readonly CatalogService _catalog;
+ private readonly ILogger _logger;
+
+ public FloatplaneSyncTask(CatalogService catalog, ILogger logger)
+ {
+ _catalog = catalog;
+ _logger = logger;
+ }
+
+ ///
+ public string Name => "Refresh Floatplane catalogue";
+
+ ///
+ public string Key => "FloatplaneSync";
+
+ ///
+ public string Description => "Reloads subscribed creators, their channels and the newest videos from Floatplane.";
+
+ ///
+ public string Category => "Floatplane";
+
+ ///
+ public bool IsHidden => false;
+
+ ///
+ public bool IsEnabled => true;
+
+ ///
+ public bool IsLogged => true;
+
+ ///
+ public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Floatplane sync started");
+ await _catalog.RefreshAsync(progress, cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("Floatplane sync finished: {Creators} creators, generation {Gen}", _catalog.Creators.Count, _catalog.Generation);
+ }
+
+ ///
+ public IEnumerable GetDefaultTriggers()
+ {
+ var hours = Math.Clamp(Plugin.Instance?.Configuration.SyncIntervalHours ?? 6, 1, 168);
+ return
+ [
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerStartup },
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(hours).Ticks }
+ ];
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..62e17c4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+# jellyfin-plugin-floatplane
+
+A Jellyfin server plugin that exposes your [Floatplane](https://www.floatplane.com)
+subscriptions (creators → channels → videos, plus live streams) inside
+Jellyfin and all its clients, so Floatplane content shows up next to the
+rest of the library on the Pi5 server and plays through the normal
+Jellyfin players.
+
+**Status (2026-10-25):** browsing works (verified against a local Jellyfin
+10.10.7); playback proxy is implemented but still needs a real session
+cookie to be verified. See [`doc/plan.md`](doc/plan.md) for milestones and
+[`doc/floatplane-api-notes.md`](doc/floatplane-api-notes.md) for the API.
+
+The plugin is a pure managed .NET assembly (AnyCPU IL), so one build runs
+on both `linux-x64` (dev box) and `linux-arm64` (the Pi5).
+
+## Prerequisites
+
+| Target | What to install |
+|--------|-----------------|
+| Arch / Garuda (dev box) | `sudo pacman -S dotnet-sdk-8.0` (Jellyfin 10.10.x = .NET 8) — for Jellyfin 12.x use `dotnet-sdk` (.NET 10) |
+| Debian / Ubuntu | `apt install dotnet-sdk-8.0` from the Microsoft repo |
+| Pi5 (runtime only) | nothing — the plugin runs inside the existing `linuxserver/jellyfin` container |
+
+Optional, for local testing: Docker (to run a throw-away Jellyfin of the
+same version as the Pi5 with the plugin folder mounted).
+
+## Build
+
+```bash
+git clone https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane.git
+cd jellyfin-plugin-floatplane
+scripts/package.sh # → build/Floatplane_/ (DLL + meta.json), build/floatplane_.zip (+ .md5)
+```
+
+The VS Code `build` task (`Ctrl+Shift+B`) runs the same script. Plain
+`dotnet build` works too (`jellyfin-plugin-floatplane.sln`).
+
+## Install
+
+**From the plugin repository (recommended):** Dashboard → Plugins →
+Repositories → add
+
+```
+https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json
+```
+
+then Catalog → Channels → Floatplane → Install → restart Jellyfin.
+
+**Manual, on the Pi5 (linuxserver image):**
+
+```bash
+curl -fsSL https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/install-pi5.sh | sh -s -- --dry-run
+# then without --dry-run; it installs into /srv/docker/jellyfin/data/plugins/ and restarts the container
+```
+
+**Manual, from a local build:**
+
+```bash
+# linuxserver image keeps data under /config/data (container path);
+# host path on the Pi5: /srv/docker/jellyfin/data/plugins/ (infra-Doc services/media-stack.md)
+cp -r build/Floatplane_0.1.0.0 /srv/docker/jellyfin/data/plugins/
+docker restart jellyfin
+```
+
+> The Pi5 runs Jellyfin **10.10.7** but `lscr.io/linuxserver/jellyfin:latest`
+> is already **12.3** (.NET 10). Pin the tag (`10.10.7ubu2404-ls80`) before
+> any `docker pull`, or the plugin becomes `NotSupported` overnight.
+
+Then Dashboard → Plugins → Floatplane → paste your `sails.sid` cookie
+(the page explains where to find it) → Validate → Save. A "Floatplane"
+channel appears under My Media: creators → sub-channels → videos.
+Without a cookie you can still browse creators listed under
+"Extra creators" (public data); playback needs the cookie.
+
+## How it works (short)
+
+- `FloatplaneChannel` (`IChannel`) lists creators → sub-channels → posts
+ from Floatplane's public listing API; ids are `creator:…`, `channel:…`,
+ `post:…`.
+- On play, `IRequiresMediaInfoCallback` fetches the post (login) and hands
+ Jellyfin signed URLs to the plugin's own `Floatplane/Stream/…` endpoints.
+- The stream endpoint calls `delivery/info` (cached, throttled to ~2/min),
+ fetches the HLS playlist, and rewrites it: the AES-128 key URI points
+ back at the plugin (which fetches the key with the cookie), segments go
+ straight to the CDN. ffmpeg then remuxes (stream copy) for the client.
+- A scheduled task refreshes the catalogue and bumps the channel cache key.
+
+## Test
+
+```bash
+dotnet test # unit tests (models, HLS rewriter, API client with fixtures from the HAR)
+```
+
+Local end-to-end test with a disposable Jellyfin matching the Pi5 version:
+
+```bash
+mkdir -p .jellyfin-test/config/data/plugins
+cp -r build .jellyfin-test/config/data/plugins/Floatplane_0.1.0.0 # writable copy, not :ro
+docker run --rm -p 8096:8096 -v $PWD/.jellyfin-test/config:/config \
+ lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80
+# logs: .jellyfin-test/config/log/log_*.log ; plugin status: Dashboard → Plugins
+```
+
+## Docs
+
+- `doc/plan.md` — goal, stack, milestones, roadmap
+- `doc/floatplane-api-notes.md` — endpoints, auth, HLS/AES-128 playback flow
+- `doc/research/jellyfin-plugin-research.md` — plugin API research (10.10 vs 12.x, channels, proxy pattern, packaging, paths)
+- `doc/research/floatplane-api-research.md` — community API docs, OAuth device flow, DPoP, rate limits, other clients
+- `doc/research/reference-sources/` — snapshots of other clients' source for study (chaosflix is the template to follow)
+- `doc/har/` — the captured browser session (**gitignored, contains credentials**)
+- Gitea wiki (once the repo exists) — architecture and design decisions
diff --git a/build.yaml b/build.yaml
new file mode 100644
index 0000000..88a79cc
--- /dev/null
+++ b/build.yaml
@@ -0,0 +1,18 @@
+---
+name: "Floatplane"
+guid: "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b"
+version: "0.1.0.0"
+targetAbi: "10.10.0.0"
+framework: "net8.0"
+overview: "Browse and play your Floatplane subscriptions inside Jellyfin"
+description: >
+ Adds a "Floatplane" channel: subscribed creators, their sub-channels and the newest
+ videos, played through Jellyfin's normal player. Log in by pasting the sails.sid
+ session cookie in the plugin settings. Built for Jellyfin 10.10.x (.NET 8), AnyCPU
+ (runs on x64 and arm64).
+category: "Channels"
+owner: "brasse"
+artifacts:
+- "Jellyfin.Plugin.Floatplane.dll"
+changelog: >
+ Initial release: channel browsing, HLS playback proxy, cookie session, search test.
diff --git a/doc/floatplane-api-notes.md b/doc/floatplane-api-notes.md
new file mode 100644
index 0000000..54160d1
--- /dev/null
+++ b/doc/floatplane-api-notes.md
@@ -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 ".
+
+## Discovery
+
+```
+GET /api/v3/user/subscriptions?active=true
+ → [{plan:{id,title,price,...}, creator:"", startDate, endDate, interval}]
+GET /api/v3/creator/list?ids[0]= → [Creator]
+GET /api/v3/creator/named?creatorURL[0]=linustechtips → [Creator]
+GET /api/v3/creator/info?id= → Creator
+GET /api/v3/creator/stats?id=
+```
+
+`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/..channel..m3u8`.
+
+## Content feed
+
+```
+POST /api/v3/content/creator/list {"ids":["",...],"limit":20}
+ → {"blogPosts":[Post...], "lastElements":[{creatorId, blogPostId, moreFetchable}]}
+ (pagination: send fetchAfter=[{creatorId, blogPostId, moreFetchable}] back)
+GET /api/v3/content/creator?id=&limit=20&fetchAfter=0&sort=DESC
+ &search=&hasVideo=false&hasAudio=false&hasPicture=false&hasText=false
+ → [Post...] (fetchAfter = offset; add &channel= to filter)
+GET /api/v3/content/post?id= → Post (full, with videoAttachments[])
+GET /api/v3/content/video?id= → Video
+GET /api/v3/content/related?id=
+GET /api/v3/content/progress?ids[0]=&...&contentType=blogPost → [{id, progress}]
+POST /api/v3/content/progress {"id":"","contentType":"video","progress":}
+```
+
+`Post` (trimmed):
+
+```json
+{
+ "id": "SSvhppI28t", "guid": "SSvhppI28t",
+ "title": "How Board Games Come ALIVE...- Board",
+ "text": "…html…
", "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//_.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": "", "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.cloudflarestorage.com/…//….vtt?X-Amz-…&X-Amz-Expires=900",
+ "kind":"captions","language":"en","generated":true} ],
+ "thumbnail": { … "content_thumbnails//…" },
+ "timelineSprite": { "width": 3840, "height": 2070, "path": "https://pbs.floatplane.com/timeline_sprite//….jpeg" }
+}
+```
+
+## Playback (VOD)
+
+```
+GET /api/v3/delivery/info?scenario=onDemand&entityId=
+```
+
+```json
+{ "groups": [ {
+ "origins": [ { "url": "https://cdn-vod-drm2.floatplane.com" } ],
+ "variants": [
+ { "name": "1080-avc1", "label": "1080p", "enabled": true, "hidden": false,
+ "url": "/Videos//1080.mp4/chunk.m3u8?token=",
+ "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
+ `.ts?token=` and starts with
+
+ ```
+ #EXT-X-KEY:METHOD=AES-128,URI="https://www.floatplane.com/api/video/watchKey?token="
+ ```
+
+- **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//`), 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]=` → `{tag: count}` (136 tags for LTT),
+`GET /api/v3/creator/channels/list?ids[0]=`, `GET /api/v3/creator/named?creatorURL[0]=`.
+
+**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=`
+(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`.
diff --git a/doc/plan.md b/doc/plan.md
new file mode 100644
index 0000000..552b9df
--- /dev/null
+++ b/doc/plan.md
@@ -0,0 +1,152 @@
+# jellyfin-plugin-floatplane — plan (2026-10-25)
+
+> Decisions taken 2026-10-25: Gitea repo `brasse/jellyfin-plugin-floatplane`
+> (private), **build for Jellyfin 10.10.x** (Pi5 stays on 10.10.7 for now),
+> **cookie session first** (OAuth client id to be requested from Floatplane
+> later), **VOD first**, and map/test the search API. Remaining open points
+> are listed at the bottom.
+
+## Goal
+
+Make Floatplane subscriptions browsable and playable inside Jellyfin on the
+Pi5 (`film.brasse-pc.eu`) with **no changes to any client**: creators and
+their sub-channels appear as a "Floatplane" channel under My Media in the
+web client, Android/TV apps and Jellyfin Media Player; videos play through
+the normal Jellyfin player with watched-state, resume and SyncPlay.
+
+## What the research established
+
+Full reports: [`research/floatplane-api-research.md`](research/floatplane-api-research.md),
+[`research/jellyfin-plugin-research.md`](research/jellyfin-plugin-research.md),
+HAR findings in [`floatplane-api-notes.md`](floatplane-api-notes.md).
+
+**Jellyfin side**
+
+- Pi5 runs **10.10.7** (image pulled 2025-08-12). `linuxserver/jellyfin:latest`
+ is now **12.3** (.NET 10). A 10.10 plugin is `NotSupported` on 12.x and
+ vice versa. Decision needed (Q2).
+- 10.10.7 = .NET 8, NuGet `Jellyfin.Controller 10.10.7`, `targetAbi 10.10.0.0`.
+ Verified locally: the template builds against 10.10.7 with the installed
+ .NET 8 SDK in 8 s.
+- Plugins are IL/AnyCPU → one build runs on x64 and the Pi5 (arm64). No
+ native deps allowed in the plugin.
+- The **channel API (`IChannel` + `IRequiresMediaInfoCallback`) exists and
+ works** in 10.10.7 *and* 12.3 (checked both NuGet packages). It is the
+ only way to get a virtual, playable catalogue into every official client.
+ Reference implementation: chaosflix-jellyfin (copy in `research/reference-sources/chaosflix/`).
+- Jellyfin forwards **only User-Agent/Referer** to ffmpeg and its relay —
+ never cookies or `Authorization`. Therefore the plugin must **proxy**
+ playback through its own ASP.NET controller with HMAC-signed anonymous
+ URLs (chaosflix pattern).
+- linuxserver image paths: plugins in `/config/data/plugins/_/`,
+ config XML in `/config/data/plugins/configurations/`, logs `/config/log/`.
+- Listing cache 3 h (bump `DataVersion`/cache key to refresh), media-info
+ cache 5 min, one parent per item id, channel `Name` must never change.
+
+**Floatplane side**
+
+- Public, no login: creator info, channel lists, post listings, thumbnails.
+- Login needed: `delivery/info` (stream URLs) and the HLS **AES-128 key**
+ (`/api/video/watchKey`). Segments and playlists only need their 6-hour
+ signed token.
+- Username/password login requires a Cloudflare Turnstile token — verified
+ today that no User-Agent bypasses it. Two real options:
+ 1. **Pasted `sails.sid` cookie** (what the web client used in the HAR;
+ works today, zero dependencies, lifetime unknown — must handle expiry).
+ 2. **OAuth2 device-code flow** against Keycloak `auth.floatplane.com`
+ (what Hydravion/Wasserflug/floatplane-downloader use; access token
+ 30 min, refresh token 90 days sliding). Needs a **client_id registered
+ by Floatplane** — verified `invalid_client` for an unregistered id.
+ Obtained by asking Floatplane staff (community Discord).
+- `delivery/info` is rate-limited (~2/min) → cache per video until token
+ expiry, serialise calls.
+- `scenario=download` gives plain MP4 URLs (seekable, no key dance) — not
+ in the capture; test once a session exists. If the token alone is enough
+ this is the preferred playback path.
+
+## Stack
+
+- C# / .NET 8 class library `Jellyfin.Plugin.Floatplane` (switch to .NET 10
+ + Jellyfin 12.x packages if Q2 says upgrade). `System.Text.Json`,
+ `IHttpClientFactory`, no third-party NuGet packages that ship natives.
+- Target OS/arch: linux-x64 (dev/test) + linux-arm64 (Pi5), single AnyCPU build.
+- Tests: xUnit against recorded JSON fixtures (shapes from the HAR + spec).
+- Packaging: `meta.json` + zip (+ md5) per release; `manifest.json` plugin
+ repository served from Gitea so Jellyfin's dashboard can install/update.
+
+## Deploy target
+
+Pattern A (playbook): Gitea Actions builds the zip on push to `master`,
+publishes rolling `latest` + `vX.Y.Z` releases, and updates the repository
+`manifest.json`. Install on the Pi5 by adding the manifest URL under
+Dashboard → Plugins → Repositories (or scp into `/config/data/plugins/`).
+
+## Architecture
+
+```
+Jellyfin clients ──► Jellyfin server (Pi5)
+ │ IChannel "Floatplane"
+ │ root → creators → sub-channels → videos (Episode/Clip)
+ │ IRequiresMediaInfoCallback
+ │ → MediaSourceInfo { Path = /Floatplane/stream/?t=, MediaStreams = h264/aac from delivery meta }
+ │ FloatplaneStreamController (anonymous, HMAC-signed)
+ │ → delivery/info (cached) → rewritten HLS playlist (key URI → /Floatplane/key/…)
+ │ or plain MP4 relay with Range passthrough
+ │ FloatplaneClient (session: cookie or OAuth tokens, UA, 429 handling, delivery throttle)
+ │ SyncTask (IScheduledTask, every N h: subscriptions → creators → posts → cache; bumps cache key)
+ │ Config page: login (cookie paste now, device-code later), quality preference, which creators/channels, sync interval
+ └──► www.floatplane.com / cdn-vod-drm2 / pbs
+```
+
+## Milestones
+
+| # | Increment | Done when |
+|---|-----------|-----------|
+| M0 | Scaffold: csproj (net8/10.10.7), Plugin.cs, config page, `meta.json`, build task, unit-test project, local docker Jellyfin 10.10.7 | **done 2026-10-25** — Active in a local 10.10.7 (LSIO image) |
+| M1 | `FloatplaneApiClient`: cookie session, `user/self`, subscriptions, creator/info, content listing with pagination + search/filters, post details; fixtures from the HAR | **code done** — config page validates the cookie; unit tests in `Jellyfin.Plugin.Floatplane.Tests` |
+| M2 | Channel browse: root → creators → sub-channels → videos with thumbnails, overview, duration, release date; sync task + cache key bump | **done locally** (web API verified: folders, paging, sorting, latest, images); Android app not yet checked |
+| M3 | Playback via proxy controller: delivery/info cache + throttle, HLS playlist rewrite with key proxy (+ optional MP4 relay), `MediaStreams` filled so Jellyfin remuxes instead of transcoding | **code done, untested** — needs a real session cookie (Bjorn, on the Pi5 or locally) |
+| M4 | Packaging + CI: Gitea Actions build → zip + md5 + `manifest.json` → releases; install on Pi5 from the repository URL or `install-pi5.sh` | **code done** — first CI run pending the initial push |
+| M5 | Polish: watched/resume sync to Floatplane progress API, subtitles (VTT text tracks), locked-tier handling, cookie-expiry warning, Android/TV app check | next |
+
+## Roadmap / expansions (to agree)
+
+- **OAuth device-code login** once Floatplane issues a client id (QR code
+ on the config page, 90-day refresh token, no more cookie copying).
+- **Live streams** (WAN Show): `scenario=live` IVS HLS as a live item or a
+ Live TV tuner; offline detection by polling the playlist.
+- **Audio posts / podcasts** as an audio channel.
+- **Archive to library**: optional scheduled download of selected channels
+ to `/media/Floatplane///` with NFO + thumbnails for
+ trickplay/offline (floatplane-downloader-style layout).
+- **Multi-user**: per-Jellyfin-user Floatplane sessions (`IsEnabledFor`).
+- **Home-screen "Latest from Floatplane"** via `ISupportsLatestMedia`.
+- **Jellyseerr-style request/notification** hook when new posts appear
+ (notifyr on the Pi5).
+
+## First real-world test (Bjorn, when home)
+
+1. Pin the Pi5 image tag (`lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80`)
+ before anything else, or a `pull` lands on 12.3.
+2. Install: Dashboard → Plugins → Repositories → add
+ `https://gitea.brasse-pc.eu/brasse/jellyfin-plugin-floatplane/releases/download/latest/manifest.json`,
+ install "Floatplane", restart. (Or `install-pi5.sh` from the release.)
+3. Settings → paste `sails.sid` → Validate → Save. Creators from the
+ subscription appear under My Media → Floatplane.
+4. Play a 1080p video in the web client; watch `log/` for
+ `FloatplaneStreamController` lines and the ffmpeg transcode log — the goal
+ is a stream **copy** (remux), not a re-encode.
+5. Try the MP4 experiment (settings → "progressive MP4 versions") and report
+ whether it plays; if it does it becomes the default (seekable, no key dance).
+
+## Open questions
+
+1. **OAuth client id**: ask Floatplane (community Discord
+ `https://discord.gg/4xKDGz5M5B`) for a device-flow client id for this
+ plugin — only Bjorn can make that request. Until then: cookie.
+2. **Cookie lifetime**: unknown; the settings page shows "rejected" when it
+ expires. Measure in practice.
+3. **Pi5 upgrade to 12.x**: separate task; the plugin needs a .NET 10 /
+ 12.x build then (`TaskTriggerInfo` enum and package versions are the
+ known deltas).
+4. **Live streams** and the other roadmap items: priority after VOD works.
diff --git a/doc/research/floatplane-api-research.md b/doc/research/floatplane-api-research.md
new file mode 100644
index 0000000..beb432c
--- /dev/null
+++ b/doc/research/floatplane-api-research.md
@@ -0,0 +1,221 @@
+
+
+# Floatplane unofficial API — research report for a Jellyfin plugin (as of 2026-10-25)
+
+Legend: **[V]** = verified from docs/spec/source code or a live fetch today; **[I]** = inferred / not directly verified. Local copies of everything I read are in `/tmp/claude-1000/-home-brasse/8a10aa19-62bd-490c-b689-b51638220e46/scratchpad/src/` (e.g. `spec_full.json`, `spec_dump.txt`, `oidc.json`, `fpts_*.ts`, `fpdl_*.ts`, `hydra_*.brs`, `wf_*.swift`, `floaty_*.dart`, `fn_*.kt`, `fn_ios_*.swift`, `ytdlp_floatplane.py`, `bz_*.mjs`).
+
+## 0. Executive summary — what changed and what it means for a Jellyfin plugin
+
+1. **Username/password login is legacy.** Every actively maintained third-party client (floatplane-downloader 5.19.x, Hydravion Roku, Wasserflug tvOS, Floaty, FloatNative) has migrated in 2025–2026 to **OAuth 2.0 / OIDC on a Keycloak server at `https://auth.floatplane.com/realms/floatplane`**, using the **device-code grant** (TV/CLI) or auth-code+PKCE (mobile/desktop), and then call the REST API with `Authorization: Bearer ` instead of the `sails.sid` cookie. **[V]** (sources: floatplaneAPI.ts `src/index.ts`, Hydravion `oauthDeviceTask.brs`, Wasserflug `Wasserflug_tvOSApp.swift`, Floaty `oauth2_service.dart`, FloatNative `OAuthApi.kt`, live OIDC discovery doc).
+2. **You need an OAuth `client_id` registered by Floatplane.** The `floatplane` npm README says `clientId: "example-client", // Has to be registered on FloatPlane`. Known registered public clients: `hydravion`, `wasserflug`, `floatplane-downloader`, `floaty`, `floatnative`, and Floatplane's own `fp-tv-app`. There is no public self-service registration page; the maintainers evidently obtained client IDs from Floatplane staff (the community Discord `https://discord.gg/4xKDGz5M5B` is where Hydravion/Wasserflug devs hang out). **[V]** for the IDs, **[I]** for the "ask staff" process. Using another app's client ID would work technically but is not yours to use; the bmlzootown canary code explicitly says "Do not invent alternate clients".
+3. **Some clients require DPoP (RFC 9449).** `fp-tv-app` and `floatnative` are configured with `usesExtendedSecurity` → PKCE S256 on the device-auth request **and** a DPoP proof (ES256/P-256) on every token-endpoint and REST request (`Authorization: DPoP `). `hydravion`, `wasserflug`, `floatplane-downloader`, `floaty` use plain `Bearer` without DPoP. So whether your plugin needs DPoP depends on how Floatplane configures your client. **[V]**
+4. **The CDN/HLS side needs auth too (for VOD).** Hydravion sets `Authorization: Bearer` on the Roku video node for all playlist/segment/key requests; Floaty passes `Authorization` + `Referer`/`Origin` headers to mpv; Wasserflug intercepts AVPlayer loads and adds Bearer; FloatNative fetches the **m3u8 manifest and the AES-128 `#EXT-X-KEY` with DPoP**, but rewrites **segment** URIs to plain absolute https ("the backend is designed around this split: keys require session-bound auth, segments don't"). Live (IVS) streams carry the token only in the URL and are played natively without headers. **[V]** For Jellyfin this means you cannot just hand the HLS URL to the client; the plugin must proxy the playlist+key (or feed ffmpeg `-headers`), see §6.6.
+5. **`/api/v3/delivery/info` is rate-limited hard** (community consensus ≈ 2 requests/minute; Floaty budgets 2 per 5 min; 429 + `Retry-After`, Cloudflare error 1015). Cache delivery responses per attachment until the embedded token expires. **[V]**
+6. The community OpenAPI spec (jamamp/FloatplaneAPI, spec version `3.10.0-c`, frontend tracked to `4.0.13`) **lags the live site** (frontend is `4.5.25` today) and still documents only `CookieAuth`; the bmlzootown fork README states "OpenAPI may still list only CookieAuth — explorer REST auth is Bearer ahead of that docs lag" and that `sails.sid` is "chat/Socket.IO-only". **[V]**
+
+---
+
+## 1. Community API documentation
+
+### 1.1 Repositories and docs sites
+- Spec repo (renamed): `https://github.com/jamamp/FloatplaneAPI` (formerly `Jman012/FloatplaneAPI`; old URL redirects). Files: `src/floatplane-openapi-specification.json` (full, 606 KB, OpenAPI 3.0.3, title "Floatplane REST API", version `3.10.0-c`), trimmed spec generated by `tools/trim.js` (drops operations whose description starts with "TODO"), AsyncAPI specs for chat (`chat.floatplane.com`) and frontend sockets. MIT. **[V]**
+- Rendered docs: `https://jamamp.github.io/FloatplaneAPIDocs/` (Swagger UI, Redoc, RapiDoc, ReSlate, Postman v2.1 collection; trimmed and full variants). The old `jman012.github.io/FloatplaneAPIDocs` now 404s. **[V]**
+- Trimmed spec download: `https://jamamp.github.io/FloatplaneAPIDocs/floatplane-openapi-specification-trimmed.json` (raw GitHub path for the trimmed file 404s; the full one is `https://raw.githubusercontent.com/jamamp/FloatplaneAPI/main/src/floatplane-openapi-specification.json`). **[V]**
+- Fork with extras: `https://github.com/bmlzootown/FloatplaneAPI` — static API explorer (`static/explorer/`, PR #6), frontend-change watcher, and an authenticated "API shape canary" (PR #7 Phase 3.0, PR #11/#12 Phase 3.1) that implements the device flow + DPoP in Node (`tools/fp-api-shape-canary/lib/{device-flow,dpop,auth-refresh,constants}.mjs`) and commits measured token lifetimes (`artifacts/api-shape/refresh-lifetime-evidence.json`). **[V]**
+- README guidance (jamamp): "Please set a custom `User-Agent` header ... that uniquely identifies what app the requests are coming from, and what version ... This is very helpful to the Floatplane team". Integration tests authenticate with env var `sails.sid`. The spec's `info.description` has an "API Flow" section (as of FP 4.0.13): login → `/api/v3/user/subscriptions` → `/api/v3/creator/info` → `/api/v3/content/creator/list` (home) → `/api/v3/content/creator` (creator page) → `/api/v3/content/post` → `/api/v3/content/{video|audio|picture}` → `/api/v3/delivery/info`; livestream via `delivery/info` + `wss://chat.floatplane.com/sails.io/`. **[V]**
+- Rate limiting text in the spec: "Rate-limited requests will respond with an HTTP 429 ... will also have a `Retry-After` header ... Clients are expected to 1) prevent too many requests ... particularly for the `/api/v2/cdn/delivery` and `/api/v3/delivery/info` endpoints, and 2) properly handle rate-limited responses". **[V]**
+- LTT forum thread announcing the docs: `https://linustechtips.com/topic/1384044-floatplane-unofficial-floatplane-api-documentation/` (Cloudflare-blocked for fetch tools; not read). **[I]**
+
+### 1.2 Authentication
+
+#### A. Legacy cookie login (documented in the spec; still exists server-side; the website bundle still contains `/api/v3/auth/login`, `/api/v3/auth/checkFor2faLogin`, `/api/v3/auth/captcha/info`, `/api/v3/auth/logout`) **[V]**
+- `POST /api/v2/auth/login` (also `/api/v3/auth/login`) body `{"username","password","captchaToken"?}`; 200 → `{"user": {id, username, profileImage}, "needs2FA": bool}`; sets cookie `sails.sid` (Sails.js session). `captchaToken` description: "The Google Recaptcha v2/v3 token ... On web browsers, this is required. For mobile or TV applications, this is not required only if the User-[Agent …]" (text truncated in my dump; the point is a non-browser User-Agent exempts you). 401 → `ErrorModel {id, errors[{id,name,message?,data?}], message?}`.
+- `POST /api/v2/auth/checkFor2faLogin` (also v3) body `{"token": ""}` → same login response, completes the session; `POST /api/v2/auth/logout` → `"OK"`.
+- `GET /api/v3/auth/captcha/info` → `{"v2":{"variants":{"android":{siteKey},"checkbox":{siteKey},"invisible":{siteKey}}},"v3":{"variants":{"invisible":{siteKey}}}}` (Google reCAPTCHA keys).
+- Cookie lifetime: not documented in the spec; Wasserflug persisted it with the `Expires` from `Set-Cookie` and rewrote the domain to `.floatplane.com`. No verified number. **[I]**
+- How clients handled captcha: Wasserflug's source comment: "Set custom user agent for network requests. This is particularly required in order to pass the login phase with bypassing the captcha" (UA `"Wasserflug tvOS App version X, CFNetwork"`); the spec's own test UA also ends in `, CFNetwork`; Hydravion's dev wrote (LTT forum, via search snippet) that the Roku store build logs in without a reCAPTCHA token via "a semi-shady workaround that won't be made public". **[V]** for the code comments, **[I]** for how robust that still is.
+- yt-dlp (master) still uses this path: it requires a `sails.sid` cookie exported from a browser (`_real_initialize` raises login-required otherwise) and sends `Origin`/`Referer: https://www.floatplane.com/`. **[V]**
+
+#### B. Current OAuth2/OIDC login (what you should implement) **[V]**
+Live discovery document fetched today: `https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration`
+```
+issuer https://auth.floatplane.com/realms/floatplane
+authorization_endpoint …/protocol/openid-connect/auth
+token_endpoint …/protocol/openid-connect/token
+device_authorization_endpoint …/protocol/openid-connect/auth/device
+revocation_endpoint …/protocol/openid-connect/revoke
+end_session_endpoint …/protocol/openid-connect/logout
+grant_types_supported authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code, … (also password, client_credentials, token-exchange, ciba)
+scopes_supported openid, profile, email, offline_access, videos:watch, videos:download, account:read, account:write, …
+code_challenge_methods plain, S256
+dpop_signing_alg_values ES256, RS256, PS256, EdDSA, …
+```
+Pre-prod realm/host used by devs: realm `floatplane-pp` with API base `https://pp.floatplane.com` (floatplaneAPI.ts `sanity.ts`, Hydravion `apiConfig.brs`). **[V]**
+
+Device-code flow as implemented by Hydravion (`oauthDeviceTask.brs`), floatplaneAPI.ts (`deviceLogin`, via `openid-client`) and the canary:
+1. `POST https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/auth/device`, form `client_id=&scope=openid profile email offline_access` (+ `code_challenge_method=S256&code_challenge=…` when the client has extended security). Response: `{device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}`. Hydravion shows `floatplane.com/link` as the friendly URL (the website bundle has `linkShortUrl:"floatplane.com/link"`) and renders `verification_uri_complete` as a QR code.
+2. Poll `POST …/openid-connect/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id=…&device_code=…` (+ `code_verifier`, + `DPoP` header for DPoP clients) every `interval` s; 400 `{"error":"authorization_pending"}` → keep polling; `slow_down` → +5 s; `expired_token`/`access_denied` → abort.
+3. Success: `{access_token, expires_in, refresh_token, refresh_expires_in, token_type ("Bearer" or "DPoP"), id_token?, scope, session_state, not-before-policy}`.
+4. Refresh: `POST …/token` form `grant_type=refresh_token&client_id=…&refresh_token=…` (DPoP proof again for DPoP clients). Refresh **before** expiry (floatplaneAPI.ts refreshes when <60 s left; Hydravion when <300 s). Don't refresh on every request: floatplane-downloader 5.19.5 had a bug refreshing before every call and after ~40 refreshes in seconds `auth.floatplane.com` returned **Cloudflare 429 / error 1015 with a 600 s retry** (issue #290).
+5. Call `https://www.floatplane.com/api/...` with `Authorization: Bearer ` (floatplaneAPI.ts adds it in a `got` `beforeRequest` hook only for URLs under the base URL; Hydravion/Floaty add it to every API request; Floaty keeps a cookie jar too but relies on Bearer).
+
+Measured lifetimes (bmlzootown canary, live 2026-10-11, client `fp-tv-app`, requested `openid offline_access`, granted `openid profile email offline_access`): **access token `expires_in=1800` s (30 min); refresh token `refresh_expires_in=7776000` s (90 days), rotated on every refresh, sliding window** ("Each refresh returned full refresh_expires_in=7776000 from that moment"). Hydravion also handles `refresh_expires_in=0` by assuming a 30-day idle limit. **[V]**
+
+DPoP details (FloatNative `DPoPManager.kt`/`.swift`, canary `dpop.mjs`): EC P-256 key stored per device; proof JWT header `{"typ":"dpop+jwt","alg":"ES256","jwk":{kty,crv,x,y}}`, payload `{iat, jti, htm, htu (URL without query), ath = b64url(sha256(access_token)) on resource requests}`; headers `DPoP: ` and `Authorization: DPoP `; honour `DPoP-Nonce` response header and the `use_dpop_nonce` / `invalid_dpop_proof` errors by retrying with the nonce; FloatNative auto-corrects clock skew from the `Date` header on a DPoP 400. **[V]**
+
+Website's own auth modes (from `frontend.floatplane.com/user/4.5.25-340-92712da/js/index-DaEmRTqt.js`): enum `cookie | token | oauth-basic | oauth-dpop`; TV config `{baseUrl:"https://auth.floatplane.com", realm:"floatplane", clientId:"fp-tv-app", usesExtendedSecurity:true, linkShortUrl:"floatplane.com/link"}`, `token_endpoint_auth_method:"none"` (public client). **[V]**
+
+#### C. `GET /api/v3/user/self` **[V]**
+```json
+{"id":"0123456789abcdef01234567","username":"my_username",
+ "profileImage":{"width":512,"height":512,"path":"https://pbs.floatplane.com/profile_images/default/user12.png","childImages":[{"width":250,"height":250,"path":"…_250x250.png"}]},
+ "email":"…","displayName":"…","creators":[],"scheduledDeletionDate":null}
+```
+Used by every client as the "am I logged in" probe (Hydravion uses `/api/v3/user/subscriptions` for that; a 401 → clear tokens). Wasserflug calls `GET /api/v3/status?platform=web&version=…` first (returns `selfUser` or 403).
+
+### 1.3 Subscriptions — `GET /api/v3/user/subscriptions` **[V]**
+```json
+[{"startDate":"2020-09-25T07:35:04.273Z","endDate":"2021-09-25T07:35:04.273Z","paymentID":12345,"interval":"year","paymentCancelled":false,
+ "plan":{"id":"5d48d0306825b5780db93d07","title":"LTT Supporter (1080p)","description":"…","price":"5.00","priceYearly":"50.00","currency":"usd","logo":null,"featured":true,"allowGrandfatheredAccess":false,"discordServers":[],"discordRoles":[]},
+ "creator":"59f94c0bdd241b70349eb72b"}]
+```
+`creator` is the creator GUID (24 hex). Duplicates are possible (Wasserflug de-dupes with a Set). Wasserflug shows an error if the list is empty.
+
+### 1.4 Creators **[V]**
+- `GET /api/v3/creator/info?id=` → `CreatorModelV3` (single creator).
+- `GET /api/v3/creator/named?creatorURL[0]=linustechtips` → `[CreatorModelV3]` (yt-dlp uses it to resolve `/channel//home/` URLs).
+- `GET /api/v3/creator/list?search=` → `[CreatorModelV3]` (discovery; FloatNative also calls it with `ids[]=` to batch-fetch creators).
+- `GET /api/v3/creator/channels/list?ids[0]=` → `[ChannelModel]` (floatplane-downloader uses this per subscription).
+- v2: `GET /api/v2/creator/info?creatorGUID[0]=…&creatorGUID[1]=…` → `[CreatorModelV2]` (multi-creator in one call; Wasserflug still uses it for livestream refresh).
+`CreatorModelV3` (trimmed):
+```json
+{"id":"59f94c0bdd241b70349eb72b","owner":{"id":"…","username":"Linus"},"title":"LinusTechTips","urlname":"linustechtips",
+ "description":"…","about":"# We're LinusTechTips\n…","category":{"id":"…","title":"Technology"},
+ "cover":{"width":1990,"height":519,"path":"https://pbs.floatplane.com/cover_images/…jpeg","childImages":[…]},
+ "icon":{"width":600,"height":600,"path":"https://pbs.floatplane.com/creator_icons/…jpeg","childImages":[…]},
+ "liveStream":{"id":"5c13f3c006f1be15e08e05c0","title":"…","description":"…
","thumbnail":{…},"owner":"…","channel":"","streamPath":"…","offline":{"title":null,"description":null,"thumbnail":null}},
+ "subscriptionPlans":[…],"discoverable":true,"subscriberCountDisplay":"total","incomeDisplay":false,
+ "defaultChannel":"63fe42c309e691e4e36de93d",
+ "channels":[{"id":"63fe42c309e691e4e36de93d","creator":"59f94c0b…","title":"Linus Tech Tips","urlname":"main","about":"…","order":0,"cover":{…},"card":{…},"icon":{…},"socialLinks":{}}, …],
+ "socialLinks":{},"card":{…}}
+```
+
+### 1.5 Content listing **[V]**
+`GET /api/v3/content/creator` — params: `id`* (creator GUID), `channel` (channel id), `limit` (clients use 20), `fetchAfter` (integer **offset** = number of posts to skip, "usually a multiple of `limit`"), `search`, `tags[0]=…`, `hasVideo`, `hasAudio`, `hasPicture`, `hasText` (mutually exclusive with the others), `sort=ASC|DESC`, `fromDuration`/`toDuration` (seconds, imply hasVideo), `fromDate`/`toDate` (ISO). Returns `[BlogPostModelV3]`; empty array = end. floatplaneAPI.ts `blogPostsIterable` simply loops `fetchAfter += 20` until an empty page; yt-dlp uses `fetchAfter = page * 20`.
+
+`BlogPostModelV3` (trimmed):
+```json
+{"id":"Dw2ms0AgL8","guid":"Dw2ms0AgL8","title":"…","text":"…
","type":"blogPost",
+ "channel":{…ChannelModel…} /* or just a channel id string in some responses */,
+ "tags":["test"],"attachmentOrder":["TViGzkuIic"],
+ "metadata":{"hasVideo":true,"videoCount":1,"videoDuration":5689,"hasAudio":false,"audioCount":0,"audioDuration":0,"hasPicture":false,"pictureCount":0,"hasGallery":false,"galleryCount":0,"isFeatured":false},
+ "releaseDate":"2021-10-09T09:29:00.039Z","likes":41,"dislikes":0,"score":41,"comments":28,
+ "creator":{"id":"…","owner":{…},"title":"…","urlname":"…","icon":{…},"liveStream":{…}|null,"defaultChannel":"…","channels":["…"]},
+ "wasReleasedSilently":false,
+ "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/blogPost_thumbnails//_.jpeg","childImages":[{"width":400,"height":225,"path":"…_400x225.jpeg"},…]},
+ "isAccessible":true,
+ "videoAttachments":["TViGzkuIic"],"audioAttachments":[],"pictureAttachments":[],"galleryAttachments":[]}
+```
+Important recent change: in list responses the attachment arrays are now **omitted/empty** — Hydravion's current code comments: "List responses omit attachment ids. Keep them when an older payload still sends them." and "Delivery and playback use the attachment id. The creator list no longer includes it." So always fetch `GET /api/v3/content/post?id=` to get attachment IDs before playback. **[V]**
+
+`GET /api/v3/content/creator/list` (multi-creator home feed): `ids[0]=…&ids[1]=…&limit=20` plus pagination objects `fetchAfter[0][creatorId]=…&fetchAfter[0][blogPostId]=…&fetchAfter[0][moreFetchable]=true` (one per creator, taken verbatim from the previous response's `lastElements`). Response `{"blogPosts":[BlogPostModelV3…],"lastElements":[{"creatorId":"…","blogPostId":"…"|null,"moreFetchable":true}]}`. Wasserflug filters out `lastElements` entries with `blogPostId == null` because "Floatplane doesn't like when we send one of these without a blogPostId". **[V]**
+
+`GET /api/v3/content/post?id=` → `ContentPostV3Response`: same as above but `channel` is a full `ChannelModel`, `creator` is `CreatorModelV2`, `userInteraction[]`, and the attachments are **full objects**:
+```json
+"videoAttachments":[{"id":"TViGzkuIic","guid":"TViGzkuIic","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…","likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8","thumbnail":{…},"isAccessible":true}],
+"audioAttachments":[{…,"duration":114,"waveform":{"dataSetLength":…,"highestValue":…,"lowestValue":…,"data":[…]}}]
+```
+Order attachments by `attachmentOrder`; a post may contain several videos (yt-dlp returns a playlist then). 403 for posts behind a plan you don't have (`isAccessible:false`).
+
+`GET /api/v3/content/video?id=` → `ContentVideoV3Response`:
+```json
+{"id":"TViGzkuIic","guid":"…","title":"…","type":"video","description":"","releaseDate":null,"duration":5689,"creator":"…",
+ "likes":0,"dislikes":0,"score":0,"isProcessing":false,"primaryBlogPost":"Dw2ms0AgL8",
+ "thumbnail":{"width":1920,"height":1080,"path":"https://pbs.floatplane.com/content_thumbnails/TViGzkuIic/…jpeg","childImages":[]},
+ "isAccessible":true,"blogPosts":["Dw2ms0AgL8"],
+ "timelineSprite":{"width":4960,"height":2610,"path":"https://pbs.floatplane.com/timeline_sprite/…jpeg"},
+ "progress":123 /* seconds, optional */,"userInteraction":[],
+ "levels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…],
+ "textTracks":[{"id":"…","src":"https://.r2.cloudflarestorage.com/prod-texttrack/text_tracks/…vtt?X-Amz-…&X-Amz-Expires=900…","kind":"caption","language":"en","generated":true,"processing":false}]}
+```
+Caption URLs are pre-signed (900 s) — fetch promptly. floatplane-downloader skips videos whose `levels` is empty. **[V]**
+
+### 1.6 Delivery **[V]**
+`GET /api/v3/delivery/info?scenario=onDemand|download|live&entityId=[&outputKind=hls.mpegts|hls.fmp4|dash.mpegts|dash.m4s|flat][&entityKind=video|audio|livestream|auto]` (`entityKind` is in the website's client but not in the community spec). Floaty always sends `outputKind=hls.mpegts` for streaming; Wasserflug sends none (gets HLS TS by default). Response:
+```json
+{"groups":[{"origins":[{"url":"https://cdn-vod-drm2.floatplane.com"}],
+ "variants":[
+ {"name":"1080-avc1","label":"1080p","url":"/Videos/Lug14XXgLx/1080.mp4/chunk.m3u8?token=","mimeType":"application/x-mpegURL","order":99293376,"hidden":false,"enabled":true,
+ "meta":{"video":{"codec":"avc1.64002a","codecSimple":"avc1","bitrate":{"average":1448573},"width":1920,"height":960,"isHdr":false,"fps":29.97,"mimeType":"video/MP2T"},
+ "audio":{"codec":"mp4a.40.2","bitrate":{"average":93340,"maximum":93340},"channelCount":2,"samplerate":48000,"mimeType":"video/MP2T"}}},
+ {"name":"2160-avc1","label":"4K","url":"/Videos/Lug14XXgLx/2160.mp4/chunk.m3u8?token=", …}]}]}
+```
+- `download` scenario: origins are edge servers (`https://edge01-na.floatplane.com`, `queryUrl`, `datacenter{latitude,longitude,countryCode,regionCode}`) and variant URLs are plain MP4s `/Videos//1080.mp4?token=` with `mimeType: video/mp4`.
+- `live` scenario: `{"origins":[{"url":"https://.us-east-1.playback.live-video.net"}],"variants":[{"name":"live-abr","label":"Auto","url":"/api/video/v1/us-east-1..channel..m3u8?allow_source=false&token=","mimeType":"application/x-mpegURL","meta":{"live":{"lowLatencyExtension":"ivshls"}}}]}` (Amazon IVS).
+- `meta.common.access.deniedReason` ∈ `isMissingPermission | isProcessing | isBroken` with `deniedMessage` when a variant is not available to you.
+- **Building the final URL**: if `variant.url` is absolute use it; else `origin.url` (trailing slash trimmed) + `variant.url`; prefer `variant.origins` over `group.origins` (Wasserflug `DeliveryHelper.getBestUrl`, random origin); floatplane-downloader round-robins origins for downloads; Hydravion/yt-dlp just take `groups[0].origins[0].url + variant.url`. The spec says v3 URLs contain **no template placeholders** (that was v2).
+- Quality selection in practice: filter `enabled && !hidden`; sort by `order` (bigger = better) or `meta.video.height`; match the user's preference on `label` ("1080p", "4K") or `name` ("1080-avc1"); Wasserflug drops variants taller than the screen (×1.15 tolerance; note LTT 2:1 videos are 2160×1080 and 4320×2160).
+- Token: the `?token=` is a JWT. The spec's v2 example decodes to `{"ressourcePath":"/Videos/TViGzkuIic/360.mp4/chunk.m3u8","userId":"…","iat":1633797311,"exp":1633818911}` → **6-hour lifetime** in that example (older format); Floaty checks an `expires` epoch query param on download URLs and re-requests delivery info one minute before expiry. Treat tokens as short-lived (hours) and per-user/per-resource. **[V]** for the example, **[I]** for current lifetime.
+- **Does the CDN need the cookie/bearer or just the signed URL?** Verified behaviour of clients: all current clients send `Authorization` (Bearer/DPoP) to the CDN as well; FloatNative documents that the **master playlist and the AES-128 key request require session-bound auth, media segments do not**; yt-dlp pulls the key URL from `#EXT-X-KEY:METHOD=AES-128,URI="https://…"` and fetches it with the same cookies/headers. Whether a `download`-scenario MP4 URL plays with the token alone is **not verified** (Floaty's downloader also goes through its Bearer-adding HTTP client). **[V]/[I]** as marked. Test both.
+- Older endpoint `GET /api/v2/cdn/delivery?type=vod|aod|live|download&guid=` (or `&creator=` for live): `{"cdn":"https://cdn-vod-drm2.floatplane.com","strategy":"cdn","resource":{"uri":"/Videos/TViGzkuIic/{qualityLevels}.mp4/chunk.m3u8?token={qualityLevelParams.token}","data":{"qualityLevels":[{"name":"360","width":640,"height":360,"label":"360p","order":0},…],"qualityLevelParams":{"360":{"token":""},…}}}}` — substitute `{qualityLevels}` with `qualityLevels[].name` and `{qualityLevelParams.X}` with `qualityLevelParams[name][X]`; later revisions used numeric keys (`{qualityLevelParams.1}` file, `{qualityLevelParams.2}`/`.4` token — Floaty and old yt-dlp). yt-dlp's old code: `path.replace(f'{{qualityLevelParams.{i}}}', val)` for each param then `urljoin(stream['cdn'], path)`. yt-dlp switched to v3 on 2025-11-10 (PR #14984) because the v2 shape broke. Treat v2 as deprecated. **[V]**
+- Floaty's download trick: take the HLS variant URL, strip `/chunk.m3u8`, replace `/Videos/` with `/`, append `&attachment=true&filename=` to get a browser-style download. **[V]** (behaviour), **[I]** (stability).
+- Rate limit evidence: spec test `test_Flow.py` sleeps 35 s between delivery calls ("Current rate limit is 2 req per min"); floatplane-downloader uses a semaphore of 2 with a 65 s hold ("only call the delivery endpoint twice a minute at most"); Floaty budgets 2 per 5 minutes and parses `Retry-After` (default 300 s) and body text `error code: 1015`. **[V]**
+
+### 1.7 Live streams **[V]**
+- Find the stream via `creator.liveStream` (`CreatorModelV2/V3`, also on posts' `creator`). `liveStream.id` → `GET /api/v3/delivery/info?scenario=live&entityId=` → IVS HLS URL with `token` (see above). Old way: `/api/v2/cdn/delivery?type=live&creator=`.
+- Live/offline detection: Hydravion (`streamCheckTask.brs`) and Wasserflug (`LivestreamViewModel`) simply `GET` the m3u8 URL every ~3–5 s; HTTP 200 = live, anything else = offline. `liveStream.offline{title,description,thumbnail}` holds the offline card. Channels (sub-channels) don't have their own livestreams ("Individual channels do not currently support livestreams" — Hydravion). FloatNative plays live URLs natively without DPoP/headers (token in URL). The AsyncAPI spec covers chat on `wss://chat.floatplane.com/sails.io/` (needs the `sails.sid` cookie — the one remaining use of the cookie per bmlzootown).
+
+### 1.8 Watch progress **[V]**
+- Batch read: `POST /api/v3/content/get/progress` body `{"ids":["",…],"contentType":"blogPost"}` → `[{"id":"3qZrz5ugsa","progress":0},{"id":"mhhrOfndeF","progress":100}]` — **percentage 0–100** of the first video/audio attachment; don't use it to seek.
+- Write: `POST /api/v3/content/progress` body `{"id":"