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
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:
239
doc/floatplane-api-notes.md
Normal file
239
doc/floatplane-api-notes.md
Normal 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`.
|
||||
152
doc/plan.md
Normal file
152
doc/plan.md
Normal file
@@ -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/<Name>_<ver>/`,
|
||||
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/<videoId>?t=<hmac>, 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/<Creator>/<Channel>/` 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.
|
||||
221
doc/research/floatplane-api-research.md
Normal file
221
doc/research/floatplane-api-research.md
Normal file
@@ -0,0 +1,221 @@
|
||||
<!-- Research report produced 2026-10-25 by a Claude research agent (web sources). [V]=verified from docs/code/live fetch, [I]=inferred. -->
|
||||
|
||||
# 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 <access_token>` 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 <token>`). `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": "<TOTP>"}` → 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=<yours>&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 <access_token>` (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: <proof>` and `Authorization: DPoP <token>`; 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=<guid>` → `CreatorModelV3` (single creator).
|
||||
- `GET /api/v3/creator/named?creatorURL[0]=linustechtips` → `[CreatorModelV3]` (yt-dlp uses it to resolve `/channel/<urlname>/home/<channel>` 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]=<guid>` → `[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":"<p>…</p>","thumbnail":{…},"owner":"…","channel":"<channelId>","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":"<p>…</p>","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/<postId>/<n>_<ts>.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=<postId>` → `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=<attachmentId>` → `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>.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=<attachmentId or liveStream.id>[&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=<JWT>","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=<JWT>", …}]}]}
|
||||
```
|
||||
- `download` scenario: origins are edge servers (`https://edge01-na.floatplane.com`, `queryUrl`, `datacenter{latitude,longitude,countryCode,regionCode}`) and variant URLs are plain MP4s `/Videos/<id>/1080.mp4?token=<JWT>` with `mimeType: video/mp4`.
|
||||
- `live` scenario: `{"origins":[{"url":"https://<id>.us-east-1.playback.live-video.net"}],"variants":[{"name":"live-abr","label":"Auto","url":"/api/video/v1/us-east-1.<acct>.channel.<id>.m3u8?allow_source=false&token=<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=<attachment>` (or `&creator=<guid>` 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":"<JWT>"},…}}}}` — 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=<name>` 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=<liveStream.id>` → IVS HLS URL with `token` (see above). Old way: `/api/v2/cdn/delivery?type=live&creator=<creatorId>`.
|
||||
- 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":["<blogPostId>",…],"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":"<video attachment id>","contentType":"video"|"audio","progress":<seconds>}` → `"OK"`. Wasserflug posts periodically during playback and reads back `ContentVideoV3Response.progress` (seconds) to resume.
|
||||
- Undocumented but present in the website client: `GET /api/v3/content/progress?ids=&contentType=`, `POST /api/v3/content/progress/clear`, `POST /api/v3/content/progress/delete`, `GET /api/v3/content/history?limit=&creatorIds=` (FloatNative uses `offset`). **[V]** (strings in bundle), shapes **[I]**.
|
||||
|
||||
### 1.9 User-Agent, rate limiting, Cloudflare **[V]**
|
||||
- Set a descriptive UA with app name + version (spec README). Observed: `Floatplane-Downloader/<ver> (Inrix, +https://github.com/Inrixia/Floatplane-Downloader), CFNetwork`, `Hydravion (Roku) v<ver>`, `Wasserflug tvOS App version <ver>, CFNetwork`, `FloatNative/1.0 (Android)`, `FloatplaneAPI-Watch/3.1 (api-shape-canary; read-only)`. The `, CFNetwork` suffix was a captcha-bypass hint for the legacy login; harmless to keep.
|
||||
- Floatplane's HTML pages sit behind a Cloudflare JS challenge (my `curl` of `/legal/terms` returned the challenge shell), but the JSON API and `auth.floatplane.com` respond to plain HTTP clients (yt-dlp sets `impersonate=None` for floatplane.com; the Node canary uses plain `fetch`). Cloudflare 1015 appears only when you hammer the token endpoint.
|
||||
- Handle 429 + `Retry-After` everywhere; serialize delivery/info calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Open-source clients to learn from (how each resolves a playable URL)
|
||||
|
||||
| Client | Lang / URL | Auth | Video resolution path |
|
||||
|---|---|---|---|
|
||||
| **floatplane-downloader** + `floatplane` npm (floatplaneAPI.ts 5.2.8) | TS — `https://github.com/Inrixia/floatplane-downloader`, `https://github.com/Inrixia/floatplaneAPI.ts` | `deviceLogin()` via `openid-client` (device grant, scope `openid profile email offline_access`, `clientId:"floatplane-downloader"`, token persisted to `db/auth.json`, auto-refresh in a `got` hook, Bearer header) | `user.subscriptions()` → `creator.channels([id])` → `creator.blogPostsIterable(creatorId)` (offset 20) → `content.post(id)` → `content.video(attachmentId)` (skip if `levels` empty) → `cdn.delivery("download", attachmentId)` (semaphore 2/65 s) → round-robin `origins`, variants with non-empty url sorted by `order` desc, pick `label.includes("1080")` else best → `got.stream(origin.url + variant.url)` → mux with ffmpeg. Channel sorting via `post.channel.id`. |
|
||||
| **Wasserflug-tvOS** | Swift — `https://github.com/jamamp/Wasserflug-tvOS` | OAuthKit device flow, `clientID:"wasserflug"`, QR code of `verification_uri_complete`; Bearer injected via generated client `Configuration.apiWrapper`; legacy cookie code kept but commented out | `getDeliveryInfo(scenario:.ondemand, entityId: attachment.guid)` + `getVideoContent`; `DeliveryHelper.getBestUrl` (absolute → variant.origins → group.origins → fallback `https://www.floatplane.com`); filters enabled/!hidden/height ≤ screen; `AVURLAsset` + `HLSResourceLoaderDelegate` that adds `Authorization: Bearer` to intercepted (key) requests; live uses `AVURLAssetHTTPCookiesKey`. Progress via `/content/progress` and `/content/get/progress`. Generated client from the trimmed OpenAPI spec (`openapi-generator swift5 --library vapor`). |
|
||||
| **Hydravion** (Roku) | BrightScript — `https://github.com/bmlzootown/Hydravion` (Android TV sibling `Hydravion-AndroidTV`, Java) | Hand-rolled device flow (`client_id=hydravion`, no PKCE/DPoP), tokens in registry, refresh with 300 s buffer, validates with `/api/v3/user/subscriptions` | `/api/v3/content/creator?id=&channel=` per channel, `/api/v3/content/post` for attachment ids, `/api/v3/delivery/info?scenario=onDemand&entityId=` → `groups[0].origins[0].url + variant.url`, match `label` against chosen resolution; Roku `Video` node gets `AddHeader("Authorization","Bearer …")` + `EnableCookies()`; live: `scenario=live` → downloads m3u8 to `tmp:/live.m3u8` or plays URL directly. |
|
||||
| **Floaty** | Flutter/Dart — `https://github.com/floatyfp/floaty` | Auth-code + PKCE (`clientId:"floaty"`, redirect `uk.bw86.floaty://oauth/callback` or `http://localhost:36479/oauth/callback`), refresh with lock, revoke endpoint; cookie mode kept behind a flag | `v3/delivery/info?scenario=onDemand&entityId=&outputKind=hls.mpegts`; mpv/media_kit opened with headers `User-Agent`, `Referer`, `Origin`, `Authorization: Bearer`; download helper derives MP4 URL (see §1.6) and tracks `expires`; `FPRateLimitManager` 2 req/5 min. |
|
||||
| **FloatNative** | Kotlin (Android/TV/Fire TV) + SwiftUI (iOS/tvOS) — `https://github.com/coulterpeterson/FloatNative` | Device flow + PKCE + **DPoP** (`clientId:"floatnative"`, scope `openid offline_access`), nonce handling, legacy `sails.sid` captured only for chat | `VideoResourceLoader.swift`: custom scheme → fetch m3u8 with DPoP, rewrite `#EXT-X-KEY` URI back through the loader (DPoP) and segment URIs to absolute https (no auth); live streams bypass the loader. Also uses `/api/v3/content/history`, `/api/v3/creator/list?ids[]=`. |
|
||||
| **yt-dlp** Floatplane extractor | Python — `https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/floatplane.py` (fix PR `https://github.com/yt-dlp/yt-dlp/pull/14984`, 2025-11-10) | Browser `sails.sid` cookie | `/api/v3/content/post` → for each video/audio attachment `/api/v3/delivery/info?scenario=onDemand&entityId=` + `/api/v3/content/{video|audio}` → `urljoin(groups[0].origins[*].url, variant.url)`; formats from `variant.meta`; HLS AES key via `#EXT-X-KEY` URI; channel pages via `/api/v3/creator/named?creatorURL[0]=` and `/api/v3/content/creator?id=&limit=20&fetchAfter=page*20[&channel=]`. Cleanest reference for the v3 shapes. |
|
||||
| Kodi `plugin.video.floatplaneclub` | Python — `https://github.com/JWolvers/plugin.video.floatplaneclub` (archived 2022-02-09) | 2017 scraper of the old "Floatplane Club" site; obsolete. |
|
||||
| `lucacasonato/floatplaneapi` | old unofficial wrapper (`https://github.com/lucacasonato/floatplaneapi`), pre-v3; obsolete. |
|
||||
|
||||
**.NET / C#**: no Floatplane client exists on NuGet or GitHub (searched). The intended route is to generate one from the trimmed OpenAPI spec (`openapi-generator -g csharp` / NSwag / Kiota) — the spec repo exists for exactly that — and add the OAuth part yourself (plain `HttpClient` form posts; for DPoP, `Microsoft.IdentityModel.Tokens` + `ECDsa` P-256 is enough). **[V]** for absence, **[I]** for tooling advice. Watch out: generated models will be strict about the schema and the live API drifts (e.g. attachment arrays missing from lists); use lenient deserialization.
|
||||
|
||||
---
|
||||
|
||||
## 3. Terms of service / API-usage considerations (brief)
|
||||
- The API is unofficial and reverse-engineered from `frontend.floatplane.com/{version}/*.js`; the spec repo is "for purposes of tinkering and creating custom clients for Floatplane". Floatplane visibly tolerates third-party clients: Hydravion is in the Roku Channel Store, Wasserflug and FloatNative in Apple's/Google's stores, and Floatplane has issued Keycloak client IDs to these projects (strong signal of cooperation). **[V]** for the facts, **[I]** for "tolerates".
|
||||
- I could not retrieve the ToS text itself (`https://www.floatplane.com/legal/terms` is a Cloudflare-challenged SPA and the ToS isn't in the JS bundle) — read it in a browser before publishing a plugin. **[V]**
|
||||
- Practical rules all clients follow: personal use with your own paid subscription; unique User-Agent; respect 429/`Retry-After` and the delivery-endpoint budget; don't share tokens; don't redistribute content. FloatNative's footer wording is the standard disclaimer ("unofficial third-party client and is not affiliated with Floatplane Media Inc.").
|
||||
|
||||
---
|
||||
|
||||
## 4. Other relevant details
|
||||
- **Channels** (sub-channels, introduced 2023): `CreatorModelV3.channels[]` (`ChannelModel {id, creator, title, urlname, about, order, cover, card, icon, socialLinks}`), `creator.defaultChannel`, posts carry `channel` (object or id). Filter with `/api/v3/content/creator?id=<creator>&channel=<channelId>`. Web URLs: `https://www.floatplane.com/channel/<creator.urlname>/home/<channel.urlname>`, posts `https://www.floatplane.com/post/<postId>`. Known LTT channel ids used by clients: main `63fe42c309e691e4e36de93d`, TechLinked `6413534d88c13c181c3e2809`, FP Exclusives `6413623f5b12cca228a28e78`, They're Just Movies `64135f82fc76ab7f9fbdc876`, GameLinked `649dbade3540dbc3945eeda7`. **[V]**
|
||||
- **IDs**: creators/channels/users = 24-hex Mongo ids; posts and attachments = 10-char alphanumeric (`Dw2ms0AgL8`, `TViGzkuIic`); `guid == id` in practice.
|
||||
- **Thumbnails/images**: `ImageModel {width,height,path,childImages[{width,height,path}]}` on `pbs.floatplane.com` (`blogPost_thumbnails/<postId>/…`, `video_thumbnails/<attId>/…`, `content_thumbnails/`, `creator_icons/`, `cover_images/`, `creator_card/`, `stream_thumbnails/`); child sizes are suffixed `_WxH.jpeg`. Some picture `imageFiles[].path` are pre-signed (`AWSAccessKeyId/Expires/Signature`). Hydravion fetches images with the Bearer header too, but pbs thumbnails are generally public. **[V]/[I]**
|
||||
- **Post metadata**: `metadata{hasVideo, videoCount, videoDuration(s), hasAudio, audioCount, audioDuration, hasPicture, pictureCount, hasGallery, galleryCount, isFeatured}`; `isAccessible:false` = locked (show as unavailable); `wasReleasedSilently`; `text` is HTML (yt-dlp `clean_html`s it).
|
||||
- **Pagination semantics**: `/content/creator` → `fetchAfter` is a plain skip count (page N = `N*limit`), stop on empty page; `/content/creator/list` → opaque per-creator cursors in `lastElements` (`moreFetchable:false` when a creator is exhausted; drop entries with null `blogPostId`). Both default to 20 per page in clients.
|
||||
- **Versions**: spec `3.10.0-c` tracks frontend ≤ `4.0.13`; live frontend today `4.5.25-340-92712da`. The bmlzootown fork's canary (baselines for `user/self`, `user/subscriptions`, `content/creator`, `content/post`) reported "no structural drift" as of 2026-10-02/11.
|
||||
- **Pre-prod** environment for testing exists (`pp.floatplane.com` + realm `floatplane-pp`) but needs a pre-prod account/client.
|
||||
|
||||
## 5. Suggested shape for the Jellyfin plugin (inferred from the above) **[I]**
|
||||
1. Config page runs the device flow: get a client ID from Floatplane first; `POST …/auth/device` → show `verification_uri_complete` + QR + `user_code`; poll `…/token`; store `refresh_token` (+ DPoP key if your client has extended security) in the plugin config; refresh proactively (<60 s left), never per-request; re-login when refresh fails with `invalid_grant`.
|
||||
2. Library/Channel provider: `user/subscriptions` → `creator/info` (+ `channels`) → `content/creator?id&channel&limit=20&fetchAfter` (filter `metadata.hasVideo`) → `content/post` for attachment objects → one Jellyfin item per video attachment (title from attachment when a post has several; `releaseDate`, `thumbnail`, `videoDuration`, `text` as overview).
|
||||
3. Playback: on request call `delivery/info?scenario=onDemand&entityId=<attachmentId>` (cache per attachment until the JWT `exp`; throttle to ~2/min globally), pick a variant, then **serve the HLS through the plugin** (an authenticated proxy endpoint that fetches the master/media playlist and the `#EXT-X-KEY` with `Authorization`, rewrites segment URIs to absolute CDN URLs) or hand ffmpeg the URL with `-headers "Authorization: Bearer …"`; alternatively try `scenario=download` MP4 URLs and verify whether the token alone is accepted. Live: `scenario=live` URL plays as-is.
|
||||
4. Progress sync: `POST /content/progress` every ~30 s with seconds; `POST /content/get/progress` to mark watched (100) in listings; `content/video.progress` for resume position.
|
||||
5. Send `User-Agent: Jellyfin-Floatplane/<version> (+<repo url>)`, honour 429/`Retry-After`.
|
||||
|
||||
Sources: `https://github.com/jamamp/FloatplaneAPI` · `https://jamamp.github.io/FloatplaneAPIDocs/` · `https://github.com/bmlzootown/FloatplaneAPI` (PRs #6, #7, #11, #12) · `https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration` · `https://github.com/Inrixia/floatplaneAPI.ts` (incl. PR #15) · `https://github.com/Inrixia/floatplane-downloader` (issue #290) · `https://github.com/NigelVanHattum/Homelab-Helm-charts/pull/89` · `https://github.com/jamamp/Wasserflug-tvOS` · `https://github.com/bmlzootown/Hydravion` · `https://github.com/bmlzootown/Hydravion-AndroidTV` · `https://github.com/floatyfp/floaty` · `https://github.com/coulterpeterson/FloatNative` · `https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/floatplane.py` · `https://github.com/yt-dlp/yt-dlp/pull/14984` · `https://github.com/JWolvers/plugin.video.floatplaneclub` · `https://github.com/lucacasonato/floatplaneapi` · `https://frontend.floatplane.com/user/4.5.25-340-92712da/js/index-DaEmRTqt.js` · `https://linustechtips.com/topic/1052617-floatplane-unofficial-rokutvosandroidtv-channel/` and `https://linustechtips.com/topic/1626139-hydravion-androidtv-login-issues/` (search snippets only; pages blocked).
|
||||
|
||||
290
doc/research/jellyfin-plugin-research.md
Normal file
290
doc/research/jellyfin-plugin-research.md
Normal file
@@ -0,0 +1,290 @@
|
||||
<!-- Research report produced 2026-10-25 by a Claude research agent, verified against Jellyfin release-10.10.z sources, jellyfin-web, jprm, chaosflix-jellyfin, MediathekViewDL, NextPVR. -->
|
||||
|
||||
# Building a Jellyfin 10.10.7 plugin for Floatplane — research report (2026-10-25)
|
||||
|
||||
**Verification basis.** Server facts below were checked against the Jellyfin source at branch `release-10.10.z` (commit `84c580b`, 2026-02-20, `SharedVersion.cs` = `AssemblyVersion("10.10.7")`), the `jellyfin-web` `release-10.10.z` branch, and the live repos of the template, jprm, chaosflix-jellyfin, MediathekViewDL, NextPVR and Floatplane-Downloader (shallow clones in the scratchpad under `src/`). Where I infer rather than verify, I say so.
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
- 10.10.7 is **.NET 8**, ABI `10.10.0.0`. Build a `net8.0` class library against NuGet `Jellyfin.Controller`/`Jellyfin.Model` **10.10.7** with `ExcludeAssets=runtime`; set `targetAbi: "10.10.0.0"` and `framework: "net8.0"` in `build.yaml`/`meta.json`. The upstream template on `master` has moved to 10.11/.NET 9, so copy its structure but downgrade the versions.
|
||||
- The **channel subsystem (`IChannel`) is alive and functional in 10.10.7** (it lives in `src/Jellyfin.LiveTv/Channels/`), still exists in 10.11/12, and is what community plugins like chaosflix (CCC talks) and MediathekViewDL use. It is the only way to get a browsable, playable, *virtual* catalog into the official web client **and** the Android/TV apps without any client modification. No deprecation statement exists; it is simply old Emby-era API with known quirks (one parent per item, cache behaviour).
|
||||
- **`ChannelMediaInfo` no longer exists** in 10.10. `IRequiresMediaInfoCallback.GetChannelItemMediaInfo` returns `IEnumerable<MediaSourceInfo>` directly; `RequiredHttpHeaders` is a `Dictionary<string,string>` on `MediaSourceInfo`.
|
||||
- Jellyfin forwards **only `User-Agent` (and `Referer` for ffmpeg)** from `RequiredHttpHeaders` to ffmpeg/ffprobe/its own HTTP relay — never cookies or `Authorization`. So the plugin must either hand Jellyfin a URL that needs no auth (signed CDN URL) or **proxy the stream through its own ASP.NET controller** (chaosflix pattern, HMAC-signed anonymous URL). The latter is the robust choice and also makes Range/seeking work.
|
||||
- Floatplane auth has moved to **OAuth2/OIDC (Keycloak at `auth.floatplane.com`) with device-code flow and DPoP** (verified in Floatplane-Downloader's `fApi.deviceLogin()` and the OIDC discovery doc in the scratchpad); yt-dlp still uses the legacy `sails.sid` cookie. The plugin's "secrets" are therefore a refresh token (+ DPoP key), stored in the plain-XML `PluginConfiguration` — Jellyfin has no secret store.
|
||||
- Nothing Floatplane+Jellyfin exists on GitHub (API search returns 0 repos). Closest prior art: Inrixia/Floatplane-Downloader (Plex-oriented downloader), a 2018 Kodi addon, GrayJay plugin.
|
||||
- A plugin DLL is IL/AnyCPU: build once on x86, run on the Pi 5 arm64 unchanged. Only RID-specific publishing or native NuGet deps break this; the Floatplane plugin needs neither.
|
||||
- **Warning:** `lscr.io/linuxserver/jellyfin:latest` is now **12.3** (release 2026-10-23). A `docker pull` on the Pi would jump from 10.10.7 to 12.x and make a 10.10 plugin `NotSupported` (12 needs .NET 10 plugins). Pin `10.10.7ubu2404-ls80` (last 10.10.7 LSIO build, 2025-10-07, amd64+arm64).
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin basics for 10.10.x
|
||||
|
||||
### 1.1 .NET and package versions (verified)
|
||||
- `Jellyfin.Server.csproj` and `MediaBrowser.Controller.csproj` on `release-10.10.z`: `<TargetFramework>net8.0</TargetFramework>`.
|
||||
- NuGet `Jellyfin.Controller 10.10.7` exists (published 2025-04-05, targets net8.0; depends on Jellyfin.Common/Model/Naming 10.10.7, Microsoft.Extensions.Configuration.* 8.0.x). https://www.nuget.org/packages/Jellyfin.Controller/10.10.7
|
||||
- Jellyfin core (Niels, forum): "We avoid ABI changes in patch releases as much as possible, so any plugin with targetAbi of 10.10.0 or higher should work with all 10.10.z releases." https://forum.jellyfin.org/t-question-about-targetabi
|
||||
- Server-side check (`Emby.Server.Implementations/Plugins/PluginManager.cs`): `Version.TryParse(manifest.TargetAbi)` then the plugin is only instantiated if `_appVersion >= targetAbi`; otherwise status `NotSupported`. So `10.10.0.0` is right; `10.10.7.0` also works but is needlessly strict.
|
||||
|
||||
### 1.2 The template repo and its state (verified)
|
||||
https://github.com/jellyfin/jellyfin-plugin-template — structure on `master`: `Jellyfin.Plugin.Template/` (`Plugin.cs`, `Configuration/PluginConfiguration.cs`, `Configuration/configPage.html`, `.csproj`), `build.yaml`, `Directory.Build.props`, `.slnx`, `.vscode/` (build-and-copy tasks), `.github/workflows/*` (thin wrappers calling `jellyfin/jellyfin-meta-plugins` reusable workflows), `README.md` (the de-facto plugin dev guide).
|
||||
|
||||
History that matters (from `git log`): `3afa1fc` (2024-10-05) "Upgrade template to 10.9.x and .NET 8" → `4291891` Jellyfin.Controller 10.9.11 → `6277846` (2025-11-23) bumped to net9.0 and added `ExcludeAssets=runtime` → `726279e` (2026-09-03) "Upgrade to Jellyfin 10.11.x". **The template never had a 10.10 commit**; current master is `targetAbi: "10.11.0.0"`, `framework: "net9.0"`, `Jellyfin.Controller 10.11.5`. For 10.10 use this csproj (derived from the net8 era + the `ExcludeAssets` fix):
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Floatplane</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||
<!-- do NOT set RuntimeIdentifier / SelfContained / PublishReadyToRun -->
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.7"><ExcludeAssets>runtime</ExcludeAssets></PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.10.7"><ExcludeAssets>runtime</ExcludeAssets></PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
`ExcludeAssets=runtime` keeps Jellyfin's own DLLs out of your publish folder (the README notes a plugin shows `NotSupported` if package version and server mismatch; shipping server DLLs inside the plugin folder causes them to be loaded into the plugin's load context — see 1.5).
|
||||
|
||||
Required classes (template README, verified against 10.10 source):
|
||||
- `PluginConfiguration : BasePluginConfiguration` (`MediaBrowser.Model.Plugins`), public get/set properties, defaults in ctor; serialized to XML.
|
||||
- `Plugin : BasePlugin<PluginConfiguration>, IHasWebPages` with ctor `(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(...)`, `override string Name`, `override Guid Id`, static `Instance`, and `GetPages()` returning `PluginPageInfo { Name, EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html" }`. `PluginPageInfo` (10.10: `MediaBrowser.Model.Plugins`) has `Name, DisplayName, EmbeddedResourcePath, EnableInMainMenu, MenuSection, MenuIcon`.
|
||||
- `IPluginServiceRegistrator` (`MediaBrowser.Controller.Plugins`): `void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)`; must have a parameterless ctor; discovered by type-scanning (`PluginManager.RegisterServices` → `Activator.CreateInstance`). **In 10.10 `ChannelManager`'s constructor takes `IEnumerable<IChannel> channels` from DI, so a channel must be registered here** (`serviceCollection.AddSingleton<IChannel, FloatplaneChannel>()`). `IScheduledTask` implementations, by contrast, are found by `GetExports<IScheduledTask>()` type scanning and need no registration.
|
||||
|
||||
### 1.3 build.yaml / meta.json / zip / repository (verified from jprm source and `PluginManifest.cs`)
|
||||
`build.yaml` for 10.10:
|
||||
```yaml
|
||||
---
|
||||
name: "Floatplane"
|
||||
guid: "<new GUID, must match Plugin.Id>"
|
||||
version: "1.0.0.0" # valid C# assembly version
|
||||
targetAbi: "10.10.0.0"
|
||||
framework: "net8.0" # optional in jprm but DEFAULTS TO netstandard2.1 — always set it
|
||||
overview: "Browse and play Floatplane videos"
|
||||
description: >
|
||||
...
|
||||
category: "Channels" # allowed: Authentication, Channels, General, LiveTV, Metadata, Notifications
|
||||
owner: "brasse"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.Floatplane.dll" # every DLL that must land in the zip
|
||||
changelog: >
|
||||
...
|
||||
```
|
||||
jprm (https://github.com/oddstr13/jellyfin-plugin-repository-manager, `pip install jprm`) looks for `jprm.yaml`, `.jprm.yaml`, `.ci/jprm.yaml`, `.github/jprm.yaml`, `.gitlab/jprm.yaml`, `meta.yaml` or `build.yaml` (Metadata.md). What it does:
|
||||
- `jprm plugin build <path> -o ./artifacts [-v VERSION] [--dotnet-configuration Release] [--dotnet-framework net8.0] [--max-cpu-count 1]` → rewrites `Version`/`TargetFramework` in the csproj(s), runs `dotnet clean`, `dotnet restore --no-cache`, `dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 -p:PublishDir=<tmp> -p:Version=<v>`, then copies the `artifacts` list + generated `meta.json` (+ optional `image`) flat into `<slug>_<version>.zip` and writes a `.md5sum` file.
|
||||
- `jprm repo init <dir>` creates an empty `manifest.json`; `jprm repo add <repo>/manifest.json <zip> --url https://host/base [--plugin-url <full zip url>]` copies the zip into `<repo>/<slug>/<slug>_<version>.zip` and appends a version entry.
|
||||
- Generated `meta.json` keys: `guid, name, description, overview, owner, category, version, changelog, targetAbi, timestamp` (+ `image`/`imageUrl`). The server's `PluginManifest` additionally understands `assemblies` (DLL whitelist, relative paths), `status`, `autoUpdate`, `imagePath`.
|
||||
- Repository `manifest.json` = JSON array of `{ guid, name, description, overview, owner, category, imageUrl?, versions: [{ version, changelog, targetAbi, sourceUrl, checksum (md5 of zip), timestamp }] }`. Add it in Dashboard → Plugins → Repositories with the raw URL (works fine from Gitea raw/releases). The jprm GitHub Action (`oddstr13/jellyfin-plugin-repository-manager@v1`, inputs `path, output, version, dotnet-config, dotnet-target, max-cpu-count, verbosity`; output `artifact`) is a composite action that `pip install`s requirements and runs `jprm plugin build`.
|
||||
- A jprm-free alternative that is byte-for-byte what Jellyfin needs (chaosflix `release.yml`): `dotnet publish X.csproj -c Release -o artifacts && cp meta.json artifacts/ && (cd artifacts && zip -j ../plugin.zip X.dll meta.json) && md5sum plugin.zip`.
|
||||
|
||||
### 1.4 Installation paths (verified)
|
||||
- `BaseApplicationPaths`: `PluginsPath = <ProgramDataPath>/plugins`, `PluginConfigurationsPath = <plugins>/configurations` (→ `<Name>.xml`). Official docs: "placed in the plugin directory ... `/var/lib/jellyfin/plugins/` on most Linux ... Once the server is restarted any additions should automatically show up." https://jellyfin.org/docs/general/server/plugins/
|
||||
- `PluginManager` enumerates **top-level** subdirectories of the plugins path, reads `meta.json` if present, otherwise synthesizes a manifest from the folder name (legacy `PluginName_1.0.0.0` form). Loads every `*.dll` found recursively (`Directory.GetFiles(plugin.Path, "*.dll", AllDirectories)`) unless `meta.json` `assemblies` whitelists specific ones; each DLL is `LoadFromAssemblyPath`'d into a collectible `PluginLoadContext : AssemblyLoadContext` (uses `AssemblyDependencyResolver`, no `LoadUnmanagedDll` override). Statuses: Active, Disabled, Deleted, Malfunctioned, NotSupported, Superceded, Restart.
|
||||
- **Official image** (`jellyfin/jellyfin`, `jellyfin-packaging/docker/Dockerfile`): `JELLYFIN_DATA_DIR=/config`, `JELLYFIN_CONFIG_DIR=/config/config`, `JELLYFIN_LOG_DIR=/config/log`, `JELLYFIN_CACHE_DIR=/cache` → plugins at **`/config/plugins/<Name>_<version>/`**.
|
||||
- **linuxserver image** (`root/etc/s6-overlay/s6-rc.d/svc-jellyfin/run`): `JELLYFIN_DATA_DIR=/config/data`, `JELLYFIN_CONFIG_DIR=/config`, `JELLYFIN_LOG_DIR=/config/log`, `JELLYFIN_CACHE_DIR=/config/cache` → plugins at **`/config/data/plugins/<Name>_<version>/`** on your Pi 5, configs in `/config/data/plugins/configurations/`, logs in `/config/log/`.
|
||||
|
||||
### 1.5 Load-context caveat (verified mechanism, inferred consequence)
|
||||
Because *all* DLLs in the plugin folder are loaded into the plugin's own `AssemblyLoadContext`, any copy of an assembly Jellyfin already provides (Jellyfin.*, Microsoft.Extensions.*, Serilog, etc.) gets a second identity inside your context and can produce `MissingMethodException`/cast failures. Keep the publish output to your own DLL plus true third-party deps (e.g. MediathekViewDL ships `FuzzySharp.dll` and lists it in `artifacts`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture portability (x64 ↔ arm64)
|
||||
|
||||
- A plain `dotnet publish` with no `-r`/`RuntimeIdentifier`, no `--self-contained`, no `PublishReadyToRun`/`PublishAot` produces an IL-only AnyCPU assembly. The Jellyfin plugin ecosystem relies on this: the official repository has **one** manifest for every platform, chaosflix's release workflow builds on `ubuntu-latest` (x64) and the same zip is what its Android/Android TV e2e suites install (verified in its repo). The counter-example you may find — `someRandomDude-a/jellyfin-plugins` with per-platform manifests — exists only because that author publishes with `package.ps1 -Runtime linux-x64` (RID-specific builds), not because Jellyfin needs it.
|
||||
- Native dependencies: `PluginLoadContext` has no `LoadUnmanagedDll` override and is constructed with the plugin *directory* (so `.deps.json`-driven `runtimes/<rid>/native` resolution is effectively absent — inferred). .NET's default `DllImport` probing still includes the directory of the calling assembly, so a native `.so` placed flat next to the DLL for the *host* architecture would load (inferred, moderate confidence). Practical rule: avoid NuGet packages that ship native binaries (SkiaSharp, SQLitePCLRaw bundles, ffmpeg wrappers); the Floatplane plugin needs only `System.Net.Http`/`System.Text.Json` from the shared framework.
|
||||
- Building on an arm64 Gitea runner: `mcr.microsoft.com/dotnet/sdk:8.0` is multi-arch (tags `8.0-bookworm-slim-arm64v8`, `8.0-noble-arm64v8`, `8.0-alpine3.24-arm64v8` verified at https://github.com/dotnet/dotnet-docker/blob/main/README.sdk.md); `actions/setup-dotnet` (mirrored at https://gitea.com/actions/setup-dotnet) resolves linux-arm64 SDKs; `act_runner` has an arm64 binary. jprm needs `python3`/`pip` in the job container (the SDK images do not ship Python — install it or use the zip-by-hand recipe). The build is IL-only, so it is equally valid to build on brasse-linux01 and push the zip; an arm64 build is only needed for CI hygiene.
|
||||
|
||||
---
|
||||
|
||||
## 3. Exposing online video as playable items: the channel API in 10.10
|
||||
|
||||
### 3.1 Status of channels (verified)
|
||||
- Code location in 10.10: `src/Jellyfin.LiveTv/Channels/ChannelManager.cs` (1216 lines), `ChannelDynamicMediaSourceProvider.cs`, `RefreshChannelsScheduledTask.cs`; API `Jellyfin.Api/Controllers/ChannelsController.cs` (`GET /Channels`, `/Channels/Features`, `/Channels/{id}/Features`, `/Channels/{id}/Items`, `/Channels/Items/Latest`).
|
||||
- Channels are injected into user views: `UserViewManager.GetUserViews` with `IncludeExternalContent` does `_channelManager.GetChannelsInternalAsync(new ChannelQuery{UserId})` and `list.AddRange(channels)` → each channel shows up as a library tile under "My Media" in web and apps; `jellyfin-web` `list.js` handles `ChannelFolderItem`.
|
||||
- Per-user gating: `GetChannelsInternalAsync` filters by `Channel.IsVisible(user)` (user policy `EnableAllChannels` default `true`, else `EnabledChannels` GUID list) and then `IChannel.IsEnabledFor(userId)`.
|
||||
- Still present in later versions: chaosflix targets 12.1 today and supported 10.11 before; issue "IChannel: an item can only live under one parent" (https://github.com/jellyfin/jellyfin/issues/18127) was filed Sept 2026 against 10.11.7/12.1 and is open with no maintainer statement about removal; jellyfin.org still lists "Channels: Allow streaming remote audio or video content" as a plugin category; the 12.0 release notes list plugin interface breaks but nothing about channels. No official jellyfin-org channel plugin is maintained (`jellyfin-plugin-iptv` is archived), so treat it as "legacy but working".
|
||||
|
||||
### 3.2 The interfaces, exactly as in 10.10 (verified)
|
||||
`MediaBrowser.Controller.Channels` contains: `IChannel`, `IRequiresMediaInfoCallback`, `IHasCacheKey`, `ISupportsLatestMedia`, `ISupportsMediaProbe`, `ISupportsDelete`, `IDisableMediaSourceDisplay`, `IHasFolderAttributes`, `InternalChannelFeatures`, `InternalChannelItemQuery`, `ChannelItemInfo`, `ChannelItemResult`, `ChannelItemType`, `ChannelParentalRating`, `ChannelSearchInfo`, `ChannelLatestMediaSearch`, `Channel`, `IChannelManager`. Enums in `MediaBrowser.Model.Channels`: `ChannelMediaType`, `ChannelMediaContentType`, `ChannelFolderType`, `ChannelItemSortField`.
|
||||
|
||||
```csharp
|
||||
public interface IChannel {
|
||||
string Name { get; } string Description { get; } string DataVersion { get; }
|
||||
string HomePageUrl { get; } ChannelParentalRating ParentalRating { get; }
|
||||
InternalChannelFeatures GetChannelFeatures();
|
||||
bool IsEnabledFor(string userId);
|
||||
Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken ct);
|
||||
Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken ct);
|
||||
IEnumerable<ImageType> GetSupportedChannelImages();
|
||||
}
|
||||
public interface IRequiresMediaInfoCallback {
|
||||
Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken ct); // NOT ChannelMediaInfo
|
||||
}
|
||||
public interface IHasCacheKey { string? GetCacheKey(string? userId); }
|
||||
public interface ISupportsLatestMedia { Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken ct); }
|
||||
public interface ISupportsMediaProbe { } // marker only
|
||||
// InternalChannelItemQuery: FolderId (string), UserId (Guid), StartIndex?, Limit?, SortBy (ChannelItemSortField?), SortDescending
|
||||
// ChannelItemResult: IReadOnlyList<ChannelItemInfo> Items; int? TotalRecordCount
|
||||
// InternalChannelFeatures: MediaTypes, ContentTypes, DefaultSortFields, MaxPageSize?, AutoRefreshLevels?, DailyDownloadLimit?, SupportsSortOrderToggle, SupportsContentDownloading
|
||||
```
|
||||
`ChannelItemInfo` fields: `Id` (your stable external id), `Name`, `SeriesName`, `Type` (`Media`/`Folder`), `MediaType` (`Video`/`Audio`/`Photo`), `ContentType` (`Clip`, `Episode`, `Movie`, `Trailer`, `Podcast`, …), `FolderType` (`Container`, `Series`, `Season`, `MusicAlbum`, …), `Overview`, `ImageUrl`, `RunTimeTicks`, `PremiereDate`, `ProductionYear`, `DateCreated`, `DateModified`, `IndexNumber`, `ParentIndexNumber`, `OfficialRating`, `CommunityRating`, `Genres`, `Studios`, `Tags`, `People`, `ProviderIds`, `HomePageUrl`, `OriginalTitle`, `ExtraType`, `TrailerTypes`, `Artists`, `AlbumArtists`, `StartDate`, `EndDate`, `IsLiveStream`, `Etag`, `MediaSources` (`List<MediaSourceInfo>`, for channels that do *not* use the callback).
|
||||
|
||||
### 3.3 How the server treats channel items (verified in `ChannelManager`)
|
||||
- Channel entity id = `GetInternalChannelId(channel.Name)` — **renaming your channel creates a new library entity**; keep `Name` stable.
|
||||
- Listing cache: `GetChannelItems` writes `ChannelItemResult` JSON to `<cache>/channels/<channelId>/<md5(DataVersion+"2")>/<md5(folderId|root + GetCacheKey(userId) + sort)>.json` and treats it as fresh for **3 hours** (`CacheLength`). On a cache hit the plugin is *not* called and items are served from the library DB. To force refresh: bump `DataVersion`, vary `IHasCacheKey.GetCacheKey` (e.g. include a counter you increment after a sync), or delete the cache folder. (MediathekViewDL buckets `DataVersion` by day for exactly this reason.)
|
||||
- Entity sync: every `ChannelItemInfo` becomes a real `BaseItem` (`Folder`/`Series`/`Season`/`Video`/`Episode`/`Movie`/`Trailer`/`Audio`/`AudioBook` by `FolderType`/`ContentType`/`MediaType`) with `SourceType.Channel`, `ChannelId`, `ExternalId = info.Id`, `ParentId = <folder you listed it under>`; internal id derived from `md5(externalId + channelName + "16")`. Items missing from a fresh listing are deleted. Metadata is only copied on first creation (except container-folder names, `DateModified`, image URL). `ImageUrl` becomes the primary image. → Design stable, unique `Id`s, and **do not list the same `Id` under two folders** (issue #18127: the item moves to the last parent; chaosflix notes duplicate-id workaround creates separate items with separate watched state).
|
||||
- Media sources: `BaseItem.GetMediaSources` for `SourceType.Channel` first returns `ChannelManager.GetStaticMediaSources` (sources you put in `ChannelItemInfo.MediaSources`, persisted to `channelmediasourceinfos.json`); then `MediaSourceManager.GetPlaybackMediaSources` appends **dynamic** sources from `ChannelDynamicMediaSourceProvider` → `IRequiresMediaInfoCallback.GetChannelItemMediaInfo(item.ExternalId)` (result memory-cached **5 minutes** per id — fine for short-lived CDN tokens). Dynamic sources are **not probed**: the server only checks `SupportsDirectStream = SupportsDirectStream(path, protocol)` (`false` if the path contains `.m3u`) and user permissions (`EnableVideoPlaybackTranscoding`, `EnablePlaybackRemuxing`). `ISupportsMediaProbe` in 10.10 only changes whether `RunTimeTicks` is re-copied on refresh — it does not trigger a probe.
|
||||
- `MediaSourceInfo` relevant fields (`MediaBrowser.Model.Dto`): `Id` (must parse as a GUID — MediathekViewDL hashes its id into a GUID "because Jellyfin's pipeline calls Guid.Parse"), `Path`, `Protocol` (`MediaProtocol.Http`), `Container`, `IsRemote`, `SupportsDirectPlay`/`SupportsDirectStream`/`SupportsTranscoding`/`SupportsProbing` (default true), `MediaStreams`, `RunTimeTicks`, `Bitrate`, `RequiredHttpHeaders`, `IsInfiniteStream`, `RequiresOpening`/`OpenToken` (live streams), `Name`.
|
||||
- Latest items: `ISupportsLatestMedia` feeds `/Channels/Items/Latest` and `RefreshChannelsScheduledTask` ("RefreshInternetChannels", default every 24 h, hidden when no channels). The 10.10 web client never calls that endpoint and excludes the `channels` view type from the home "Recently added" sections, so this is low priority.
|
||||
|
||||
### 3.4 Where the bytes go: the three playback paths for a remote HTTP source (verified)
|
||||
Given a `MediaSourceInfo { Protocol = Http, Path = <url> }`, `MediaInfoHelper.SetDeviceSpecificData` runs `StreamBuilder` against the client's device profile and sets `SupportsDirectPlay/DirectStream` + `TranscodingUrl`. The web client then (`playbackmanager.js`):
|
||||
|
||||
1. **Browser direct play** — `supportsDirectPlay()` requires `SupportsDirectPlay` (server says codecs/container fit), `IsRemote` + `appHost.supports('remotevideo')` (true in browsers except Tizen/webOS/Opera TV/Orsay/Edge UWP), `Protocol === 'Http'` **and `RequiredHttpHeaders` empty**; then `mediaUrl = mediaSource.Path` — the browser fetches the CDN URL itself (CORS and cookie rules of the CDN apply; HLS goes through hls.js). Any required header disables this.
|
||||
2. **Server relay ("Static")** — otherwise if `SupportsDirectPlay || SupportsDirectStream`: `GET /Videos/{id}/stream.{container}?Static=true&mediaSourceId=…&api_key=…`; `VideosController` sees `InputProtocol == Http` and calls `FileStreamResponseHelpers.GetStaticRemoteStreamResult`: Jellyfin's `HttpClient` (NamedClient.Default) GETs the URL, forwards **only `User-Agent`** from `RequiredHttpHeaders`, sets `Accept-Ranges: none` and streams the body → **no seeking**. Not available for `.m3u8` paths (`SupportsDirectStream` forced false).
|
||||
3. **ffmpeg (remux or transcode)** — `TranscodingUrl` → `EncodingHelper.AttachMediaSourceInfo` copies `RequiredHttpHeaders` into `state.RemoteHttpHeaders`; `GetInputModifier` emits only `-user_agent` and `-referer`; `MediaEncoder.GetMediaInfo` (ffprobe) emits only `-user_agent`. Nothing else (no `Cookie`, no `Authorization`, no custom `-headers`) reaches ffmpeg. Whether ffmpeg *copies* streams (cheap HLS remux) or re-encodes depends on `CanStreamCopyVideo(state, videoStream)`, which needs a probed/declared `MediaStream` — with an empty `MediaStreams` list StreamBuilder treats the source as having no video stream and you get a full transcode. **On the Pi 5 that is CPU-only (V4L2 deprecated in 10.10, Pi 5 has no H.264 encoder; ~1–2 1080p software streams)**, so you want either direct play or remux, which means supplying `MediaStreams` (codec `h264`/`aac`, width/height, bitrate — Floatplane's delivery `variants[].meta.video/audio` carry this) or probing yourself via `IMediaEncoder.GetMediaInfo` as chaosflix does.
|
||||
|
||||
Consequences for Floatplane: yt-dlp shows Floatplane's `delivery/info` returns HLS (`m3u8`) origins/variants and that fetching them needs `Origin`/`Referer` headers and sometimes browser impersonation, plus HLS-AES keys. Those headers cannot be forwarded by Jellyfin, so the proven pattern is the **chaosflix pattern**: return `Path = http://<jellyfin>/api/FloatplaneStream/proxy/<id>?t=<hmac>` (anonymous endpoint, process-lifetime HMAC secret, forwards `Range`, retries mirrors), `Protocol = Http`, `IsRemote = false` so clients always go through the server, `SupportsDirectPlay = false`, `SupportsDirectStream = true`, `SupportsTranscoding = true`, `Container` set correctly, `MediaStreams` filled. For a flat MP4 (`outputKind=flat` / `scenario=download` variants) the proxy gives seekable progressive download; for HLS your proxy must rewrite the playlist's segment URLs to point back at itself (or ask Floatplane for `flat`).
|
||||
|
||||
### 3.5 Real examples to crib from
|
||||
- **chaosflix-jellyfin** (https://github.com/praetorianer777/chaosflix-jellyfin): `ChaosflixChannel : IChannel, IRequiresMediaInfoCallback, ISupportsLatestMedia, IHasCacheKey`; `ChaosflixStreamController : ControllerBase` with `[Route("api/[controller]")]`, `[HttpGet("proxy/{eventGuid}")]`, HMAC-signed anonymous URLs (`ProxySignature`), Range passthrough, mirror failover; registrator does `AddHttpClient(...)`, `AddSingleton<ChaosflixChannel>()`, `AddSingleton<IChannel>(sp => sp.GetRequiredService<ChaosflixChannel>())`, `AddHostedService<...>`; an `IScheduledTask` sync; probes streams with `IMediaEncoder`; Playwright e2e against `jellyfin/jellyfin:<tag>` in docker-compose with the plugin bind-mounted at `/config/plugins/Chaosflix`; Android and Android TV e2e flows. README: Android/Android TV apps need an app restart to show a newly installed channel; channel items can't be downloaded offline; same item in two folders duplicates watch state. Supported 10.11 until v0.4.0 (now 12.1); meta.json-based packaging, no jprm.
|
||||
- **jellyfin-plugin-MediathekViewDL** PR #179 "virtual subscriptions as channel" (https://github.com/CatNoir2006/jellyfin-plugin-MediathekViewDL/pull/179): minimal `IChannel + IRequiresMediaInfoCallback`, returns `new MediaSourceInfo { Protocol = Http, Id = <guid from hash>, Path = url, IsRemote = true, SupportsDirectPlay/DirectStream/Transcoding = true }`, no `MediaStreams`; follow-up issues #180–#185 document the thread-safety and cache-expiry traps (listing cache 1 day vs media-info cache 30 min → playback failures).
|
||||
- **NextPVR** (`ILiveTvService`): `GetChannelStream` returns `MediaSourceInfo { Path = http://…, Protocol = Http, RequiresOpening = true, Container = "mpegts", SupportsProbing = true, MediaStreams = [video/audio with Index = -1] }`; `SharedHttpStream` copies the HTTP stream to a temp file and re-serves `/LiveTv/LiveStreamFiles/{id}/stream.ts`, without applying `RequiredHttpHeaders`.
|
||||
- **Not channel plugins, but relevant**: `BingleP/jellyfin-youtube-feed` (writes `.strm` files pointing to a local proxy on `127.0.0.1:3003` that 302s to the CDN; 10.11, system install only), `Bakeneko/jellyfin-plugin-vod` (12.1+, virtual library + unauthenticated stream endpoint resolving yt-dlp HLS at play time), `kingschnulli/jellyfin-youtube-plugin` (10.11.6; "simple" mode hands the client a direct stream URL, "enhanced" mode re-streams via local ffmpeg as HLS), `tubearchivist/tubearchivist-jf-plugin` (metadata provider for on-disk files).
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration approaches and trade-offs (what works in 10.10 with official clients)
|
||||
|
||||
| Approach | Works in 10.10 web + apps? | Pros | Cons / gotchas |
|
||||
|---|---|---|---|
|
||||
| **(a) IChannel (+ callback)** | Yes — native UI in web, Android, Android TV (chaosflix e2e); no client changes | Browsable folders, metadata, images, watched state, SyncPlay, search; nothing on disk | One parent per item; cache quirks (3 h listing cache, 5 min media-info cache); items are DB entities; must supply codec info to avoid transcodes; Android TV app restart to see new channel |
|
||||
| **(b) Live TV `ILiveTvService`/tuner** | Yes | Built for linear streams, EPG | Wrong model for VOD; only sensible for Floatplane *livestreams* (`scenario=live`) |
|
||||
| **(c) Download to disk + metadata** | Yes (ordinary library) | Full seeking, trickplay, offline, no CDN/auth at play time; Floatplane-Downloader already produces a Plex-style Series/Season layout that Jellyfin can scan | Disk and bandwidth, download latency, two moving parts; needs NFO/images or a metadata provider; `IScheduledTask` + `ILibraryManager` scan trigger if done in-plugin |
|
||||
| **(d) `.strm` files** | Yes but fragile | Library items with NFO metadata | Jellyfin forces a remote ffprobe on first play (`GetPlaybackMediaSources` → `EnableRemoteContentProbe`, UA only); static relay has `Accept-Ranges: none` (seeking issues #13974), bitrate cap ignored (#18438), subtitle path bugs (#13976); URLs with expiring tokens must point at a stable local endpoint anyway |
|
||||
| **(e) Plugin ASP.NET controller (+ web injection)** | Controllers: yes (since PR #3871; 10.10 registers plugin assemblies with `AddApplicationPart`; no fallback auth policy, so endpoints without `[Authorize]` are anonymous — use `[Authorize]`/`[Authorize(Policy = Policies.RequiresElevation)]` for admin endpoints and HMAC-signed URLs for media). Web UI injection: only via `jellyfin-plugin-file-transformation` (one build per Jellyfin version, web client only, reflection-based) | Full control of streaming/auth | Custom pages never reach the Android/TV apps; file-transformation is version-locked |
|
||||
|
||||
**Recommendation for Floatplane:** (a)+(e) — `IChannel` for the catalog (creators → channels → videos as `Episode`/`Clip` items, folders per creator/channel/year), `IRequiresMediaInfoCallback` returning a **signed URL to your own proxy controller**, which holds the OAuth session, calls `delivery/info` and relays (with `Range`) from the CDN. Keep (c) as an optional "archive to library" scheduled task for favourites if offline/trickplay matters. Legacy `api_key`/`ApiKey` query auth still works in 10.10 (`AuthorizationContext` reads both), but signed URLs avoid leaking the user's token into ffmpeg command lines and logs (Jellyfin itself already puts `api_key` in its HLS sub-playlist URLs).
|
||||
|
||||
---
|
||||
|
||||
## 5. Existing Floatplane + Jellyfin work
|
||||
|
||||
- GitHub repository search `jellyfin floatplane` → **0 results** (API, 2026-10-25). `floatplane plugin` → `VoxelPrismatic/grayjay-floatplane` (archived), `JWolvers/plugin.video.floatplaneclub` (Kodi, 2018, archived), a GrayJay test plugin. No Reddit/forum thread about a Jellyfin integration surfaced.
|
||||
- **Inrixia/Floatplane-Downloader** (https://github.com/Inrixia/Floatplane-Downloader, v5.19.9, AGPL-3.0, actively maintained, Docker `inrix/floatplane-downloader`): downloads via `fApi.cdn.delivery("download", attachmentId)` → `groups[0].origins` + `variants` (sorted by `order`), rate-limits delivery calls to ~2/min, writes Plex-friendly Series/Season folders with thumbnails and can trigger a Plex refresh; **no Jellyfin support**, but its output is a normal TV-shows folder tree (inferred: usable by a Jellyfin "Shows" library). Auth is `fApi.deviceLogin()` (npm `floatplane` ^5.2.8, `openid-client` device-authorization against `https://auth.floatplane.com/realms/floatplane`, scopes `openid profile email offline_access`, Bearer access token + refresh token). The OIDC discovery document (in scratchpad `oidc.json`) confirms `device_authorization_endpoint`, `urn:ietf:params:oauth:grant-type:device_code`, DPoP algorithms, scopes `videos:watch`, `videos:download`. FloatNative (`fn_README.md`) describes "official OAuth flow; tokens are DPoP-bound".
|
||||
- **yt-dlp Floatplane extractor** (`yt_dlp/extractor/floatplane.py`): still requires the `sails.sid` cookie; calls `/api/v3/content/post`, `/api/v3/content/{video|audio}`, `/api/v3/delivery/info?scenario=onDemand&entityId=…`; builds `origins[].url + variants[].url` → HLS m3u8 (may be HLS-AES, needs `Origin`/`Referer`, sometimes TLS impersonation).
|
||||
- **Floatplane OpenAPI spec** (jamamp/FloatplaneAPI, https://jamamp.github.io/FloatplaneAPIDocs/; full spec in scratchpad `spec_full.json`): `GET /api/v3/delivery/info` params `scenario` ∈ {`onDemand`,`download`,`live`}, `entityId` (video/audio attachment id or `liveStream.id`), optional `outputKind` ∈ {`hls.mpegts`,`hls.fmp4`,`dash.mpegts`,`dash.m4s`,`flat`}; response `groups[]{ origins[]{url, queryUrl, datacenter}, variants[]{name, label, url, origins, order, enabled, hidden, mimeType, meta{video, audio, …}} }`. Legacy cookie login endpoints: `/api/v2/auth/login`, `/api/v3/auth/login`, `/api/v3/auth/checkFor2faLogin`, plus `/api/v3/user/subscriptions`, `/api/v3/content/creator?id&limit&fetchAfter`, `/api/v3/creator/info`.
|
||||
- Reference clients with reusable logic already in the scratchpad from earlier work: Wasserflug (tvOS, `wf_DeliveryHelper.swift`), FloatNative (Kotlin/Swift OAuth+DPoP), floatplaneAPI.ts, Hydra (Roku).
|
||||
|
||||
---
|
||||
|
||||
## 6. Scheduled tasks, HTTP client, config page, secrets (verified)
|
||||
|
||||
- **`IScheduledTask`** (`MediaBrowser.Model.Tasks`): `Name`, `Key`, `Description`, `Category`, `Task ExecuteAsync(IProgress<double> progress, CancellationToken ct)`, `IEnumerable<TaskTriggerInfo> GetDefaultTriggers()`; optional `IConfigurableScheduledTask` (`IsHidden`, `IsEnabled`, `IsLogged`). In 10.10 `TaskTriggerInfo.Type` is a **string** (`TaskTriggerInfo.TriggerDaily/TriggerWeekly/TriggerInterval/TriggerStartup`) with `IntervalTicks`, `TimeOfDayTicks`, `DayOfWeek`, `MaxRuntimeTicks` (10.11 changed this to an enum — one of the things that will need touching when you upgrade). Discovered by type scanning (`GetExports<IScheduledTask>()`), constructor-injected; no DI registration. Appears in Dashboard → Scheduled Tasks. Example trigger: `new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(6).Ticks }`.
|
||||
- **HTTP**: inject `IHttpClientFactory` and use `CreateClient(NamedClient.Default)` (constants `Default`, `MusicBrainz`, `Dlna`, `DirectIp`), or register your own named client in the registrator (`serviceCollection.AddHttpClient("Floatplane").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false, UseCookies = … })`, as chaosflix does).
|
||||
- **Config storage**: `BasePlugin<T>` serializes `Configuration` with `IXmlSerializer` to `<plugins>/configurations/<PluginName>.xml` (`Plugin.Instance.Configuration`, `SaveConfiguration()`). It is plain XML on disk — there is no secret store or encryption API in 10.10. Store the OAuth refresh token (and DPoP private key, if you implement DPoP) there or in a file under `IApplicationPaths.PluginConfigurationsPath`/`DataPath` with 0600 permissions; never a password. Note the XML serializer needs public settable properties and simple types.
|
||||
- **Dashboard page**: embed `Configuration/configPage.html`; the page root is `<div id="…" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">`; JS uses `ApiClient.getPluginConfiguration(guid)`, `ApiClient.updatePluginConfiguration(guid, cfg)`, `Dashboard.showLoadingMsg()/hideLoadingMsg()/processPluginConfigurationUpdateResult(result)` on `pageshow`/`submit` (template verbatim). For the device-code login you can call your own controller from that page (`ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl('Floatplane/Login/Start') })` — the ApiClient attaches the auth header), show `verification_uri_complete` + user code, and poll a status endpoint. `PluginPageInfo.EnableInMainMenu = true` adds a sidebar entry.
|
||||
- **Logging**: inject `ILogger<T>`; appears in the server log with your namespace as `SourceContext`.
|
||||
|
||||
### 6.1 Minimal 10.10 channel skeleton (illustrative, not a file)
|
||||
```csharp
|
||||
// Registrator (parameterless ctor, found by type scanning)
|
||||
public class ServiceRegistrator : IPluginServiceRegistrator {
|
||||
public void RegisterServices(IServiceCollection s, IServerApplicationHost host) {
|
||||
s.AddHttpClient("Floatplane");
|
||||
s.AddSingleton<FloatplaneClient>(); // OAuth session + API wrapper
|
||||
s.AddSingleton<FloatplaneChannel>();
|
||||
s.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>()); // required: ChannelManager takes IEnumerable<IChannel>
|
||||
}
|
||||
}
|
||||
|
||||
public class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey {
|
||||
public string Name => "Floatplane"; // keep stable: channel entity id derives from it
|
||||
public string Description => "Floatplane subscriptions";
|
||||
public string DataVersion => "1"; // bump to invalidate the 3h listing cache
|
||||
public string HomePageUrl => "https://www.floatplane.com";
|
||||
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||
public bool IsEnabledFor(string userId) => true;
|
||||
public string? GetCacheKey(string? userId) => _syncGeneration.ToString();
|
||||
public InternalChannelFeatures GetChannelFeatures() => new() {
|
||||
MediaTypes = [ChannelMediaType.Video],
|
||||
ContentTypes = [ChannelMediaContentType.Episode, ChannelMediaContentType.Clip],
|
||||
DefaultSortFields = [ChannelItemSortField.PremiereDate, ChannelItemSortField.Name],
|
||||
SupportsSortOrderToggle = true, MaxPageSize = 100 };
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary];
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType t, CancellationToken ct)
|
||||
=> Task.FromResult(new DynamicImageResponse { Path = "https://…/logo.png", Protocol = MediaProtocol.Http, HasImage = true });
|
||||
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery q, CancellationToken ct) {
|
||||
if (string.IsNullOrEmpty(q.FolderId)) // root: one folder per creator/channel
|
||||
return new ChannelItemResult { Items = creators.Select(c => new ChannelItemInfo {
|
||||
Id = $"creator:{c.Id}", Name = c.Title, Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container, ImageUrl = c.IconUrl }).ToList() };
|
||||
// folder: videos (unique Id per video, never reused under another folder)
|
||||
return new ChannelItemResult { Items = posts.Select(p => new ChannelItemInfo {
|
||||
Id = $"video:{p.VideoAttachmentId}", Name = p.Title, Overview = p.Text,
|
||||
Type = ChannelItemType.Media, MediaType = ChannelMediaType.Video,
|
||||
ContentType = ChannelMediaContentType.Clip, ImageUrl = p.ThumbnailUrl,
|
||||
PremiereDate = p.ReleaseDate, RunTimeTicks = TimeSpan.FromSeconds(p.Duration).Ticks }).ToList(),
|
||||
TotalRecordCount = total };
|
||||
}
|
||||
|
||||
public Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken ct) {
|
||||
var proxy = $"{_host.GetApiUrlForLocalAccess()}/FloatplaneStream/proxy/{attachmentId}?t={ProxySignature.Create(attachmentId)}";
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>([ new MediaSourceInfo {
|
||||
Id = StableGuid(id).ToString("N"), Name = "1080p",
|
||||
Path = proxy, Protocol = MediaProtocol.Http, Container = "mp4", // or "ts"/"hls" per outputKind
|
||||
IsRemote = false, SupportsProbing = false,
|
||||
SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true,
|
||||
MediaStreams = [ new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264", Width = 1920, Height = 1080 },
|
||||
new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac", Channels = 2 } ] } ]);
|
||||
}
|
||||
}
|
||||
```
|
||||
(`IServerApplicationHost.GetApiUrlForLocalAccess()` is what `SharedHttpStream` uses to build server-local URLs in 10.10.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing locally on x86 (Arch) with Docker
|
||||
|
||||
Host status checked: `dotnet` SDK **8.0.130** installed (`pacman` package `dotnet-sdk-8.0` 8.0.31), Docker **29.7.2**, Python 3.14 (jprm not installed — `pip install --user jprm` or skip it).
|
||||
|
||||
- Image: `jellyfin/jellyfin:10.10.7` exists as a multi-arch manifest (linux/amd64, arm64/v8, arm/v7; pushed 2025-04-05). For parity with the Pi use `lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80` (amd64+arm64) — same package, different paths (see 1.4).
|
||||
- Loop: `dotnet publish Jellyfin.Plugin.Floatplane -c Release -o ./dist`, then copy `dist/Jellyfin.Plugin.Floatplane.dll` (+ `meta.json`) into the mounted plugin folder and **restart the container** (plugins are loaded only at startup). Example (official image):
|
||||
```
|
||||
docker run -d --name jf-dev -p 8096:8096 \
|
||||
-v $PWD/jfdev/config:/config -v $PWD/jfdev/cache:/cache \
|
||||
jellyfin/jellyfin:10.10.7
|
||||
# plugin folder: $PWD/jfdev/config/plugins/Floatplane_1.0.0.0/ (LSIO: /config/data/plugins/…)
|
||||
```
|
||||
Chaosflix's e2e compose bind-mounts its build output directly at `/config/plugins/Chaosflix` with `JELLYFIN_PublishedServerUrl` set; prefer a writable copy over a `:ro` mount because `PluginManager` may write a synthesized `meta.json`. A folder named `Floatplane_1.0.0.0` without `meta.json` is accepted (manifest synthesized from the name).
|
||||
- Verify: Dashboard → Plugins shows the plugin with status (NotSupported = targetAbi/.NET mismatch; Malfunctioned = load error, see log); Dashboard → Users → Access shows "Enable access to all channels"; the channel tile appears under My Media after the first `GET /Channels` (user views). Use `GET /Channels/{id}/Items` and `POST /Items/{id}/PlaybackInfo` to inspect the `MediaSources` the server actually hands out (`SupportsDirectPlay/DirectStream`, `TranscodingUrl`).
|
||||
- Logs: `docker logs -f jf-dev` (console sink) or `jfdev/config/log/log_YYYYMMDD.log` (LSIO: `/config/log/`). To get Debug from your namespace, copy `logging.default.json` to `logging.json` in the config dir (official: `/config/config/`, LSIO: `/config/`) and add `"Jellyfin.Plugin.Floatplane": "Debug"` under `Serilog.MinimumLevel.Override`. ffmpeg transcode logs land in `<log dir>/FFmpeg.Transcode-*.log`.
|
||||
- Pi 5 deployment: zip → Gitea release/raw manifest.json → add repo URL in Dashboard, or scp the folder into `/config/data/plugins/Floatplane_1.0.0.0/` and `docker restart`. Pin the image tag before touching the Pi (`:latest` = 12.3 now).
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Template: https://github.com/jellyfin/jellyfin-plugin-template (README, `build.yaml`, csproj, `Plugin.cs`, `configPage.html`, commit history of `build.yaml`/csproj)
|
||||
- Jellyfin source `release-10.10.z`: `SharedVersion.cs`, `Jellyfin.Server.csproj`, `MediaBrowser.Controller/Channels/*`, `MediaBrowser.Model/Channels/*`, `MediaBrowser.Model/Dto/MediaSourceInfo.cs`, `src/Jellyfin.LiveTv/Channels/ChannelManager.cs`, `ChannelDynamicMediaSourceProvider.cs`, `RefreshChannelsScheduledTask.cs`, `Emby.Server.Implementations/Library/UserViewManager.cs`, `MediaSourceManager.cs`, `Plugins/PluginManager.cs`, `Plugins/PluginLoadContext.cs`, `AppBase/BaseApplicationPaths.cs`, `ApplicationHost.cs`, `MediaBrowser.Common/Plugins/PluginManifest.cs`, `MediaBrowser.Common/Api/Policies.cs`, `MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs`, `EncodingJobInfo.cs`, `MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs`, `EncodingUtils.cs`, `Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs`, `StreamingHelpers.cs`, `MediaInfoHelper.cs`, `Jellyfin.Api/Controllers/VideosController.cs`, `ChannelsController.cs`, `Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs`, `Jellyfin.Server.Implementations/Security/AuthorizationContext.cs`, `MediaBrowser.Model/Tasks/IScheduledTask.cs`, `TaskTriggerInfo.cs`, `MediaBrowser.Controller/Plugins/IPluginServiceRegistrator.cs`, `MediaBrowser.Model/Plugins/PluginPageInfo.cs`, `src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs`, `MediaBrowser.Controller/LiveTv/ILiveTvService.cs`, `Jellyfin.Server/Resources/Configuration/logging.json` — https://github.com/jellyfin/jellyfin/tree/release-10.10.z
|
||||
- jellyfin-web `release-10.10.z`: `src/components/playback/playbackmanager.js`, `src/components/apphost.js`, `src/controllers/list.js`, `src/components/homesections/sections/recentlyAdded.ts` — https://github.com/jellyfin/jellyfin-web/tree/release-10.10.z
|
||||
- targetAbi forum answer: https://forum.jellyfin.org/t-question-about-targetabi
|
||||
- NuGet: https://www.nuget.org/packages/Jellyfin.Controller/10.10.7
|
||||
- Plugins docs: https://jellyfin.org/docs/general/server/plugins/ ; Docker docs: https://jellyfin.org/docs/general/installation/container/ ; 10.10.7 release: https://github.com/jellyfin/jellyfin/releases/tag/v10.10.7 ; 12.0 post: https://jellyfin.org/posts/jellyfin-release-12.0/ ; auth gist: https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f
|
||||
- jprm: https://github.com/oddstr13/jellyfin-plugin-repository-manager (README, `Metadata.md`, `build_plugin.sh`, `action.yaml`, `jprm/__init__.py`), https://pypi.org/project/jprm/
|
||||
- Plugin controllers PR: https://github.com/jellyfin/jellyfin/pull/3871 ; IChannel parent issue: https://github.com/jellyfin/jellyfin/issues/18127
|
||||
- Examples: https://github.com/praetorianer777/chaosflix-jellyfin ; https://github.com/CatNoir2006/jellyfin-plugin-MediathekViewDL/pull/179 (+ issues #180–#185) ; https://github.com/jellyfin/jellyfin-plugin-nextpvr ; https://github.com/BingleP/jellyfin-youtube-feed ; https://github.com/Bakeneko/jellyfin-plugin-vod ; https://github.com/kingschnulli/jellyfin-youtube-plugin ; https://github.com/tubearchivist/tubearchivist-jf-plugin ; https://github.com/IAmParadox27/jellyfin-plugin-file-transformation ; https://github.com/someRandomDude-a/jellyfin-plugins ; https://github.com/jellyfin-archive/jellyfin-plugin-iptv
|
||||
- .strm issues: https://github.com/jellyfin/jellyfin/issues/13974 , https://github.com/jellyfin/jellyfin/issues/18438 , https://github.com/jellyfin/jellyfin/issues/13976
|
||||
- Images: https://github.com/jellyfin/jellyfin-packaging/blob/master/docker/Dockerfile ; https://github.com/linuxserver/docker-jellyfin (Dockerfile, `root/etc/s6-overlay/s6-rc.d/svc-jellyfin/run`, releases) ; Docker Hub tag APIs for `jellyfin/jellyfin:10.10.7` and `linuxserver/jellyfin` 10.10.7 tags ; https://docs.linuxserver.io/images/docker-jellyfin/ ; https://github.com/dotnet/dotnet-docker/blob/main/README.sdk.md
|
||||
- Pi 5 transcoding: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/ , https://github.com/jellyfin/jellyfin/releases/tag/v10.10.0 (V4L2 deprecation)
|
||||
- Gitea arm64 runners: https://gitea.com/actions/setup-dotnet , https://github.com/actions/setup-dotnet
|
||||
- Floatplane: https://github.com/Inrixia/Floatplane-Downloader (`src/logins.ts`, `src/lib/Video.ts`, `package.json`) ; https://github.com/jamamp/FloatplaneAPI and https://jamamp.github.io/FloatplaneAPIDocs/ (spec in scratchpad `spec_full.json`) ; https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/floatplane.py ; https://github.com/ytdl-org/youtube-dl/pull/21504 (legacy v2 cdn/delivery) ; https://github.com/Inrixia/floatplaneAPI.ts ; GitHub search API `q=jellyfin+floatplane` (0 results) and `q=floatplane+plugin`
|
||||
- Local artifacts consulted (scratchpad `src/`): cloned repos above plus earlier-session files `oidc.json`, `fn_README.md`, `fpts_index.ts`, `fpts_cdn.ts`, `fpdl_onDeviceCode.ts`, `ytdlp_floatplane.py`.
|
||||
18
doc/research/reference-sources/README.md
Normal file
18
doc/research/reference-sources/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Third-party reference sources (NOT part of the plugin)
|
||||
|
||||
Snapshots of files from other open-source Floatplane clients, fetched
|
||||
2026-10-25 for study only. Each file keeps its upstream licence; nothing
|
||||
here is compiled into or shipped with the plugin.
|
||||
|
||||
| Prefix | Project | Why it matters |
|
||||
|--------|---------|----------------|
|
||||
| `bz_*` | bmlzootown/FloatplaneAPI (API-shape canary, Node) | cleanest device-flow + DPoP + refresh implementation; measured token lifetimes |
|
||||
| `fpts_*`, `fpapi_*` | Inrixia/floatplaneAPI.ts (`floatplane` npm) | typed REST wrapper, device login via openid-client, got hooks |
|
||||
| `fpdl_*` | Inrixia/floatplane-downloader | delivery `download` scenario, rate-limit semaphore, channel sorting |
|
||||
| `hydra_*` | bmlzootown/Hydravion (Roku, BrightScript) | hand-rolled device flow, live-stream polling, Bearer on the video node |
|
||||
| `wf_*` | jamamp/Wasserflug-tvOS (Swift) | `DeliveryHelper.getBestUrl`, HLS key loader adding Bearer, progress sync |
|
||||
| `floaty_*` | floatyfp/floaty (Flutter) | auth-code+PKCE, rate-limit manager, mpv headers, download URL trick |
|
||||
| `fn_*` | coulterpeterson/FloatNative (Kotlin/Swift) | DPoP manager, playlist rewrite (key via auth, segments plain) |
|
||||
| `ytdlp_floatplane.py` | yt-dlp extractor | v3 shapes, `#EXT-X-KEY` handling, channel URL resolution |
|
||||
| `spec_*`, `oidc.json` | jamamp/FloatplaneAPI OpenAPI spec + live OIDC discovery | request/response schemas |
|
||||
| `chaosflix/` | praetorianer777/chaosflix-jellyfin (C#) | **closest prior art**: `IChannel` + `IRequiresMediaInfoCallback` + HMAC-signed proxy controller + sync task + meta.json packaging + CI release workflow |
|
||||
24
doc/research/reference-sources/bz_README.md
Normal file
24
doc/research/reference-sources/bz_README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# API shape artifacts (Phase 3.1)
|
||||
|
||||
Authenticated (and optional unauth list) **response field-tree** baselines for the
|
||||
Floatplane API Watch canary. Separate from `artifacts/frontend/`.
|
||||
|
||||
```
|
||||
artifacts/api-shape/
|
||||
baselines/{endpointId}.schema.json
|
||||
captures/{captureId}/
|
||||
meta.json
|
||||
trees/{endpointId}.schema.json
|
||||
diff.json
|
||||
report.md
|
||||
refresh-evidence.local.json # gitignored — local probe output
|
||||
refresh-lifetime-evidence.json # committed redacted lifetimes (no tokens)
|
||||
```
|
||||
|
||||
- Commit baselines and reviewable capture reports (structure only).
|
||||
- Never commit access/refresh tokens, DPoP private keys, or raw response bodies with PII/content.
|
||||
- Auth session: `state/api-shape-auth.local.json` (gitignored; refresh + DPoP).
|
||||
- Schedule: `make api-shape-schedule-json` (separate from Phase 1/2 frontend watch).
|
||||
- Offline refresh window (live 2026-10-11): `refresh_expires_in=7776000` (90d sliding) with rotation.
|
||||
|
||||
See `tools/fp-api-shape-canary/README.md` and `automation/PROMPT.md`.
|
||||
185
doc/research/reference-sources/bz_auth-refresh.mjs
Normal file
185
doc/research/reference-sources/bz_auth-refresh.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Durable auth: DPoP-bound refresh_token grant + local session persistence.
|
||||
*
|
||||
* Evidence (frontend build 4.5.22-316-d74397b):
|
||||
* - refreshAccessToken requires DPoP handle when USE_DPOP (fp-tv-app usesExtendedSecurity)
|
||||
* - setOAuthTokenFromResponse reads refresh_expires_in as a finite lifetime
|
||||
* - offline_access appears in OIDC scopes_supported; not referenced in archived frontend JS
|
||||
*/
|
||||
|
||||
import { OIDC, USER_AGENT } from './constants.mjs';
|
||||
import { attachDpopHeaders, readDpopNonce } from './dpop.mjs';
|
||||
import { discoverOidc } from './device-flow.mjs';
|
||||
|
||||
/**
|
||||
* Normalize token-endpoint success JSON into a redacted-safe session patch.
|
||||
* Never invent fields — only copy what Keycloak returned.
|
||||
* @param {Record<string, unknown>} json
|
||||
* @param {{ dpopKeyPair: object, obtainedAtMs?: number }} opts
|
||||
*/
|
||||
export function normalizeTokenResponse(json, opts) {
|
||||
const obtainedAtMs = opts.obtainedAtMs ?? Date.now();
|
||||
const expiresIn =
|
||||
typeof json.expires_in === 'number'
|
||||
? json.expires_in
|
||||
: json.expires_in != null
|
||||
? Number(json.expires_in)
|
||||
: null;
|
||||
const refreshExpiresIn =
|
||||
typeof json.refresh_expires_in === 'number'
|
||||
? json.refresh_expires_in
|
||||
: json.refresh_expires_in != null
|
||||
? Number(json.refresh_expires_in)
|
||||
: null;
|
||||
const accessExpiresAtMs =
|
||||
expiresIn != null && Number.isFinite(expiresIn)
|
||||
? obtainedAtMs + expiresIn * 1000
|
||||
: null;
|
||||
const refreshExpiresAtMs =
|
||||
refreshExpiresIn != null && Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0
|
||||
? obtainedAtMs + refreshExpiresIn * 1000
|
||||
: null;
|
||||
|
||||
return {
|
||||
accessToken: String(json.access_token),
|
||||
refreshToken: json.refresh_token ? String(json.refresh_token) : null,
|
||||
tokenType: json.token_type ? String(json.token_type) : 'DPoP',
|
||||
scope: json.scope != null ? String(json.scope) : null,
|
||||
expiresIn: Number.isFinite(expiresIn) ? expiresIn : null,
|
||||
refreshExpiresIn: Number.isFinite(refreshExpiresIn) ? refreshExpiresIn : null,
|
||||
accessExpiresAtMs,
|
||||
refreshExpiresAtMs,
|
||||
sessionState: json.session_state != null ? String(json.session_state) : null,
|
||||
obtainedAtMs,
|
||||
dpopKeyPair: opts.dpopKeyPair,
|
||||
/** Raw keys observed (for evidence; values not retained except lifetimes). */
|
||||
responseKeys: Object.keys(json).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacted lifetime snapshot suitable for commits / reports (no secrets).
|
||||
* @param {ReturnType<typeof normalizeTokenResponse> | object} session
|
||||
* @param {{ label?: string, refreshTokenRotated?: boolean|null }} [extra]
|
||||
*/
|
||||
export function redactAuthLifetime(session, extra = {}) {
|
||||
return {
|
||||
label: extra.label || null,
|
||||
obtainedAt: session.obtainedAtMs
|
||||
? new Date(session.obtainedAtMs).toISOString()
|
||||
: null,
|
||||
expiresIn: session.expiresIn ?? null,
|
||||
refreshExpiresIn: session.refreshExpiresIn ?? null,
|
||||
accessExpiresAt: session.accessExpiresAtMs
|
||||
? new Date(session.accessExpiresAtMs).toISOString()
|
||||
: null,
|
||||
refreshExpiresAt: session.refreshExpiresAtMs
|
||||
? new Date(session.refreshExpiresAtMs).toISOString()
|
||||
: null,
|
||||
hasRefreshToken: Boolean(session.refreshToken),
|
||||
scope: session.scope || null,
|
||||
sessionStatePresent: Boolean(session.sessionState),
|
||||
responseKeys: session.responseKeys || null,
|
||||
refreshTokenRotated: extra.refreshTokenRotated ?? null,
|
||||
dpopRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange refresh_token for new access (+ optional rotated refresh) with DPoP.
|
||||
* @param {{
|
||||
* refreshToken: string,
|
||||
* dpopKeyPair: { publicJwk: object, privateKey: CryptoKey, privateJwk?: object },
|
||||
* clientId?: string,
|
||||
* discovery?: Awaited<ReturnType<typeof discoverOidc>>,
|
||||
* fetchImpl?: typeof fetch,
|
||||
* now?: () => number,
|
||||
* }} opts
|
||||
*/
|
||||
export async function refreshAccessToken(opts) {
|
||||
if (!opts.refreshToken) throw new Error('No refresh token available');
|
||||
if (!opts.dpopKeyPair) throw new Error('Missing DPoP key pair for refresh token request');
|
||||
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
const discovery = opts.discovery || (await discoverOidc({ fetchImpl }));
|
||||
const clientId = opts.clientId || OIDC.clientId;
|
||||
const now = opts.now || (() => Date.now());
|
||||
/** @type {string|null} */
|
||||
let dpopNonce = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', OIDC.grantTypeRefreshToken);
|
||||
body.set('refresh_token', opts.refreshToken);
|
||||
body.set('client_id', clientId);
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
accept: 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
};
|
||||
await attachDpopHeaders({
|
||||
headers,
|
||||
keyPair: opts.dpopKeyPair,
|
||||
method: 'POST',
|
||||
url: discovery.tokenEndpoint,
|
||||
nonce: dpopNonce,
|
||||
});
|
||||
|
||||
const res = await fetchImpl(discovery.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const nonceHeader = readDpopNonce(res);
|
||||
if (nonceHeader) dpopNonce = nonceHeader;
|
||||
const json = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok && json.access_token) {
|
||||
return normalizeTokenResponse(json, {
|
||||
dpopKeyPair: opts.dpopKeyPair,
|
||||
obtainedAtMs: now(),
|
||||
});
|
||||
}
|
||||
|
||||
const err = json.error || `HTTP ${res.status}`;
|
||||
if (err === 'use_dpop_nonce' || (err === 'invalid_dpop_proof' && nonceHeader)) {
|
||||
continue;
|
||||
}
|
||||
const desc = json.error_description ? `: ${json.error_description}` : '';
|
||||
const e = new Error(`refresh_token grant failed: ${err}${desc}`);
|
||||
e.code = err;
|
||||
e.httpStatus = res.status;
|
||||
throw e;
|
||||
}
|
||||
throw new Error('refresh_token grant failed: dpop nonce retry exhausted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the access token should be refreshed before a probe.
|
||||
* @param {{ accessExpiresAtMs?: number|null, refreshToken?: string|null }} session
|
||||
* @param {{ skewMs?: number, nowMs?: number }} [opts]
|
||||
*/
|
||||
export function accessNeedsRefresh(session, opts = {}) {
|
||||
if (!session?.refreshToken) return false;
|
||||
const skewMs = opts.skewMs ?? 60_000;
|
||||
const nowMs = opts.nowMs ?? Date.now();
|
||||
if (session.accessExpiresAtMs == null) {
|
||||
// Unknown expiry — refresh opportunistically when we have a refresh token.
|
||||
return true;
|
||||
}
|
||||
return nowMs + skewMs >= session.accessExpiresAtMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether refresh is already past its wall-clock expiry (re-login required).
|
||||
* @param {{ refreshExpiresAtMs?: number|null, refreshToken?: string|null }} session
|
||||
* @param {{ nowMs?: number }} [opts]
|
||||
*/
|
||||
export function refreshIsExpired(session, opts = {}) {
|
||||
if (!session?.refreshToken) return true;
|
||||
if (session.refreshExpiresAtMs == null) return false;
|
||||
const nowMs = opts.nowMs ?? Date.now();
|
||||
return nowMs >= session.refreshExpiresAtMs;
|
||||
}
|
||||
131
doc/research/reference-sources/bz_constants.mjs
Normal file
131
doc/research/reference-sources/bz_constants.mjs
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Phase 3.0 authenticated API response-shape canary — constants.
|
||||
*
|
||||
* OIDC clientId / realm / issuer are taken from frontend archive evidence
|
||||
* (build 4.5.22-316-d74397b entry JS: baseUrl auth.floatplane.com, realm floatplane,
|
||||
* clientId fp-tv-app) and public OIDC discovery. Do not invent alternate clients.
|
||||
*/
|
||||
|
||||
export const TOOL_ID = 'fp-api-shape-canary';
|
||||
export const TOOL_VERSION = '3.1.0';
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
/** Custom User-Agent for read-only probes (identify the watch project). */
|
||||
export const USER_AGENT = 'FloatplaneAPI-Watch/3.1 (api-shape-canary; read-only)';
|
||||
|
||||
export const API_BASE_URL = 'https://www.floatplane.com';
|
||||
|
||||
/**
|
||||
* Keycloak / OIDC — evidence-backed defaults.
|
||||
* Discovery: https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration
|
||||
*/
|
||||
export const OIDC = Object.freeze({
|
||||
issuer: 'https://auth.floatplane.com/realms/floatplane',
|
||||
wellKnownUrl:
|
||||
'https://auth.floatplane.com/realms/floatplane/.well-known/openid-configuration',
|
||||
/** Frontend TV device-auth config: clientId:"fp-tv-app" */
|
||||
clientId: 'fp-tv-app',
|
||||
realm: 'floatplane',
|
||||
baseUrl: 'https://auth.floatplane.com',
|
||||
/**
|
||||
* One-shot / interactive default. Scheduled canary prefers scheduleScope.
|
||||
* offline_access is advertised in discovery scopes_supported (observed 2026-10-11).
|
||||
*/
|
||||
defaultScope: 'openid',
|
||||
/**
|
||||
* Prefer offline refresh for scheduled runs. Still not "indefinite" — see
|
||||
* docs/phase-3-1-api-shape-schedule.md for measured refresh_expires_in.
|
||||
*/
|
||||
scheduleScope: 'openid offline_access',
|
||||
grantTypeDeviceCode: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
grantTypeRefreshToken: 'refresh_token',
|
||||
});
|
||||
|
||||
/** Separate lane from Phase 1/2 frontend observation branch. */
|
||||
export const SHAPE_MONITOR_BRANCH = 'cursor/api-shape-observation';
|
||||
export const SHAPE_DEFAULT_BRANCH = 'main';
|
||||
export const SHAPE_PR_TITLE_PREFIX = 'API shape canary:';
|
||||
|
||||
/** Evidence notes committed beside captures for auditability. */
|
||||
export const OIDC_EVIDENCE = Object.freeze({
|
||||
clientIdSource:
|
||||
'artifacts/frontend/4.5.22-316-d74397b/.../js/index-BZVDPgzb.js — o_={baseUrl:"https://auth.floatplane.com",realm:"floatplane",clientId:"fp-tv-app",usesExtendedSecurity:!0,...}',
|
||||
deviceGrantSource:
|
||||
'OIDC discovery grant_types_supported includes urn:ietf:params:oauth:grant-type:device_code; device_authorization_endpoint present (observed 2026-10-02)',
|
||||
refreshGrantSource:
|
||||
'OIDC discovery grant_types_supported includes refresh_token; scopes_supported includes offline_access (observed 2026-10-11). Frontend refreshAccessToken requires DPoP when USE_DPOP; setOAuthTokenFromResponse reads refresh_expires_in as finite wall clock.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Fixed read-only allowlist (Phase 3.0). Order matters for id discovery.
|
||||
* No writes. No /delivery/info. No Hydravion.
|
||||
*/
|
||||
export const ALLOWLIST = Object.freeze([
|
||||
{
|
||||
id: 'user-self',
|
||||
method: 'GET',
|
||||
path: '/api/v3/user/self',
|
||||
role: 'token-sanity',
|
||||
auth: 'bearer',
|
||||
},
|
||||
{
|
||||
id: 'user-subscriptions',
|
||||
method: 'GET',
|
||||
path: '/api/v3/user/subscriptions',
|
||||
role: 'pick-creator',
|
||||
auth: 'bearer',
|
||||
},
|
||||
{
|
||||
id: 'content-creator',
|
||||
method: 'GET',
|
||||
path: '/api/v3/content/creator',
|
||||
role: 'list-shape + post-id',
|
||||
auth: 'bearer',
|
||||
/** Query built at runtime: id, limit=1 */
|
||||
queryKeys: ['id', 'limit'],
|
||||
},
|
||||
{
|
||||
id: 'content-post',
|
||||
method: 'GET',
|
||||
path: '/api/v3/content/post',
|
||||
role: 'primary-canary',
|
||||
auth: 'bearer',
|
||||
queryKeys: ['id'],
|
||||
},
|
||||
{
|
||||
id: 'content-video',
|
||||
method: 'GET',
|
||||
path: '/api/v3/content/video',
|
||||
role: 'optional-attachment',
|
||||
auth: 'bearer',
|
||||
optional: true,
|
||||
queryKeys: ['id'],
|
||||
},
|
||||
]);
|
||||
|
||||
/** Unauthenticated companion (list shape only; detail post remains 403). */
|
||||
export const UNAUTH_LIST = Object.freeze({
|
||||
id: 'content-creator-unauth',
|
||||
method: 'GET',
|
||||
path: '/api/v3/content/creator',
|
||||
role: 'unauth-list-companion',
|
||||
auth: 'none',
|
||||
/** Creator id used in assessment unauth sample (Linus Tech Tips). */
|
||||
defaultCreatorId: '59f94c0bdd241b70349eb72b',
|
||||
});
|
||||
|
||||
export const EXIT = Object.freeze({
|
||||
SUCCESS: 0,
|
||||
FAILURE: 1,
|
||||
/** Structural drift vs baseline */
|
||||
DRIFT: 2,
|
||||
/** Missing token / needs human device approval */
|
||||
NEEDS_AUTH: 3,
|
||||
});
|
||||
|
||||
export const DEFAULT_ARTIFACTS_ROOT = 'artifacts/api-shape';
|
||||
export const DEFAULT_TOKEN_FILE = 'state/api-shape-token.local';
|
||||
/** Combined access token + DPoP key material (gitignored). Prefer over bare token file. */
|
||||
export const DEFAULT_AUTH_FILE = 'state/api-shape-auth.local.json';
|
||||
export const BASELINES_DIR = 'baselines';
|
||||
export const CAPTURES_DIR = 'captures';
|
||||
245
doc/research/reference-sources/bz_device-flow.mjs
Normal file
245
doc/research/reference-sources/bz_device-flow.mjs
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* OIDC discovery + Keycloak device authorization grant helpers.
|
||||
*
|
||||
* Frontend TV client (fp-tv-app, usesExtendedSecurity):
|
||||
* - PKCE S256 on device authorization + code_verifier on token poll
|
||||
* - DPoP (ES256) proof on token poll (and later on REST calls)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { OIDC, USER_AGENT } from './constants.mjs';
|
||||
import { attachDpopHeaders, generateDpopKeyPair, readDpopNonce } from './dpop.mjs';
|
||||
|
||||
/**
|
||||
* Generate a PKCE code_verifier (43–128 chars, unreserved).
|
||||
* @param {number} [bytes]
|
||||
*/
|
||||
export function generateCodeVerifier(bytes = 32) {
|
||||
return base64Url(randomBytes(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* S256 code_challenge for a verifier.
|
||||
* @param {string} verifier
|
||||
*/
|
||||
export function generateCodeChallenge(verifier) {
|
||||
return base64Url(createHash('sha256').update(verifier, 'ascii').digest());
|
||||
}
|
||||
|
||||
/** @param {Buffer} buf */
|
||||
function base64Url(buf) {
|
||||
return buf
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fetchImpl?: typeof fetch, wellKnownUrl?: string }} [opts]
|
||||
*/
|
||||
export async function discoverOidc(opts = {}) {
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
const url = opts.wellKnownUrl || OIDC.wellKnownUrl;
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json', 'user-agent': USER_AGENT },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OIDC discovery failed: HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
const doc = await res.json();
|
||||
if (!doc.device_authorization_endpoint) {
|
||||
throw new Error('OIDC discovery missing device_authorization_endpoint');
|
||||
}
|
||||
if (!doc.token_endpoint) {
|
||||
throw new Error('OIDC discovery missing token_endpoint');
|
||||
}
|
||||
const grants = doc.grant_types_supported || [];
|
||||
if (!grants.includes(OIDC.grantTypeDeviceCode)) {
|
||||
throw new Error(`OIDC discovery missing grant ${OIDC.grantTypeDeviceCode}`);
|
||||
}
|
||||
return {
|
||||
issuer: doc.issuer,
|
||||
deviceAuthorizationEndpoint: doc.device_authorization_endpoint,
|
||||
tokenEndpoint: doc.token_endpoint,
|
||||
scopesSupported: doc.scopes_supported || [],
|
||||
grantTypesSupported: grants,
|
||||
raw: doc,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start device authorization (public client — no client_secret; PKCE required).
|
||||
* @param {{
|
||||
* discovery: Awaited<ReturnType<typeof discoverOidc>>,
|
||||
* clientId?: string,
|
||||
* scope?: string,
|
||||
* fetchImpl?: typeof fetch,
|
||||
* codeVerifier?: string,
|
||||
* }} opts
|
||||
*/
|
||||
export async function requestDeviceAuthorization(opts) {
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
const clientId = opts.clientId || OIDC.clientId;
|
||||
const scope = opts.scope || OIDC.defaultScope;
|
||||
const codeVerifier = opts.codeVerifier || generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const body = new URLSearchParams();
|
||||
body.set('client_id', clientId);
|
||||
if (scope) body.set('scope', scope);
|
||||
// Required by fp-tv-app (frontend USE_CODE_CHALLENGE / Keycloak public client)
|
||||
body.set('code_challenge_method', 'S256');
|
||||
body.set('code_challenge', codeChallenge);
|
||||
|
||||
const res = await fetchImpl(opts.discovery.deviceAuthorizationEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
accept: 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body,
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = json.error || `HTTP ${res.status}`;
|
||||
const desc = json.error_description ? `: ${json.error_description}` : '';
|
||||
throw new Error(`device authorization failed: ${err}${desc}`);
|
||||
}
|
||||
if (!json.device_code || !json.user_code) {
|
||||
throw new Error('device authorization response missing device_code/user_code');
|
||||
}
|
||||
return {
|
||||
deviceCode: json.device_code,
|
||||
userCode: json.user_code,
|
||||
verificationUri: json.verification_uri || json.verification_uri_complete,
|
||||
verificationUriComplete: json.verification_uri_complete || null,
|
||||
expiresIn: Number(json.expires_in) || 600,
|
||||
interval: Number(json.interval) || 5,
|
||||
codeVerifier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll token endpoint until authorized, expired, or aborted.
|
||||
* Always sends DPoP (fp-tv-app usesExtendedSecurity / USE_DPOP).
|
||||
* @param {{
|
||||
* discovery: Awaited<ReturnType<typeof discoverOidc>>,
|
||||
* deviceCode: string,
|
||||
* clientId?: string,
|
||||
* codeVerifier?: string,
|
||||
* dpopKeyPair?: Awaited<ReturnType<typeof generateDpopKeyPair>>,
|
||||
* intervalSec?: number,
|
||||
* expiresInSec?: number,
|
||||
* fetchImpl?: typeof fetch,
|
||||
* sleep?: (ms: number) => Promise<void>,
|
||||
* now?: () => number,
|
||||
* onPoll?: (info: { status: string, attempt: number }) => void,
|
||||
* signal?: AbortSignal,
|
||||
* }} opts
|
||||
*/
|
||||
export async function pollDeviceToken(opts) {
|
||||
const fetchImpl = opts.fetchImpl || fetch;
|
||||
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
||||
const now = opts.now || (() => Date.now());
|
||||
const clientId = opts.clientId || OIDC.clientId;
|
||||
const dpopKeyPair = opts.dpopKeyPair || (await generateDpopKeyPair());
|
||||
let intervalMs = Math.max(1, (opts.intervalSec ?? 5) * 1000);
|
||||
const deadline = now() + Math.max(1, opts.expiresInSec ?? 600) * 1000;
|
||||
let attempt = 0;
|
||||
/** @type {string|null} */
|
||||
let dpopNonce = null;
|
||||
|
||||
while (now() < deadline) {
|
||||
if (opts.signal?.aborted) {
|
||||
throw new Error('device token poll aborted');
|
||||
}
|
||||
attempt += 1;
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', OIDC.grantTypeDeviceCode);
|
||||
body.set('device_code', opts.deviceCode);
|
||||
body.set('client_id', clientId);
|
||||
if (opts.codeVerifier) body.set('code_verifier', opts.codeVerifier);
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
accept: 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
};
|
||||
await attachDpopHeaders({
|
||||
headers,
|
||||
keyPair: dpopKeyPair,
|
||||
method: 'POST',
|
||||
url: opts.discovery.tokenEndpoint,
|
||||
nonce: dpopNonce,
|
||||
});
|
||||
|
||||
const res = await fetchImpl(opts.discovery.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const nonceHeader = readDpopNonce(res);
|
||||
if (nonceHeader) dpopNonce = nonceHeader;
|
||||
const json = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok && json.access_token) {
|
||||
opts.onPoll?.({ status: 'authorized', attempt });
|
||||
const obtainedAtMs = now();
|
||||
const expiresIn =
|
||||
typeof json.expires_in === 'number' ? json.expires_in : Number(json.expires_in);
|
||||
const refreshExpiresIn =
|
||||
typeof json.refresh_expires_in === 'number'
|
||||
? json.refresh_expires_in
|
||||
: json.refresh_expires_in != null
|
||||
? Number(json.refresh_expires_in)
|
||||
: null;
|
||||
return {
|
||||
accessToken: json.access_token,
|
||||
refreshToken: json.refresh_token || null,
|
||||
expiresIn: Number.isFinite(expiresIn) ? expiresIn : null,
|
||||
refreshExpiresIn: Number.isFinite(refreshExpiresIn) ? refreshExpiresIn : null,
|
||||
accessExpiresAtMs:
|
||||
Number.isFinite(expiresIn) ? obtainedAtMs + expiresIn * 1000 : null,
|
||||
refreshExpiresAtMs:
|
||||
Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0
|
||||
? obtainedAtMs + refreshExpiresIn * 1000
|
||||
: null,
|
||||
sessionState: json.session_state || null,
|
||||
tokenType: json.token_type || 'DPoP',
|
||||
scope: json.scope || null,
|
||||
obtainedAtMs,
|
||||
responseKeys: Object.keys(json).sort(),
|
||||
dpopKeyPair,
|
||||
};
|
||||
}
|
||||
|
||||
const err = json.error || `HTTP ${res.status}`;
|
||||
opts.onPoll?.({ status: err, attempt });
|
||||
|
||||
if (err === 'authorization_pending') {
|
||||
await sleep(intervalMs);
|
||||
continue;
|
||||
}
|
||||
if (err === 'slow_down') {
|
||||
intervalMs += 5000;
|
||||
await sleep(intervalMs);
|
||||
continue;
|
||||
}
|
||||
// Keycloak may require a nonce retry without waiting the full interval
|
||||
if (err === 'use_dpop_nonce' || (err === 'invalid_dpop_proof' && nonceHeader)) {
|
||||
continue;
|
||||
}
|
||||
if (err === 'expired_token' || err === 'access_denied') {
|
||||
throw new Error(
|
||||
`device token poll ended: ${err}${json.error_description ? `: ${json.error_description}` : ''}`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`device token poll failed: ${err}${json.error_description ? `: ${json.error_description}` : ''}`,
|
||||
);
|
||||
}
|
||||
throw new Error('device token poll timed out');
|
||||
}
|
||||
159
doc/research/reference-sources/bz_dpop.mjs
Normal file
159
doc/research/reference-sources/bz_dpop.mjs
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Minimal DPoP (RFC 9449) helper for fp-tv-app extended security.
|
||||
*
|
||||
* Frontend evidence: USE_DPOP === usesExtendedSecurity === true for clientId fp-tv-app.
|
||||
* Token + resource requests require a DPoP proof JWT (ES256 / P-256).
|
||||
*/
|
||||
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
const subtle = webcrypto.subtle;
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ publicKey: CryptoKey, privateKey: CryptoKey, publicJwk: JsonWebKey, privateJwk: JsonWebKey }>}
|
||||
*/
|
||||
export async function generateDpopKeyPair() {
|
||||
const pair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
const publicJwk = await subtle.exportKey('jwk', pair.publicKey);
|
||||
const privateJwk = await subtle.exportKey('jwk', pair.privateKey);
|
||||
// Strip private material from public jwk for JWT header
|
||||
const publicOnly = {
|
||||
kty: publicJwk.kty,
|
||||
crv: publicJwk.crv,
|
||||
x: publicJwk.x,
|
||||
y: publicJwk.y,
|
||||
};
|
||||
return {
|
||||
publicKey: pair.publicKey,
|
||||
privateKey: pair.privateKey,
|
||||
publicJwk: publicOnly,
|
||||
privateJwk,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a persisted JWK key pair.
|
||||
* @param {{ publicJwk: JsonWebKey, privateJwk: JsonWebKey }} stored
|
||||
*/
|
||||
export async function importDpopKeyPair(stored) {
|
||||
const publicKey = await subtle.importKey(
|
||||
'jwk',
|
||||
stored.publicJwk,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['verify'],
|
||||
);
|
||||
const privateKey = await subtle.importKey(
|
||||
'jwk',
|
||||
stored.privateJwk,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign'],
|
||||
);
|
||||
return {
|
||||
publicKey,
|
||||
privateKey,
|
||||
publicJwk: {
|
||||
kty: stored.publicJwk.kty,
|
||||
crv: stored.publicJwk.crv,
|
||||
x: stored.publicJwk.x,
|
||||
y: stored.publicJwk.y,
|
||||
},
|
||||
privateJwk: stored.privateJwk,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DPoP proof JWT and return the header value.
|
||||
* @param {{
|
||||
* keyPair: { publicJwk: JsonWebKey, privateKey: CryptoKey },
|
||||
* htm: string,
|
||||
* htu: string | URL,
|
||||
* accessToken?: string | null,
|
||||
* nonce?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function createDpopProof(opts) {
|
||||
const url = typeof opts.htu === 'string' ? new URL(opts.htu) : opts.htu;
|
||||
const htu = `${url.origin}${url.pathname}`;
|
||||
const header = {
|
||||
alg: 'ES256',
|
||||
typ: 'dpop+jwt',
|
||||
jwk: opts.keyPair.publicJwk,
|
||||
};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const payload = {
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
jti: base64Url(randomBytes(16)),
|
||||
htm: opts.htm.toUpperCase(),
|
||||
htu,
|
||||
};
|
||||
if (opts.nonce) payload.nonce = opts.nonce;
|
||||
if (opts.accessToken) {
|
||||
payload.ath = base64Url(createHash('sha256').update(opts.accessToken, 'ascii').digest());
|
||||
}
|
||||
|
||||
const data = `${base64UrlJson(header)}.${base64UrlJson(payload)}`;
|
||||
const sig = await subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
opts.keyPair.privateKey,
|
||||
new TextEncoder().encode(data),
|
||||
);
|
||||
return `${data}.${base64Url(Buffer.from(sig))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach DPoP proof to headers; returns Authorization scheme for bound tokens.
|
||||
* @param {{
|
||||
* headers: Record<string, string>,
|
||||
* keyPair: { publicJwk: JsonWebKey, privateKey: CryptoKey },
|
||||
* method: string,
|
||||
* url: string | URL,
|
||||
* accessToken?: string | null,
|
||||
* nonce?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function attachDpopHeaders(opts) {
|
||||
const proof = await createDpopProof({
|
||||
keyPair: opts.keyPair,
|
||||
htm: opts.method,
|
||||
htu: opts.url,
|
||||
accessToken: opts.accessToken || null,
|
||||
nonce: opts.nonce || null,
|
||||
});
|
||||
opts.headers.dpop = proof;
|
||||
if (opts.accessToken) {
|
||||
opts.headers.authorization = `DPoP ${opts.accessToken}`;
|
||||
}
|
||||
return opts.headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read dpop-nonce from a fetch Response (case-insensitive).
|
||||
* @param {{ headers?: { get?: (name: string) => string | null } } | null} res
|
||||
*/
|
||||
export function readDpopNonce(res) {
|
||||
try {
|
||||
return res?.headers?.get?.('dpop-nonce') || res?.headers?.get?.('DPoP-Nonce') || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {object} obj */
|
||||
function base64UrlJson(obj) {
|
||||
return base64Url(Buffer.from(JSON.stringify(obj), 'utf8'));
|
||||
}
|
||||
|
||||
/** @param {Buffer|Uint8Array} buf */
|
||||
function base64Url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"capturedAt": "2026-10-11T22:50:18.819Z",
|
||||
"toolId": "fp-api-shape-canary",
|
||||
"toolVersion": "3.1.0",
|
||||
"clientId": "fp-tv-app",
|
||||
"requestedScope": "openid offline_access",
|
||||
"grantedScope": "openid profile email offline_access",
|
||||
"dpopRequired": true,
|
||||
"method": "device-login then grant_type=refresh_token with DPoP (4 then 3 successive refreshes)",
|
||||
"lifetimes": {
|
||||
"expires_in_seconds": 1800,
|
||||
"refresh_expires_in_seconds": 7776000,
|
||||
"refresh_expires_in_days": 90
|
||||
},
|
||||
"rotation": {
|
||||
"refreshTokenRotatedEveryRefresh": true
|
||||
},
|
||||
"wallClock": {
|
||||
"sliding": true,
|
||||
"note": "Each refresh returned full refresh_expires_in=7776000 from that moment; refreshExpiresAt advanced with each refresh."
|
||||
},
|
||||
"responseKeysObservedOnRefresh": [
|
||||
"access_token",
|
||||
"expires_in",
|
||||
"id_token",
|
||||
"not-before-policy",
|
||||
"refresh_expires_in",
|
||||
"refresh_token",
|
||||
"scope",
|
||||
"session_state",
|
||||
"token_type"
|
||||
],
|
||||
"conclusion": {
|
||||
"indefiniteRollingRefresh": "YES",
|
||||
"caveat": "YES means sliding 90-day offline window: keep refreshing (and retaining DPoP keys) before the window elapses. Missing the window, revoked offline session, or lost DPoP material requires device re-login. Not immortal without activity."
|
||||
},
|
||||
"publicRealmDocNote": "GET /realms/floatplane does not publish ssoSessionMaxLifespan / offlineSessionMaxLifespan; lifetimes taken from token endpoint responses only."
|
||||
}
|
||||
3880
doc/research/reference-sources/bz_tree.json
Normal file
3880
doc/research/reference-sources/bz_tree.json
Normal file
File diff suppressed because it is too large
Load Diff
97
doc/research/reference-sources/chaosflix/.github/workflows/android-e2e.yml
vendored
Normal file
97
doc/research/reference-sources/chaosflix/.github/workflows/android-e2e.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
name: Android e2e
|
||||
|
||||
# Never on push: booting an emulator and installing the SDK takes far longer
|
||||
# than the rest of the test suite. Nightly plus on demand.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
jellyfin_android_version:
|
||||
description: "jellyfin-android release tag to test against"
|
||||
required: false
|
||||
default: "v2.7.3"
|
||||
|
||||
concurrency:
|
||||
group: android-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
# The emulator needs hardware acceleration; without this the device node
|
||||
# is not writable for the runner user.
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Start the Jellyfin stack
|
||||
env:
|
||||
JELLYFIN_PORT: "8098"
|
||||
COMPOSE_PROJECT_NAME: chaosflix-e2e-android
|
||||
E2E_ARTIFACTS_DIR: ${{ github.workspace }}/e2e/android/.artifacts/stack
|
||||
E2E_FIXTURE_SECONDS: "180"
|
||||
run: |
|
||||
e2e/scripts/build-artifacts.sh
|
||||
docker compose -f e2e/docker-compose.yml up -d --wait
|
||||
|
||||
- name: Run the Android e2e suite
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
env:
|
||||
# The emulator is provided by the action, and the stack is already up.
|
||||
ANDROID_E2E_USE_RUNNING_EMULATOR: "1"
|
||||
E2E_REUSE_STACK: "1"
|
||||
JELLYFIN_PORT: "8098"
|
||||
COMPOSE_PROJECT_NAME: chaosflix-e2e-android
|
||||
JELLYFIN_ANDROID_VERSION: ${{ inputs.jellyfin_android_version || 'v2.7.3' }}
|
||||
with:
|
||||
api-level: 33
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_5
|
||||
emulator-options: -no-window -no-boot-anim -no-snapshot -gpu swiftshader_indirect -no-audio -camera-back none
|
||||
disable-animations: true
|
||||
script: e2e/android/run.sh
|
||||
|
||||
- name: Collect artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-e2e-artifacts
|
||||
path: |
|
||||
e2e/android/.artifacts/*.xml
|
||||
e2e/android/.artifacts/*.log
|
||||
e2e/android/.artifacts/*.png
|
||||
e2e/android/.artifacts/*.txt
|
||||
# .artifacts is a hidden directory, and upload-artifact skips hidden
|
||||
# files by default — without this the step matched nothing and every
|
||||
# red nightly was undiagnosable (#123).
|
||||
include-hidden-files: true
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Stop the Jellyfin stack
|
||||
if: always()
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: chaosflix-e2e-android
|
||||
E2E_ARTIFACTS_DIR: ${{ github.workspace }}/e2e/android/.artifacts/stack
|
||||
run: docker compose -f e2e/docker-compose.yml down -v
|
||||
117
doc/research/reference-sources/chaosflix/.github/workflows/ci.yml
vendored
Normal file
117
doc/research/reference-sources/chaosflix/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
# Nightly the suite also runs against the newest Jellyfin, so a break there is
|
||||
# found within a day instead of by hand (#82). It is not on every PR: the
|
||||
# pinned version is what targetAbi promises, and a second leg would double the
|
||||
# runner minutes of every push.
|
||||
schedule:
|
||||
- cron: "11 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: "1"
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
# The e2e stack binds a host port and names its containers after the compose
|
||||
# project; both have to be unique so a second run cannot collide with this one.
|
||||
JELLYFIN_PORT: "8124"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: tests (jellyfin ${{ matrix.jellyfin.tag }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# && binds tighter than ||, so the scheduled check has to be one
|
||||
# parenthesised term: without it the expression yields a boolean and
|
||||
# fromJSON fails.
|
||||
# Each leg carries a slug as well as the tag: a compose project name may
|
||||
# not contain dots, and "12.1" does.
|
||||
jellyfin: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '[{"tag":"12.1","slug":"pinned"},{"tag":"latest","slug":"latest"}]' || '[{"tag":"12.1","slug":"pinned"}]') }}
|
||||
env:
|
||||
JELLYFIN_TAG: ${{ matrix.jellyfin.tag }}
|
||||
COMPOSE_PROJECT_NAME: chaosflix-e2e-ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.jellyfin.slug }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# run-tests.sh falls back to the SDK Docker image only when no dotnet is
|
||||
# on PATH; installing the SDK here keeps the build out of a container.
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: e2e/package-lock.json
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Build.props') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Resolve the Playwright version
|
||||
id: playwright
|
||||
working-directory: e2e
|
||||
run: |
|
||||
version=$(node -p "require('./package-lock.json').packages['node_modules/@playwright/test'].version")
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}
|
||||
|
||||
- name: Install the Playwright browser
|
||||
working-directory: e2e
|
||||
run: |
|
||||
npm ci
|
||||
if [[ "${{ steps.playwright-cache.outputs.cache-hit }}" == "true" ]]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
# Shell tests, Release build, xUnit suite and the Playwright e2e stack,
|
||||
# against the Jellyfin of this matrix leg (JELLYFIN_TAG).
|
||||
# ANDROID_E2E stays unset: that suite is nightly (android-e2e.yml).
|
||||
# ffmpeg is deliberately not installed: the runner has none, so the
|
||||
# fixtures are built through build-artifacts.sh's container fallback,
|
||||
# which stayed broken for as long as nothing exercised it.
|
||||
- name: Run the test suite
|
||||
run: ./run-tests.sh
|
||||
|
||||
- name: Upload the Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Two legs upload on a nightly run; identical names collide.
|
||||
name: playwright-report-${{ matrix.jellyfin.slug }}
|
||||
path: |
|
||||
e2e/playwright-report
|
||||
e2e/test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Tear down the e2e stack
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose down -v --remove-orphans || true
|
||||
49
doc/research/reference-sources/chaosflix/.github/workflows/contract.yml
vendored
Normal file
49
doc/research/reference-sources/chaosflix/.github/workflows/contract.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: CCC API contract
|
||||
|
||||
# Never on push or pull_request: these tests query api.media.ccc.de, so a third-party
|
||||
# outage would turn every PR red and every push would add load to someone else's free
|
||||
# public service. Nightly instead — an upstream field rename is then found within a day
|
||||
# (#18), and one run costs the API well under a dozen requests.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "43 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: contract-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: "1"
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
|
||||
jobs:
|
||||
contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Build.props') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
# Without CCC_CONTRACT the [ContractFact] tests skip, which is what keeps them out of
|
||||
# run-tests.sh; the Category filter keeps this job from re-running the offline suite.
|
||||
- name: Run the contract tests against the real API
|
||||
env:
|
||||
CCC_CONTRACT: "1"
|
||||
run: |
|
||||
dotnet test Jellyfin.Plugin.Chaosflix.Tests/Jellyfin.Plugin.Chaosflix.Tests.csproj \
|
||||
-c Release --filter "Category=Contract" --logger "console;verbosity=detailed"
|
||||
49
doc/research/reference-sources/chaosflix/.github/workflows/install-e2e.yml
vendored
Normal file
49
doc/research/reference-sources/chaosflix/.github/workflows/install-e2e.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Install from manifest
|
||||
|
||||
# Tests the published release, not the checked-out code: a fresh Jellyfin adds
|
||||
# the repository manifest, downloads the asset, verifies its checksum and loads
|
||||
# the plugin. It therefore cannot run on a pull request — it runs after a
|
||||
# release, and nightly to catch a new Jellyfin breaking an existing release.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release"]
|
||||
types: [completed]
|
||||
schedule:
|
||||
- cron: "43 4 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
jellyfin_tag:
|
||||
description: "Jellyfin image tag to install into"
|
||||
required: false
|
||||
default: "latest"
|
||||
manifest_url:
|
||||
description: "Repository manifest to install from"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: install-e2e
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
install:
|
||||
# A failed release has nothing worth installing.
|
||||
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The manifest is fetched over the network, so the run tests whatever is
|
||||
# published on the default branch right now — the same thing a user gets.
|
||||
- name: Install the published plugin
|
||||
env:
|
||||
JELLYFIN_TAG: ${{ inputs.jellyfin_tag || 'latest' }}
|
||||
MANIFEST_URL: ${{ inputs.manifest_url || '' }}
|
||||
INSTALL_PORT: "8099"
|
||||
run: |
|
||||
if [[ -n "${MANIFEST_URL}" ]]; then
|
||||
./e2e/install/install-from-manifest.sh
|
||||
else
|
||||
MANIFEST_URL="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${{ github.event.repository.default_branch }}/manifest.json" \
|
||||
./e2e/install/install-from-manifest.sh
|
||||
fi
|
||||
150
doc/research/reference-sources/chaosflix/.github/workflows/release.yml
vendored
Normal file
150
doc/research/reference-sources/chaosflix/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
name: Release
|
||||
|
||||
# release.sh commits a manifest.json whose sourceUrl points at the release
|
||||
# asset, so the asset has to exist the moment the tag is pushed — otherwise
|
||||
# every server that has the repository added downloads a 404 (see #19).
|
||||
# This job owns the artifact: release.sh no longer builds one, because a ZIP
|
||||
# built on the tagging machine is never byte-identical to the published one and
|
||||
# its checksum described bytes nobody could download (#49).
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
DOTNET_NOLOGO: "1"
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# The annotated tag's own message is the fallback for the release notes,
|
||||
# and it only arrives with the tag object itself.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Build.props') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Check the tag against meta.json
|
||||
run: |
|
||||
meta=$(python3 -c "import json;print(json.load(open('Jellyfin.Plugin.Chaosflix/meta.json'))['version'])")
|
||||
tagged="${GITHUB_REF_NAME#v}"
|
||||
if [[ "${meta%.*}" != "$tagged" ]]; then
|
||||
echo "::warning::tag ${GITHUB_REF_NAME} does not match meta.json version ${meta}"
|
||||
fi
|
||||
|
||||
# Same output as release.sh: publish to ./artifacts, drop meta.json next
|
||||
# to the DLL, and zip exactly those two files flat.
|
||||
- name: Build the plugin
|
||||
run: |
|
||||
dotnet publish Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj -c Release -o ./artifacts
|
||||
cp Jellyfin.Plugin.Chaosflix/meta.json artifacts/
|
||||
|
||||
- name: Create the ZIP
|
||||
id: zip
|
||||
run: |
|
||||
zip_name="chaosflix-jellyfin-${GITHUB_REF_NAME}.zip"
|
||||
(cd artifacts && zip -j "../${zip_name}" Jellyfin.Plugin.Chaosflix.dll meta.json)
|
||||
md5=$(md5sum "${zip_name}" | cut -d' ' -f1)
|
||||
echo "name=${zip_name}" >> "$GITHUB_OUTPUT"
|
||||
echo "md5=${md5}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### ${GITHUB_REF_NAME}"
|
||||
echo
|
||||
echo "- asset: \`${zip_name}\`"
|
||||
echo "- md5: \`${md5}\` — recorded as \`checksum\` in manifest.json"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# release.sh writes release-notes-v<version>.md but .gitignore keeps it
|
||||
# out of the commit, so in practice the tag message is what is used.
|
||||
- name: Collect the release notes
|
||||
id: notes
|
||||
run: |
|
||||
notes_file="release-notes-${GITHUB_REF_NAME}.md"
|
||||
if [[ -s "${notes_file}" ]]; then
|
||||
cp "${notes_file}" notes.md
|
||||
else
|
||||
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" > notes.md
|
||||
fi
|
||||
if [[ ! -s notes.md ]]; then
|
||||
echo "Release ${GITHUB_REF_NAME}" > notes.md
|
||||
fi
|
||||
|
||||
# Re-runnable on purpose: a failure in a later step used to be
|
||||
# unrecoverable, because re-running the job started here and gh refused an
|
||||
# existing release, leaving the checksum unrecorded (#100).
|
||||
- name: Create or update the GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ASSET: ${{ steps.zip.outputs.name }}
|
||||
run: |
|
||||
if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
|
||||
echo "release ${GITHUB_REF_NAME} exists; uploading the asset again"
|
||||
gh release upload "${GITHUB_REF_NAME}" "${ASSET}" --clobber
|
||||
else
|
||||
gh release create "${GITHUB_REF_NAME}" "${ASSET}" \
|
||||
--title "${GITHUB_REF_NAME}" \
|
||||
--notes-file notes.md
|
||||
fi
|
||||
|
||||
# The checksum is taken from the asset as it was downloaded back from the
|
||||
# release, and it lands on the default branch rather than on the tag: a
|
||||
# tag is immutable, and what servers read is manifest.json on that branch.
|
||||
- name: Record the checksum in manifest.json
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ASSET: ${{ steps.zip.outputs.name }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
git fetch origin "${BRANCH}"
|
||||
git checkout "${BRANCH}"
|
||||
|
||||
gh release download "${TAG}" --pattern "${ASSET}" --dir published --clobber
|
||||
MD5=$(md5sum "published/${ASSET}" | cut -d' ' -f1) && export MD5
|
||||
rm -rf published
|
||||
|
||||
python3 - <<'PYTHON'
|
||||
import json, os, sys
|
||||
|
||||
with open("manifest.json") as f:
|
||||
data = json.load(f)
|
||||
|
||||
wanted = os.environ["TAG"].lstrip("v")
|
||||
for entry in data[0]["versions"]:
|
||||
if entry["version"] == wanted or entry["version"].startswith(wanted + "."):
|
||||
entry["checksum"] = os.environ["MD5"]
|
||||
break
|
||||
else:
|
||||
sys.exit(f"manifest.json has no entry for {wanted}")
|
||||
|
||||
with open("manifest.json", "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
PYTHON
|
||||
|
||||
if git diff --quiet manifest.json; then
|
||||
echo "manifest.json already carries this checksum"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add manifest.json
|
||||
git commit -m "release: record the ${TAG} checksum [skip ci]"
|
||||
git push origin "HEAD:${BRANCH}"
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.4.0.0</Version>
|
||||
<AssemblyVersion>0.4.0.0</AssemblyVersion>
|
||||
<FileVersion>0.4.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,354 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the media.ccc.de public API with built-in caching.
|
||||
/// </summary>
|
||||
public class CccApiClient : IDisposable
|
||||
{
|
||||
/// <summary>Default public API endpoint; overridable for mirrors and tests.</summary>
|
||||
public const string DefaultBaseUrl = "https://api.media.ccc.de/public";
|
||||
|
||||
/// <summary>
|
||||
/// Default endpoint reporting which rooms are on air. A separate service from the
|
||||
/// archive API, hence its own base url.
|
||||
/// </summary>
|
||||
public const string DefaultStreamingBaseUrl = "https://streaming.media.ccc.de";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the <see cref="IHttpClientFactory"/> client used to resolve CDN redirects.
|
||||
/// It must be configured with <c>AllowAutoRedirect = false</c>.
|
||||
/// </summary>
|
||||
public const string RedirectClientName = "Chaosflix.CdnRedirect";
|
||||
|
||||
private const string ConferencesCacheKey = "conferences";
|
||||
|
||||
/// <summary>
|
||||
/// How long the conference list and conference details stay cached.
|
||||
/// <see cref="Channel.ChaosflixSyncTask"/> rewrites both on every run, so this only has to
|
||||
/// outlive <see cref="Channel.ChaosflixSyncTask.SyncInterval"/>; the extra hour of margin keeps
|
||||
/// the pre-warmed data usable when a run is late, skipped or still in progress.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan ConferenceCacheTtl = TimeSpan.FromHours(7);
|
||||
|
||||
/// <summary>Event detail with recordings — cache for 15 minutes.</summary>
|
||||
private static readonly TimeSpan EventTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>Search results — cache for 10 minutes.</summary>
|
||||
private static readonly TimeSpan SearchTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Which rooms are live — cache for a minute. Everything else here describes an
|
||||
/// archive that changes by the day; this changes between two talks.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan LiveTtl = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// How long the configuration page's live check waits. Short on purpose: an endpoint
|
||||
/// that needs longer than this is the answer the page is asking for.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ReachabilityTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<CccApiClient> _logger;
|
||||
private readonly MemoryCache _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CccApiClient"/> class.
|
||||
/// </summary>
|
||||
public CccApiClient(IHttpClientFactory httpClientFactory, ILogger<CccApiClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_httpClient = httpClientFactory.CreateClient(nameof(CccApiClient));
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all conferences (cached).
|
||||
/// </summary>
|
||||
public Task<List<CccConference>> GetConferencesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _cache.GetOrCreateAsync(ConferencesCacheKey, ConferenceCacheTtl, async ct =>
|
||||
{
|
||||
_logger.LogDebug("Fetching conferences from CCC API");
|
||||
var response = await _httpClient
|
||||
.GetFromJsonAsync<CccConferencesResponse>(Url("/conferences"), ct)
|
||||
.ConfigureAwait(false);
|
||||
return response?.Conferences ?? new List<CccConference>();
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single conference with its events (cached).
|
||||
/// </summary>
|
||||
public Task<CccConference?> GetConferenceAsync(string acronym, CancellationToken cancellationToken)
|
||||
{
|
||||
return _cache.GetOrCreateAsync(ConferenceCacheKey(acronym), ConferenceCacheTtl, async ct =>
|
||||
{
|
||||
_logger.LogDebug("Fetching conference {Acronym} from CCC API", acronym);
|
||||
return await _httpClient
|
||||
.GetFromJsonAsync<CccConference>(Url($"/conferences/{Uri.EscapeDataString(acronym)}"), ct)
|
||||
.ConfigureAwait(false);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refetches the conference list and replaces the cached copy, leaving every other
|
||||
/// cache entry (events, CDN redirects) untouched.
|
||||
/// </summary>
|
||||
public Task<List<CccConference>> RefreshConferencesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cache.Remove(ConferencesCacheKey);
|
||||
return GetConferencesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refetches a single conference and replaces the cached copy, leaving every other
|
||||
/// cache entry untouched.
|
||||
/// </summary>
|
||||
public Task<CccConference?> RefreshConferenceAsync(string acronym, CancellationToken cancellationToken)
|
||||
{
|
||||
_cache.Remove(ConferenceCacheKey(acronym));
|
||||
return GetConferenceAsync(acronym, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single event with its recordings (cached).
|
||||
/// </summary>
|
||||
public Task<CccEvent?> GetEventAsync(string guid, CancellationToken cancellationToken)
|
||||
{
|
||||
return _cache.GetOrCreateAsync($"event:{guid}", EventTtl, async ct =>
|
||||
{
|
||||
_logger.LogDebug("Fetching event {Guid} from CCC API", guid);
|
||||
try
|
||||
{
|
||||
return await _httpClient
|
||||
.GetFromJsonAsync<CccEvent>(Url($"/events/{Uri.EscapeDataString(guid)}"), ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Event {Guid} is unknown to the CCC API", guid);
|
||||
return null;
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conferences that are streaming right now (cached briefly).
|
||||
/// Outside a congress the endpoint answers with an empty list, which is not an
|
||||
/// error and is what keeps the live folder from appearing all year.
|
||||
/// </summary>
|
||||
public Task<List<CccLiveConference>> GetLiveConferencesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The endpoint is part of the key: pointing the plugin at a different streaming
|
||||
// site has to take effect at once, not after the entry expires.
|
||||
return _cache.GetOrCreateAsync($"live:{StreamingBaseUrl}", LiveTtl, async ct =>
|
||||
{
|
||||
_logger.LogDebug("Fetching live streams from {Url}", StreamingBaseUrl);
|
||||
try
|
||||
{
|
||||
var conferences = await _httpClient
|
||||
.GetFromJsonAsync<List<CccLiveConference>>($"{StreamingBaseUrl}/streams/v2.json", ct)
|
||||
.ConfigureAwait(false);
|
||||
return conferences ?? new List<CccLiveConference>();
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException)
|
||||
{
|
||||
// A congress is the one time the archive is least interesting, but a
|
||||
// streaming site that is down must not take the rest of the channel with it.
|
||||
_logger.LogWarning(ex, "Could not read the live streams; treating nothing as live");
|
||||
return new List<CccLiveConference>();
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for events (cached).
|
||||
/// </summary>
|
||||
public Task<List<CccEvent>> SearchEventsAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
return _cache.GetOrCreateAsync($"search:{query}", SearchTtl, async ct =>
|
||||
{
|
||||
_logger.LogDebug("Searching CCC API for {Query}", query);
|
||||
var encoded = Uri.EscapeDataString(query);
|
||||
var response = await _httpClient
|
||||
.GetFromJsonAsync<CccEventsResponse>(Url($"/events/search?q={encoded}"), ct)
|
||||
.ConfigureAwait(false);
|
||||
return response?.Events ?? new List<CccEvent>();
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint requests actually go to: the configured one, or the public default.
|
||||
/// </summary>
|
||||
public static string BaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var configured = Plugin.Instance?.Configuration.ApiBaseUrl;
|
||||
return string.IsNullOrWhiteSpace(configured) ? DefaultBaseUrl : configured.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint the live stream list is read from: the configured one, or the
|
||||
/// public default.
|
||||
/// </summary>
|
||||
public static string StreamingBaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var configured = Plugin.Instance?.Configuration.StreamingBaseUrl;
|
||||
return string.IsNullOrWhiteSpace(configured) ? DefaultStreamingBaseUrl : configured.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
private static string Url(string path) => BaseUrl + path;
|
||||
|
||||
/// <summary>
|
||||
/// Asks the configured endpoint for the conference list, bypassing the cache, and
|
||||
/// reports whether it answered and how long it took. Used by the configuration page.
|
||||
/// </summary>
|
||||
internal async Task<ReachabilityResult> CheckReachabilityAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = Url("/conferences");
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(ReachabilityTimeout);
|
||||
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, timeout.Token).ConfigureAwait(false);
|
||||
int? conferences = null;
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content
|
||||
.ReadFromJsonAsync<CccConferencesResponse>(timeout.Token).ConfigureAwait(false);
|
||||
conferences = payload?.Conferences?.Count ?? 0;
|
||||
}
|
||||
|
||||
return new ReachabilityResult(
|
||||
response.IsSuccessStatusCode,
|
||||
(int)response.StatusCode,
|
||||
Elapsed(started),
|
||||
conferences,
|
||||
response.IsSuccessStatusCode ? null : response.ReasonPhrase,
|
||||
url);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var reason = ex is OperationCanceledException
|
||||
? $"no answer within {ReachabilityTimeout.TotalSeconds:N0}s"
|
||||
: ex.Message;
|
||||
_logger.LogWarning(ex, "Reachability check for {Url} failed", url);
|
||||
return new ReachabilityResult(false, null, Elapsed(started), null, reason, url);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts what the cache currently holds, per kind of entry.
|
||||
/// </summary>
|
||||
internal CacheSnapshot GetCacheSnapshot() => new(
|
||||
_cache.CountLive(),
|
||||
_cache.CountLive(key => key == ConferencesCacheKey || key.StartsWith("conf:", StringComparison.Ordinal)),
|
||||
_cache.CountLive(key => key.StartsWith("event:", StringComparison.Ordinal)),
|
||||
_cache.CountLive(key => key.StartsWith("search:", StringComparison.Ordinal)),
|
||||
_cache.CountLive(key => key.StartsWith("redirect:", StringComparison.Ordinal)),
|
||||
_cache.Capacity,
|
||||
_cache.Hits,
|
||||
_cache.Misses);
|
||||
|
||||
private static double Elapsed(long startedAt) =>
|
||||
Math.Round(Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, 1);
|
||||
|
||||
/// <summary>Outcome of <see cref="CheckReachabilityAsync"/>.</summary>
|
||||
internal sealed record ReachabilityResult(
|
||||
bool Ok,
|
||||
int? StatusCode,
|
||||
double ElapsedMs,
|
||||
int? ConferenceCount,
|
||||
string? Error,
|
||||
string Url);
|
||||
|
||||
/// <summary>What the API cache holds right now.</summary>
|
||||
internal sealed record CacheSnapshot(
|
||||
int Entries,
|
||||
int Conferences,
|
||||
int Events,
|
||||
int Searches,
|
||||
int Redirects,
|
||||
int Capacity,
|
||||
long Hits,
|
||||
long Misses);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the entire cache.
|
||||
/// </summary>
|
||||
public void ClearCache() => _cache.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a CDN URL by following redirects and returning the final URL.
|
||||
/// CCC CDN (cdn.media.ccc.de) does 302 redirects to mirror servers.
|
||||
/// Some clients (e.g. Android ExoPlayer) struggle with cross-domain redirects.
|
||||
/// </summary>
|
||||
public async Task<string> ResolveRedirectAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _cache.GetOrCreateAsync(RedirectCacheKey(url), TimeSpan.FromHours(2), async ct =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(RedirectClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, new Uri(url));
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if ((int)response.StatusCode is >= 301 and <= 308
|
||||
&& response.Headers.Location is { } location)
|
||||
{
|
||||
var resolved = location.IsAbsoluteUri
|
||||
? location.ToString()
|
||||
: new Uri(new Uri(url), location).ToString();
|
||||
_logger.LogDebug("Resolved CDN redirect {Url} -> {Resolved}", url, resolved);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to resolve redirect for {Url}, using original", url);
|
||||
}
|
||||
|
||||
return url;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the cached mirror for <paramref name="url"/> so that the next resolution
|
||||
/// asks the CDN again. Used when the cached mirror stopped serving the recording.
|
||||
/// </summary>
|
||||
public void InvalidateRedirect(string url) => _cache.Remove(RedirectCacheKey(url));
|
||||
|
||||
private static string ConferenceCacheKey(string acronym) => $"conf:{acronym}";
|
||||
|
||||
private static string RedirectCacheKey(string url) => $"redirect:{url}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Channel;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Reports what the plugin currently believes to be true, for its configuration page:
|
||||
/// whether the CCC API answers, when the sync task last ran and what the caches hold.
|
||||
/// Nothing here is expensive unless it is asked for explicitly.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = ElevationPolicy)]
|
||||
public class ChaosflixStatusController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Jellyfin's administrator policy. The constant lives in Jellyfin.Api, which plugins
|
||||
/// do not reference, so the name is repeated here; the server registers it by name.
|
||||
/// </summary>
|
||||
private const string ElevationPolicy = "RequiresElevation";
|
||||
|
||||
private static readonly HttpClient _probeClient = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly ChaosflixChannel _channel;
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly ILogger<ChaosflixStatusController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixStatusController"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixStatusController(
|
||||
CccApiClient apiClient,
|
||||
ChaosflixChannel channel,
|
||||
ITaskManager taskManager,
|
||||
ILogger<ChaosflixStatusController> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_channel = channel;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the plugin's state without touching the network.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public ActionResult<PluginStatus> GetStatus() => BuildStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Asks the configured endpoint whether it answers, and how fast.
|
||||
/// </summary>
|
||||
[HttpPost("CheckApi")]
|
||||
public async Task<ActionResult<ApiCheck>> CheckApi()
|
||||
{
|
||||
var result = await _apiClient.CheckReachabilityAsync(HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return new ApiCheck(
|
||||
result.Ok,
|
||||
result.Url,
|
||||
result.StatusCode,
|
||||
result.ElapsedMs,
|
||||
result.ConferenceCount,
|
||||
result.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties every cache the plugin keeps, along the same path a configuration change
|
||||
/// takes, and returns the state afterwards.
|
||||
/// </summary>
|
||||
[HttpPost("ClearCaches")]
|
||||
public ActionResult<PluginStatus> ClearCaches()
|
||||
{
|
||||
var before = BuildStatus();
|
||||
|
||||
_apiClient.ClearCache();
|
||||
_channel.ClearProbeCache();
|
||||
_channel.InvalidateCachedMediaSources();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Caches cleared from the configuration page: {Api} api entries, {Probe} probes",
|
||||
before.ApiCache.Entries,
|
||||
before.ProbeCache.Entries);
|
||||
|
||||
return BuildStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one talk the whole way the channel does — API lookup, recording choice,
|
||||
/// signed proxy url, proxy request, probe — and reports the stage that fails.
|
||||
/// </summary>
|
||||
/// <param name="id">A talk guid, slug, channel item id or media.ccc.de url.</param>
|
||||
[HttpPost("CheckTalk")]
|
||||
public async Task<ActionResult<TalkCheck>> CheckTalk([FromQuery] string? id)
|
||||
{
|
||||
var guid = NormalizeTalkId(id);
|
||||
if (string.IsNullOrEmpty(guid))
|
||||
{
|
||||
return BadRequest("Give a talk guid, slug or media.ccc.de url.");
|
||||
}
|
||||
|
||||
var cancellationToken = HttpContext.RequestAborted;
|
||||
var stages = new List<TalkCheckStage>();
|
||||
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var cccEvent = await _apiClient.GetEventAsync(guid, cancellationToken).ConfigureAwait(false);
|
||||
if (cccEvent?.Recordings is null || cccEvent.Recordings.Count == 0)
|
||||
{
|
||||
stages.Add(new TalkCheckStage(
|
||||
"API lookup",
|
||||
false,
|
||||
cccEvent is null
|
||||
? $"{CccApiClient.BaseUrl}/events/{guid} returned no event"
|
||||
: "the event has no recordings",
|
||||
Elapsed(started)));
|
||||
return Report(guid, null, stages);
|
||||
}
|
||||
|
||||
stages.Add(new TalkCheckStage(
|
||||
"API lookup",
|
||||
true,
|
||||
$"{cccEvent.Title} — {cccEvent.Recordings.Count} recordings",
|
||||
Elapsed(started)));
|
||||
|
||||
started = Stopwatch.GetTimestamp();
|
||||
var sources = (await _channel
|
||||
.GetChannelItemMediaInfo($"event:{guid}", cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
.ToList();
|
||||
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
stages.Add(new TalkCheckStage(
|
||||
"Recording choice",
|
||||
false,
|
||||
"no recording survived the configured quality, format and language preferences",
|
||||
Elapsed(started)));
|
||||
return Report(guid, cccEvent.Title, stages);
|
||||
}
|
||||
|
||||
var chosen = sources[0];
|
||||
stages.Add(new TalkCheckStage(
|
||||
"Recording choice",
|
||||
true,
|
||||
$"{chosen.Name} ({chosen.Container}), {sources.Count} versions offered",
|
||||
Elapsed(started)));
|
||||
|
||||
var proxyUrl = chosen.Path ?? string.Empty;
|
||||
var signed = ProxyUrl.IsSignedCorrectly(proxyUrl);
|
||||
stages.Add(new TalkCheckStage(
|
||||
"Signed proxy url",
|
||||
signed,
|
||||
signed
|
||||
? ProxyUrl.Redact(proxyUrl)
|
||||
: $"the url this plugin just produced does not carry a valid signature: {ProxyUrl.Redact(proxyUrl)}",
|
||||
0));
|
||||
|
||||
if (!signed)
|
||||
{
|
||||
return Report(guid, cccEvent.Title, stages);
|
||||
}
|
||||
|
||||
started = Stopwatch.GetTimestamp();
|
||||
var proxy = await HeadProxyAsync(proxyUrl, cancellationToken).ConfigureAwait(false);
|
||||
stages.Add(proxy with { ElapsedMs = Elapsed(started) });
|
||||
if (!proxy.Ok)
|
||||
{
|
||||
return Report(guid, cccEvent.Title, stages);
|
||||
}
|
||||
|
||||
var streams = chosen.MediaStreams ?? new List<MediaStream>();
|
||||
stages.Add(new TalkCheckStage(
|
||||
"Probe",
|
||||
streams.Count > 0,
|
||||
streams.Count > 0
|
||||
? string.Join(", ", streams.Select(s => $"{s.Type}@{s.Index} {s.Codec}"))
|
||||
: "ffprobe reported no streams for the chosen recording",
|
||||
0));
|
||||
|
||||
return Report(guid, cccEvent.Title, stages);
|
||||
}
|
||||
|
||||
private static TalkCheck Report(string guid, string? title, List<TalkCheckStage> stages)
|
||||
{
|
||||
var failed = stages.FirstOrDefault(s => !s.Ok);
|
||||
return new TalkCheck(failed is null, guid, title, failed?.Name, stages);
|
||||
}
|
||||
|
||||
private async Task<TalkCheckStage> HeadProxyAsync(string proxyUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, proxyUrl);
|
||||
using var response = await _probeClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var size = response.Content.Headers.ContentLength;
|
||||
var length = size.HasValue
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{size.Value / 1048576.0:N1} MiB")
|
||||
: "no length announced";
|
||||
|
||||
return new TalkCheckStage(
|
||||
"Proxy",
|
||||
response.IsSuccessStatusCode,
|
||||
response.IsSuccessStatusCode
|
||||
? $"{(int)response.StatusCode} {response.Content.Headers.ContentType}, {length}"
|
||||
: $"the proxy answered {(int)response.StatusCode} {response.ReasonPhrase}",
|
||||
0);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Talk check could not reach the proxy");
|
||||
return new TalkCheckStage("Proxy", false, ex.Message, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private PluginStatus BuildStatus()
|
||||
{
|
||||
var cache = _apiClient.GetCacheSnapshot();
|
||||
var probe = _channel.GetProbeCacheStats();
|
||||
|
||||
return new PluginStatus(
|
||||
Plugin.Instance?.Version?.ToString() ?? "unknown",
|
||||
CccApiClient.BaseUrl,
|
||||
string.IsNullOrWhiteSpace(Plugin.Instance?.Configuration.ApiBaseUrl),
|
||||
SyncStatus(),
|
||||
new CacheStatus(
|
||||
cache.Entries,
|
||||
cache.Capacity,
|
||||
cache.Conferences,
|
||||
cache.Events,
|
||||
cache.Searches,
|
||||
cache.Redirects,
|
||||
cache.Hits,
|
||||
cache.Misses,
|
||||
HitRate(cache.Hits, cache.Misses)),
|
||||
new ProbeStatus(
|
||||
probe.Entries,
|
||||
probe.Capacity,
|
||||
probe.Hits,
|
||||
probe.Misses,
|
||||
HitRate(probe.Hits, probe.Misses),
|
||||
probe.ServedItems));
|
||||
}
|
||||
|
||||
private SyncStatus SyncStatus()
|
||||
{
|
||||
var worker = _taskManager.ScheduledTasks
|
||||
.FirstOrDefault(t => string.Equals(t.ScheduledTask.Key, ChaosflixSyncTask.TaskKey, StringComparison.Ordinal));
|
||||
|
||||
if (worker is null)
|
||||
{
|
||||
return new SyncStatus(false, null, null, null, null, null);
|
||||
}
|
||||
|
||||
var last = worker.LastExecutionResult;
|
||||
return new SyncStatus(
|
||||
true,
|
||||
worker.State.ToString(),
|
||||
last?.StartTimeUtc,
|
||||
last is null ? null : Math.Round((last.EndTimeUtc - last.StartTimeUtc).TotalSeconds, 1),
|
||||
last?.Status.ToString(),
|
||||
last?.ErrorMessage);
|
||||
}
|
||||
|
||||
private static double? HitRate(long hits, long misses) =>
|
||||
hits + misses == 0 ? null : Math.Round(100.0 * hits / (hits + misses), 1);
|
||||
|
||||
private static double Elapsed(long startedAt) =>
|
||||
Math.Round(Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Reduces whatever a reader pasted to the identifier the API takes: a bare guid, a
|
||||
/// channel item id, or the last segment of a media.ccc.de url.
|
||||
/// </summary>
|
||||
internal static string NormalizeTalkId(string? id)
|
||||
{
|
||||
var value = id?.Trim() ?? string.Empty;
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var query = value.IndexOfAny(['?', '#']);
|
||||
if (query >= 0)
|
||||
{
|
||||
value = value[..query];
|
||||
}
|
||||
|
||||
value = value.TrimEnd('/');
|
||||
var slash = value.LastIndexOf('/');
|
||||
if (slash >= 0)
|
||||
{
|
||||
value = value[(slash + 1)..];
|
||||
}
|
||||
|
||||
var colon = value.LastIndexOf(':');
|
||||
return colon >= 0 ? value[(colon + 1)..] : value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The plugin's state, as the configuration page shows it.</summary>
|
||||
/// <param name="Version">Plugin version.</param>
|
||||
/// <param name="ApiBaseUrl">Endpoint requests go to.</param>
|
||||
/// <param name="ApiBaseUrlIsDefault">Whether that is the public default rather than a configured mirror.</param>
|
||||
/// <param name="Sync">The scheduled sync task's last run.</param>
|
||||
/// <param name="ApiCache">What the CCC API cache holds.</param>
|
||||
/// <param name="ProbeCache">What the probe cache holds.</param>
|
||||
public record PluginStatus(
|
||||
string Version,
|
||||
string ApiBaseUrl,
|
||||
bool ApiBaseUrlIsDefault,
|
||||
SyncStatus Sync,
|
||||
CacheStatus ApiCache,
|
||||
ProbeStatus ProbeCache);
|
||||
|
||||
/// <summary>The sync task's last run.</summary>
|
||||
/// <param name="Registered">Whether the server knows the task at all.</param>
|
||||
/// <param name="State">Idle, running or cancelled.</param>
|
||||
/// <param name="LastRunUtc">When the last run started.</param>
|
||||
/// <param name="DurationSeconds">How long it took.</param>
|
||||
/// <param name="LastResult">Completed, failed, aborted or cancelled.</param>
|
||||
/// <param name="Error">The failure message, when there was one.</param>
|
||||
public record SyncStatus(
|
||||
bool Registered,
|
||||
string? State,
|
||||
DateTime? LastRunUtc,
|
||||
double? DurationSeconds,
|
||||
string? LastResult,
|
||||
string? Error);
|
||||
|
||||
/// <summary>What the CCC API cache holds.</summary>
|
||||
/// <param name="Entries">Entries that would still be served.</param>
|
||||
/// <param name="Capacity">Entries kept before the soonest-expiring ones are evicted.</param>
|
||||
/// <param name="Conferences">Conference list and conference detail entries.</param>
|
||||
/// <param name="Events">Talk detail entries.</param>
|
||||
/// <param name="Searches">Cached searches.</param>
|
||||
/// <param name="Redirects">Resolved CDN mirrors.</param>
|
||||
/// <param name="Hits">Reads answered from the cache since the server started.</param>
|
||||
/// <param name="Misses">Reads that had to call the API.</param>
|
||||
/// <param name="HitRatePercent">Hits as a percentage, or null when nothing was read yet.</param>
|
||||
public record CacheStatus(
|
||||
int Entries,
|
||||
int Capacity,
|
||||
int Conferences,
|
||||
int Events,
|
||||
int Searches,
|
||||
int Redirects,
|
||||
long Hits,
|
||||
long Misses,
|
||||
double? HitRatePercent);
|
||||
|
||||
/// <summary>What the probe cache holds.</summary>
|
||||
/// <param name="Entries">Probed stream layouts still valid.</param>
|
||||
/// <param name="Capacity">Entries kept before the oldest are evicted.</param>
|
||||
/// <param name="Hits">Playbacks answered from a cached probe.</param>
|
||||
/// <param name="Misses">Playbacks that had to run ffprobe.</param>
|
||||
/// <param name="HitRatePercent">Hits as a percentage, or null when nothing was probed yet.</param>
|
||||
/// <param name="ServedItems">Talks whose media sources a configuration change would invalidate.</param>
|
||||
public record ProbeStatus(
|
||||
int Entries,
|
||||
int Capacity,
|
||||
long Hits,
|
||||
long Misses,
|
||||
double? HitRatePercent,
|
||||
int ServedItems);
|
||||
|
||||
/// <summary>Outcome of the explicit endpoint check.</summary>
|
||||
/// <param name="Ok">Whether the endpoint answered with a success status.</param>
|
||||
/// <param name="Url">The url that was asked.</param>
|
||||
/// <param name="StatusCode">The HTTP status, when there was an answer.</param>
|
||||
/// <param name="ElapsedMs">Round-trip time.</param>
|
||||
/// <param name="ConferenceCount">Conferences the endpoint listed.</param>
|
||||
/// <param name="Error">Why it failed, when it did.</param>
|
||||
public record ApiCheck(
|
||||
bool Ok,
|
||||
string Url,
|
||||
int? StatusCode,
|
||||
double ElapsedMs,
|
||||
int? ConferenceCount,
|
||||
string? Error);
|
||||
|
||||
/// <summary>Outcome of resolving one talk end to end.</summary>
|
||||
/// <param name="Ok">Whether every stage passed.</param>
|
||||
/// <param name="TalkId">The identifier that was resolved.</param>
|
||||
/// <param name="Title">The talk's title, once the API answered.</param>
|
||||
/// <param name="FailedStage">The first stage that failed.</param>
|
||||
/// <param name="Stages">Every stage that was reached, in order.</param>
|
||||
public record TalkCheck(
|
||||
bool Ok,
|
||||
string TalkId,
|
||||
string? Title,
|
||||
string? FailedStage,
|
||||
IReadOnlyList<TalkCheckStage> Stages);
|
||||
|
||||
/// <summary>One stage of the talk check.</summary>
|
||||
/// <param name="Name">Stage name.</param>
|
||||
/// <param name="Ok">Whether it passed.</param>
|
||||
/// <param name="Detail">What it found, or why it failed.</param>
|
||||
/// <param name="ElapsedMs">How long it took.</param>
|
||||
public record TalkCheckStage(string Name, bool Ok, string Detail, double ElapsedMs);
|
||||
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
using Jellyfin.Plugin.Chaosflix.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// API controller that acts as a transparent reverse proxy for CCC recordings.
|
||||
/// Proxies CDN content with full HTTP Range request support, enabling seeking.
|
||||
/// By serving content through a local endpoint, the Jellyfin server treats it
|
||||
/// as a non-remote source and can use DirectStream instead of forced transcoding.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ChaosflixStreamController : ControllerBase
|
||||
{
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly ILogger<ChaosflixStreamController> _logger;
|
||||
private static readonly HttpClient _proxyClient = new HttpClient { Timeout = TimeSpan.FromMinutes(60) };
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixStreamController"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixStreamController(CccApiClient apiClient, ILogger<ChaosflixStreamController> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxies a CCC recording from the CDN with full Range request support.
|
||||
/// This enables seeking in both browser and Android clients.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second route is what subtitles use. Their signature and filename sit in
|
||||
/// the path so the url ends in <c>.srt</c> or <c>.vtt</c>: Jellyfin and its
|
||||
/// clients derive a subtitle's format from the extension of the url they are
|
||||
/// handed, and a query string would end up in <c>Path.GetExtension</c>.
|
||||
/// </remarks>
|
||||
[HttpGet("proxy/{eventGuid}")]
|
||||
[HttpHead("proxy/{eventGuid}")]
|
||||
[HttpGet("proxy/{eventGuid}/{signature}/{filename}")]
|
||||
[HttpHead("proxy/{eventGuid}/{signature}/{filename}")]
|
||||
public async Task ProxyStream(
|
||||
[FromRoute] string eventGuid,
|
||||
[FromRoute] string? signature = null,
|
||||
[FromRoute] string? filename = null,
|
||||
[FromQuery] string? recordingFolder = null,
|
||||
[FromQuery] string? language = null,
|
||||
[FromQuery(Name = ProxySignature.QueryParameter)] string? t = null)
|
||||
{
|
||||
// The endpoint is anonymous because the server's own ffmpeg fetches this
|
||||
// url; the signature is what keeps it from being a general purpose relay.
|
||||
if (!ProxySignature.Verify(eventGuid, recordingFolder, language, signature ?? t, filename))
|
||||
{
|
||||
_logger.LogWarning("Rejected unsigned proxy request for {EventGuid}", eventGuid);
|
||||
Response.StatusCode = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationToken = HttpContext.RequestAborted;
|
||||
|
||||
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (cccEvent?.Recordings == null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
CccRecording? recording;
|
||||
|
||||
if (filename != null)
|
||||
{
|
||||
// Subtitles share the folder "" across every language and format, so the
|
||||
// filename is the only thing that identifies one; it is signed like the rest.
|
||||
recording = cccEvent.Recordings
|
||||
.FirstOrDefault(r => r.Filename.Equals(filename, StringComparison.Ordinal));
|
||||
}
|
||||
else
|
||||
{
|
||||
var videoRecordings = cccEvent.Recordings
|
||||
.Where(r => r.MimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (videoRecordings.Count == 0)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
recording = videoRecordings.FirstOrDefault(r =>
|
||||
(recordingFolder == null || r.Folder.Equals(recordingFolder, StringComparison.OrdinalIgnoreCase)) &&
|
||||
(language == null || r.Language.Equals(language, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
recording ??= videoRecordings
|
||||
.OrderByDescending(r => r.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
|
||||
.ThenByDescending(r => r.HighQuality ? 1 : 0)
|
||||
.First();
|
||||
}
|
||||
|
||||
if (recording == null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
var originalUrl = recording.RecordingUrl;
|
||||
var resolvedUrl = await _apiClient.ResolveRedirectAsync(originalUrl, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Proxying {EventGuid} from {Url}", eventGuid, resolvedUrl);
|
||||
|
||||
RangeHeaderValue? range = null;
|
||||
if (Request.Headers.TryGetValue("Range", out var rangeHeader)
|
||||
&& !RangeHeaderValue.TryParse(rangeHeader.ToString(), out range))
|
||||
{
|
||||
// RFC 9110: a Range header with invalid syntax is ignored, not rejected.
|
||||
_logger.LogDebug("Ignoring malformed Range header {Range}", rangeHeader.ToString());
|
||||
range = null;
|
||||
}
|
||||
|
||||
var method = HttpContext.Request.Method == "HEAD" ? HttpMethod.Head : HttpMethod.Get;
|
||||
|
||||
var upstreamResponse = await SendUpstreamAsync(method, resolvedUrl, range, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A cached mirror can die long before its two-hour cache entry expires; drop it
|
||||
// and give the CDN one chance to hand out a mirror that still has the recording.
|
||||
if (upstreamResponse?.IsSuccessStatusCode != true
|
||||
&& !string.Equals(resolvedUrl, originalUrl, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Mirror {Url} failed for {EventGuid} ({Status}), retrying via {Original}",
|
||||
resolvedUrl,
|
||||
eventGuid,
|
||||
upstreamResponse?.StatusCode.ToString() ?? "no response",
|
||||
originalUrl);
|
||||
|
||||
upstreamResponse?.Dispose();
|
||||
_apiClient.InvalidateRedirect(originalUrl);
|
||||
upstreamResponse = await SendUpstreamAsync(method, originalUrl, range, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (upstreamResponse == null)
|
||||
{
|
||||
Response.StatusCode = 502;
|
||||
return;
|
||||
}
|
||||
|
||||
using var finalResponse = upstreamResponse;
|
||||
|
||||
// Set response status (200 or 206)
|
||||
Response.StatusCode = (int)finalResponse.StatusCode;
|
||||
|
||||
if (!finalResponse.IsSuccessStatusCode)
|
||||
{
|
||||
// An error body is not media, so it gets neither Accept-Ranges nor a video type.
|
||||
// The unsatisfied-range of a 416 is the exception: RFC 9110 has clients read the
|
||||
// real size off it and ask again for a range that fits (#104).
|
||||
if (finalResponse.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable
|
||||
&& finalResponse.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers["Content-Range"] = finalResponse.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward relevant headers
|
||||
if (finalResponse.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = finalResponse.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
Response.Headers["Accept-Ranges"] = "bytes";
|
||||
|
||||
if (filename != null)
|
||||
{
|
||||
// The subtitle mirrors hand out .srt as application/octet-stream, which
|
||||
// no client renders as a caption track; the API's own mime type is the
|
||||
// one that describes the bytes.
|
||||
Response.ContentType = recording.MimeType;
|
||||
}
|
||||
else if (finalResponse.Content.Headers.ContentType != null)
|
||||
{
|
||||
Response.ContentType = finalResponse.Content.Headers.ContentType.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
Response.ContentType = recording.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase)
|
||||
? "video/mp4" : "video/webm";
|
||||
}
|
||||
|
||||
if (finalResponse.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers["Content-Range"] = finalResponse.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
// Stream body (skip for HEAD requests)
|
||||
if (HttpContext.Request.Method != "HEAD")
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var upstreamStream = await finalResponse.Content.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await upstreamStream.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// ffmpeg being killed at the end of a transcode, a player seeking or a
|
||||
// browser tab closing all hang up mid-file: that is how a proxied media
|
||||
// response normally ends. Letting it unwind would reach Jellyfin's
|
||||
// exception middleware with the response already on the wire, which can
|
||||
// only log a warning about it (#58).
|
||||
_logger.LogDebug("Client aborted the stream for {EventGuid}", eventGuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage?> SendUpstreamAsync(
|
||||
HttpMethod method,
|
||||
string url,
|
||||
RangeHeaderValue? range,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, url);
|
||||
if (range != null)
|
||||
{
|
||||
request.Headers.Range = range;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _proxyClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Upstream request to {Url} failed", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory cache with TTL expiration, a capacity bound and stampede protection.
|
||||
/// </summary>
|
||||
public class MemoryCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Default number of entries kept before the soonest-expiring ones are evicted.
|
||||
/// </summary>
|
||||
internal const int DefaultCapacity = 256;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly ConcurrentDictionary<string, Gate> _locks = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly int _capacity;
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MemoryCache"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Maximum number of entries to keep.</param>
|
||||
/// <param name="timeProvider">Clock used for expiry, for tests.</param>
|
||||
public MemoryCache(int capacity = DefaultCapacity, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_capacity = capacity;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
internal int Count => _cache.Count;
|
||||
|
||||
internal int LockCount => _locks.Count;
|
||||
|
||||
internal int Capacity => _capacity;
|
||||
|
||||
internal long Hits => Interlocked.Read(ref _hits);
|
||||
|
||||
internal long Misses => Interlocked.Read(ref _misses);
|
||||
|
||||
/// <summary>
|
||||
/// Counts the entries that would still be served. Expired entries are only dropped
|
||||
/// when something is written, so <see cref="Count"/> can outrun what is usable.
|
||||
/// </summary>
|
||||
internal int CountLive(Func<string, bool>? predicate = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return _cache.Count(entry =>
|
||||
entry.Value.ExpiresAt > now && (predicate is null || predicate(entry.Key)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a cached value.
|
||||
/// </summary>
|
||||
public async Task<T> GetOrCreateAsync<T>(string key, TimeSpan ttl, Func<CancellationToken, Task<T>> factory, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TryGet<T>(key, out var hit))
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
return hit;
|
||||
}
|
||||
|
||||
var gate = RentGate(key);
|
||||
try
|
||||
{
|
||||
await gate.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (TryGet<T>(key, out hit))
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
return hit;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _misses);
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
_cache[key] = new CacheEntry(value!, _timeProvider.GetUtcNow().Add(ttl));
|
||||
Evict();
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Semaphore.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnGate(key, gate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates a single cached entry.
|
||||
/// </summary>
|
||||
public void Remove(string key) => _cache.TryRemove(key, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all cached entries and releases the per-key locks that are idle.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
foreach (var pair in _locks)
|
||||
{
|
||||
TryDropGate(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGet<T>(string key, out T value)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
{
|
||||
value = (T)entry.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Evict()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var entry in _cache)
|
||||
{
|
||||
if (entry.Value.ExpiresAt <= now)
|
||||
{
|
||||
_cache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
while (_cache.Count > _capacity)
|
||||
{
|
||||
var oldest = _cache.OrderBy(e => e.Value.ExpiresAt).Select(e => e.Key).FirstOrDefault();
|
||||
if (oldest is null || !_cache.TryRemove(oldest, out _))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A gate lives only as long as someone is using it, so the lock dictionary stays
|
||||
// bounded by the number of in-flight calls instead of the number of keys ever seen.
|
||||
private Gate RentGate(string key)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var gate = _locks.GetOrAdd(key, _ => new Gate());
|
||||
lock (gate)
|
||||
{
|
||||
if (!gate.Dropped)
|
||||
{
|
||||
gate.Users++;
|
||||
return gate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReturnGate(string key, Gate gate)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
gate.Users--;
|
||||
DropIfIdle(key, gate);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDropGate(string key, Gate gate)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
DropIfIdle(key, gate);
|
||||
}
|
||||
}
|
||||
|
||||
private void DropIfIdle(string key, Gate gate)
|
||||
{
|
||||
if (gate.Users > 0 || gate.Dropped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
gate.Dropped = true;
|
||||
_locks.TryRemove(new KeyValuePair<string, Gate>(key, gate));
|
||||
gate.Semaphore.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(object Value, DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed class Gate
|
||||
{
|
||||
public SemaphoreSlim Semaphore { get; } = new(1, 1);
|
||||
|
||||
public int Users { get; set; }
|
||||
|
||||
public bool Dropped { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CCC API conference (e.g. 38C3, Camp 2019).
|
||||
/// </summary>
|
||||
public class CccConference
|
||||
{
|
||||
/// <summary>Gets or sets the acronym (e.g. "38c3").</summary>
|
||||
[JsonPropertyName("acronym")]
|
||||
public string Acronym { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the slug.</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the logo URL.</summary>
|
||||
[JsonPropertyName("logo_url")]
|
||||
public string? LogoUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the aspect ratio.</summary>
|
||||
[JsonPropertyName("aspect_ratio")]
|
||||
public string? AspectRatio { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the API URL.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the last released event date.</summary>
|
||||
[JsonPropertyName("event_last_released_at")]
|
||||
public DateTimeOffset? EventLastReleasedAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the events (only present in detail response).</summary>
|
||||
[JsonPropertyName("events")]
|
||||
public List<CccEvent>? Events { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for conference list response.
|
||||
/// </summary>
|
||||
public class CccConferencesResponse
|
||||
{
|
||||
/// <summary>Gets or sets the conferences.</summary>
|
||||
[JsonPropertyName("conferences")]
|
||||
public List<CccConference> Conferences { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CCC API event (a single talk/recording).
|
||||
/// </summary>
|
||||
public class CccEvent
|
||||
{
|
||||
/// <summary>Gets or sets the GUID.</summary>
|
||||
[JsonPropertyName("guid")]
|
||||
public string Guid { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the subtitle.</summary>
|
||||
[JsonPropertyName("subtitle")]
|
||||
public string? Subtitle { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the slug.</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the original language.</summary>
|
||||
[JsonPropertyName("original_language")]
|
||||
public string? OriginalLanguage { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the speakers.</summary>
|
||||
[JsonPropertyName("persons")]
|
||||
public List<string> Persons { get; set; } = new();
|
||||
|
||||
/// <summary>Gets or sets the tags.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
/// <summary>Gets or sets the view count.</summary>
|
||||
[JsonPropertyName("view_count")]
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the event date.</summary>
|
||||
[JsonPropertyName("date")]
|
||||
public DateTimeOffset? Date { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the release date.</summary>
|
||||
[JsonPropertyName("release_date")]
|
||||
public DateTimeOffset? ReleaseDate { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the duration in seconds.</summary>
|
||||
[JsonPropertyName("duration")]
|
||||
public int Duration { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the thumbnail URL.</summary>
|
||||
[JsonPropertyName("thumb_url")]
|
||||
public string? ThumbUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the poster URL.</summary>
|
||||
[JsonPropertyName("poster_url")]
|
||||
public string? PosterUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the frontend link.</summary>
|
||||
[JsonPropertyName("frontend_link")]
|
||||
public string? FrontendLink { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the API URL.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the conference title.</summary>
|
||||
[JsonPropertyName("conference_title")]
|
||||
public string? ConferenceTitle { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the conference API URL.</summary>
|
||||
[JsonPropertyName("conference_url")]
|
||||
public string? ConferenceUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the recordings (only in detail response).</summary>
|
||||
[JsonPropertyName("recordings")]
|
||||
public List<CccRecording>? Recordings { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the related events.</summary>
|
||||
[JsonPropertyName("related")]
|
||||
public List<CccRelatedEvent>? Related { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for event search response.
|
||||
/// </summary>
|
||||
public class CccEventsResponse
|
||||
{
|
||||
/// <summary>Gets or sets the events.</summary>
|
||||
[JsonPropertyName("events")]
|
||||
public List<CccEvent> Events { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A conference as streaming.media.ccc.de describes it while it is running. The shape
|
||||
/// follows the generator at voc/streaming-website (view/streams-json-v2.php); outside a
|
||||
/// congress the endpoint answers with an empty list.
|
||||
/// </summary>
|
||||
public class CccLiveConference
|
||||
{
|
||||
/// <summary>Gets or sets the conference title.</summary>
|
||||
[JsonPropertyName("conference")]
|
||||
public string Conference { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the acronym, e.g. "39c3".</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether anything is on air right now. The site sets
|
||||
/// it from the rooms' schedules, which is what decides whether the channel offers a
|
||||
/// live folder at all.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isCurrentlyStreaming")]
|
||||
public bool IsCurrentlyStreaming { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the room groups.</summary>
|
||||
[JsonPropertyName("groups")]
|
||||
public List<CccLiveGroup> Groups { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A named set of rooms, e.g. "Live" or "Selfhosted".</summary>
|
||||
public class CccLiveGroup
|
||||
{
|
||||
/// <summary>Gets or sets the group name.</summary>
|
||||
[JsonPropertyName("group")]
|
||||
public string Group { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the rooms.</summary>
|
||||
[JsonPropertyName("rooms")]
|
||||
public List<CccLiveRoom> Rooms { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One room with its stream variants and what is on in it.</summary>
|
||||
public class CccLiveRoom
|
||||
{
|
||||
/// <summary>Gets or sets the room slug, e.g. "halla".</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the display name, e.g. "Adams".</summary>
|
||||
[JsonPropertyName("display")]
|
||||
public string Display { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thumbnail url.</summary>
|
||||
[JsonPropertyName("thumb")]
|
||||
public string? Thumb { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the room's page on the streaming site.</summary>
|
||||
[JsonPropertyName("link")]
|
||||
public string? Link { get; set; }
|
||||
|
||||
/// <summary>Gets or sets what is running now and next.</summary>
|
||||
[JsonPropertyName("talks")]
|
||||
public CccLiveTalks? Talks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the stream variants.</summary>
|
||||
[JsonPropertyName("streams")]
|
||||
public List<CccLiveStreamVariant> Streams { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>The talk on air and the one after it; both are null between talks.</summary>
|
||||
public class CccLiveTalks
|
||||
{
|
||||
/// <summary>Gets or sets the talk currently on air.</summary>
|
||||
[JsonPropertyName("current")]
|
||||
public CccLiveTalk? Current { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the next talk.</summary>
|
||||
[JsonPropertyName("next")]
|
||||
public CccLiveTalk? Next { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A scheduled talk, as the streaming site reports it.</summary>
|
||||
public class CccLiveTalk
|
||||
{
|
||||
/// <summary>Gets or sets the title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the speakers, already joined by the site.</summary>
|
||||
[JsonPropertyName("speaker")]
|
||||
public string? Speaker { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One way to watch a room: a resolution, a language and a player type. "native" is the
|
||||
/// floor language, "translated" an interpreter's channel.
|
||||
/// </summary>
|
||||
public class CccLiveStreamVariant
|
||||
{
|
||||
/// <summary>Gets or sets the variant slug, e.g. "hd-native".</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the human readable description.</summary>
|
||||
[JsonPropertyName("display")]
|
||||
public string Display { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the player type: video, slides, audio, music or dash.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this is an interpreter's channel.</summary>
|
||||
[JsonPropertyName("isTranslated")]
|
||||
public bool IsTranslated { get; set; }
|
||||
|
||||
/// <summary>Gets or sets width and height, absent for audio.</summary>
|
||||
[JsonPropertyName("videoSize")]
|
||||
public List<int>? VideoSize { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the urls by protocol ("hls", "webm", "dash", "mp3", "opus").</summary>
|
||||
[JsonPropertyName("urls")]
|
||||
public Dictionary<string, CccLiveStreamUrl> Urls { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One protocol's url for a stream variant.</summary>
|
||||
public class CccLiveStreamUrl
|
||||
{
|
||||
/// <summary>Gets or sets the description the site shows for it.</summary>
|
||||
[JsonPropertyName("display")]
|
||||
public string? Display { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the technical description, e.g. "hls".</summary>
|
||||
[JsonPropertyName("tech")]
|
||||
public string? Tech { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the stream url.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CCC API recording (a specific video/audio file for an event).
|
||||
/// </summary>
|
||||
public class CccRecording
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in MB. Null on subtitle recordings, which the
|
||||
/// API publishes without size, length or dimensions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public int? Size { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the length in seconds; null on subtitle recordings.</summary>
|
||||
[JsonPropertyName("length")]
|
||||
public int? Length { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the MIME type.</summary>
|
||||
[JsonPropertyName("mime_type")]
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the language.</summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the filename.</summary>
|
||||
[JsonPropertyName("filename")]
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the state.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the folder (e.g. "h264-hd", "webm-sd").</summary>
|
||||
[JsonPropertyName("folder")]
|
||||
public string Folder { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets whether this is the high quality version.</summary>
|
||||
[JsonPropertyName("high_quality")]
|
||||
public bool HighQuality { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the width; null on subtitle recordings.</summary>
|
||||
[JsonPropertyName("width")]
|
||||
public int? Width { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the height; null on subtitle recordings.</summary>
|
||||
[JsonPropertyName("height")]
|
||||
public int? Height { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the direct recording URL.</summary>
|
||||
[JsonPropertyName("recording_url")]
|
||||
public string RecordingUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the API URL.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the updated timestamp.</summary>
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Related event reference.
|
||||
/// </summary>
|
||||
public class CccRelatedEvent
|
||||
{
|
||||
/// <summary>Gets or sets the related event GUID.</summary>
|
||||
[JsonPropertyName("event_guid")]
|
||||
public string EventGuid { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the relationship weight.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public int Weight { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Signs the proxy URLs the channel hands out, so the endpoint only serves
|
||||
/// recordings this plugin selected. Without a signature anyone who can reach
|
||||
/// the server could stream arbitrary CCC recordings through it.
|
||||
/// </summary>
|
||||
public static class ProxySignature
|
||||
{
|
||||
/// <summary>Query parameter carrying the signature.</summary>
|
||||
public const string QueryParameter = "t";
|
||||
|
||||
/// <summary>
|
||||
/// Secret for this server process. It is deliberately not persisted: media
|
||||
/// sources are handed out per playback, so a restart simply means the next
|
||||
/// request gets a freshly signed url, and nothing has to be stored or
|
||||
/// migrated.
|
||||
/// </summary>
|
||||
public static string Secret { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Signs one recording. The signature covers exactly the parameters the
|
||||
/// controller acts on, so none of them can be swapped afterwards.
|
||||
/// </summary>
|
||||
public static string Create(string eventGuid, string? recordingFolder, string? language, string? filename = null)
|
||||
{
|
||||
var payload = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{eventGuid}\n{recordingFolder ?? string.Empty}\n{language ?? string.Empty}\n{filename ?? string.Empty}");
|
||||
|
||||
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature in constant time.
|
||||
/// </summary>
|
||||
public static bool Verify(string eventGuid, string? recordingFolder, string? language, string? signature, string? filename = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = Create(eventGuid, recordingFolder, language, filename);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(signature));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Reads back a proxy url the channel handed out, so a diagnostic can check it and show
|
||||
/// it without handing the reader a url they could stream through.
|
||||
/// </summary>
|
||||
internal static class ProxyUrl
|
||||
{
|
||||
private const string FolderParameter = "recordingFolder";
|
||||
private const string LanguageParameter = "language";
|
||||
|
||||
/// <summary>
|
||||
/// Renders the url without its signature. The result is rebuilt from the parts that
|
||||
/// were understood rather than edited in place, so nothing of the signature can
|
||||
/// survive a url shaped differently than expected.
|
||||
/// </summary>
|
||||
internal static string Redact(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return "<unparseable proxy url>";
|
||||
}
|
||||
|
||||
var query = Parse(uri);
|
||||
var parts = new List<string>();
|
||||
if (query.TryGetValue(FolderParameter, out var folder))
|
||||
{
|
||||
parts.Add($"{FolderParameter}={folder}");
|
||||
}
|
||||
|
||||
if (query.TryGetValue(LanguageParameter, out var language))
|
||||
{
|
||||
parts.Add($"{LanguageParameter}={language}");
|
||||
}
|
||||
|
||||
parts.Add($"{ProxySignature.QueryParameter}=<redacted>");
|
||||
return $"{uri.GetLeftPart(UriPartial.Path)}?{string.Join('&', parts)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the url carries a signature the proxy endpoint would accept for exactly
|
||||
/// the recording the url names.
|
||||
/// </summary>
|
||||
internal static bool IsSignedCorrectly(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var guid = uri.Segments.LastOrDefault()?.TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(guid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var query = Parse(uri);
|
||||
query.TryGetValue(ProxySignature.QueryParameter, out var signature);
|
||||
return ProxySignature.Verify(
|
||||
Uri.UnescapeDataString(guid),
|
||||
query.GetValueOrDefault(FolderParameter),
|
||||
query.GetValueOrDefault(LanguageParameter),
|
||||
signature);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Parse(Uri uri)
|
||||
{
|
||||
var query = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var separator = pair.IndexOf('=', StringComparison.Ordinal);
|
||||
if (separator > 0)
|
||||
{
|
||||
query[pair[..separator]] = Uri.UnescapeDataString(pair[(separator + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// Turns the weighted recommendations media.ccc.de publishes for a talk into the
|
||||
/// "More like this" row every Jellyfin client already shows.
|
||||
///
|
||||
/// The plugin has always fetched them and could already list them behind a folder id
|
||||
/// of <c>related-<guid></c>, but nothing ever handed out such a folder, so the
|
||||
/// feature was unreachable (#72). Answering here instead of emitting folders is what
|
||||
/// keeps it from making the duplicate problem worse: a channel item is stored under
|
||||
/// exactly one parent, so every folder of related talks would add another library
|
||||
/// item for a talk that already exists (#54, #15). This adds none.
|
||||
/// </summary>
|
||||
public class ChaosflixSimilarItems : ILocalSimilarItemsProvider
|
||||
{
|
||||
/// <summary>How many related talks are offered when the caller names no limit.</summary>
|
||||
internal const int DefaultLimit = 12;
|
||||
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<ChaosflixSimilarItems> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixSimilarItems"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixSimilarItems(
|
||||
CccApiClient apiClient, ILibraryManager libraryManager, ILogger<ChaosflixSimilarItems> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => ChaosflixChannel.ChannelName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Gets how long Jellyfin may reuse an answer. The recommendations change when
|
||||
/// media.ccc.de recomputes them, which is far slower than people browse.
|
||||
/// </summary>
|
||||
public TimeSpan? CacheDuration => TimeSpan.FromHours(6);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Supports(Type itemType) => typeof(Video).IsAssignableFrom(itemType);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
|
||||
BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
|
||||
if (eventGuid == null)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (cccEvent?.Related == null || cccEvent.Related.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
var stored = StoredTalks(item.ChannelId);
|
||||
var excluded = query.ExcludeItemIds?.ToHashSet() ?? new HashSet<Guid>();
|
||||
|
||||
var similar = cccEvent.Related
|
||||
.OrderByDescending(r => r.Weight)
|
||||
.Select(r => stored.GetValueOrDefault(r.EventGuid))
|
||||
.OfType<BaseItem>()
|
||||
.Where(i => i.Id != item.Id && !excluded.Contains(i.Id))
|
||||
.Take(query.Limit ?? DefaultLimit)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Chaosflix: {Stored} of {Related} related talks of {Title} are in the library",
|
||||
similar.Count,
|
||||
cccEvent.Related.Count,
|
||||
item.Name);
|
||||
|
||||
return similar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The talks of this channel that Jellyfin has stored, by CCC event guid. Only a
|
||||
/// talk somebody has browsed to — or that the sync walked in (#8) — is in here at
|
||||
/// all; a recommendation pointing anywhere else is simply left out, because a
|
||||
/// similar-items provider can only answer with items that exist.
|
||||
/// </summary>
|
||||
private Dictionary<string, BaseItem> StoredTalks(Guid channelId)
|
||||
{
|
||||
var talks = new Dictionary<string, BaseItem>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var stored in _libraryManager.GetItemList(new InternalItemsQuery { ChannelIds = [channelId] }))
|
||||
{
|
||||
var guid = ChaosflixChannel.EventGuidOf(stored.ExternalId);
|
||||
if (guid == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A talk is its own library item in every folder that lists it, so the same
|
||||
// guid turns up several times. The conference copy is the canonical one.
|
||||
if (!talks.ContainsKey(guid) || ChaosflixChannel.IsConferenceCopy(stored.ExternalId))
|
||||
{
|
||||
talks[guid] = stored;
|
||||
}
|
||||
}
|
||||
|
||||
return talks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api;
|
||||
using Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Channels;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task that refreshes the CCC API cache for the conferences the user follows
|
||||
/// and then walks them once, so Jellyfin stores their talks as library items. Entries are
|
||||
/// replaced one at a time, so cached event details and resolved CDN redirects survive a run.
|
||||
///
|
||||
/// The walk is what makes a talk findable by name: Jellyfin creates a channel item the
|
||||
/// first time a client asks for the folder holding it and never before (measured — neither
|
||||
/// its own channel refresh nor a library scan descends into channel content), so without
|
||||
/// this a talk is only searchable once somebody has clicked their way to it (#8).
|
||||
/// </summary>
|
||||
public class ChaosflixSyncTask : IScheduledTask
|
||||
{
|
||||
/// <summary>
|
||||
/// Interval between runs. <see cref="CccApiClient.ConferenceCacheTtl"/> outlives it, so the
|
||||
/// data this task caches is still there when the next run becomes due.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan SyncInterval = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>
|
||||
/// Identifier the server files this task under; the status endpoint looks it up by it.
|
||||
/// </summary>
|
||||
public const string TaskKey = "ChaosflixSync";
|
||||
|
||||
/// <summary>
|
||||
/// How many conferences are refreshed and walked when the user has named none. The
|
||||
/// whole catalogue is several hundred conferences, and expanding every one of them
|
||||
/// four times a day would be a poor way to treat an API the CCC runs on donated time.
|
||||
/// Naming conferences in the settings lifts this: then exactly those are walked (#86).
|
||||
/// </summary>
|
||||
internal const int DefaultConferenceCount = 20;
|
||||
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly IChannelManager _channelManager;
|
||||
private readonly ILogger<ChaosflixSyncTask> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixSyncTask"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixSyncTask(
|
||||
CccApiClient apiClient, IChannelManager channelManager, ILogger<ChaosflixSyncTask> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_channelManager = channelManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Chaosflix: Sync CCC Media";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => TaskKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Refreshes the conference and talk cache from media.ccc.de";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "Chaosflix";
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||
IntervalTicks = SyncInterval.Ticks
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Chaosflix sync: starting cache refresh");
|
||||
progress.Report(0);
|
||||
|
||||
var selected = await RefreshConferencesAsync(progress, cancellationToken).ConfigureAwait(false);
|
||||
await IngestAsync(selected, progress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private async Task<List<CccConference>> RefreshConferencesAsync(
|
||||
IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var all = await _apiClient.RefreshConferencesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Chaosflix sync: found {Count} conferences", all.Count);
|
||||
progress.Report(5);
|
||||
|
||||
var filter = Plugin.Instance?.Configuration.ConferenceFilter;
|
||||
var selected = ChaosflixChannel.SelectConferences(all, filter)
|
||||
.Where(c => c.EventLastReleasedAt != null)
|
||||
.OrderByDescending(c => c.EventLastReleasedAt)
|
||||
.ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
selected = selected.Take(DefaultConferenceCount).ToList();
|
||||
}
|
||||
|
||||
var refreshed = 0;
|
||||
for (var i = 0; i < selected.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var conf = selected[i];
|
||||
try
|
||||
{
|
||||
var detail = await _apiClient.RefreshConferenceAsync(conf.Acronym, cancellationToken).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
_logger.LogDebug("Chaosflix sync: cached {Title} ({EventCount} events)",
|
||||
conf.Title, detail?.Events?.Count ?? 0);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Chaosflix sync: failed to fetch {Acronym}", conf.Acronym);
|
||||
}
|
||||
|
||||
progress.Report(5 + (55.0 * (i + 1) / selected.Count));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Chaosflix sync: completed — {Count} of {Total} conferences cached",
|
||||
refreshed, selected.Count);
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks Jellyfin for the children of every selected conference folder, which is what
|
||||
/// makes it store their talks. The walk goes top down because a folder can only be
|
||||
/// addressed by the item id Jellyfin gave it, and that id does not exist until its
|
||||
/// parent has been listed once.
|
||||
/// </summary>
|
||||
private async Task IngestAsync(
|
||||
List<CccConference> conferences, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (conferences.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = await _channelManager.GetChannelsInternalAsync(new ChannelQuery()).ConfigureAwait(false);
|
||||
var channel = channels.Items
|
||||
.FirstOrDefault(c => string.Equals(c.Name, ChaosflixChannel.ChannelName, StringComparison.Ordinal));
|
||||
|
||||
if (channel == null)
|
||||
{
|
||||
_logger.LogDebug("Chaosflix sync: the channel is not registered yet, nothing to ingest");
|
||||
return;
|
||||
}
|
||||
|
||||
var wanted = conferences
|
||||
.Select(c => ChaosflixChannel.ConferenceFolderId(c.Acronym))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var root = await ChildrenAsync(channel.Id, Guid.Empty, cancellationToken).ConfigureAwait(false);
|
||||
var years = root.FirstOrDefault(i =>
|
||||
string.Equals(i.ExternalId, ChaosflixChannel.FolderBrowseByYear, StringComparison.Ordinal));
|
||||
|
||||
if (years == null)
|
||||
{
|
||||
_logger.LogWarning("Chaosflix sync: the channel root has no year folder, skipping the walk");
|
||||
return;
|
||||
}
|
||||
|
||||
var yearFolders = await ChildrenAsync(channel.Id, years.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var walked = 0;
|
||||
foreach (var yearFolder in yearFolders)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var conferenceFolders = await ChildrenAsync(channel.Id, yearFolder.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var folder in conferenceFolders.Where(f => wanted.Contains(f.ExternalId ?? string.Empty)))
|
||||
{
|
||||
var talks = await ChildrenAsync(channel.Id, folder.Id, cancellationToken).ConfigureAwait(false);
|
||||
walked++;
|
||||
_logger.LogDebug("Chaosflix sync: stored {Count} talks of {Conference}",
|
||||
talks.Count, folder.Name);
|
||||
|
||||
progress.Report(60 + (40.0 * walked / conferences.Count));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Chaosflix sync: walked {Count} of {Total} conferences into the library",
|
||||
walked, conferences.Count);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BaseItem>> ChildrenAsync(
|
||||
Guid channelId, Guid parentId, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
ChannelIds = [channelId],
|
||||
ParentId = parentId
|
||||
};
|
||||
|
||||
var result = await _channelManager
|
||||
.GetChannelItemsInternal(query, new Progress<double>(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result.Items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Channel;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the watch state of one talk in sync across the copies the channel
|
||||
/// hands out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Jellyfin stores a channel item as one library item with exactly one parent,
|
||||
/// so a talk listed in several folders needs one id per folder (#15) and ends up
|
||||
/// as several library items — each with its own user data. Marking a talk
|
||||
/// unwatched or clearing its position then only affects the copy in front of the
|
||||
/// user (#53). This mirrors played state and resume position from the copy that
|
||||
/// was saved to its siblings.
|
||||
/// </remarks>
|
||||
public sealed class ChaosflixUserDataMirror : IHostedService
|
||||
{
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<ChaosflixUserDataMirror> _logger;
|
||||
|
||||
// Jellyfin raises UserDataSaved from inside SaveUserData, so a mirrored save
|
||||
// comes back as another event; the copies it is about are in here while it runs.
|
||||
private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixUserDataMirror"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userDataManager">Source of the save events and sink for the mirrored state.</param>
|
||||
/// <param name="libraryManager">Used to find the other copies of a talk.</param>
|
||||
/// <param name="userManager">Resolves the user a save belongs to.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ChaosflixUserDataMirror(
|
||||
IUserDataManager userDataManager,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
ILogger<ChaosflixUserDataMirror> logger)
|
||||
{
|
||||
_userDataManager = userDataManager;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved += OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_userDataManager.UserDataSaved -= OnUserDataSaved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
Mirror(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not mirror the watch state of {Item}", e.Item?.Name);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Mirror(UserDataSaveEventArgs e)
|
||||
{
|
||||
var item = e.Item;
|
||||
var source = e.UserData;
|
||||
if (item is null || source is null || !IsWatchState(e.SaveReason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
|
||||
if (eventGuid is null || _mirroring.ContainsKey((e.UserId, item.Id)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(e.UserId);
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var sibling in Siblings(item, eventGuid))
|
||||
{
|
||||
var target = _userDataManager.GetUserData(user, sibling);
|
||||
if (target is null || !Differs(source, target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
target.Played = source.Played;
|
||||
target.PlaybackPositionTicks = source.PlaybackPositionTicks;
|
||||
target.PlayCount = source.PlayCount;
|
||||
target.LastPlayedDate = source.LastPlayedDate;
|
||||
|
||||
if (!_mirroring.TryAdd((e.UserId, sibling.Id), 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_userDataManager.SaveUserData(user, sibling, target, UserDataSaveReason.UpdateUserData, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_mirroring.TryRemove((e.UserId, sibling.Id), out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import is a bulk reason and carries no user action; the rest is what a
|
||||
// client does when it plays a talk or toggles its watched state.
|
||||
private static bool IsWatchState(UserDataSaveReason reason) => reason is
|
||||
UserDataSaveReason.PlaybackStart or
|
||||
UserDataSaveReason.PlaybackProgress or
|
||||
UserDataSaveReason.PlaybackFinished or
|
||||
UserDataSaveReason.TogglePlayed or
|
||||
UserDataSaveReason.UpdateUserData;
|
||||
|
||||
// A save whose state already matches produces no save of its own, which is
|
||||
// what stops a mirrored save from travelling back and forth.
|
||||
private static bool Differs(UserItemData source, UserItemData target) =>
|
||||
source.Played != target.Played ||
|
||||
source.PlaybackPositionTicks != target.PlaybackPositionTicks ||
|
||||
source.PlayCount != target.PlayCount ||
|
||||
source.LastPlayedDate != target.LastPlayedDate;
|
||||
|
||||
private IEnumerable<BaseItem> Siblings(BaseItem item, string eventGuid)
|
||||
{
|
||||
// Looked up by name within the channel rather than by scanning every
|
||||
// channel item: the scopes a talk can appear under are open-ended
|
||||
// (related-<guid> exists per talk), so its other ids cannot be enumerated.
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
Name = item.Name,
|
||||
ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty<Guid>() : new[] { item.ChannelId }
|
||||
};
|
||||
|
||||
IReadOnlyList<BaseItem> candidates;
|
||||
try
|
||||
{
|
||||
candidates = _libraryManager.GetItemList(query);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not look up the other copies of {Item}", item.Name);
|
||||
return Array.Empty<BaseItem>();
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Where(candidate =>
|
||||
!candidate.Id.Equals(item.Id) &&
|
||||
string.Equals(ChaosflixChannel.EventGuidOf(candidate.ExternalId), eventGuid, StringComparison.Ordinal))
|
||||
// Through the library cache, not as the query materialised them: user
|
||||
// data is read off the BaseItem instance the cache holds, so saving
|
||||
// against a fresh instance reaches the database but leaves what the
|
||||
// server serves stale until a restart.
|
||||
.Select(candidate => _libraryManager.GetItemById(candidate.Id) ?? candidate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Net.Http;
|
||||
using Jellyfin.Plugin.Chaosflix.Api;
|
||||
using Jellyfin.Plugin.Chaosflix.Channel;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix;
|
||||
|
||||
/// <summary>
|
||||
/// Registers plugin services in the DI container.
|
||||
/// </summary>
|
||||
public class ChaosflixServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
// Redirect resolution needs the 302 itself, not the mirror behind it.
|
||||
serviceCollection
|
||||
.AddHttpClient(CccApiClient.RedirectClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
|
||||
serviceCollection.AddSingleton<CccApiClient>();
|
||||
|
||||
// The status endpoint reports this channel's caches, so it has to reach the very
|
||||
// instance Jellyfin serves from — registering the class twice would give it a
|
||||
// second one with empty caches.
|
||||
serviceCollection.AddSingleton<ChaosflixChannel>();
|
||||
serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<ChaosflixChannel>());
|
||||
// Jellyfin collects the similar-items providers out of the container, the same
|
||||
// way it collects channels.
|
||||
serviceCollection.AddSingleton<ISimilarItemsProvider, ChaosflixSimilarItems>();
|
||||
serviceCollection.AddHostedService<ChaosflixUserDataMirror>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred video quality.
|
||||
/// </summary>
|
||||
public VideoQuality PreferredQuality { get; set; } = VideoQuality.High;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred language (ISO 639 code, e.g. "deu", "eng").
|
||||
/// Empty means original language.
|
||||
/// </summary>
|
||||
public string PreferredLanguage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred video format.
|
||||
/// </summary>
|
||||
public VideoFormat PreferredFormat { get; set; } = VideoFormat.Mp4;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether a talk starts on a version every client
|
||||
/// can stream without a re-encode, leaving <see cref="PreferredFormat"/> to order
|
||||
/// the versions behind it.
|
||||
/// </summary>
|
||||
public bool CompatibleDefaultVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the conferences to show, separated by commas or newlines. An entry
|
||||
/// is either an acronym ("38c3") or a series as media.ccc.de files it in the slug
|
||||
/// ("congress", "gpn"), which also covers the editions not published yet.
|
||||
/// Empty means every conference, which is what the plugin did before this existed.
|
||||
/// </summary>
|
||||
public string ConferenceFilter { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the streaming endpoint that reports which rooms are on air.
|
||||
/// Empty means the public one at streaming.media.ccc.de.
|
||||
/// </summary>
|
||||
public string StreamingBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the CCC API endpoint. Empty means the public API at media.ccc.de;
|
||||
/// set it to use a mirror or, in the e2e tests, a local stand-in.
|
||||
/// </summary>
|
||||
public string ApiBaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Video quality preference.
|
||||
/// </summary>
|
||||
public enum VideoQuality
|
||||
{
|
||||
/// <summary>High quality (1080p).</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Standard quality (576p).</summary>
|
||||
Standard
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Video format preference.
|
||||
/// </summary>
|
||||
public enum VideoFormat
|
||||
{
|
||||
/// <summary>H.264 MP4.</summary>
|
||||
Mp4,
|
||||
|
||||
/// <summary>WebM/VP9.</summary>
|
||||
WebM
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Chaosflix</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ChaosflixConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-select,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="ChaosflixConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Chaosflix Settings</h2>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredQuality">Preferred Quality</label>
|
||||
<select is="emby-select" id="PreferredQuality" class="emby-select-withcolor">
|
||||
<option value="High">High (1080p)</option>
|
||||
<option value="Standard">Standard (576p)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredFormat">Preferred Format</label>
|
||||
<select is="emby-select" id="PreferredFormat" class="emby-select-withcolor">
|
||||
<option value="Mp4">MP4 (H.264)</option>
|
||||
<option value="WebM">WebM (VP9)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">WebM costs a full re-encode on clients that can only transcode to H.264 — the Android app is one of them.</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="CompatibleDefaultVersion" />
|
||||
<span>Start on a version every client can play untouched</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Jellyfin hands every client the same list of versions, so the plugin cannot order it per device. With this on a talk starts on its H.264 MP4, which no client has to re-encode, and the preferred format only orders the versions behind it. Leave it off if your clients decode VP9 but not H.264.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredLanguage">Preferred Language</label>
|
||||
<select is="emby-select" id="PreferredLanguage" class="emby-select-withcolor">
|
||||
<option value="">Original</option>
|
||||
<option value="deu">Deutsch</option>
|
||||
<option value="eng">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="ConferenceFilter">Conferences</label>
|
||||
<input is="emby-input" type="text" id="ConferenceFilter" placeholder="congress, gpn, 38c3" />
|
||||
<div class="fieldDescription">Leave empty for every conference media.ccc.de publishes. Otherwise list what you follow, separated by commas: a series as it appears in the media.ccc.de address ("congress", "gpn", "easterhegg") keeps every edition including future ones, an acronym ("38c3") keeps exactly that one. This also narrows Popular and Recommended.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="StreamingBaseUrl">Streaming Endpoint</label>
|
||||
<input is="emby-input" type="text" id="StreamingBaseUrl" placeholder="https://streaming.media.ccc.de" />
|
||||
<div class="fieldDescription">Where the plugin asks which rooms are on air. Leave empty for streaming.media.ccc.de. A "🔴 Live now" folder appears while a congress is running and disappears again afterwards.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="ApiBaseUrl">API Endpoint</label>
|
||||
<input is="emby-input" type="text" id="ApiBaseUrl" placeholder="https://api.media.ccc.de/public" />
|
||||
<div class="fieldDescription">Leave empty for media.ccc.de. Set it to use a mirror.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Status</h2>
|
||||
<div class="fieldDescription">What the plugin thinks is true right now. Nothing here talks to media.ccc.de until you ask it to.</div>
|
||||
|
||||
<div id="ChaosflixStatus" class="paperList" style="padding: 0.5em 1em;">Loading…</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="ChaosflixRefresh" class="raised emby-button">
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="ChaosflixCheckApi" class="raised emby-button">
|
||||
<span>Check endpoint now</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="ChaosflixClearCaches" class="raised emby-button">
|
||||
<span>Clear caches</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ChaosflixApiCheck" class="fieldDescription"></div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Check a talk</h2>
|
||||
<div class="fieldDescription">Resolves one talk the way playback does — API lookup, recording choice, signed proxy url, proxy request, probe — and names the stage that fails.</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ChaosflixTalkId">Talk guid, slug or media.ccc.de url</label>
|
||||
<input is="emby-input" type="text" id="ChaosflixTalkId" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="ChaosflixCheckTalk" class="raised emby-button">
|
||||
<span>Check this talk</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ChaosflixTalkCheck" class="fieldDescription"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var ChaosflixConfig = {
|
||||
pluginUniqueId: 'c4a05f11-4ccc-4b00-bdea-dbeef1337000',
|
||||
statusEndpoint: 'api/ChaosflixStatus'
|
||||
};
|
||||
|
||||
(function () {
|
||||
var page = document.querySelector('#ChaosflixConfigPage');
|
||||
|
||||
function statusUrl(action) {
|
||||
return ApiClient.getUrl(ChaosflixConfig.statusEndpoint + (action ? '/' + action : ''));
|
||||
}
|
||||
|
||||
function post(action, query) {
|
||||
return ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: statusUrl(action) + (query || ''),
|
||||
dataType: 'json'
|
||||
});
|
||||
}
|
||||
|
||||
// Talk titles and error messages come from media.ccc.de, so every value
|
||||
// is written as text and never as markup.
|
||||
function row(container, label, value) {
|
||||
var line = document.createElement('div');
|
||||
var name = document.createElement('strong');
|
||||
name.textContent = label + ': ';
|
||||
line.appendChild(name);
|
||||
line.appendChild(document.createTextNode(value));
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
function percent(value) {
|
||||
return value === null || value === undefined ? 'n/a' : value + '%';
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
var target = page.querySelector('#ChaosflixStatus');
|
||||
target.textContent = '';
|
||||
|
||||
row(target, 'Plugin version', status.Version);
|
||||
row(target, 'Endpoint', status.ApiBaseUrl + (status.ApiBaseUrlIsDefault ? ' (default)' : ' (configured)'));
|
||||
|
||||
var sync = status.Sync;
|
||||
if (!sync.Registered) {
|
||||
row(target, 'Sync task', 'not registered with this server');
|
||||
} else if (!sync.LastRunUtc) {
|
||||
row(target, 'Sync task', sync.State + ', has not run yet');
|
||||
} else {
|
||||
row(target, 'Sync task', sync.LastResult + ' — ' + new Date(sync.LastRunUtc).toLocaleString() +
|
||||
', took ' + sync.DurationSeconds + 's (now ' + sync.State + ')');
|
||||
if (sync.Error) {
|
||||
row(target, 'Sync error', sync.Error);
|
||||
}
|
||||
}
|
||||
|
||||
var cache = status.ApiCache;
|
||||
row(target, 'API cache', cache.Entries + '/' + cache.Capacity + ' entries — ' +
|
||||
cache.Conferences + ' conferences, ' + cache.Events + ' talks, ' +
|
||||
cache.Searches + ' searches, ' + cache.Redirects + ' CDN mirrors');
|
||||
row(target, 'API cache hit rate', percent(cache.HitRatePercent) +
|
||||
' (' + cache.Hits + ' hits, ' + cache.Misses + ' misses since server start)');
|
||||
|
||||
var probe = status.ProbeCache;
|
||||
row(target, 'Probe cache', probe.Entries + '/' + probe.Capacity + ' stream layouts, hit rate ' +
|
||||
percent(probe.HitRatePercent) + ' (' + probe.Hits + ' hits, ' + probe.Misses + ' misses)');
|
||||
row(target, 'Media sources handed out', probe.ServedItems +
|
||||
' talks a configuration change or a cache clear would invalidate');
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
return ApiClient.getJSON(statusUrl()).then(renderStatus, function () {
|
||||
page.querySelector('#ChaosflixStatus').textContent = 'The status endpoint did not answer.';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTalkCheck(result) {
|
||||
var target = page.querySelector('#ChaosflixTalkCheck');
|
||||
target.textContent = '';
|
||||
|
||||
row(target, result.Ok ? 'Result' : 'Failed at',
|
||||
result.Ok ? 'resolved end to end' : result.FailedStage);
|
||||
if (result.Title) {
|
||||
row(target, 'Talk', result.Title);
|
||||
}
|
||||
|
||||
result.Stages.forEach(function (stage) {
|
||||
row(target, (stage.Ok ? '✓ ' : '✗ ') + stage.Name,
|
||||
stage.Detail + (stage.ElapsedMs ? ' (' + stage.ElapsedMs + ' ms)' : ''));
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(ChaosflixConfig.pluginUniqueId).then(function (config) {
|
||||
page.querySelector('#PreferredQuality').value = config.PreferredQuality || 'High';
|
||||
page.querySelector('#PreferredFormat').value = config.PreferredFormat || 'Mp4';
|
||||
page.querySelector('#PreferredLanguage').value = config.PreferredLanguage || '';
|
||||
page.querySelector('#CompatibleDefaultVersion').checked = config.CompatibleDefaultVersion === true;
|
||||
page.querySelector('#ConferenceFilter').value = config.ConferenceFilter || '';
|
||||
page.querySelector('#StreamingBaseUrl').value = config.StreamingBaseUrl || '';
|
||||
page.querySelector('#ApiBaseUrl').value = config.ApiBaseUrl || '';
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
||||
// Cheap by construction: the endpoint only reads counters the plugin
|
||||
// already keeps. The checks below are the ones that cost something,
|
||||
// and they run on click.
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixRefresh').addEventListener('click', loadStatus);
|
||||
|
||||
page.querySelector('#ChaosflixCheckApi').addEventListener('click', function () {
|
||||
var target = page.querySelector('#ChaosflixApiCheck');
|
||||
target.textContent = 'Asking the endpoint…';
|
||||
post('CheckApi').then(function (result) {
|
||||
target.textContent = '';
|
||||
row(target, result.Ok ? 'Reachable' : 'Unreachable', result.Url);
|
||||
row(target, 'Round trip', result.ElapsedMs + ' ms');
|
||||
if (result.Ok) {
|
||||
row(target, 'Answer', result.StatusCode + ', ' + result.ConferenceCount + ' conferences listed');
|
||||
} else {
|
||||
row(target, 'Error', (result.StatusCode ? result.StatusCode + ' ' : '') + (result.Error || 'no answer'));
|
||||
}
|
||||
}, function () {
|
||||
target.textContent = 'The check could not be run.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixClearCaches').addEventListener('click', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
post('ClearCaches').then(function (status) {
|
||||
renderStatus(status);
|
||||
Dashboard.hideLoadingMsg();
|
||||
}, function () {
|
||||
Dashboard.hideLoadingMsg();
|
||||
page.querySelector('#ChaosflixStatus').textContent = 'Clearing the caches failed.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixCheckTalk').addEventListener('click', function () {
|
||||
var id = page.querySelector('#ChaosflixTalkId').value.trim();
|
||||
var target = page.querySelector('#ChaosflixTalkCheck');
|
||||
if (!id) {
|
||||
target.textContent = 'Enter a talk guid, slug or media.ccc.de url first.';
|
||||
return;
|
||||
}
|
||||
|
||||
target.textContent = 'Resolving ' + id + '…';
|
||||
post('CheckTalk', '?id=' + encodeURIComponent(id)).then(renderTalkCheck, function () {
|
||||
target.textContent = 'The check could not be run.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixConfigForm').addEventListener('submit', function (e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(ChaosflixConfig.pluginUniqueId).then(function (config) {
|
||||
config.PreferredQuality = page.querySelector('#PreferredQuality').value;
|
||||
config.PreferredFormat = page.querySelector('#PreferredFormat').value;
|
||||
config.PreferredLanguage = page.querySelector('#PreferredLanguage').value;
|
||||
config.CompatibleDefaultVersion = page.querySelector('#CompatibleDefaultVersion').checked;
|
||||
config.ConferenceFilter = page.querySelector('#ConferenceFilter').value;
|
||||
config.StreamingBaseUrl = page.querySelector('#StreamingBaseUrl').value;
|
||||
config.ApiBaseUrl = page.querySelector('#ApiBaseUrl').value;
|
||||
ApiClient.updatePluginConfiguration(ChaosflixConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
loadStatus();
|
||||
});
|
||||
});
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Chaosflix</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Built against the oldest supported server (targetAbi in meta.json), so the
|
||||
compiler rejects APIs that a server on that version would not have.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="12.1.0">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="12.1.0">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Jellyfin.Plugin.Chaosflix.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Chaosflix.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix;
|
||||
|
||||
/// <summary>
|
||||
/// Chaosflix plugin — streams CCC media content into Jellyfin.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Chaosflix";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("c4a05f11-4ccc-4b00-bdea-dbeef1337000");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Browse and stream Chaos Computer Club conference talks directly in Jellyfin.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.configPage.html",
|
||||
GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"guid": "c4a05f11-4ccc-4b00-bdea-dbeef1337000",
|
||||
"name": "Chaosflix",
|
||||
"description": "Browse and stream Chaos Computer Club conference talks from media.ccc.de",
|
||||
"overview": "CCC Media Channel for Jellyfin",
|
||||
"owner": "praetorianer777",
|
||||
"category": "Channels",
|
||||
"version": "0.4.0.0",
|
||||
"targetAbi": "12.1.0.0",
|
||||
"status": "Active",
|
||||
"autoUpdate": true,
|
||||
"imageUrl": "https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/assets/logo.svg"
|
||||
}
|
||||
21
doc/research/reference-sources/chaosflix/LICENSE
Normal file
21
doc/research/reference-sources/chaosflix/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Steve Berthold
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
420
doc/research/reference-sources/chaosflix/README.md
Normal file
420
doc/research/reference-sources/chaosflix/README.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# 📺 Chaosflix — CCC Media Plugin for Jellyfin
|
||||
|
||||
[](https://github.com/praetorianer777/chaosflix-jellyfin/actions/workflows/ci.yml)
|
||||
|
||||
Browse and stream **Chaos Computer Club** conference talks directly in Jellyfin.
|
||||
|
||||
All content is sourced from [media.ccc.de](https://media.ccc.de) via their public API — no downloads, no local storage needed.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 **Stream talks** from the CCC CDN through the server — no downloads, no local storage
|
||||
- 🔴 **Live now** — while a congress is running, the rooms on air with what is
|
||||
playing in them; the folder is absent the rest of the year
|
||||
- 🔥 **Popular Talks** — most viewed talks across conferences
|
||||
- ⭐ **Recommended** — trending talks ranked by views and recency
|
||||
- 📅 **Browse by Year** — conferences grouped by year (2024 → 38C3, Camp…)
|
||||
- 📂 **Browse by Conference** — 38C3, Camp 2019, FOSSGIS, and hundreds more
|
||||
- 🔗 **Related Talks** — CCC's weighted recommendations fill the "More like this"
|
||||
row of any client, for the talks your library holds
|
||||
- 🏷️ **Tags as Genres** — filter by topic (security, ethics, hardware…)
|
||||
- 👤 **Browse by speaker** — every talk a person has given, across all conferences
|
||||
- 🕐 **Watch history & resume** — powered by Jellyfin (per-user, cross-device)
|
||||
- 👥 **SyncPlay** — watch together with multiple users
|
||||
- 🆕 **Latest talks** — newly released recordings on your home screen
|
||||
- 🔄 **Scheduled sync** — background task keeps cache fresh (every 6h)
|
||||
- ⚙️ **Quality & format preferences** — HD/SD, MP4/WebM, language selection
|
||||
- 🎚️ **Every recording selectable** — pick quality or language per playback from the player's version list
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Jellyfin Plugin Repository (recommended)
|
||||
|
||||
1. Open Jellyfin **Dashboard → Plugins → Repositories**
|
||||
2. Click **Add** and enter:
|
||||
- **Name:** `Chaosflix`
|
||||
- **URL:** `https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/manifest.json`
|
||||
3. Go to **Catalog → Channels** and install **Chaosflix**
|
||||
4. Restart Jellyfin
|
||||
5. The **Chaosflix** channel appears under **Home → My Media → Channels**
|
||||
|
||||
### Option 2: Manual Installation
|
||||
|
||||
1. Build the plugin (see [Building](#building) below)
|
||||
2. Create the plugin directory and copy files:
|
||||
```bash
|
||||
# Linux (standalone)
|
||||
mkdir -p ~/.local/share/jellyfin/plugins/Chaosflix
|
||||
cp artifacts/Jellyfin.Plugin.Chaosflix.dll artifacts/meta.json \
|
||||
~/.local/share/jellyfin/plugins/Chaosflix/
|
||||
|
||||
# Docker
|
||||
mkdir -p /path/to/jellyfin-config/plugins/Chaosflix
|
||||
cp artifacts/Jellyfin.Plugin.Chaosflix.dll artifacts/meta.json \
|
||||
/path/to/jellyfin-config/plugins/Chaosflix/
|
||||
```
|
||||
3. Restart Jellyfin
|
||||
4. The **Chaosflix** channel appears under **Home → My Media → Channels**
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**The channel is missing for one user.** Jellyfin decides per user which channels
|
||||
exist at all: Dashboard → Users → _user_ → Access → **Channel Access**. When
|
||||
Chaosflix is not ticked the server leaves it out of that user's views entirely,
|
||||
so clients show nothing rather than an error. New users get access to all
|
||||
channels by default, so this only bites where an admin once picked channels by
|
||||
hand — a plugin installed afterwards is not added to that list.
|
||||
|
||||
**The channel is missing on one device but visible elsewhere.** The Android TV
|
||||
app builds its library row once and registers no refresh trigger, so a client
|
||||
that has been running since before the plugin was installed keeps its old set of
|
||||
libraries. Force-stop the app (Settings → Apps → Jellyfin → Force stop) and
|
||||
reopen it; returning to the launcher is not enough.
|
||||
|
||||
**A talk does not play.** Dashboard → Plugins → Chaosflix shows what the plugin
|
||||
believes: whether media.ccc.de answers, what is cached, and a **check a talk**
|
||||
box that resolves one talk end to end — API lookup, recording choice, signed
|
||||
proxy url, proxy reachability and stream probe — and names the stage that fails.
|
||||
|
||||
## Channel Structure
|
||||
|
||||
```
|
||||
Chaosflix
|
||||
├── 🔴 Live now ← only while a congress is on air
|
||||
│ ├── Saal 1: current talk ▶️
|
||||
│ └── Saal ZIGZAG: … ▶️
|
||||
├── 🔥 Popular Talks ← Top 50 by view count
|
||||
├── ⭐ Recommended ← Trending (views × recency)
|
||||
└── 📅 Browse by Year
|
||||
├── 2024
|
||||
│ ├── 38C3: Illegal Instructions
|
||||
│ │ ├── Talk 1 ▶️
|
||||
│ │ ├── Talk 2 ▶️
|
||||
│ │ └── ...
|
||||
│ ├── FOSSGIS 2024
|
||||
│ └── ...
|
||||
├── 2023
|
||||
│ ├── 37C3: Unlocked
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Speakers and topics
|
||||
|
||||
Talks are not only reachable through the folders. Every talk carries its
|
||||
speakers and its tags, and Jellyfin indexes both, so two more ways in exist
|
||||
without the channel offering a folder for them.
|
||||
|
||||
**By speaker** works in any client: open a talk, click a name in its cast list,
|
||||
and you get that person's other talks — across every conference your library
|
||||
holds, not just the one you came from. Over the API that is
|
||||
`/Persons` for the list and `/Items?personIds=<id>&recursive=true` for one
|
||||
person's talks.
|
||||
|
||||
**By topic** works as a filter, but there is no list to pick from:
|
||||
`/Items?genres=Security` returns the right talks, while Jellyfin's genre
|
||||
browse stays empty for them — see Known Limitations.
|
||||
|
||||
Neither costs anything: they use the talks already in the library rather than
|
||||
listing them a second time, which a channel folder would (see Known
|
||||
Limitations). Which talks are in the library is decided by the **Conferences**
|
||||
setting below.
|
||||
|
||||
## Configuration
|
||||
|
||||
Go to **Dashboard → Plugins → Chaosflix** to set:
|
||||
|
||||
| Setting | Options | Default |
|
||||
|---------|---------|---------|
|
||||
| Preferred Quality | HD (1080p) / SD (576p) | HD |
|
||||
| Preferred Format | MP4 (H.264) / WebM (VP9) | MP4 |
|
||||
| Preferred Language | Original / Deutsch / English | Original |
|
||||
| Start on a version every client can play untouched | on / off | off |
|
||||
| Conferences | comma-separated list | empty — every conference |
|
||||
| Streaming Endpoint | url | empty — streaming.media.ccc.de |
|
||||
|
||||
**Conferences** narrows the whole channel to what you actually follow.
|
||||
media.ccc.de publishes hundreds of them, and Browse by Year lists every one.
|
||||
An entry is either a series as it appears in a media.ccc.de address —
|
||||
`congress` for `media.ccc.de/c/congress/2025`, and likewise `gpn`,
|
||||
`easterhegg` — which keeps every edition including the ones not announced yet,
|
||||
or a single acronym like `38c3`. The filter also applies to Popular,
|
||||
Recommended and the latest row, so they are drawn from the same conferences as
|
||||
the folders, and the plugin stops fetching the details of everything else.
|
||||
Left empty, nothing is filtered.
|
||||
|
||||
**Streaming Endpoint** is where the plugin asks which rooms are on air, which
|
||||
is what fills 🔴 Live now. Left empty it uses the public one at
|
||||
`streaming.media.ccc.de`; point it elsewhere for a mirror, or at a local
|
||||
stand-in when testing. Outside a congress the endpoint reports nothing and the
|
||||
folder is not shown at all.
|
||||
|
||||
Naming your conferences also makes their talks **findable by name**. Jellyfin
|
||||
creates a channel item the first time something asks for the folder holding it
|
||||
and at no other time, so a talk nobody has browsed to cannot be searched for.
|
||||
The scheduled sync walks the conferences you named once per run, which puts
|
||||
them in the library and therefore in Jellyfin's search. With no conferences
|
||||
named it walks the 20 most recent ones instead — expanding all of media.ccc.de
|
||||
four times a day would be a poor way to treat an API the CCC runs on donated
|
||||
time.
|
||||
|
||||
Every recording of a talk is offered as its own version ("HD MP4 · Deutsch",
|
||||
"SD WebM · English") in the player's version selector; these settings decide
|
||||
which of them is the default. A viewer can pick another one per playback and
|
||||
per device without changing anything here.
|
||||
|
||||
The order is the same for every client: Jellyfin builds a talk's version list
|
||||
once and hands it to whoever asks next, so the plugin cannot sort it per
|
||||
device. That matters for WebM, because a client whose only transcoding video
|
||||
codec is H.264 — the Android app — has to re-encode VP9/Opus for the whole
|
||||
talk. Preferring MP4, or the setting above, keeps the default version one that
|
||||
every client streams untouched while WebM stays selectable.
|
||||
|
||||
### Status
|
||||
|
||||
The same page reports what the plugin currently believes to be true: the
|
||||
endpoint in effect, when the sync task last ran and how long it took, what the
|
||||
API and probe caches hold and how often they answer. Three actions cost
|
||||
something and therefore only run when clicked — **Check endpoint now** (does
|
||||
the endpoint answer, and how fast), **Clear caches** (the same invalidation a
|
||||
configuration change triggers) and **Check a talk**, which resolves one talk
|
||||
the way playback does (API lookup → recording choice → signed proxy url →
|
||||
proxy request → probe) and names the stage that fails.
|
||||
|
||||
### Scheduled Sync
|
||||
|
||||
The plugin automatically syncs conference data every **6 hours** via a scheduled task.
|
||||
You can trigger a manual sync in **Dashboard → Scheduled Tasks → Chaosflix: Sync CCC Media**.
|
||||
|
||||
## Building
|
||||
|
||||
### With Dockerfile (recommended)
|
||||
|
||||
```bash
|
||||
# Standard build — outputs to ./artifacts/
|
||||
docker build --target artifact --output type=local,dest=./artifacts .
|
||||
|
||||
# Behind a corporate SSL proxy? Place .crt files in certs/ first:
|
||||
cp /usr/local/share/ca-certificates/*.crt certs/
|
||||
docker build --target artifact --output type=local,dest=./artifacts .
|
||||
```
|
||||
|
||||
### Without Docker
|
||||
|
||||
```bash
|
||||
dotnet publish Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj -c Release -o ./artifacts
|
||||
```
|
||||
|
||||
### Rebuilding for a new Jellyfin Version
|
||||
|
||||
The plugin must be compiled against the same Jellyfin SDK version as your server.
|
||||
|
||||
#### Automatisch (empfohlen)
|
||||
|
||||
```bash
|
||||
# Auto-detect latest Jellyfin version from NuGet
|
||||
./upgrade-jellyfin.sh
|
||||
|
||||
# Or specify a version manually
|
||||
./upgrade-jellyfin.sh 12.1.0
|
||||
```
|
||||
|
||||
Das Script:
|
||||
1. Updated NuGet-Pakete im `.csproj`
|
||||
2. Updated `targetAbi` in `meta.json` — `release.sh` trägt ihn in `manifest.json` nach
|
||||
3. Zieht Target Framework und Docker-SDK mit, wenn der Major das verlangt
|
||||
4. Macht einen Test-Build via Docker
|
||||
5. Zeigt Fehler + Lösungsvorschläge bei Breaking Changes
|
||||
|
||||
Danach:
|
||||
```bash
|
||||
git add -A && git commit -m "chore: upgrade to Jellyfin 12.1.0"
|
||||
./release.sh
|
||||
git push origin main --tags # the release workflow publishes the ZIP
|
||||
```
|
||||
|
||||
#### Manuell
|
||||
|
||||
```bash
|
||||
# 1. Check your Jellyfin server version (Dashboard → General)
|
||||
|
||||
# 2. Update the SDK references in the .csproj
|
||||
sed -i 's/Version="12.1.0"/Version="12.2.0"/g' \
|
||||
Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj
|
||||
|
||||
# 3. Update targetAbi in meta.json — the single source of truth; release.sh
|
||||
# copies it into the manifest entry it writes. Published manifest entries
|
||||
# keep the targetAbi their ZIP was released with and are never rewritten.
|
||||
sed -i 's/"targetAbi": "12.1.0.0"/"targetAbi": "12.2.0.0"/' \
|
||||
Jellyfin.Plugin.Chaosflix/meta.json
|
||||
|
||||
# 4. Release
|
||||
./release.sh
|
||||
```
|
||||
|
||||
#### Was bei Major-Updates brechen kann
|
||||
|
||||
| Änderung | Symptom | Fix |
|
||||
|----------|---------|-----|
|
||||
| Target Framework (z.B. net10→net11) | `TargetFramework 'netX.0' is not supported` | `tfm_for()` in `upgrade-jellyfin.sh` ergänzen; `.csproj`, Dockerfile und `dotnet-version` in den Workflows ziehen mit |
|
||||
| Namespace-Umbenennung | `The type or namespace 'X' does not exist` | `using`-Statements anpassen |
|
||||
| API-Signatur-Änderung | `does not contain a definition for 'X'` | Jellyfin Release Notes lesen, Code anpassen |
|
||||
| DI-Registration | Plugin wird nicht geladen | `ChaosflixServiceRegistrator.cs` prüfen |
|
||||
|
||||
> **Tip:** Check available SDK versions: https://www.nuget.org/packages/Jellyfin.Controller
|
||||
> Check release notes: https://github.com/jellyfin/jellyfin/releases
|
||||
|
||||
### Creating a Release (automated)
|
||||
|
||||
```bash
|
||||
# The version is worked out from the commits since the last tag:
|
||||
./release.sh
|
||||
|
||||
# Look first — prints the version and the notes, writes nothing:
|
||||
./release.sh --dry-run
|
||||
|
||||
# A version given by hand always wins:
|
||||
./release.sh 0.0.2
|
||||
|
||||
# The changelog can still be written by hand; it then overrides the generated one:
|
||||
./release.sh 0.0.2 "Rebuild for Jellyfin 12.1"
|
||||
```
|
||||
|
||||
This updates all version strings, writes the changelog and the release notes,
|
||||
commits and tags. It builds nothing: the release workflow builds the plugin from
|
||||
the tag, publishes the release with the ZIP attached and writes the checksum of
|
||||
exactly those bytes into `manifest.json`. A ZIP built locally would never be
|
||||
byte-identical to the published one, so its checksum described bytes nobody
|
||||
could download (#49).
|
||||
|
||||
Releasing therefore needs no .NET SDK and no `zip` on the machine that tags:
|
||||
|
||||
```bash
|
||||
git fetch origin && git reset --hard origin/main # release what the remote has
|
||||
./release.sh
|
||||
git push origin main --tags # the tag publishes the release
|
||||
```
|
||||
|
||||
`release.sh` refuses to run when the branch is behind its upstream and names the
|
||||
missing commits, because a release cut from a stale checkout publishes code the
|
||||
remote does not have (#64).
|
||||
|
||||
Without a version argument the next number is derived from the Conventional
|
||||
Commit subjects since the previous tag, and the reasoning is printed before
|
||||
anything is written:
|
||||
|
||||
| Since the last tag | Bump |
|
||||
|--------------------|------|
|
||||
| `feat!:` or a `BREAKING CHANGE:` trailer | MINOR while below 1.0.0 (a 0.x MAJOR bump would declare 1.0), MAJOR from 1.0.0 on |
|
||||
| a `feat:` | MINOR |
|
||||
| a raised `targetAbi` in `meta.json` | MINOR — it changes which servers may install the plugin |
|
||||
| a `fix:` | PATCH |
|
||||
| only `chore`/`docs`/`test`/`build`/`ci`/`refactor` | PATCH, and the output says so |
|
||||
| nothing | the script aborts and changes nothing |
|
||||
|
||||
Without a changelog argument the release notes are generated from the
|
||||
Conventional Commit subjects since the previous tag, grouped into breaking
|
||||
changes, features, bug fixes and other changes. They go into three places: a new
|
||||
section on top of `CHANGELOG.md`, a short list in `manifest.json` (that is what
|
||||
Jellyfin's plugin catalogue shows), and `release-notes-v<version>.md`, which the
|
||||
script prints. The script itself never pushes; pushing the tag is what publishes
|
||||
the release.
|
||||
|
||||
## Testing
|
||||
|
||||
One entry point runs everything that gates a push:
|
||||
|
||||
```bash
|
||||
./run-tests.sh
|
||||
```
|
||||
|
||||
That is the shell tests, a Release build, the unit tests (`*.Tests.csproj`) and
|
||||
the Playwright suite in `e2e/`, which starts a real Jellyfin in Docker against a
|
||||
fake `media.ccc.de`. The branch guard runs it on every `git push`.
|
||||
|
||||
Five suites stay out of it because they need an emulator, a network clone, a
|
||||
published release or the live media.ccc.de, and are opt-in:
|
||||
|
||||
| Suite | How | What it covers |
|
||||
|---|---|---|
|
||||
| CCC API contract | `CCC_CONTRACT=1 ./run-tests.sh` | the **real** api.media.ccc.de still has the fields the plugin reads ([`Contract/`](Jellyfin.Plugin.Chaosflix.Tests/Contract)) |
|
||||
| Android phone app | `ANDROID_E2E=1 ./run-tests.sh` | real ExoPlayer on an emulator ([`e2e/android`](e2e/android)) |
|
||||
| Cast receiver | `RECEIVER_E2E=1 ./run-tests.sh` | the real jellyfin-chromecast bundle in a browser |
|
||||
| Android TV | `e2e/android/tv-repro.sh` | jellyfin-androidtv on a TV emulator, driven by hand |
|
||||
| Published plugin | `e2e/install/install-from-manifest.sh` | a fresh Jellyfin installing the **released** plugin from `manifest.json`, checksum included |
|
||||
|
||||
The e2e stack is configurable: `JELLYFIN_TAG` (defaults to `12.1`, the version
|
||||
`targetAbi` promises), `JELLYFIN_PORT`,
|
||||
`COMPOSE_PROJECT_NAME` and `E2E_FIXTURE_SECONDS`. Unless you set the middle two
|
||||
yourself, `run-tests.sh` picks a compose project and a host port from the path of
|
||||
the checkout it runs in, so several worktrees can run the suite side by side.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
| Workflow | When | What |
|
||||
|---|---|---|
|
||||
| `ci.yml` | every PR and push to `main` | `./run-tests.sh` on a GitHub-hosted runner |
|
||||
| `release.yml` | a `v*` tag | build, publish the release with its ZIP, commit the checksum |
|
||||
| `android-e2e.yml` | nightly, or on demand | the Android emulator suite |
|
||||
| `install-e2e.yml` | after a release, and nightly | install the published plugin into the newest Jellyfin |
|
||||
| `contract.yml` | nightly, or on demand | the contract tests against the real media.ccc.de API |
|
||||
|
||||
The runner deliberately has no `ffmpeg`, so the fixture build exercises its
|
||||
container fallback — that path was broken for as long as nothing ran it.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Jellyfin stores a channel item under exactly one parent, so a talk listed in
|
||||
several folders needs one id per folder and becomes several library items.
|
||||
Watch state is mirrored between them, but the copies show up as duplicates in
|
||||
"Recently added" ([#54](https://github.com/praetorianer777/chaosflix-jellyfin/issues/54)).
|
||||
- Talks cannot be downloaded for offline playback. Jellyfin offers a download
|
||||
only for items backed by a file on the server — `Video.CanDownload()` returns
|
||||
`IsFileProtocol`, and a channel item's path is whatever its media source says,
|
||||
which here is an http url. `/Items/{id}/Download` then serves that path off
|
||||
disk, so there is nothing for it to send
|
||||
([#90](https://github.com/praetorianer777/chaosflix-jellyfin/issues/90)).
|
||||
- Topics can be filtered but not browsed. A talk's tags are mapped to genres
|
||||
and filtering by one works (`/Items?genres=Security`), but Jellyfin's genre
|
||||
browse lists nothing: it builds genre entities during a library scan, which
|
||||
channel items never go through. Asking Jellyfin to create them turns out not
|
||||
to be enough either — with them present, `/Genres` listed three of five while
|
||||
all five resolved correctly by id and by name, stable across a restart, so the
|
||||
listing is unreliable independently of this plugin
|
||||
([#88](https://github.com/praetorianer777/chaosflix-jellyfin/issues/88)).
|
||||
- Playback runs through `/api/ChaosflixStream/proxy` with a signed url rather
|
||||
than straight from the CDN: ExoPlayer cannot follow the CDN's cross-domain
|
||||
redirects, so mirror failover, range requests and redirects are resolved
|
||||
server-side. The signature is per server process.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Jellyfin UI → Chaosflix Channel → media.ccc.de API → CDN streaming
|
||||
│
|
||||
├── 🔴 Live now (only during a congress)
|
||||
├── 🔥 Popular (top by views)
|
||||
├── ⭐ Recommended (views × recency)
|
||||
├── 📅 Year → Conference → Talks
|
||||
├── 🔗 Related talks, as Jellyfin's similar items
|
||||
├── Speaker/tag metadata
|
||||
└── Multiple quality/language sources per talk
|
||||
```
|
||||
|
||||
The plugin implements Jellyfin's `IChannel` interface, plus
|
||||
`ILocalSimilarItemsProvider` for the related talks. All heavy lifting (watch history, playback position, user management, transcoding, SyncPlay) is handled by Jellyfin itself.
|
||||
|
||||
## CCC API
|
||||
|
||||
This plugin uses the public [media.ccc.de API](https://api.media.ccc.de):
|
||||
|
||||
- `/public/conferences` — list all conferences
|
||||
- `/public/conferences/{id}` — conference detail with events
|
||||
- `/public/events/{guid}` — event detail with recordings + related talks
|
||||
- `/public/events/search?q=` — full-text search
|
||||
|
||||
No API key required. Content is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
29
doc/research/reference-sources/chaosflix/manifest.json
Normal file
29
doc/research/reference-sources/chaosflix/manifest.json
Normal file
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"guid": "c4a05f11-4ccc-4b00-bdea-dbeef1337000",
|
||||
"name": "Chaosflix",
|
||||
"description": "Browse and stream Chaos Computer Club conference talks from media.ccc.de directly in Jellyfin. Includes watch history, resume, SyncPlay support, popular/recommended talks, year-based browsing, and related talk suggestions.",
|
||||
"overview": "CCC Media Channel for Jellyfin",
|
||||
"owner": "praetorianer777",
|
||||
"category": "Channels",
|
||||
"imageUrl": "https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/assets/logo.svg",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.4.0.0",
|
||||
"changelog": "Breaking changes:\n- requires Jellyfin 12.1.0 or newer (raised from 10.11.0)\nBug fixes:\n- report a raised minimum server version as breaking (#113)\n- stop Popular and Recommended reshuffling on every refresh (#111)\n- stop inventing a community rating from the view count (#108)\n- judge a conflicted rebase by the branch being rebased (#106)\n- and 3 more\nPlus 3 other changes.",
|
||||
"targetAbi": "12.1.0.0",
|
||||
"sourceUrl": "https://github.com/praetorianer777/chaosflix-jellyfin/releases/download/v0.4.0/chaosflix-jellyfin-v0.4.0.zip",
|
||||
"checksum": "6c7ff793e07ce26c73a4f4c9430fee91",
|
||||
"timestamp": "2026-09-20T17:54:07Z"
|
||||
},
|
||||
{
|
||||
"version": "0.3.0.0",
|
||||
"changelog": "Features:\n- show the plugin's state on its configuration page (#93)\nPlus 5 other changes.",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://github.com/praetorianer777/chaosflix-jellyfin/releases/download/v0.3.0/chaosflix-jellyfin-v0.3.0.zip",
|
||||
"checksum": "b4b7c4d4eeac9e4277957e966c311210",
|
||||
"timestamp": "2026-09-19T14:42:40Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
121
doc/research/reference-sources/floaty_README.md
Normal file
121
doc/research/reference-sources/floaty_README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Floaty
|
||||
#### An unofficial Floatplane client for Windows, Linux, MacOS, iOS and Android.
|
||||
#### ⚠️Spaghetti code
|
||||
This repository is the tartar sauce spaghetti code of Floaty.
|
||||
|
||||
### [Download Here](https://floaty.fyi/download)
|
||||
|
||||
## 📃Feature Matrix
|
||||
| **Feature** | **Windows** | **MacOS** | **Linux** | **Android** | **iOS** | **Info** |
|
||||
|----------------------------|-------------|-----------|-----------|-------------|---------|---------------------------|
|
||||
| **Channel Page** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Post Page** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Playback** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Livestream Page** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Livesteam playback** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Live Chat** | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | Awaiting Fix from floatplane. |
|
||||
| **Live Chat Polls** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Live Chat Emotes** | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | |
|
||||
| **Floatplane Settings** | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | Some settings are there. |
|
||||
| **Home Page** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Whenplane intergration** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Updater** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **PiP** | ✅ | ✅ | ✅ | ✅ | ❌ | |
|
||||
| **Mini Player** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Subtitles** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Download** | ✅ | ✅ | ✅ | ✅ | ✅ | |
|
||||
| **Login** | ✅ | ✅ | ✅ | ✅ | ✅ | | |
|
||||
| **Notifications** | ❌ | ❌ | ❌ | ❌ | ❌ | Deciding on how to complete this. |
|
||||
|
||||
|
||||
## 🛠️ How to Compile and Run
|
||||
|
||||
### 1. Install Flutter
|
||||
|
||||
First, install Flutter by following the official guide:
|
||||
➡️ [Flutter Installation Guide](https://docs.flutter.dev/get-started/install)
|
||||
|
||||
Make sure to set up your system PATH to include the `flutter/bin` directory.
|
||||
Verify installation:
|
||||
```bash
|
||||
flutter --version
|
||||
```
|
||||
|
||||
### 2. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/floatyfp/floaty.git
|
||||
cd floaty
|
||||
```
|
||||
|
||||
### 3. Install dependencies
|
||||
|
||||
Some dependencies rely on Rust, install Rust first by following the official guide:
|
||||
➡️ [Rust Installation Guide](https://www.rust-lang.org/tools/install)
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 4. Run the app
|
||||
|
||||
Make sure you have an Android/iOS device or a desktop environment ready.
|
||||
|
||||
To get a list of available devices and select an option to run on:
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
Or list devices and run on a specific one:
|
||||
```bash
|
||||
flutter devices
|
||||
flutter run -d DEVICE_ID
|
||||
```
|
||||
|
||||
### 5. Build the app
|
||||
You can build the app in either release or debug mode. If you're building it in release refer to step 6 to setup certificates.
|
||||
|
||||
- **Android APK**
|
||||
Build a release APK:
|
||||
```bash
|
||||
flutter build apk --<INSERT MODE>
|
||||
```
|
||||
|
||||
- **iOS**
|
||||
Build for iOS (requires macOS and Xcode):
|
||||
```bash
|
||||
flutter build ios --<INSERT MODE>
|
||||
```
|
||||
|
||||
- **Windows**
|
||||
```bash
|
||||
flutter build windows --<INSERT MODE>
|
||||
```
|
||||
|
||||
- **macOS**
|
||||
```bash
|
||||
flutter build macos --<INSERT MODE>
|
||||
```
|
||||
|
||||
- **Linux**
|
||||
```bash
|
||||
flutter build linux --<INSERT MODE>
|
||||
```
|
||||
|
||||
### 6. (Optional) Setup Release Signing for Android
|
||||
|
||||
To publish a signed APK or App Bundle:
|
||||
|
||||
1. Generate a signing key:
|
||||
```bash
|
||||
keytool -genkey -v -keystore ~/your_keystore_name.jks -keyalg RSA -keysize 2048 -validity 10000 -alias your_key_alias
|
||||
```
|
||||
2. Create a `key.properties` file in the `android/` directory:
|
||||
```properties
|
||||
storePassword=your_store_password
|
||||
keyPassword=your_key_password
|
||||
keyAlias=your_key_alias
|
||||
storeFile=/path/to/your_keystore_name.jks
|
||||
```
|
||||
3. Edit `android/app/build.gradle` to load the `key.properties` and configure signingConfigs.
|
||||
|
||||
Full guide here: [Signing Flutter apps](https://docs.flutter.dev/deployment/android#signing-the-app)
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:floaty/features/api/repositories/fpapi.dart';
|
||||
import 'package:floaty/features/download/controllers/fp_rate_limit_manager.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Helper for managing download URL expiry and refresh
|
||||
class FPDownloadUrlHelper {
|
||||
static final Logger _log = Logger('FPDownloadUrlHelper');
|
||||
|
||||
/// Check if a download URL is expired
|
||||
static bool isUrlExpired(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final expiresParam = uri.queryParameters['expires'];
|
||||
|
||||
if (expiresParam == null) {
|
||||
// No expires param, assume not expired
|
||||
return false;
|
||||
}
|
||||
|
||||
final expiresEpoch = int.tryParse(expiresParam);
|
||||
if (expiresEpoch == null) return false;
|
||||
|
||||
final expiresAt =
|
||||
DateTime.fromMillisecondsSinceEpoch(expiresEpoch * 1000);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Consider expired if within 1 minute of expiry
|
||||
return now.isAfter(expiresAt.subtract(const Duration(minutes: 1)));
|
||||
} catch (e) {
|
||||
_log.warning('Error checking URL expiry: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get expiry time from URL
|
||||
static DateTime? getExpiryTime(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final expiresParam = uri.queryParameters['expires'];
|
||||
|
||||
if (expiresParam == null) return null;
|
||||
|
||||
final expiresEpoch = int.tryParse(expiresParam);
|
||||
if (expiresEpoch == null) return null;
|
||||
|
||||
return DateTime.fromMillisecondsSinceEpoch(expiresEpoch * 1000);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh a download URL by fetching new delivery info
|
||||
/// Returns the new URL or null if rate limited or failed
|
||||
static Future<RefreshResult> refreshDownloadUrl({
|
||||
required String attachmentId,
|
||||
required String attachmentTitle,
|
||||
required String qualityLabel,
|
||||
required String whitelabel,
|
||||
}) async {
|
||||
try {
|
||||
// Attempt the delivery request even if local counters indicate a rate limit.
|
||||
final deliveryResponse = await fpApiRequests.getDelivery(
|
||||
whitelabel,
|
||||
'download',
|
||||
attachmentId,
|
||||
);
|
||||
|
||||
final response = deliveryResponse['body'] as String;
|
||||
final headers = deliveryResponse['headers'] as Map<String, String>;
|
||||
final statusCode = deliveryResponse['statusCode'] as int;
|
||||
|
||||
// If server indicates rate limiting, honor it and return the retry-after
|
||||
if (statusCode == 429 || response.contains('error code: 1015')) {
|
||||
final retryAfter =
|
||||
fpRateLimitManager.parseRetryAfter(headers, response);
|
||||
fpRateLimitManager.setRateLimited(retryAfter);
|
||||
return RefreshResult(
|
||||
success: false,
|
||||
isRateLimited: true,
|
||||
secondsUntilRetry: retryAfter,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.isEmpty) {
|
||||
return RefreshResult(success: false, error: 'Empty response');
|
||||
}
|
||||
|
||||
// Record successful request locally so counters stay accurate
|
||||
fpRateLimitManager.recordRequest();
|
||||
|
||||
final decoded = jsonDecode(response);
|
||||
final newUrl = _parseDownloadUrl(decoded, attachmentTitle, qualityLabel);
|
||||
|
||||
if (newUrl == null) {
|
||||
return RefreshResult(success: false, error: 'Could not parse new URL');
|
||||
}
|
||||
|
||||
return RefreshResult(success: true, newUrl: newUrl);
|
||||
} catch (e) {
|
||||
_log.severe('Error refreshing download URL: $e');
|
||||
return RefreshResult(success: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse download URL from delivery response
|
||||
static String? _parseDownloadUrl(
|
||||
Map<String, dynamic> decoded,
|
||||
String attachmentTitle,
|
||||
String qualityLabel,
|
||||
) {
|
||||
// Try new format first (groups/origins/variants)
|
||||
final groups = decoded['groups'] as List?;
|
||||
if (groups != null && groups.isNotEmpty) {
|
||||
final group = groups[0] as Map<String, dynamic>;
|
||||
final origins = group['origins'] as List?;
|
||||
final variants = group['variants'] as List?;
|
||||
|
||||
if (origins != null && origins.isNotEmpty && variants != null) {
|
||||
final baseUrl = (origins[0] as Map<String, dynamic>)['url'] as String;
|
||||
|
||||
for (var variant in variants) {
|
||||
if (variant['enabled'] == true) {
|
||||
final label = variant['label'] as String;
|
||||
|
||||
// Match by quality label
|
||||
if (label == qualityLabel) {
|
||||
final variantUrl = variant['url'] as String;
|
||||
|
||||
// Convert HLS URL to direct download URL
|
||||
String downloadUrl = '$baseUrl$variantUrl';
|
||||
|
||||
// Remove /chunk.m3u8 if present
|
||||
if (downloadUrl.contains('/chunk.m3u8')) {
|
||||
downloadUrl = downloadUrl.replaceFirst('/chunk.m3u8', '');
|
||||
}
|
||||
|
||||
// Remove /Videos/ prefix if present
|
||||
downloadUrl = downloadUrl.replaceFirst('/Videos/', '/');
|
||||
|
||||
// Add required download parameters
|
||||
final filename = Uri.encodeComponent('$attachmentTitle ($label)');
|
||||
downloadUrl += '&attachment=true&filename=$filename';
|
||||
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to old format (cdn/resource/qualityLevels)
|
||||
final cdn = decoded['cdn'] as String?;
|
||||
final resource = decoded['resource'] as Map<String, dynamic>?;
|
||||
|
||||
if (cdn != null && resource != null) {
|
||||
final data = resource['data'] as Map<String, dynamic>?;
|
||||
final qualityLevels = data?['qualityLevels'] as List?;
|
||||
final qualityLevelParams =
|
||||
data?['qualityLevelParams'] as Map<String, dynamic>?;
|
||||
final uri = resource['uri'] as String?;
|
||||
|
||||
if (qualityLevels != null && qualityLevelParams != null && uri != null) {
|
||||
for (var quality in qualityLevels) {
|
||||
final qualityName = quality['name'] as String;
|
||||
final label = quality['label'] as String;
|
||||
|
||||
if (label == qualityLabel) {
|
||||
final params =
|
||||
qualityLevelParams[qualityName] as Map<String, dynamic>?;
|
||||
|
||||
if (params != null) {
|
||||
final videoFile = params['1'] as String?;
|
||||
final token = params['2'] as String?;
|
||||
|
||||
if (videoFile != null && token != null) {
|
||||
final downloadUri = uri
|
||||
.replaceFirst('{qualityLevelParams.1}', videoFile)
|
||||
.replaceFirst('{qualityLevelParams.2}', token);
|
||||
|
||||
// Add required download parameters
|
||||
final filename =
|
||||
Uri.encodeComponent('$attachmentTitle ($qualityLabel)');
|
||||
return '$cdn$downloadUri&attachment=true&filename=$filename';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of URL refresh operation
|
||||
class RefreshResult {
|
||||
final bool success;
|
||||
final String? newUrl;
|
||||
final bool isRateLimited;
|
||||
final int secondsUntilRetry;
|
||||
final String? error;
|
||||
|
||||
RefreshResult({
|
||||
required this.success,
|
||||
this.newUrl,
|
||||
this.isRateLimited = false,
|
||||
this.secondsUntilRetry = 0,
|
||||
this.error,
|
||||
});
|
||||
}
|
||||
185
doc/research/reference-sources/floaty_fp_rate_limit_manager.dart
Normal file
185
doc/research/reference-sources/floaty_fp_rate_limit_manager.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Manages Floatplane API rate limits for the delivery API
|
||||
/// Rate limit: 2 requests per 5 minutes, up to 8 concurrent downloads
|
||||
class FPRateLimitManager {
|
||||
static final FPRateLimitManager _instance = FPRateLimitManager._internal();
|
||||
factory FPRateLimitManager() => _instance;
|
||||
FPRateLimitManager._internal();
|
||||
|
||||
// Track when we can make the next delivery API request
|
||||
DateTime? _rateLimitedUntil;
|
||||
|
||||
// Track recent delivery API requests (within 5 minutes)
|
||||
final List<DateTime> _recentRequests = [];
|
||||
|
||||
// Stream for UI updates
|
||||
final StreamController<RateLimitState> _stateController =
|
||||
StreamController.broadcast();
|
||||
|
||||
Stream<RateLimitState> get stateStream => _stateController.stream;
|
||||
|
||||
/// Maximum delivery API requests per 5 minutes
|
||||
static const int maxRequestsPerWindow = 2;
|
||||
|
||||
/// Window duration for rate limit
|
||||
static const Duration rateLimitWindow = Duration(minutes: 5);
|
||||
|
||||
/// Maximum concurrent downloads allowed
|
||||
static const int maxConcurrentDownloads = 8;
|
||||
|
||||
/// Check if we're currently rate limited
|
||||
bool get isRateLimited {
|
||||
if (_rateLimitedUntil == null) return false;
|
||||
if (DateTime.now().isAfter(_rateLimitedUntil!)) {
|
||||
_rateLimitedUntil = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get seconds remaining until rate limit expires
|
||||
int get secondsRemaining {
|
||||
if (_rateLimitedUntil == null) return 0;
|
||||
final remaining = _rateLimitedUntil!.difference(DateTime.now()).inSeconds;
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/// Check if we can make a delivery API request
|
||||
bool get canMakeRequest {
|
||||
if (isRateLimited) return false;
|
||||
|
||||
// Clean up old requests
|
||||
_cleanupOldRequests();
|
||||
|
||||
// Check if we've hit the limit
|
||||
return _recentRequests.length < maxRequestsPerWindow;
|
||||
}
|
||||
|
||||
/// Get seconds until we can make another request (if at limit)
|
||||
int get secondsUntilNextRequest {
|
||||
if (isRateLimited) return secondsRemaining;
|
||||
|
||||
_cleanupOldRequests();
|
||||
|
||||
if (_recentRequests.length >= maxRequestsPerWindow) {
|
||||
// Find when the oldest request will expire
|
||||
final oldest = _recentRequests.first;
|
||||
final expiresAt = oldest.add(rateLimitWindow);
|
||||
final remaining = expiresAt.difference(DateTime.now()).inSeconds;
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Record a delivery API request
|
||||
void recordRequest() {
|
||||
_cleanupOldRequests();
|
||||
_recentRequests.add(DateTime.now());
|
||||
_emitState();
|
||||
}
|
||||
|
||||
/// Set rate limited state from 429 response
|
||||
void setRateLimited(int retryAfterSeconds) {
|
||||
_rateLimitedUntil =
|
||||
DateTime.now().add(Duration(seconds: retryAfterSeconds));
|
||||
_emitState();
|
||||
|
||||
// Start countdown timer
|
||||
_startCountdownTimer();
|
||||
}
|
||||
|
||||
/// Parse retry-after from response headers or body
|
||||
int parseRetryAfter(Map<String, String> headers, String? body) {
|
||||
// Try retry-after header first
|
||||
if (headers.containsKey('retry-after')) {
|
||||
final value = headers['retry-after'];
|
||||
if (value != null) {
|
||||
final seconds = int.tryParse(value);
|
||||
if (seconds != null) return seconds;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 5 minutes if not specified
|
||||
return 300;
|
||||
}
|
||||
|
||||
/// Clear rate limit (e.g., after successful request)
|
||||
void clearRateLimit() {
|
||||
_rateLimitedUntil = null;
|
||||
_emitState();
|
||||
}
|
||||
|
||||
/// Get current state for UI
|
||||
RateLimitState get currentState {
|
||||
_cleanupOldRequests();
|
||||
return RateLimitState(
|
||||
isRateLimited: isRateLimited,
|
||||
secondsRemaining: secondsRemaining,
|
||||
recentRequestCount: _recentRequests.length,
|
||||
maxRequestsPerWindow: maxRequestsPerWindow,
|
||||
canMakeRequest: canMakeRequest,
|
||||
secondsUntilNextRequest: secondsUntilNextRequest,
|
||||
);
|
||||
}
|
||||
|
||||
void _cleanupOldRequests() {
|
||||
final cutoff = DateTime.now().subtract(rateLimitWindow);
|
||||
_recentRequests.removeWhere((time) => time.isBefore(cutoff));
|
||||
}
|
||||
|
||||
void _emitState() {
|
||||
_stateController.add(currentState);
|
||||
}
|
||||
|
||||
Timer? _countdownTimer;
|
||||
|
||||
void _startCountdownTimer() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!isRateLimited) {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = null;
|
||||
}
|
||||
_emitState();
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
_stateController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// State of rate limiting for UI
|
||||
class RateLimitState {
|
||||
final bool isRateLimited;
|
||||
final int secondsRemaining;
|
||||
final int recentRequestCount;
|
||||
final int maxRequestsPerWindow;
|
||||
final bool canMakeRequest;
|
||||
final int secondsUntilNextRequest;
|
||||
|
||||
const RateLimitState({
|
||||
required this.isRateLimited,
|
||||
required this.secondsRemaining,
|
||||
required this.recentRequestCount,
|
||||
required this.maxRequestsPerWindow,
|
||||
required this.canMakeRequest,
|
||||
required this.secondsUntilNextRequest,
|
||||
});
|
||||
|
||||
String get formattedTimeRemaining {
|
||||
if (secondsRemaining <= 0) return '';
|
||||
final minutes = secondsRemaining ~/ 60;
|
||||
final seconds = secondsRemaining % 60;
|
||||
if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
}
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
/// Global instance
|
||||
final fpRateLimitManager = FPRateLimitManager();
|
||||
1016
doc/research/reference-sources/floaty_fpapi.dart
Normal file
1016
doc/research/reference-sources/floaty_fpapi.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
|
||||
class LiveStatusNotifier extends StateNotifier<String> {
|
||||
Timer? _timer;
|
||||
final String? lastLiveTime;
|
||||
|
||||
LiveStatusNotifier(this.lastLiveTime) : super('') {
|
||||
_updateOfflineTime();
|
||||
_timer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
_updateOfflineTime();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateOfflineTime() {
|
||||
if (lastLiveTime == null || lastLiveTime!.isEmpty) {
|
||||
state = '';
|
||||
return;
|
||||
}
|
||||
|
||||
final lastLive = DateTime.tryParse(lastLiveTime!);
|
||||
if (lastLive == null) {
|
||||
state = '';
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime unixTime = DateTime.now();
|
||||
DateTime isoTime = DateTime.parse(lastLiveTime!);
|
||||
|
||||
Duration diff = unixTime.difference(isoTime).abs();
|
||||
|
||||
int days = diff.inDays;
|
||||
int hours = diff.inHours.remainder(24);
|
||||
int minutes = diff.inMinutes.remainder(60);
|
||||
|
||||
List<String> parts = [];
|
||||
if (days > 0) parts.add("$days day${days > 1 ? 's' : ''}");
|
||||
if (hours > 0) parts.add("$hours hour${hours > 1 ? 's' : ''}");
|
||||
if (minutes > 0) parts.add("$minutes minute${minutes > 1 ? 's' : ''}");
|
||||
|
||||
state = parts.isNotEmpty ? parts.join(" ") : "just now";
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final liveStatusProvider =
|
||||
StateNotifierProvider.family<LiveStatusNotifier, String, String?>(
|
||||
(ref, lastLiveTime) {
|
||||
return LiveStatusNotifier(lastLiveTime);
|
||||
});
|
||||
472
doc/research/reference-sources/floaty_login_screen.dart
Normal file
472
doc/research/reference-sources/floaty_login_screen.dart
Normal file
@@ -0,0 +1,472 @@
|
||||
import 'package:floaty/features/authentication/services/oauth2_service.dart';
|
||||
import 'package:floaty/features/api/repositories/fpapi.dart';
|
||||
import 'package:floaty/whitelabels.dart';
|
||||
import 'package:floaty/settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:floaty/shared/components/switcher.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
|
||||
class LoginManager {
|
||||
final OAuth2Service _oauth2Service = OAuth2Service.instance;
|
||||
|
||||
/// Manual cookie login for testing
|
||||
Future<void> loginWithCookie(
|
||||
BuildContext context, String cookieString, Function logincomplete) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
try {
|
||||
if (cookieString.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Please enter a cookie.', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the cookie in the cookie jar
|
||||
final whiteLabel = await whitelabels.getSelectedWhitelabel();
|
||||
final uri = Uri.parse('https://${whiteLabel.domain}/');
|
||||
|
||||
// Create cookie with far-future expiration (1 year from now)
|
||||
// Use the actual domain from the whitelabel (not the URI domain)
|
||||
// For multi-subdomain support, we could use .floatplane.com, but for now use exact domain
|
||||
final cookie = Cookie(whiteLabel.cookieName, cookieString)
|
||||
..expires = DateTime.now().add(const Duration(days: 365))
|
||||
..domain = whiteLabel.domain // Use the exact domain from whitelabel
|
||||
..path = '/'
|
||||
..httpOnly = true;
|
||||
|
||||
// Save cookie to the domain's URI
|
||||
await fpApiRequests.cookieJar.saveFromResponse(
|
||||
uri,
|
||||
[cookie],
|
||||
);
|
||||
|
||||
// Mark this whitelabel as using cookie auth
|
||||
await settings.setKey('${whiteLabel.friendlyName}_auth_method', 'cookie');
|
||||
|
||||
await whitelabels
|
||||
.addLoggedInLabel('${whiteLabel.friendlyName}-aaaaaaaaaa');
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Cookie saved successfully!', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
logincomplete();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showCloseIcon: true,
|
||||
closeIconColor: Colors.white,
|
||||
content: Text('Cookie error: $e', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth2 login flow
|
||||
Future<void> loginWithOAuth2(
|
||||
BuildContext context, Function logincomplete) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 30),
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text('Opening browser for authentication...',
|
||||
style: textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _oauth2Service.login();
|
||||
|
||||
// Clear loading indicator
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
}
|
||||
|
||||
if (result.isSuccess && result.accessToken != null) {
|
||||
// Get whitelabel info
|
||||
final whiteLabel = await whitelabels.getSelectedWhitelabel();
|
||||
|
||||
// Store tokens with whitelabel
|
||||
await _oauth2Service.storeTokens(result,
|
||||
whitelabel: whiteLabel.friendlyName);
|
||||
|
||||
// Mark as logged in
|
||||
final userId = result.userInfo?['sub'] ?? 'oauth2_user';
|
||||
await whitelabels
|
||||
.addLoggedInLabel('${whiteLabel.friendlyName}-$userId');
|
||||
// Sync offline progress after successful login
|
||||
try {
|
||||
await fpApiRequests.syncOfflineProgress(whiteLabel.friendlyName);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to sync offline progress after login: \$e');
|
||||
}
|
||||
logincomplete();
|
||||
} else if (result.isCancelled) {
|
||||
// User cancelled authentication
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Authentication was cancelled.',
|
||||
style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Authentication failed
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showCloseIcon: true,
|
||||
closeIconColor: Colors.white,
|
||||
content: Text(result.error ?? 'OAuth2 authentication failed.',
|
||||
style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Clear loading indicator
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showCloseIcon: true,
|
||||
closeIconColor: Colors.white,
|
||||
content:
|
||||
Text('Authentication error: $e', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController _cookieController = TextEditingController();
|
||||
bool _showCookieInput = false;
|
||||
String? sitekey;
|
||||
String? token;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// Feature flag for cookie testing
|
||||
const bool useCookieAuth =
|
||||
bool.fromEnvironment('USE_COOKIE_AUTH', defaultValue: false);
|
||||
|
||||
return Scaffold(
|
||||
body: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: colorScheme.surface,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Image.asset(
|
||||
'assets/app_foreground.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Welcome to Floaty',
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Sign in to access your Floatplane content',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (!_showCookieInput) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
LoginManager().loginWithOAuth2(
|
||||
context,
|
||||
() {
|
||||
if (context.mounted) {
|
||||
context.pushReplacement('/home');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Sign In with OAuth2'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (useCookieAuth) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showCookieInput = true;
|
||||
});
|
||||
},
|
||||
child: const Text('Use Cookie (Testing)'),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _cookieController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cookie',
|
||||
hintText: 'Paste sails.sid cookie value',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'For testing purposes only',
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
LoginManager().loginWithCookie(
|
||||
context,
|
||||
_cookieController.text,
|
||||
() {
|
||||
if (context.mounted) {
|
||||
context.pushReplacement('/home');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.cookie),
|
||||
label: const Text('Login with Cookie'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showCookieInput = false;
|
||||
});
|
||||
},
|
||||
child: const Text('Back to OAuth2'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Switcher(
|
||||
onSwitch: (String value) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 5),
|
||||
showCloseIcon: true,
|
||||
content: Text(
|
||||
'After you complete login you can sign into the other service and you will get the options of viewing each service individually.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
whitelabels: whitelabels.getWhitelabels(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginFields extends StatelessWidget {
|
||||
const LoginFields({
|
||||
required this.passwordController,
|
||||
required this.usernameController,
|
||||
required this.onSubmitted,
|
||||
required this.needstwofa,
|
||||
required this.logincomplete,
|
||||
this.onOAuth2Login,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController usernameController;
|
||||
final Function(String, String, BuildContext, Function, Function) onSubmitted;
|
||||
final Function() needstwofa;
|
||||
final Function() logincomplete;
|
||||
final Function()? onOAuth2Login;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: true,
|
||||
onSubmitted: (_) => FocusScope.of(context).nextFocus(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'Enter your email',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
textInputAction: TextInputAction.done,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onSubmitted: (_) => onSubmitted(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
context,
|
||||
needstwofa,
|
||||
logincomplete,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => onSubmitted(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
context,
|
||||
needstwofa,
|
||||
logincomplete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
if (onOAuth2Login != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('or'),
|
||||
),
|
||||
Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onOAuth2Login,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Sign in with OAuth2'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1313
doc/research/reference-sources/floaty_media_player_service.dart
Normal file
1313
doc/research/reference-sources/floaty_media_player_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
100
doc/research/reference-sources/floaty_middleware.dart
Normal file
100
doc/research/reference-sources/floaty_middleware.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:floaty/whitelabels.dart';
|
||||
import 'package:floaty/features/authentication/services/oauth2_service.dart';
|
||||
import 'package:floaty/features/api/repositories/fpapi.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
|
||||
class Middleware {
|
||||
final OAuth2Service _oauth2Service = OAuth2Service.instance;
|
||||
|
||||
// Feature flag for cookie-based testing
|
||||
bool get useCookieAuth =>
|
||||
const bool.fromEnvironment('USE_COOKIE_AUTH', defaultValue: false);
|
||||
|
||||
/// Check if the user was previously authenticated (has stored tokens/cookies)
|
||||
/// This is different from isAuthenticated - it checks if credentials exist,
|
||||
/// not if they're currently valid
|
||||
Future<bool> wasPreviouslyAuthenticated(
|
||||
{String? whitelabelFriendlyName}) async {
|
||||
final whitelabelsToCheck = whitelabelFriendlyName != null
|
||||
? [whitelabels.getWhitelabel(whitelabelFriendlyName)]
|
||||
: whitelabels.getWhitelabels();
|
||||
|
||||
for (var whitelabel in whitelabelsToCheck) {
|
||||
final authMethod = await _oauth2Service.getAuthMethod(
|
||||
whitelabel: whitelabel.friendlyName);
|
||||
|
||||
final useCookie =
|
||||
(authMethod == 'cookie') || (authMethod == null && useCookieAuth);
|
||||
|
||||
if (!useCookie) {
|
||||
// Check if OAuth2 tokens exist (regardless of expiration)
|
||||
final hasTokens = await _oauth2Service.hasStoredTokens(
|
||||
whitelabel: whitelabel.friendlyName);
|
||||
if (hasTokens) return true;
|
||||
} else {
|
||||
// Check if cookies exist (regardless of expiration)
|
||||
final cookieJar = fpApiRequests.cookieJar;
|
||||
final uri = Uri.parse('https://${whitelabel.domain}/');
|
||||
final cookies = await cookieJar.loadForRequest(uri);
|
||||
|
||||
final authCookie = cookies.firstWhere(
|
||||
(c) => c.name == whitelabel.cookieName,
|
||||
orElse: () => Cookie('', ''),
|
||||
);
|
||||
|
||||
if (authCookie.name.isNotEmpty && authCookie.value.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> isAuthenticated({String? whitelabelFriendlyName}) async {
|
||||
final whitelabelsToCheck = whitelabelFriendlyName != null
|
||||
? [whitelabels.getWhitelabel(whitelabelFriendlyName)]
|
||||
: whitelabels.getWhitelabels();
|
||||
|
||||
// Check each whitelabel
|
||||
for (var whitelabel in whitelabelsToCheck) {
|
||||
// Get the auth method for this whitelabel
|
||||
final authMethod = await _oauth2Service.getAuthMethod(
|
||||
whitelabel: whitelabel.friendlyName);
|
||||
|
||||
// If no auth method is set and we have the USE_COOKIE_AUTH flag, use cookies
|
||||
// Otherwise, default to OAuth2
|
||||
final useCookie =
|
||||
(authMethod == 'cookie') || (authMethod == null && useCookieAuth);
|
||||
|
||||
if (!useCookie) {
|
||||
// Check OAuth2 authentication
|
||||
final result = await _oauth2Service.isAuthenticated(
|
||||
whitelabel: whitelabel.friendlyName);
|
||||
if (result) return true;
|
||||
} else {
|
||||
// Check cookie authentication
|
||||
final cookieJar = fpApiRequests.cookieJar;
|
||||
final uri = Uri.parse('https://${whitelabel.domain}/');
|
||||
final cookies = await cookieJar.loadForRequest(uri);
|
||||
|
||||
final authCookie = cookies.firstWhere(
|
||||
(c) => c.name == whitelabel.cookieName,
|
||||
orElse: () => Cookie('', ''),
|
||||
);
|
||||
|
||||
if (authCookie.name.isNotEmpty && authCookie.value.isNotEmpty) {
|
||||
if (authCookie.expires != null &&
|
||||
authCookie.expires!.isAfter(DateTime.now())) {
|
||||
return true;
|
||||
} else {
|
||||
cookieJar.delete(uri);
|
||||
await whitelabels.removeLoggedInLabel(whitelabel.friendlyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1137
doc/research/reference-sources/floaty_oauth2_service.dart
Normal file
1137
doc/research/reference-sources/floaty_oauth2_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
9
doc/research/reference-sources/floaty_video_quality.dart
Normal file
9
doc/research/reference-sources/floaty_video_quality.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class VideoQuality {
|
||||
final String label; // e.g., "1080p", "720p"
|
||||
final String url; // URL for this quality
|
||||
|
||||
const VideoQuality({
|
||||
required this.label,
|
||||
required this.url,
|
||||
});
|
||||
}
|
||||
275
doc/research/reference-sources/fn_AuthInterceptor.kt
Normal file
275
doc/research/reference-sources/fn_AuthInterceptor.kt
Normal file
@@ -0,0 +1,275 @@
|
||||
package com.coulterpeterson.floatnative.api
|
||||
|
||||
import com.coulterpeterson.floatnative.data.TokenManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import retrofit2.HttpException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
class AuthInterceptor(
|
||||
private val tokenManager: TokenManager,
|
||||
private val dpopManager: com.coulterpeterson.floatnative.data.DPoPManager,
|
||||
private val authApiProvider: () -> OAuthApi // Lazy provider to avoid circular dependency
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val builder = originalRequest.newBuilder()
|
||||
|
||||
// 1. Add Headers
|
||||
val accessToken = tokenManager.accessToken
|
||||
val authCookie = tokenManager.authCookie
|
||||
|
||||
val url = originalRequest.url.toString()
|
||||
val isFloatplane = url.contains("floatplane.com") || url.contains("floatnative.coulterpeterson.com")
|
||||
|
||||
if (accessToken != null && isFloatplane) {
|
||||
// Generate DPoP Proof
|
||||
try {
|
||||
val method = originalRequest.method
|
||||
val url = originalRequest.url.toString()
|
||||
val proof = dpopManager.generateProof(method, url, accessToken)
|
||||
|
||||
builder.header("DPoP", proof)
|
||||
builder.header("Authorization", "DPoP $accessToken")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// Fallback (though DPoP is required now)
|
||||
builder.header("Authorization", "Bearer $accessToken")
|
||||
}
|
||||
}
|
||||
|
||||
// Add Cookie if present (Sails requires this for chat, even if we have DPoP)
|
||||
if (authCookie != null) {
|
||||
builder.header("Cookie", "sails.sid=$authCookie")
|
||||
}
|
||||
|
||||
builder.header("User-Agent", "FloatNative/1.0 (Android)")
|
||||
|
||||
val finalRequest = builder.build()
|
||||
|
||||
var response: Response? = null
|
||||
try {
|
||||
response = chain.proceed(finalRequest)
|
||||
} catch (e: Exception) {
|
||||
// Check for SSL Handshake or Peer Unverified exceptions which might indicate
|
||||
// clock skew or cert issues that a fresh token *might* help with (user request),
|
||||
// or network glitches we want to retry once.
|
||||
if (e is javax.net.ssl.SSLHandshakeException || e is javax.net.ssl.SSLPeerUnverifiedException) {
|
||||
val refreshToken = tokenManager.refreshToken
|
||||
if (refreshToken != null) {
|
||||
synchronized(this) {
|
||||
try {
|
||||
// Attempt to refresh token
|
||||
val authApi = authApiProvider()
|
||||
val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token"
|
||||
val refreshProof = dpopManager.generateProof("POST", tokenEndpoint)
|
||||
|
||||
// Run blocking because Interceptor is synchronous
|
||||
val tokenResponse = runBlocking {
|
||||
authApi.getToken(
|
||||
dpop = refreshProof,
|
||||
grantType = "refresh_token",
|
||||
clientId = "floatnative",
|
||||
refreshToken = refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
// Save new tokens
|
||||
tokenManager.accessToken = tokenResponse.access_token
|
||||
tokenManager.refreshToken = tokenResponse.refresh_token
|
||||
|
||||
// Retry original request with new token
|
||||
val method = originalRequest.method
|
||||
val url = originalRequest.url.toString()
|
||||
val proof = dpopManager.generateProof(method, url, tokenResponse.access_token)
|
||||
|
||||
return chain.proceed(
|
||||
originalRequest.newBuilder()
|
||||
.header("DPoP", proof)
|
||||
.header("Authorization", "DPoP ${tokenResponse.access_token}")
|
||||
.build()
|
||||
)
|
||||
} catch (refreshEx: Exception) {
|
||||
android.util.Log.e("AuthInterceptor", "Refresh failed during SSL recovery. Forcing logout.", refreshEx)
|
||||
// Force logout so user can try to log in again (as requested)
|
||||
tokenManager.clearAll()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If not SSL error or no refresh token, rethrow
|
||||
throw e
|
||||
}
|
||||
|
||||
// 2. Extract Cookie from Response (if logging in via legacy or hybrid)
|
||||
val cookies = response.headers("Set-Cookie")
|
||||
for (cookie in cookies) {
|
||||
if (cookie.contains("sails.sid")) {
|
||||
// Simple parsing to get value
|
||||
val parts = cookie.split(";")
|
||||
for (part in parts) {
|
||||
val pair = part.trim().split("=")
|
||||
if (pair.size == 2 && pair[0] == "sails.sid") {
|
||||
tokenManager.authCookie = pair[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle 401 Unauthorized or 403 Forbidden
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
val refreshToken = tokenManager.refreshToken
|
||||
if (refreshToken != null) {
|
||||
synchronized(this) {
|
||||
// Double check if token was updated by another thread
|
||||
val currentAccessToken = tokenManager.accessToken
|
||||
if (currentAccessToken != null && currentAccessToken != accessToken) {
|
||||
// Token was updated, retry with new token
|
||||
response.close()
|
||||
// Generate new proof for the retried request with new token
|
||||
try {
|
||||
val method = originalRequest.method
|
||||
val url = originalRequest.url.toString()
|
||||
val proof = dpopManager.generateProof(method, url, currentAccessToken)
|
||||
|
||||
return chain.proceed(
|
||||
originalRequest.newBuilder()
|
||||
.header("DPoP", proof)
|
||||
.header("Authorization", "DPoP $currentAccessToken")
|
||||
.build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return chain.proceed(
|
||||
originalRequest.newBuilder()
|
||||
.header("Authorization", "Bearer $currentAccessToken")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to refresh
|
||||
try {
|
||||
val authApi = authApiProvider()
|
||||
|
||||
// Generate DPoP proof for Token Endpoint
|
||||
val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token"
|
||||
val refreshProof = dpopManager.generateProof("POST", tokenEndpoint)
|
||||
|
||||
// Run blocking because Interceptor is synchronous
|
||||
val tokenResponse = runBlocking {
|
||||
authApi.getToken(
|
||||
dpop = refreshProof,
|
||||
grantType = "refresh_token",
|
||||
clientId = "floatnative",
|
||||
refreshToken = refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
// Save new tokens
|
||||
tokenManager.accessToken = tokenResponse.access_token
|
||||
tokenManager.refreshToken = tokenResponse.refresh_token
|
||||
// Update expiry if needed
|
||||
|
||||
// Retry request
|
||||
response.close()
|
||||
|
||||
// Generate DPoP proof for the retried request with NEW token
|
||||
val method = originalRequest.method
|
||||
val url = originalRequest.url.toString()
|
||||
val proof = dpopManager.generateProof(method, url, tokenResponse.access_token)
|
||||
|
||||
return chain.proceed(
|
||||
originalRequest.newBuilder()
|
||||
.header("DPoP", proof)
|
||||
.header("Authorization", "DPoP ${tokenResponse.access_token}")
|
||||
.build()
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// Handle DPoP Time Skew for Refresh Token
|
||||
if (e is HttpException && e.code() == 400) {
|
||||
val errorBody = e.response()?.errorBody()?.string()
|
||||
if (!errorBody.isNullOrEmpty() && errorBody.contains("DPoP", ignoreCase = true)) {
|
||||
try {
|
||||
// 1. Auto-correct time
|
||||
val dateHeader = e.response()?.headers()?.get("date")
|
||||
if (dateHeader != null) {
|
||||
val sdf = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
|
||||
val serverTime = sdf.parse(dateHeader)?.time
|
||||
if (serverTime != null) {
|
||||
val deviceTime = System.currentTimeMillis()
|
||||
val offsetSeconds = (serverTime - deviceTime) / 1000
|
||||
dpopManager.timeOffsetSeconds = offsetSeconds
|
||||
android.util.Log.i("AuthInterceptor", "Auto-corrected DPoP time offset: $offsetSeconds s")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Retry Refresh
|
||||
val authApi = authApiProvider()
|
||||
val tokenEndpoint = "https://auth.floatplane.com/realms/floatplane/protocol/openid-connect/token"
|
||||
val retryProof = dpopManager.generateProof("POST", tokenEndpoint)
|
||||
|
||||
val retryTokenResponse = runBlocking {
|
||||
authApi.getToken(
|
||||
dpop = retryProof,
|
||||
grantType = "refresh_token",
|
||||
clientId = "floatnative",
|
||||
refreshToken = refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Save new tokens
|
||||
tokenManager.accessToken = retryTokenResponse.access_token
|
||||
tokenManager.refreshToken = retryTokenResponse.refresh_token
|
||||
|
||||
// 4. Retry Original Request
|
||||
response.close()
|
||||
val method = originalRequest.method
|
||||
val url = originalRequest.url.toString()
|
||||
val proof = dpopManager.generateProof(method, url, retryTokenResponse.access_token)
|
||||
|
||||
return chain.proceed(
|
||||
originalRequest.newBuilder()
|
||||
.header("DPoP", proof)
|
||||
.header("Authorization", "DPoP ${retryTokenResponse.access_token}")
|
||||
.build()
|
||||
)
|
||||
|
||||
} catch (retryEx: Exception) {
|
||||
android.util.Log.e("AuthInterceptor", "Retry refresh failed", retryEx)
|
||||
com.coulterpeterson.floatnative.utils.DebugLogManager.auth(
|
||||
"Token refresh retry failed; signing out",
|
||||
"${retryEx.javaClass.simpleName}: ${retryEx.message}"
|
||||
)
|
||||
tokenManager.clearAll()
|
||||
}
|
||||
} else {
|
||||
com.coulterpeterson.floatnative.utils.DebugLogManager.auth(
|
||||
"Token refresh failed (non-DPoP error); signing out",
|
||||
"${e.javaClass.simpleName}: ${e.message}"
|
||||
)
|
||||
tokenManager.clearAll()
|
||||
}
|
||||
} else {
|
||||
// Refresh failed, clear tokens to force re-login
|
||||
com.coulterpeterson.floatnative.utils.DebugLogManager.auth(
|
||||
"Token refresh failed; signing out",
|
||||
"${e.javaClass.simpleName}: ${e.message}"
|
||||
)
|
||||
tokenManager.clearAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
234
doc/research/reference-sources/fn_DPoPManager.kt
Normal file
234
doc/research/reference-sources/fn_DPoPManager.kt
Normal file
@@ -0,0 +1,234 @@
|
||||
package com.coulterpeterson.floatnative.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.json.JSONObject
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.security.interfaces.ECPublicKey
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import java.util.UUID
|
||||
|
||||
class DPoPManager(context: Context) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"floatnative_dpop_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KEY_PRIVATE_KEY = "dpop_private_key"
|
||||
private const val KEY_PUBLIC_KEY = "dpop_public_key"
|
||||
private const val KEY_TIME_OFFSET = "dpop_time_offset"
|
||||
}
|
||||
|
||||
|
||||
// Time offset in seconds (Service Time - Local Time)
|
||||
var timeOffsetSeconds: Long = prefs.getLong(KEY_TIME_OFFSET, 0L)
|
||||
set(value) {
|
||||
field = value
|
||||
prefs.edit().putLong(KEY_TIME_OFFSET, value).apply()
|
||||
}
|
||||
|
||||
private var keyPair: KeyPair? = null
|
||||
|
||||
@Synchronized
|
||||
private fun getOrGenerateKeyPair(): KeyPair {
|
||||
if (keyPair != null) return keyPair!!
|
||||
|
||||
// Try to load
|
||||
val privString = prefs.getString(KEY_PRIVATE_KEY, null)
|
||||
val pubString = prefs.getString(KEY_PUBLIC_KEY, null)
|
||||
|
||||
if (privString != null && pubString != null) {
|
||||
try {
|
||||
val privBytes = Base64.decode(privString, Base64.NO_WRAP)
|
||||
val pubBytes = Base64.decode(pubString, Base64.NO_WRAP)
|
||||
|
||||
val kf = KeyFactory.getInstance("EC")
|
||||
val privSpec = PKCS8EncodedKeySpec(privBytes)
|
||||
val pubSpec = X509EncodedKeySpec(pubBytes)
|
||||
|
||||
val privateKey = kf.generatePrivate(privSpec)
|
||||
val publicKey = kf.generatePublic(pubSpec)
|
||||
|
||||
keyPair = KeyPair(publicKey, privateKey)
|
||||
return keyPair!!
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new
|
||||
val kpg = KeyPairGenerator.getInstance("EC")
|
||||
kpg.initialize(ECGenParameterSpec("secp256r1")) // P-256
|
||||
val map = kpg.generateKeyPair()
|
||||
|
||||
// Save
|
||||
val privEnc = Base64.encodeToString(map.private.encoded, Base64.NO_WRAP)
|
||||
val pubEnc = Base64.encodeToString(map.public.encoded, Base64.NO_WRAP)
|
||||
|
||||
prefs.edit()
|
||||
.putString(KEY_PRIVATE_KEY, privEnc)
|
||||
.putString(KEY_PUBLIC_KEY, pubEnc)
|
||||
.apply()
|
||||
|
||||
keyPair = map
|
||||
return map
|
||||
}
|
||||
|
||||
fun generateProof(httpMethod: String, httpUrl: String, accessToken: String? = null): String {
|
||||
val keys = getOrGenerateKeyPair()
|
||||
val publicKey = keys.public as ECPublicKey
|
||||
|
||||
// 1. JWK Header
|
||||
// We need to construct a JWK manually from the public key parameters (x, y)
|
||||
// This is a bit tedious in Java/Kotlin without a library like Nimbus, but feasible.
|
||||
// Or we can just include the "jwk" claim with basic kty/crv/x/y.
|
||||
|
||||
// Get X and Y coordinates from EC Public Key
|
||||
val w = publicKey.w
|
||||
val xBytes = toByteArray(w.affineX, 32)
|
||||
val yBytes = toByteArray(w.affineY, 32)
|
||||
|
||||
val jwk = JSONObject()
|
||||
jwk.put("kty", "EC")
|
||||
jwk.put("crv", "P-256")
|
||||
jwk.put("x", base64UrlEncode(xBytes))
|
||||
jwk.put("y", base64UrlEncode(yBytes))
|
||||
|
||||
val header = JSONObject()
|
||||
header.put("typ", "dpop+jwt")
|
||||
header.put("alg", "ES256")
|
||||
header.put("jwk", jwk)
|
||||
|
||||
// 2. Payload
|
||||
// Strip query params from URL
|
||||
var htu = httpUrl
|
||||
if (httpUrl.contains("?")) {
|
||||
htu = httpUrl.split("?")[0]
|
||||
}
|
||||
|
||||
val payload = JSONObject()
|
||||
payload.put("iat", (System.currentTimeMillis() / 1000) + timeOffsetSeconds)
|
||||
payload.put("jti", UUID.randomUUID().toString())
|
||||
payload.put("htm", httpMethod)
|
||||
payload.put("htu", htu)
|
||||
|
||||
if (accessToken != null) {
|
||||
// ath: base64url(sha256(access_token))
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val hash = md.digest(accessToken.toByteArray(Charsets.UTF_8))
|
||||
payload.put("ath", base64UrlEncode(hash))
|
||||
}
|
||||
|
||||
// 3. Sign
|
||||
val headerStr = base64UrlEncode(header.toString().toByteArray(Charsets.UTF_8))
|
||||
val payloadStr = base64UrlEncode(payload.toString().toByteArray(Charsets.UTF_8))
|
||||
val toSign = "$headerStr.$payloadStr"
|
||||
|
||||
val signature = Signature.getInstance("SHA256withECDSA")
|
||||
signature.initSign(keys.private)
|
||||
signature.update(toSign.toByteArray(Charsets.UTF_8))
|
||||
val sigBytes = signature.sign()
|
||||
|
||||
// Convert DER signature to JOSE (Client-side R+S concatenation) requires parsing DER.
|
||||
// Android's typical crypto providers output DER.
|
||||
// We need R and S as raw 32-byte integers concatenated used for JWT ES256.
|
||||
val joseSig = derToJose(sigBytes)
|
||||
|
||||
return "$toSign.${base64UrlEncode(joseSig)}"
|
||||
}
|
||||
|
||||
private fun base64UrlEncode(bytes: ByteArray): String {
|
||||
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun toByteArray(value: java.math.BigInteger, size: Int): ByteArray {
|
||||
val array = value.toByteArray()
|
||||
if (array.size == size) return array
|
||||
// BigInteger might add a sign byte if MSB is set, or remove leading zeros.
|
||||
// If > size, strip leading bytes (sign extension).
|
||||
// If < size, pad with zeros.
|
||||
if (array.size > size) {
|
||||
return array.copyOfRange(array.size - size, array.size)
|
||||
}
|
||||
val padded = ByteArray(size)
|
||||
System.arraycopy(array, 0, padded, size - array.size, array.size)
|
||||
return padded
|
||||
}
|
||||
|
||||
// Convert DER encoded signature (ASN.1) to Raw R|S concatenation (64 bytes)
|
||||
private fun derToJose(der: ByteArray): ByteArray {
|
||||
// DER Structure: 0x30 <len> 0x02 <lenR> <R> 0x02 <lenS> <S>
|
||||
// Use a simple parser or generic ASN1 parser
|
||||
// Assuming strictly correct DER from system provider
|
||||
|
||||
var pos = 0
|
||||
if (der[pos++] != 0x30.toByte()) throw IllegalArgumentException("Not a DER sequence")
|
||||
|
||||
var len = der[pos++].toInt()
|
||||
if (len < 0) { // multi-byte length, skip (unlikely for ECDSA P-256 sigs which are small)
|
||||
// simplified for this context, usually < 127
|
||||
}
|
||||
|
||||
// R
|
||||
if (der[pos++] != 0x02.toByte()) throw IllegalArgumentException("Not an integer")
|
||||
var rLen = der[pos++].toInt()
|
||||
var rStart = pos
|
||||
pos += rLen
|
||||
|
||||
// S
|
||||
if (der[pos++] != 0x02.toByte()) throw IllegalArgumentException("Not an integer")
|
||||
var sLen = der[pos++].toInt()
|
||||
var sStart = pos
|
||||
|
||||
// Extract R and S, remove leading zero if present for unsigned BigInteger interpretation
|
||||
val rBytes = Arrays_copyOfRange(der, rStart, rStart + rLen)
|
||||
val sBytes = Arrays_copyOfRange(der, sStart, sStart + sLen)
|
||||
|
||||
// Normalize to 32 bytes
|
||||
val rNorm = normalizeInt(rBytes)
|
||||
val sNorm = normalizeInt(sBytes)
|
||||
|
||||
val result = ByteArray(64)
|
||||
System.arraycopy(rNorm, 0, result, 0, 32)
|
||||
System.arraycopy(sNorm, 0, result, 32, 32)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Arrays_copyOfRange(original: ByteArray, from: Int, to: Int): ByteArray {
|
||||
val newLength = to - from
|
||||
val copy = ByteArray(newLength)
|
||||
System.arraycopy(original, from, copy, 0, newLength)
|
||||
return copy
|
||||
}
|
||||
|
||||
private fun normalizeInt(bytes: ByteArray): ByteArray {
|
||||
var start = 0
|
||||
// Skip leading zero if present (sign byte)
|
||||
if (bytes.size > 32 && bytes[0] == 0.toByte()) {
|
||||
start = 1
|
||||
}
|
||||
val len = bytes.size - start
|
||||
|
||||
val result = ByteArray(32)
|
||||
val destPos = if (len < 32) 32 - len else 0
|
||||
System.arraycopy(bytes, start, result, destPos, if (len > 32) 32 else len)
|
||||
return result
|
||||
}
|
||||
}
|
||||
16
doc/research/reference-sources/fn_ManualApi.kt
Normal file
16
doc/research/reference-sources/fn_ManualApi.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.coulterpeterson.floatnative.api
|
||||
|
||||
import com.coulterpeterson.floatnative.openapi.models.UserSubscriptionModel
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
|
||||
interface ManualApi {
|
||||
@GET("api/v3/user/subscriptions")
|
||||
suspend fun getSubscriptions(): Response<List<UserSubscriptionModel>>
|
||||
|
||||
@GET("api/v3/content/history")
|
||||
suspend fun getWatchHistory(@retrofit2.http.Query("offset") offset: Int): Response<List<WatchHistoryResponse>>
|
||||
|
||||
@GET("api/v3/creator/list")
|
||||
suspend fun getCreatorsByIds(@retrofit2.http.Query("ids[]") ids: List<String>): Response<List<com.coulterpeterson.floatnative.openapi.models.CreatorModelV3>>
|
||||
}
|
||||
49
doc/research/reference-sources/fn_OAuthApi.kt
Normal file
49
doc/research/reference-sources/fn_OAuthApi.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.coulterpeterson.floatnative.api
|
||||
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface OAuthApi {
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("realms/floatplane/protocol/openid-connect/token")
|
||||
suspend fun getToken(
|
||||
@retrofit2.http.Header("DPoP") dpop: String,
|
||||
@Field("grant_type") grantType: String,
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("code") code: String? = null,
|
||||
@Field("code_verifier") codeVerifier: String? = null,
|
||||
@Field("refresh_token") refreshToken: String? = null,
|
||||
@Field("redirect_uri") redirectUri: String? = null,
|
||||
@Field("device_code") deviceCode: String? = null
|
||||
): OAuthTokenResponse
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("realms/floatplane/protocol/openid-connect/auth/device")
|
||||
suspend fun startDeviceAuth(
|
||||
@Field("client_id") clientId: String,
|
||||
@Field("scope") scope: String = "openid offline_access"
|
||||
): DeviceCodeResponse
|
||||
}
|
||||
|
||||
data class OAuthTokenResponse(
|
||||
val access_token: String,
|
||||
val expires_in: Int,
|
||||
val refresh_token: String?,
|
||||
val refresh_expires_in: Int?,
|
||||
val token_type: String,
|
||||
val id_token: String?,
|
||||
val not_before_policy: Int?,
|
||||
val session_state: String?,
|
||||
val scope: String?
|
||||
)
|
||||
|
||||
data class DeviceCodeResponse(
|
||||
val device_code: String,
|
||||
val user_code: String,
|
||||
val verification_uri: String,
|
||||
val verification_uri_complete: String?,
|
||||
val expires_in: Int,
|
||||
val interval: Int
|
||||
)
|
||||
22
doc/research/reference-sources/fn_PKCEManager.kt
Normal file
22
doc/research/reference-sources/fn_PKCEManager.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.coulterpeterson.floatnative.data
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
object PKCEManager {
|
||||
|
||||
fun generateCodeVerifier(): String {
|
||||
val secureRandom = SecureRandom()
|
||||
val codeVerifier = ByteArray(32)
|
||||
secureRandom.nextBytes(codeVerifier)
|
||||
return Base64.encodeToString(codeVerifier, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
}
|
||||
|
||||
fun generateCodeChallenge(verifier: String): String {
|
||||
val bytes = verifier.toByteArray(Charsets.US_ASCII)
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val digest = md.digest(bytes)
|
||||
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
}
|
||||
}
|
||||
113
doc/research/reference-sources/fn_README.md
Normal file
113
doc/research/reference-sources/fn_README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# FloatNative
|
||||
|
||||
<table align="center"><tr>
|
||||
<td>
|
||||
<a href="https://apps.apple.com/ca/app/floatnative/id6754177516">
|
||||
<img alt="Download on the App Store"
|
||||
src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83"
|
||||
height="40">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.coulterpeterson.floatnative">
|
||||
<img alt="Get it on Google Play"
|
||||
src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
||||
height="60">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://coulterpeterson.com/floatnative/">
|
||||
<img alt="APK Download"
|
||||
src="https://img.shields.io/badge/APK_Download-181717?style=for-the-badge&logo=android&logoColor=white"
|
||||
height="40">
|
||||
</a>
|
||||
</td>
|
||||
</tr></table>
|
||||
|
||||
A modern, native client for [Floatplane](https://www.floatplane.com) — built from scratch for iPhone, iPad, Apple TV, Android phone, Android TV, and Fire TV.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Native everywhere.** SwiftUI on Apple platforms, Jetpack Compose on Android. Not a wrapped web view.
|
||||
- **Closed captions** on every platform, with system-styling support.
|
||||
- **Picture-in-picture and background audio** on iPhone, iPad, and Android.
|
||||
- **Watch Later, custom Playlists, and Enhanced LTT search** via a companion API.
|
||||
- **Simple Floatplane OAuth login** — sign in with your Floatplane account through Floatplane's official OAuth flow; tokens are DPoP-bound to a private key in the device Keychain / Keystore so they can't be replayed if intercepted.
|
||||
- **Player polish**: sleep timer, force-landscape, screen-stay-awake, playback-speed memory, tappable timestamps in descriptions, watch-progress restore, multi-part post support.
|
||||
- **TV-first TV apps** for tvOS, Android TV, and Fire TV — not phone apps stretched onto a 65" screen.
|
||||
|
||||
## Repository layout
|
||||
|
||||
This is a pnpm + Gradle monorepo.
|
||||
|
||||
```
|
||||
floatnative/
|
||||
├── apps/
|
||||
│ ├── ios/ # SwiftUI app — iOS, iPadOS, tvOS
|
||||
│ ├── android/ # Jetpack Compose app — Android phone, Android TV, Fire TV
|
||||
│ └── chrome-extension/ # Companion extension for Floatplane playlists in the web UI
|
||||
│
|
||||
├── packages/
|
||||
│ ├── api/ # Companion API — original Cloudflare Workers (TS, Hono, D1)
|
||||
│ ├── api-go/ # Companion API — Go reimplementation for self-hosting (chi, Postgres)
|
||||
│ └── openapi/ # Community-maintained OpenAPI spec + Swift/Kotlin model generation
|
||||
│
|
||||
└── tools/
|
||||
└── floatcli/ # Diagnostic CLI that mirrors what the apps do, for reproducing bugs
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
### iOS / iPadOS / tvOS
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
open apps/ios/FloatNative.xcodeproj
|
||||
```
|
||||
|
||||
Requirements: Xcode 16.4+, iOS 17+ / tvOS 17+.
|
||||
|
||||
### Android / Android TV / Fire TV
|
||||
|
||||
Open `apps/android/` in Android Studio (Hedgehog or newer) and let Gradle sync. Min SDK 26, target SDK 36.
|
||||
|
||||
### Companion API
|
||||
|
||||
```bash
|
||||
pnpm api:dev # Cloudflare Workers local dev
|
||||
pnpm api:deploy # ship to Cloudflare
|
||||
```
|
||||
|
||||
For the Go variant (self-hosting on a Linux box with Postgres), see [`packages/api-go/README.md`](packages/api-go/README.md).
|
||||
|
||||
### Regenerate API models
|
||||
|
||||
```bash
|
||||
pnpm openapi:generate:swift # iOS models
|
||||
pnpm openapi:generate:kotlin # Android models
|
||||
pnpm openapi:generate:all # both
|
||||
pnpm openapi:update-spec # pull latest community spec
|
||||
```
|
||||
|
||||
Spec and generation scripts live in [`packages/openapi/`](packages/openapi/).
|
||||
|
||||
## Authentication & DPoP
|
||||
|
||||
Floatplane's V2 auth uses **DPoP (Demonstrating Proof-of-Possession)** to bind OAuth tokens to a device-held private key, so intercepted tokens can't be replayed elsewhere.
|
||||
|
||||
Both apps store the DPoP key in the platform's secure enclave (iOS Keychain, Android Keystore) and sign every request with a fresh proof. For HLS playback — where neither AVPlayer nor ExoPlayer can sign individual segment requests — we use a **manifest interception** strategy: a custom resource loader signs the master playlist and key requests with DPoP, then rewrites segment URIs to absolute `https://` so the player streams them natively without DPoP. The backend is designed around this split: keys require session-bound auth, segments don't.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- The community-maintained [FloatplaneAPI](https://github.com/jamamp/FloatplaneAPI) spec
|
||||
- [Hydravion-AndroidTV](https://github.com/bmlzootown/Hydravion-AndroidTV) — invaluable as a reverse-engineering reference
|
||||
- [Wasserflug-tvOS](https://github.com/jamamp/Wasserflug-tvOS) — a great tvOS client my wife and I used every day for years, and a strong codebase to learn from
|
||||
- [apple-docs MCP](https://github.com/kimsungwhee/apple-docs-mcp)
|
||||
- Claude Code (why hide it)
|
||||
- The Floatplane team — for building a service worth writing a client for, and for putting up with nerds like me
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
This is an unofficial third-party client and is not affiliated with Floatplane Media Inc.
|
||||
116
doc/research/reference-sources/fn_TokenManager.kt
Normal file
116
doc/research/reference-sources/fn_TokenManager.kt
Normal file
@@ -0,0 +1,116 @@
|
||||
package com.coulterpeterson.floatnative.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class TokenManager(context: Context) {
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"floatnative_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_AUTH_COOKIE = "sails.sid"
|
||||
private const val KEY_TOKEN_EXPIRY = "token_expiry"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
private const val KEY_COMPANION_API_KEY = "companion_api_key"
|
||||
}
|
||||
|
||||
// Auth State Flow
|
||||
private val _authStateFlow = MutableStateFlow(retrieve(KEY_ACCESS_TOKEN))
|
||||
val authStateFlow = _authStateFlow.asStateFlow()
|
||||
|
||||
var accessToken: String?
|
||||
get() = retrieve(KEY_ACCESS_TOKEN)
|
||||
set(value) {
|
||||
save(KEY_ACCESS_TOKEN, value)
|
||||
_authStateFlow.value = value
|
||||
}
|
||||
|
||||
var refreshToken: String?
|
||||
get() = retrieve(KEY_REFRESH_TOKEN)
|
||||
set(value) = save(KEY_REFRESH_TOKEN, value)
|
||||
|
||||
var authCookie: String?
|
||||
get() = retrieve(KEY_AUTH_COOKIE)
|
||||
set(value) = save(KEY_AUTH_COOKIE, value)
|
||||
|
||||
var tokenExpiry: Long
|
||||
get() = prefs.getLong(KEY_TOKEN_EXPIRY, 0)
|
||||
set(value) = prefs.edit().putLong(KEY_TOKEN_EXPIRY, value).apply()
|
||||
|
||||
var userId: String?
|
||||
get() = retrieve(KEY_USER_ID)
|
||||
set(value) = save(KEY_USER_ID, value)
|
||||
|
||||
var companionApiKey: String?
|
||||
get() = retrieve(KEY_COMPANION_API_KEY)
|
||||
set(value) = save(KEY_COMPANION_API_KEY, value)
|
||||
|
||||
var codeVerifier: String?
|
||||
get() = retrieve("pkce_verifier")
|
||||
set(value) = save("pkce_verifier", value)
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
_authStateFlow.value = null
|
||||
}
|
||||
|
||||
private fun save(key: String, value: String?) {
|
||||
if (value == null) {
|
||||
prefs.edit().remove(key).apply()
|
||||
} else {
|
||||
prefs.edit().putString(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun retrieve(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
// Settings
|
||||
private val _themeFlow = kotlinx.coroutines.flow.MutableStateFlow(prefs.getString("theme_mode", "dark") ?: "dark")
|
||||
val themeFlow = _themeFlow.asStateFlow()
|
||||
|
||||
var themeMode: String
|
||||
get() = prefs.getString("theme_mode", "dark") ?: "dark"
|
||||
set(value) {
|
||||
prefs.edit().putString("theme_mode", value).apply()
|
||||
_themeFlow.value = value
|
||||
}
|
||||
|
||||
var enhancedLttSearchEnabled: Boolean
|
||||
get() = prefs.getBoolean("enhanced_ltt_search_enabled", false)
|
||||
set(value) {
|
||||
prefs.edit().putBoolean("enhanced_ltt_search_enabled", value).apply()
|
||||
}
|
||||
|
||||
var fakeLiveStreamEnabled: Boolean
|
||||
get() = prefs.getBoolean("fake_live_stream_enabled", false)
|
||||
set(value) {
|
||||
prefs.edit().putBoolean("fake_live_stream_enabled", value).apply()
|
||||
}
|
||||
|
||||
var downloadLocationUri: String?
|
||||
get() = prefs.getString("download_location_uri", null)
|
||||
set(value) {
|
||||
if (value == null) {
|
||||
prefs.edit().remove("download_location_uri").apply()
|
||||
} else {
|
||||
prefs.edit().putString("download_location_uri", value).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
38
doc/research/reference-sources/fn_VideoPlayerManager.kt
Normal file
38
doc/research/reference-sources/fn_VideoPlayerManager.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.coulterpeterson.floatnative.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class VideoPlayerManager(context: Context) {
|
||||
private val _player = ExoPlayer.Builder(context).build()
|
||||
val player: Player = _player
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying = _isPlaying.asStateFlow()
|
||||
|
||||
init {
|
||||
_player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun playMedia(url: String) {
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
_player.setMediaItem(mediaItem)
|
||||
_player.prepare()
|
||||
_player.play()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
_player.release()
|
||||
}
|
||||
}
|
||||
863
doc/research/reference-sources/fn_ios_AVPlayerManager.swift
Normal file
863
doc/research/reference-sources/fn_ios_AVPlayerManager.swift
Normal file
@@ -0,0 +1,863 @@
|
||||
//
|
||||
// AVPlayerManager.swift
|
||||
// FloatNative
|
||||
//
|
||||
// Advanced video player manager with background audio and PIP support
|
||||
// Created by Claude on 2025-10-08.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import MediaPlayer
|
||||
import MediaAccessibility
|
||||
|
||||
// MARK: - Player State
|
||||
|
||||
enum PlayerState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case playing
|
||||
case paused
|
||||
case buffering
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: PlayerState, rhs: PlayerState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle),
|
||||
(.loading, .loading),
|
||||
(.playing, .playing),
|
||||
(.paused, .paused),
|
||||
(.buffering, .buffering):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayer Manager
|
||||
|
||||
@MainActor
|
||||
class AVPlayerManager: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AVPlayerManager()
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var player: AVPlayer?
|
||||
@Published private(set) var playerState: PlayerState = .idle
|
||||
@Published private(set) var currentTime: Double = 0
|
||||
@Published private(set) var duration: Double = 0
|
||||
@Published private(set) var isPlaying = false
|
||||
@Published private(set) var availableQualities: [QualityVariant] = []
|
||||
@Published private(set) var currentQuality: QualityVariant?
|
||||
|
||||
// Captions (GH #11). The synthetic-master HLS path didn't survive
|
||||
// AVPlayer's parser, so we render captions ourselves as a SwiftUI
|
||||
// overlay driven by currentTime + cues. The CC button in the player
|
||||
// chrome toggles `captionsEnabled`.
|
||||
@Published private(set) var captionCues: [VTTCue] = []
|
||||
@Published var captionsEnabled: Bool = false
|
||||
|
||||
/// Cached system caption preference. We seed `captionsEnabled` from
|
||||
/// this when loading a video that has captions, so users with system
|
||||
/// captions on don't have to tap the in-app CC button on every video.
|
||||
static var systemCaptionsEnabled: Bool {
|
||||
MACaptionAppearanceGetDisplayType(.user) != .automatic
|
||||
}
|
||||
|
||||
// MARK: - PIP State (managed by CustomVideoPlayer)
|
||||
|
||||
@Published var isPIPActive = false // True only while PiP window is actively displayed
|
||||
@Published var isPIPSupported = true // AVPlayerViewController always supports PiP if device supports it
|
||||
|
||||
// Track if we have a PiP session that can be restored (true even when paused)
|
||||
// This stays true as long as playerViewController/delegate are stored for restoration
|
||||
@Published var hasPIPSession = false
|
||||
|
||||
// Keep playerViewController and its delegate alive during PiP
|
||||
// This prevents the delegate from being deallocated when the view is popped
|
||||
var playerViewController: AVPlayerViewController?
|
||||
var playerViewControllerDelegate: NSObject? // Stores the Coordinator
|
||||
|
||||
/// True the entire time AVPlayerViewController owns the fullscreen
|
||||
/// presentation — flipped on at `willBegin`, off at exit-complete.
|
||||
/// Used by VideoPlayerView.onDisappear to skip the singleton-player
|
||||
/// teardown during landscape rotation: SwiftUI fires .onDisappear on
|
||||
/// the host AFTER the fullscreen animation completes, so a "true only
|
||||
/// during the animation" flag (the original attempt) didn't catch the
|
||||
/// teardown window. Resetting the player while AVPlayerViewController
|
||||
/// is still presenting it produces the PlayerRemoteXPC -12860 black
|
||||
/// screen.
|
||||
var isInFullScreenTransition: Bool = false
|
||||
|
||||
/// tvOS: whether AVPlayerViewController is currently showing its
|
||||
/// transport bar / playback controls. Fed by the AVPlayerViewController
|
||||
/// delegate in CustomVideoPlayer. Published because the tvOS player
|
||||
/// views decide whether to claim the Menu press based on it — see
|
||||
/// VideoPlayerTvosView.exitCommandAction. It only flips on user
|
||||
/// interaction (a few times a minute at most), so the extra body
|
||||
/// evaluations are cheap.
|
||||
@Published var isTransportBarVisible: Bool = false
|
||||
|
||||
// MARK: - Current Video Info
|
||||
|
||||
private(set) var currentVideoId: String?
|
||||
private(set) var currentVideoTitle: String?
|
||||
@Published var currentPost: BlogPost?
|
||||
@Published var shouldRestoreVideoPlayer: Bool = false
|
||||
private(set) var isLiveStream: Bool = false
|
||||
|
||||
// MARK: - Observers
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var statusObserver: AnyCancellable?
|
||||
private var itemObserver: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var progressTimer: DispatchSourceTimer?
|
||||
private var backgroundObserver: NSObjectProtocol?
|
||||
private var videoEndObserver: NSObjectProtocol?
|
||||
|
||||
// MARK: - Buffer State
|
||||
|
||||
@Published private(set) var isBuffering = false
|
||||
@Published private(set) var bufferProgress: Double = 0
|
||||
private var lastBufferUpdate: TimeInterval = 0
|
||||
|
||||
// MARK: - Audio Session
|
||||
|
||||
private let audioSession = AVAudioSession.sharedInstance()
|
||||
|
||||
// MARK: - Persisted Playback Rate
|
||||
|
||||
/// UserDefaults-backed playback rate that sticks across videos and app
|
||||
/// launches. Mirrors what YouTube / the official Floatplane app do.
|
||||
private static let playbackRateKey = "playbackRate"
|
||||
static let availablePlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
var persistedPlaybackRate: Float {
|
||||
get {
|
||||
let raw = UserDefaults.standard.float(forKey: Self.playbackRateKey)
|
||||
return raw > 0 ? raw : 1.0
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Self.playbackRateKey)
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
setupAudioSession()
|
||||
setupRemoteCommandCenter()
|
||||
setupBackgroundObserver()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session Setup
|
||||
|
||||
private func setupAudioSession() {
|
||||
do {
|
||||
// Configure audio session for background playback (needed for
|
||||
// PiP and AirPlay to behave correctly).
|
||||
try audioSession.setCategory(.playback, mode: .moviePlayback)
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("⚠️ Failed to setup audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Command Center (Lock Screen Controls)
|
||||
|
||||
private func setupRemoteCommandCenter() {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
// Play command
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
self?.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Pause command
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Skip forward
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [15]
|
||||
commandCenter.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.seek(by: 15)
|
||||
return .success
|
||||
}
|
||||
|
||||
// Skip backward
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
self?.seek(by: -15)
|
||||
return .success
|
||||
}
|
||||
|
||||
// Seek command
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .commandFailed
|
||||
}
|
||||
self?.seek(to: event.positionTime)
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background & Lifecycle Observers
|
||||
|
||||
private func setupBackgroundObserver() {
|
||||
backgroundObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.saveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupVideoEndObserver() {
|
||||
guard let playerItem = player?.currentItem else { return }
|
||||
|
||||
videoEndObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.saveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Now Playing Info
|
||||
|
||||
private func updateNowPlayingInfo() {
|
||||
#if os(tvOS)
|
||||
// AVPlayerViewController owns Now Playing on tvOS: it overrides the
|
||||
// duration/rate/elapsed fields, drops our title, and logs a warning
|
||||
// burst on every write — and this runs on every time-observer tick.
|
||||
// The title goes in via `externalMetadata` instead (see loadStream).
|
||||
return
|
||||
#else
|
||||
var nowPlayingInfo = [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = currentVideoTitle ?? "Floatplane Video"
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player?.rate ?? 0
|
||||
|
||||
// TODO: Add artwork from thumbnail
|
||||
// if let thumbnail = currentThumbnail {
|
||||
// nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(...)
|
||||
// }
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
/// Title metadata for AVKit's Now Playing / info panel on tvOS.
|
||||
private static func externalMetadata(title: String?) -> [AVMetadataItem] {
|
||||
guard let title, !title.isEmpty else { return [] }
|
||||
let item = AVMutableMetadataItem()
|
||||
item.identifier = .commonIdentifierTitle
|
||||
item.value = title as NSString
|
||||
item.extendedLanguageTag = "und"
|
||||
return [item]
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Load Video
|
||||
|
||||
func loadVideo(
|
||||
videoId: String,
|
||||
title: String,
|
||||
post: BlogPost? = nil,
|
||||
startTime: Double = 0,
|
||||
qualities: [QualityVariant],
|
||||
isLive: Bool = false,
|
||||
// Caption cues parsed from Floatplane's WebVTT (GH #11). Stored on
|
||||
// the manager so CaptionsOverlayView can render the active cue based
|
||||
// on currentTime. Empty for videos without captions.
|
||||
captionCues: [VTTCue] = []
|
||||
) async throws {
|
||||
self.currentVideoId = videoId
|
||||
self.currentVideoTitle = title
|
||||
self.currentPost = post
|
||||
self.availableQualities = qualities
|
||||
self.playerState = .loading
|
||||
self.isLiveStream = isLive
|
||||
self.captionCues = captionCues
|
||||
// Auto-enable when the user has system captions on AND the post
|
||||
// ships a caption track. Otherwise leave the in-app toggle off.
|
||||
self.captionsEnabled = !captionCues.isEmpty && Self.systemCaptionsEnabled
|
||||
|
||||
// Use highest quality by default
|
||||
guard let quality = qualities.first else {
|
||||
throw FloatplaneAPIError.invalidResponse
|
||||
}
|
||||
|
||||
self.currentQuality = quality
|
||||
|
||||
print("🎬 [AVPlayerManager] Loading video: \(title) (Live: \(isLive)) cues: \(captionCues.count)")
|
||||
try await loadStream(url: quality.url, startTime: startTime, isLive: isLive)
|
||||
}
|
||||
|
||||
private let resourceLoader = VideoResourceLoader()
|
||||
|
||||
/// Load stream from URL
|
||||
private func loadStream(url: String, startTime: Double = 0, isLive: Bool) async throws {
|
||||
// Clean up old player
|
||||
cleanupPlayer()
|
||||
|
||||
// Re-activate the audio session in case it was deactivated by a
|
||||
// previous reset() (GH #40). setupAudioSession() only runs once at
|
||||
// init, so without this a video loaded after a reset would play
|
||||
// silent on background routes.
|
||||
try? audioSession.setActive(true)
|
||||
|
||||
let asset: AVURLAsset
|
||||
|
||||
if isLive {
|
||||
// Bypass VideoResourceLoader for Live Streams
|
||||
// Use the original URL directly so AVPlayer handles HLS natively
|
||||
guard let streamURL = URL(string: url) else {
|
||||
throw FloatplaneAPIError.invalidURL
|
||||
}
|
||||
print("📡 [AVPlayerManager] Loading LIVE stream directly: \(streamURL.redactingQuery)")
|
||||
|
||||
// Create asset without custom resource loader
|
||||
asset = AVURLAsset(url: streamURL)
|
||||
// No resourceLoader delegate set for live streams
|
||||
} else {
|
||||
// Convert HTTP/HTTPS to custom scheme to force interception via VideoResourceLoader.
|
||||
// This is what gives DPoP + key-rewrite a hook into the HLS pipeline. Captions
|
||||
// no longer ride this path — they're rendered as a SwiftUI overlay (GH #11).
|
||||
guard var components = URLComponents(string: url) else {
|
||||
throw FloatplaneAPIError.invalidURL
|
||||
}
|
||||
components.scheme = "floatnative" // Must match VideoResourceLoader.customScheme
|
||||
guard let streamURL = components.url else {
|
||||
throw FloatplaneAPIError.invalidURL
|
||||
}
|
||||
print("📼 [AVPlayerManager] Loading VOD stream with interception: \(streamURL.redactingQuery)")
|
||||
|
||||
// Create new player with Interceptor
|
||||
// We do NOT pass headers here because the ResourceLoader will handle the request.
|
||||
asset = AVURLAsset(url: streamURL)
|
||||
asset.resourceLoader.setDelegate(resourceLoader, queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
#if os(tvOS)
|
||||
playerItem.externalMetadata = Self.externalMetadata(title: currentVideoTitle)
|
||||
#endif
|
||||
|
||||
// Configure buffer limits for tvOS to prevent memory issues during long playback
|
||||
#if os(tvOS)
|
||||
// Limit buffer to 30 seconds ahead to reduce memory pressure on tvOS
|
||||
playerItem.preferredForwardBufferDuration = 30
|
||||
#else
|
||||
// iOS can handle larger buffers
|
||||
playerItem.preferredForwardBufferDuration = 60
|
||||
#endif
|
||||
|
||||
|
||||
let newPlayer = AVPlayer(playerItem: playerItem)
|
||||
newPlayer.allowsExternalPlayback = true
|
||||
newPlayer.appliesMediaSelectionCriteriaAutomatically = true
|
||||
|
||||
// Configure player to minimize stalling while respecting buffer limits
|
||||
if #available(iOS 10.0, tvOS 10.0, *) {
|
||||
newPlayer.automaticallyWaitsToMinimizeStalling = true
|
||||
}
|
||||
|
||||
self.player = newPlayer
|
||||
|
||||
// Apply the user's preferred playback speed before the first play()
|
||||
// so they don't see a 1.0× flash. defaultRate is iOS/tvOS 16+; our
|
||||
// deployment target is 18+.
|
||||
newPlayer.defaultRate = persistedPlaybackRate
|
||||
|
||||
// Persist any future rate changes the user makes via the
|
||||
// AVPlayerViewController chrome (iOS) or the transport bar menu
|
||||
// (tvOS). Only persist while playing — rate goes to 0 on pause,
|
||||
// we don't want to lose the user's selection. Also push the new
|
||||
// rate into defaultRate so a subsequent pause → play resumes at
|
||||
// the chosen speed instead of snapping back to 1.0×.
|
||||
rateObservation = newPlayer.observe(\.rate, options: [.new]) { [weak self] player, change in
|
||||
guard let newRate = change.newValue, newRate > 0 else { return }
|
||||
self?.persistedPlaybackRate = newRate
|
||||
if abs(player.defaultRate - newRate) > 0.01 {
|
||||
player.defaultRate = newRate
|
||||
}
|
||||
}
|
||||
|
||||
// Add observers for detailed logging
|
||||
addDebugObservers(to: playerItem)
|
||||
|
||||
// Observe player status
|
||||
observePlayer()
|
||||
|
||||
// Setup video end observer
|
||||
setupVideoEndObserver()
|
||||
|
||||
// Seek to start time if specified
|
||||
if startTime > 0 && !isLive {
|
||||
seek(to: startTime)
|
||||
}
|
||||
|
||||
// Update state
|
||||
playerState = .paused
|
||||
}
|
||||
|
||||
private func addDebugObservers(to item: AVPlayerItem) {
|
||||
NotificationCenter.default.addObserver(forName: .AVPlayerItemNewErrorLogEntry, object: item, queue: .main) { notification in
|
||||
guard let playerItem = notification.object as? AVPlayerItem,
|
||||
let errorLog = playerItem.errorLog()?.events.last else { return }
|
||||
print("🚨 [AVPlayer] Error Log: \(errorLog.errorDomain) \(errorLog.errorStatusCode) — \(errorLog.errorComment ?? "(no comment)") @ \((errorLog.uri ?? "?").redactingURLQuery)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .AVPlayerItemNewAccessLogEntry, object: item, queue: .main) { notification in
|
||||
guard let playerItem = notification.object as? AVPlayerItem,
|
||||
let accessLog = playerItem.accessLog()?.events.last else { return }
|
||||
print("ℹ️ [AVPlayer] Access Log: URI: \((accessLog.uri ?? "").redactingURLQuery) | Bitrate: \(accessLog.indicatedBitrate)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .AVPlayerItemFailedToPlayToEndTime, object: item, queue: .main) { notification in
|
||||
if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
|
||||
print("❌ [AVPlayer] Failed to play to end: \(error.localizedDescription)")
|
||||
TVLog.log(.player, "failed to play to end", detail: error.localizedDescription, notable: true)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall is one of the plausible explanations for "the remote did
|
||||
// nothing for ten seconds" — the UI is alive but nothing visibly
|
||||
// reacts. Timestamped here so it can be lined up against the press
|
||||
// and hang traces.
|
||||
NotificationCenter.default.addObserver(forName: AVPlayerItem.playbackStalledNotification, object: item, queue: .main) { _ in
|
||||
TVLog.log(.player, "playback STALLED", notable: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Change Quality
|
||||
|
||||
func changeQuality(_ quality: QualityVariant) async throws {
|
||||
guard let player = player else { return }
|
||||
|
||||
let currentTime = player.currentTime().seconds
|
||||
self.currentQuality = quality
|
||||
|
||||
try await loadStream(url: quality.url, startTime: currentTime, isLive: isLiveStream)
|
||||
|
||||
if isPlaying {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
// PiP is now handled by CustomVideoPlayer (AVPlayerViewController)
|
||||
// which has native PiP support built-in
|
||||
|
||||
// MARK: - Player Observation
|
||||
|
||||
private func observePlayer() {
|
||||
guard let player = player else { return }
|
||||
|
||||
// Time observer
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
Task { @MainActor in
|
||||
// Update without animation to avoid AttributeGraph cycles
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
self?.currentTime = time.seconds
|
||||
}
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// Duration observer
|
||||
player.currentItem?.publisher(for: \.duration)
|
||||
.sink { [weak self] duration in
|
||||
Task { @MainActor in
|
||||
if duration.isNumeric {
|
||||
self?.duration = duration.seconds
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Status observer
|
||||
player.publisher(for: \.timeControlStatus)
|
||||
.sink { [weak self] status in
|
||||
Task { @MainActor in
|
||||
switch status {
|
||||
case .playing:
|
||||
self?.isPlaying = true
|
||||
self?.playerState = .playing
|
||||
case .paused:
|
||||
self?.isPlaying = false
|
||||
if self?.playerState == .playing {
|
||||
self?.playerState = .paused
|
||||
}
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
let reason = player.reasonForWaitingToPlay?.rawValue ?? "unknown"
|
||||
TVLog.log(.player, "waitingToPlayAtSpecifiedRate reason=\(reason)", notable: true)
|
||||
self?.playerState = .buffering
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Item status observer
|
||||
player.currentItem?.publisher(for: \.status)
|
||||
.sink { [weak self] status in
|
||||
Task { @MainActor in
|
||||
switch status {
|
||||
case .failed:
|
||||
if let error = player.currentItem?.error {
|
||||
TVLog.log(.player, "item status FAILED", detail: String(describing: error), notable: true)
|
||||
self?.playerState = .failed(error)
|
||||
}
|
||||
case .readyToPlay:
|
||||
if self?.playerState == .loading {
|
||||
self?.playerState = .paused
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Buffer empty observer - critical for detecting stalling
|
||||
player.currentItem?.publisher(for: \.isPlaybackBufferEmpty)
|
||||
.sink { [weak self] isEmpty in
|
||||
Task { @MainActor in
|
||||
if isEmpty {
|
||||
TVLog.log(.player, "playback buffer EMPTY — stalling", notable: true)
|
||||
self?.isBuffering = true
|
||||
self?.playerState = .buffering
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Buffer likely to keep up observer
|
||||
player.currentItem?.publisher(for: \.isPlaybackLikelyToKeepUp)
|
||||
.sink { [weak self] likelyToKeepUp in
|
||||
Task { @MainActor in
|
||||
if likelyToKeepUp {
|
||||
if self?.isBuffering == true {
|
||||
TVLog.log(.player, "playback likely to keep up — buffering cleared", notable: true)
|
||||
}
|
||||
self?.isBuffering = false
|
||||
if self?.isPlaying == true {
|
||||
self?.playerState = .playing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Buffer full observer
|
||||
player.currentItem?.publisher(for: \.isPlaybackBufferFull)
|
||||
.sink { [weak self] isFull in
|
||||
Task { @MainActor in
|
||||
// Log removed as per instruction
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Loaded time ranges observer - track buffer progress
|
||||
player.currentItem?.publisher(for: \.loadedTimeRanges)
|
||||
.sink { [weak self] timeRanges in
|
||||
Task { @MainActor in
|
||||
guard let self = self,
|
||||
let currentTime = self.player?.currentTime(),
|
||||
let timeRange = timeRanges.first?.timeRangeValue else {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle updates to once every 2 seconds to prevent excessive view redraws
|
||||
// This is critical for tvOS performance where view updates are expensive
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - self.lastBufferUpdate > 2.0 else { return }
|
||||
|
||||
let bufferEnd = CMTimeGetSeconds(CMTimeRangeGetEnd(timeRange))
|
||||
let currentTimeSeconds = CMTimeGetSeconds(currentTime)
|
||||
let bufferedAhead = bufferEnd - currentTimeSeconds
|
||||
|
||||
self.bufferProgress = max(0, bufferedAhead)
|
||||
self.lastBufferUpdate = now
|
||||
|
||||
#if os(tvOS)
|
||||
// Log buffer status on tvOS to help diagnose issues
|
||||
if Int(currentTimeSeconds) % 60 == 0 && Int(currentTimeSeconds) > 0 {
|
||||
print("📊 tvOS Buffer Status at \(Int(currentTimeSeconds))s: \(bufferedAhead)s ahead")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
func play() {
|
||||
player?.play()
|
||||
updateNowPlayingInfo()
|
||||
startProgressTimer()
|
||||
setIdleTimerDisabled(true) // Keep screen awake during playback
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
updateNowPlayingInfo()
|
||||
stopProgressTimer()
|
||||
setIdleTimerDisabled(false) // Allow screen to sleep when paused
|
||||
}
|
||||
|
||||
// MARK: - Idle Timer Management
|
||||
|
||||
private func setIdleTimerDisabled(_ disabled: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = disabled
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
if isPlaying {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
player?.seek(to: cmTime) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seek(by seconds: Double) {
|
||||
let newTime = currentTime + seconds
|
||||
let clampedTime = max(0, min(newTime, duration))
|
||||
seek(to: clampedTime)
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
player?.rate = rate
|
||||
}
|
||||
|
||||
// MARK: - Progress Tracking
|
||||
|
||||
/// Start periodic progress saving (every 2 minutes)
|
||||
private func startProgressTimer() {
|
||||
stopProgressTimer()
|
||||
|
||||
// Use DispatchSourceTimer on background queue to avoid blocking main thread
|
||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
|
||||
timer.schedule(deadline: .now() + 120, repeating: 120)
|
||||
timer.setEventHandler { [weak self] in
|
||||
Task { @MainActor in
|
||||
await self?.saveProgressWithTimeout()
|
||||
}
|
||||
}
|
||||
timer.resume()
|
||||
progressTimer = timer
|
||||
}
|
||||
|
||||
/// Stop periodic progress saving
|
||||
private func stopProgressTimer() {
|
||||
progressTimer?.cancel()
|
||||
progressTimer = nil
|
||||
}
|
||||
|
||||
/// Save progress to Floatplane API with timeout protection
|
||||
private func saveProgressWithTimeout() async {
|
||||
// Use Task.withTimeout-like pattern to prevent hanging
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await self.saveProgress()
|
||||
}
|
||||
|
||||
// Add timeout task (10 seconds)
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
}
|
||||
|
||||
// Wait for first to complete, then cancel others
|
||||
await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Save progress to Floatplane API
|
||||
func saveProgress() async {
|
||||
guard let videoId = currentVideoId else { return }
|
||||
|
||||
let progressInSeconds = Int(currentTime)
|
||||
|
||||
do {
|
||||
_ = try await FloatplaneAPI.shared.updateProgress(
|
||||
videoId: videoId,
|
||||
contentType: "video",
|
||||
progress: progressInSeconds
|
||||
)
|
||||
} catch {
|
||||
print("Failed to save progress: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanupPlayer() {
|
||||
// FIRST: Immediately stop playback and audio to prevent overlap
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
|
||||
// Re-enable idle timer (allow screen to sleep)
|
||||
setIdleTimerDisabled(false)
|
||||
|
||||
// Remove time observer
|
||||
if let timeObserver = timeObserver {
|
||||
player?.removeTimeObserver(timeObserver)
|
||||
self.timeObserver = nil
|
||||
}
|
||||
|
||||
// Remove background observer
|
||||
if let backgroundObserver = backgroundObserver {
|
||||
NotificationCenter.default.removeObserver(backgroundObserver)
|
||||
self.backgroundObserver = nil
|
||||
}
|
||||
|
||||
// Remove video end observer
|
||||
if let videoEndObserver = videoEndObserver {
|
||||
NotificationCenter.default.removeObserver(videoEndObserver)
|
||||
self.videoEndObserver = nil
|
||||
}
|
||||
|
||||
// Stop progress timer
|
||||
stopProgressTimer()
|
||||
|
||||
// Stop observing rate changes
|
||||
rateObservation?.invalidate()
|
||||
rateObservation = nil
|
||||
|
||||
// Cancel all subscriptions
|
||||
cancellables.removeAll()
|
||||
|
||||
player = nil
|
||||
}
|
||||
|
||||
// MARK: - Fullscreen Control
|
||||
|
||||
func enterFullScreen(animated: Bool = true) {
|
||||
guard let playerViewController = playerViewController else { return }
|
||||
let selector = NSSelectorFromString("enterFullScreenAnimated:completionHandler:")
|
||||
if playerViewController.responds(to: selector) {
|
||||
playerViewController.perform(selector, with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func exitFullScreen(animated: Bool = true) {
|
||||
guard let playerViewController = playerViewController else { return }
|
||||
let selector = NSSelectorFromString("exitFullScreenAnimated:completionHandler:")
|
||||
if playerViewController.responds(to: selector) {
|
||||
playerViewController.perform(selector, with: animated, with: nil)
|
||||
} else {
|
||||
// Fallback: Try private API selector for older/other versions if the standard one fails
|
||||
let privateSelector = NSSelectorFromString("_transitionFromFullScreenAnimated:completionHandler:")
|
||||
if playerViewController.responds(to: privateSelector) {
|
||||
playerViewController.perform(privateSelector, with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// Force the device into landscape orientation and enter native fullscreen,
|
||||
/// overriding the system rotation lock. Mirrors what YouTube / the official
|
||||
/// Floatplane app do when you tap their rotate-to-landscape button.
|
||||
///
|
||||
/// Uses iOS 16's `requestGeometryUpdate` API; deployment target is 18.5+
|
||||
/// so the availability check is just defensive.
|
||||
func forceLandscape() {
|
||||
requestOrientation(.landscape)
|
||||
enterFullScreen()
|
||||
}
|
||||
|
||||
/// Mirror of `forceLandscape()` — invoked when the user exits fullscreen
|
||||
/// or otherwise wants to return to portrait while rotation lock is on.
|
||||
func forcePortrait() {
|
||||
requestOrientation(.portrait)
|
||||
}
|
||||
|
||||
private func requestOrientation(_ mask: UIInterfaceOrientationMask) {
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive })
|
||||
else { return }
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in
|
||||
// Errors here (e.g. user-locked rotation refusing) are non-fatal.
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func reset() {
|
||||
cleanupPlayer()
|
||||
currentVideoId = nil
|
||||
currentVideoTitle = nil
|
||||
currentPost = nil
|
||||
shouldRestoreVideoPlayer = false
|
||||
availableQualities = []
|
||||
currentQuality = nil
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
isPlaying = false
|
||||
playerState = .idle
|
||||
playerViewController = nil
|
||||
playerViewControllerDelegate = nil
|
||||
hasPIPSession = false
|
||||
|
||||
// Release audio focus so we don't keep ducking other apps after the
|
||||
// user has fully left the player. setActive(true) is re-issued from
|
||||
// setupAudioSession() the next time a video loads (GH #40).
|
||||
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
// Note: deinit cannot call async methods, so cleanup happens via onDisappear in views
|
||||
}
|
||||
111
doc/research/reference-sources/fn_ios_AuthModels.swift
Normal file
111
doc/research/reference-sources/fn_ios_AuthModels.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// AuthModels.swift
|
||||
// FloatNative
|
||||
//
|
||||
// Created by Claude on 2025-10-08.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Login
|
||||
|
||||
struct LoginRequest: Codable {
|
||||
let username: String
|
||||
let password: String
|
||||
let captchaToken: String?
|
||||
}
|
||||
|
||||
// Using OpenAPI-generated models
|
||||
typealias LoginResponse = AuthLoginV2Response
|
||||
typealias User = UserModel // Basic user info (id, username, profileImage)
|
||||
|
||||
struct TwoFactorRequest: Codable {
|
||||
let token: String
|
||||
}
|
||||
|
||||
// MARK: - Captcha Info
|
||||
|
||||
struct CaptchaInfo: Codable {
|
||||
let v2: CaptchaKeys
|
||||
let v3: CaptchaKeys
|
||||
}
|
||||
|
||||
struct CaptchaKeys: Codable {
|
||||
let android: String
|
||||
let ios: String
|
||||
let web: String
|
||||
}
|
||||
|
||||
// MARK: - User Activity
|
||||
|
||||
struct UserActivity: Codable {
|
||||
let activity: [ActivityItem]
|
||||
}
|
||||
|
||||
struct ActivityItem: Codable {
|
||||
let time: Date
|
||||
let comment: Comment?
|
||||
let post: BlogPost?
|
||||
}
|
||||
|
||||
// MARK: - Notification Settings
|
||||
|
||||
struct NotificationSetting: Codable {
|
||||
let creator: String
|
||||
let property: String
|
||||
let newContent: NotificationChannel?
|
||||
let contentEmail: NotificationChannel?
|
||||
}
|
||||
|
||||
struct NotificationChannel: Codable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
|
||||
struct NotificationUpdateRequest: Codable {
|
||||
let creator: String
|
||||
let property: String
|
||||
let newValue: Bool
|
||||
}
|
||||
|
||||
// MARK: - OAuth
|
||||
|
||||
struct OAuthTokenResponse: Codable {
|
||||
let accessToken: String
|
||||
let expiresIn: Int
|
||||
let refreshExpiresIn: Int
|
||||
let refreshToken: String
|
||||
let tokenType: String
|
||||
let notBeforePolicy: Int
|
||||
let sessionState: String
|
||||
let scope: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case expiresIn = "expires_in"
|
||||
case refreshExpiresIn = "refresh_expires_in"
|
||||
case refreshToken = "refresh_token"
|
||||
case tokenType = "token_type"
|
||||
case notBeforePolicy = "not-before-policy"
|
||||
case sessionState = "session_state"
|
||||
case scope
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceCodeResponse: Codable {
|
||||
let deviceCode: String
|
||||
let userCode: String
|
||||
let verificationUri: String
|
||||
let verificationUriComplete: String
|
||||
let expiresIn: Int
|
||||
let interval: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case deviceCode = "device_code"
|
||||
case userCode = "user_code"
|
||||
case verificationUri = "verification_uri"
|
||||
case verificationUriComplete = "verification_uri_complete"
|
||||
case expiresIn = "expires_in"
|
||||
case interval
|
||||
}
|
||||
}
|
||||
1346
doc/research/reference-sources/fn_ios_FloatplaneAPI.swift
Normal file
1346
doc/research/reference-sources/fn_ios_FloatplaneAPI.swift
Normal file
File diff suppressed because it is too large
Load Diff
188
doc/research/reference-sources/fn_ios_VideoResourceLoader.swift
Normal file
188
doc/research/reference-sources/fn_ios_VideoResourceLoader.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// VideoResourceLoader.swift
|
||||
// FloatNative
|
||||
//
|
||||
// Intercepts AVPlayer's HLS subresource requests over a custom scheme so
|
||||
// we can attach DPoP / rewrite EXT-X-KEY URIs. The synthetic-HLS-master
|
||||
// caption path that lived here previously was abandoned (AVPlayer's HLS
|
||||
// parser kept rejecting it); captions are now rendered as a SwiftUI
|
||||
// overlay driven by the parsed cues — see CaptionsOverlay + AVPlayerManager.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
|
||||
|
||||
private let session: URLSession
|
||||
private let customScheme = "floatnative"
|
||||
private let log = Logger(subsystem: "ca.maplespace.FloatNative", category: "VideoResourceLoader")
|
||||
|
||||
override init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
self.session = URLSession(configuration: config)
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Delegate Methods
|
||||
|
||||
func resourceLoader(
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||
) -> Bool {
|
||||
guard let url = loadingRequest.request.url else { return false }
|
||||
|
||||
if url.scheme == customScheme {
|
||||
handleCustomSchemeRequest(loadingRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Handlers
|
||||
|
||||
private func handleCustomSchemeRequest(_ loadingRequest: AVAssetResourceLoadingRequest) {
|
||||
guard let url = loadingRequest.request.url,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
|
||||
loadingRequest.finishLoading(with: URLError(.badURL))
|
||||
return
|
||||
}
|
||||
|
||||
// Switch scheme back to https
|
||||
components.scheme = "https"
|
||||
guard let realURL = components.url else {
|
||||
loadingRequest.finishLoading(with: URLError(.badURL))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
if realURL.pathExtension.caseInsensitiveCompare("m3u8") == .orderedSame {
|
||||
try await handleManifestRequest(loadingRequest, realURL: realURL)
|
||||
}
|
||||
else if realURL.absoluteString.range(of: "key", options: .caseInsensitive) != nil
|
||||
|| realURL.pathExtension == "key" {
|
||||
try await handleKeyRequest(loadingRequest, realURL: realURL)
|
||||
}
|
||||
else {
|
||||
try await handleGenericRequest(loadingRequest, realURL: realURL)
|
||||
}
|
||||
} catch {
|
||||
loadingRequest.finishLoading(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Manifest Handling
|
||||
|
||||
private func handleManifestRequest(_ loadingRequest: AVAssetResourceLoadingRequest, realURL: URL) async throws {
|
||||
let data = try await fetchWithDPoP(url: realURL)
|
||||
guard let manifestString = String(data: data, encoding: .utf8) else {
|
||||
throw URLError(.cannotDecodeContentData)
|
||||
}
|
||||
|
||||
// Rewrite the manifest so AES-128 keys come back through us (we add
|
||||
// DPoP), but segments are fetched directly via https — there's no
|
||||
// need to proxy thousands of chunks.
|
||||
let baseURL = realURL.deletingLastPathComponent()
|
||||
var newLines: [String] = []
|
||||
manifestString.enumerateLines { line, _ in
|
||||
var processedLine = line
|
||||
|
||||
if line.contains("#EXT-X-KEY") {
|
||||
if let range = line.range(of: "URI=\"") {
|
||||
let rest = line[range.upperBound...]
|
||||
if let endQuote = rest.firstIndex(of: "\"") {
|
||||
let keyUriString = String(rest[..<endQuote])
|
||||
if let keyURL = URL(string: keyUriString, relativeTo: baseURL) {
|
||||
var keyComponents = URLComponents(url: keyURL, resolvingAgainstBaseURL: true)
|
||||
keyComponents?.scheme = self.customScheme
|
||||
if let newKeyUri = keyComponents?.string {
|
||||
processedLine = line.replacingOccurrences(of: keyUriString, with: newKeyUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if !line.hasPrefix("#") && !line.isEmpty {
|
||||
// Rewrite relative segment URIs to absolute https so AVPlayer
|
||||
// fetches them directly (bypassing this loader).
|
||||
if let segmentURL = URL(string: line, relativeTo: baseURL) {
|
||||
if segmentURL.scheme == "http" || segmentURL.scheme == "https" {
|
||||
processedLine = segmentURL.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newLines.append(processedLine)
|
||||
}
|
||||
|
||||
let modifiedManifest = newLines.joined(separator: "\n")
|
||||
guard let modifiedData = modifiedManifest.data(using: .utf8) else {
|
||||
throw URLError(.cannotDecodeContentData)
|
||||
}
|
||||
respond(loadingRequest, data: modifiedData, contentType: "public.m3u-playlist")
|
||||
}
|
||||
|
||||
private func handleKeyRequest(_ loadingRequest: AVAssetResourceLoadingRequest, realURL: URL) async throws {
|
||||
let data = try await fetchWithDPoP(url: realURL)
|
||||
respond(loadingRequest, data: data, contentType: "application/octet-stream")
|
||||
}
|
||||
|
||||
private func handleGenericRequest(_ loadingRequest: AVAssetResourceLoadingRequest, realURL: URL) async throws {
|
||||
let data = try await fetchWithDPoP(url: realURL)
|
||||
respond(loadingRequest, data: data, contentType: "application/octet-stream")
|
||||
}
|
||||
|
||||
/// Fill both contentInformationRequest and dataRequest from an in-memory
|
||||
/// buffer. AVAssetResourceLoader requires contentType on the first
|
||||
/// content-information request or it rejects the asset.
|
||||
private func respond(_ loadingRequest: AVAssetResourceLoadingRequest, data: Data, contentType: String) {
|
||||
if let info = loadingRequest.contentInformationRequest {
|
||||
info.contentType = contentType
|
||||
info.contentLength = Int64(data.count)
|
||||
info.isByteRangeAccessSupported = true
|
||||
}
|
||||
if let dataRequest = loadingRequest.dataRequest {
|
||||
let offset = Int(dataRequest.requestedOffset)
|
||||
let length = dataRequest.requestedLength
|
||||
let end = min(offset &+ length, data.count)
|
||||
if offset < data.count {
|
||||
dataRequest.respond(with: data.subdata(in: offset..<max(end, offset)))
|
||||
}
|
||||
}
|
||||
loadingRequest.finishLoading()
|
||||
}
|
||||
|
||||
// MARK: - Network
|
||||
|
||||
private func fetchWithDPoP(url: URL) async throws -> Data {
|
||||
let accessToken = await FloatplaneAPI.shared.accessToken
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
|
||||
if let token = accessToken {
|
||||
if let dpopProof = try? DPoPManager.shared.generateProof(
|
||||
httpMethod: "GET",
|
||||
httpUrl: url.absoluteString,
|
||||
accessToken: token
|
||||
) {
|
||||
request.setValue(dpopProof, forHTTPHeaderField: "DPoP")
|
||||
request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization")
|
||||
} else {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
114
doc/research/reference-sources/fn_repo.json
Normal file
114
doc/research/reference-sources/fn_repo.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"id": 1111899821,
|
||||
"node_id": "R_kgDOQkY-rQ",
|
||||
"name": "FloatNative",
|
||||
"full_name": "coulterpeterson/FloatNative",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "coulterpeterson",
|
||||
"id": 29346967,
|
||||
"node_id": "MDQ6VXNlcjI5MzQ2OTY3",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/29346967?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/coulterpeterson",
|
||||
"html_url": "https://github.com/coulterpeterson",
|
||||
"followers_url": "https://api.github.com/users/coulterpeterson/followers",
|
||||
"following_url": "https://api.github.com/users/coulterpeterson/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/coulterpeterson/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/coulterpeterson/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/coulterpeterson/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/coulterpeterson/orgs",
|
||||
"repos_url": "https://api.github.com/users/coulterpeterson/repos",
|
||||
"events_url": "https://api.github.com/users/coulterpeterson/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/coulterpeterson/received_events",
|
||||
"type": "User",
|
||||
"user_view_type": "public",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/coulterpeterson/FloatNative",
|
||||
"description": null,
|
||||
"fork": false,
|
||||
"url": "https://api.github.com/repos/coulterpeterson/FloatNative",
|
||||
"forks_url": "https://api.github.com/repos/coulterpeterson/FloatNative/forks",
|
||||
"keys_url": "https://api.github.com/repos/coulterpeterson/FloatNative/keys{/key_id}",
|
||||
"collaborators_url": "https://api.github.com/repos/coulterpeterson/FloatNative/collaborators{/collaborator}",
|
||||
"teams_url": "https://api.github.com/repos/coulterpeterson/FloatNative/teams",
|
||||
"hooks_url": "https://api.github.com/repos/coulterpeterson/FloatNative/hooks",
|
||||
"issue_events_url": "https://api.github.com/repos/coulterpeterson/FloatNative/issues/events{/number}",
|
||||
"events_url": "https://api.github.com/repos/coulterpeterson/FloatNative/events",
|
||||
"assignees_url": "https://api.github.com/repos/coulterpeterson/FloatNative/assignees{/user}",
|
||||
"branches_url": "https://api.github.com/repos/coulterpeterson/FloatNative/branches{/branch}",
|
||||
"tags_url": "https://api.github.com/repos/coulterpeterson/FloatNative/tags",
|
||||
"blobs_url": "https://api.github.com/repos/coulterpeterson/FloatNative/git/blobs{/sha}",
|
||||
"git_tags_url": "https://api.github.com/repos/coulterpeterson/FloatNative/git/tags{/sha}",
|
||||
"git_refs_url": "https://api.github.com/repos/coulterpeterson/FloatNative/git/refs{/sha}",
|
||||
"trees_url": "https://api.github.com/repos/coulterpeterson/FloatNative/git/trees{/sha}",
|
||||
"statuses_url": "https://api.github.com/repos/coulterpeterson/FloatNative/statuses/{sha}",
|
||||
"languages_url": "https://api.github.com/repos/coulterpeterson/FloatNative/languages",
|
||||
"stargazers_url": "https://api.github.com/repos/coulterpeterson/FloatNative/stargazers",
|
||||
"contributors_url": "https://api.github.com/repos/coulterpeterson/FloatNative/contributors",
|
||||
"subscribers_url": "https://api.github.com/repos/coulterpeterson/FloatNative/subscribers",
|
||||
"subscription_url": "https://api.github.com/repos/coulterpeterson/FloatNative/subscription",
|
||||
"commits_url": "https://api.github.com/repos/coulterpeterson/FloatNative/commits{/sha}",
|
||||
"git_commits_url": "https://api.github.com/repos/coulterpeterson/FloatNative/git/commits{/sha}",
|
||||
"comments_url": "https://api.github.com/repos/coulterpeterson/FloatNative/comments{/number}",
|
||||
"issue_comment_url": "https://api.github.com/repos/coulterpeterson/FloatNative/issues/comments{/number}",
|
||||
"contents_url": "https://api.github.com/repos/coulterpeterson/FloatNative/contents/{+path}",
|
||||
"compare_url": "https://api.github.com/repos/coulterpeterson/FloatNative/compare/{base}...{head}",
|
||||
"merges_url": "https://api.github.com/repos/coulterpeterson/FloatNative/merges",
|
||||
"archive_url": "https://api.github.com/repos/coulterpeterson/FloatNative/{archive_format}{/ref}",
|
||||
"downloads_url": "https://api.github.com/repos/coulterpeterson/FloatNative/downloads",
|
||||
"issues_url": "https://api.github.com/repos/coulterpeterson/FloatNative/issues{/number}",
|
||||
"pulls_url": "https://api.github.com/repos/coulterpeterson/FloatNative/pulls{/number}",
|
||||
"milestones_url": "https://api.github.com/repos/coulterpeterson/FloatNative/milestones{/number}",
|
||||
"notifications_url": "https://api.github.com/repos/coulterpeterson/FloatNative/notifications{?since,all,participating}",
|
||||
"labels_url": "https://api.github.com/repos/coulterpeterson/FloatNative/labels{/name}",
|
||||
"releases_url": "https://api.github.com/repos/coulterpeterson/FloatNative/releases{/id}",
|
||||
"deployments_url": "https://api.github.com/repos/coulterpeterson/FloatNative/deployments",
|
||||
"created_at": "2025-12-07T20:53:55Z",
|
||||
"updated_at": "2026-10-18T23:31:00Z",
|
||||
"pushed_at": "2026-10-18T23:32:41Z",
|
||||
"git_url": "git://github.com/coulterpeterson/FloatNative.git",
|
||||
"ssh_url": "git@github.com:coulterpeterson/FloatNative.git",
|
||||
"clone_url": "https://github.com/coulterpeterson/FloatNative.git",
|
||||
"svn_url": "https://github.com/coulterpeterson/FloatNative",
|
||||
"homepage": null,
|
||||
"size": 7071,
|
||||
"stargazers_count": 22,
|
||||
"watchers_count": 22,
|
||||
"language": "Kotlin",
|
||||
"has_issues": true,
|
||||
"has_projects": true,
|
||||
"has_downloads": false,
|
||||
"has_wiki": true,
|
||||
"has_pages": false,
|
||||
"has_discussions": false,
|
||||
"forks_count": 7,
|
||||
"mirror_url": null,
|
||||
"archived": false,
|
||||
"disabled": false,
|
||||
"open_issues_count": 24,
|
||||
"license": {
|
||||
"key": "mit",
|
||||
"name": "MIT License",
|
||||
"spdx_id": "MIT",
|
||||
"url": "https://api.github.com/licenses/mit",
|
||||
"node_id": "MDc6TGljZW5zZTEz"
|
||||
},
|
||||
"allow_forking": true,
|
||||
"is_template": false,
|
||||
"web_commit_signoff_required": false,
|
||||
"has_pull_requests": true,
|
||||
"pull_request_creation_policy": "all",
|
||||
"topics": [
|
||||
|
||||
],
|
||||
"visibility": "public",
|
||||
"forks": 7,
|
||||
"open_issues": 24,
|
||||
"watchers": 22,
|
||||
"default_branch": "main",
|
||||
"temp_clone_token": null,
|
||||
"network_count": 7,
|
||||
"subscribers_count": 1
|
||||
}
|
||||
6147
doc/research/reference-sources/fn_tree.json
Normal file
6147
doc/research/reference-sources/fn_tree.json
Normal file
File diff suppressed because it is too large
Load Diff
139
doc/research/reference-sources/fpapi_README.md
Normal file
139
doc/research/reference-sources/fpapi_README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Floatplane API Specification
|
||||
|
||||
Visit the pre-generated documentation, trimmed OpenAPI spec, and Postman collection at https://jamamp.github.io/FloatplaneAPIDocs/.
|
||||
|
||||
Visit Floatplane at https://www.floatplane.com.
|
||||
|
||||
---
|
||||
|
||||
This repository is an API specification of the video streaming service [Floatplane](https://www.floatplane.com) using in the [OpenAPI 3.0.3](https://swagger.io/specification/) specification for REST and [AsyncAPI 2.4.0](https://www.asyncapi.com/) specification for asynchronous events. The main files for this repository are `floatplane-openapi-specification.json`, `floatplane-asyncapi-chat-specification.json`, and `floatplane-asyncapi-frontend-specification.json`. Contained in them are definitions for all of the paths and channels in the Floatplane API, definitions of common models between the API endpoints, descriptions of authentication and authorization mechanisms, and more.
|
||||
|
||||
This repository serves as an open and central source specification for the Floatplane API by the community, for purposes of tinkering and creating custom clients for Floatplane. At the time of writing, Floatplane has its main website, along with both Android and iOS applications. The main use case envisioned in the creation of this repository is to make it easier to create TV-first applications for, e.g., tvOS, Roku, Google TV, etc.
|
||||
|
||||
⚠️ NOTE ⚠️: **Please** set a custom `User-Agent` header in your library of choice that uniquely identifies *what* app the requests are coming from, and *what **version** of your app is sending the requests. This is very helpful to the Floatplane team, and is good practice to do in general.
|
||||
|
||||
# Automatic Generation
|
||||
|
||||
The main purpose of this repository is to enable automatic generation of documentation and client code libraries.
|
||||
|
||||
## OpenAPI/AsyncAPI & Code Generation
|
||||
|
||||
The Floatplane API specification can be used to automatically generate client code of the Floatplane API in most major programming languages. It is best advised to use the [trimmed version](https://jamamp.github.io/FloatplaneAPIDocs/floatplane-openapi-specification-trimmed.json) of the REST API to only generate the endpoints that have been thoroughly documented. Various generators exist for different use cases.
|
||||
|
||||
A notable open-source generator is [OpenAPI Generator](https://openapi-generator.tech/docs/generators) which supports 38 different languages, along with variations for different networking libraries in some languages. It additionally includes many configurations when generating clients. For AsyncAPI, the [AsyncAPI Generator](https://github.com/asyncapi/generator) is recommended.
|
||||
|
||||
It would be best to keep your version of the specification, and a script to auto-generate the library with all of the correct configurations, in source control. Then, run the script to generate the code library or files, and keep those in source control as well in your project, or execute the script in your build scripts.
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
openapi-generator openapi-generator generate -i floatplane-openapi-specification-trimmed.json -o Swift -g swift5 --library vapor
|
||||
```
|
||||
|
||||
```sh
|
||||
ag -o FloatplaneChatAPI floatplane-asyncapi-chat-specification.json @asyncapi/nodejs-template
|
||||
```
|
||||
|
||||
## OpenAPI/AsyncAPI & Documentation Generation
|
||||
|
||||
The API specifications can also be used to generate documentation. Pre-generated renders of the documentation for this repository are available at https://jamamp.github.io/FloatplaneAPIDocs. There are a variety of renders available, including:
|
||||
- Swagger UI - https://github.com/swagger-api/swagger-ui
|
||||
- Redoc - https://redoc.ly/redoc
|
||||
- ReSlate & Widdershins - https://github.com/Mermade/reslate - https://github.com/Mermade/widdershins
|
||||
- RapiDoc - https://mrin9.github.io/RapiDoc/
|
||||
- OpenAPI Generator - https://openapi-generator.tech/
|
||||
- OpenAPI to Postman v2.1 Converter - https://github.com/postmanlabs/openapi-to-postman
|
||||
- AsyncAPI Generator - https://github.com/asyncapi/generator
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
redoc-cli bundle -o Docs/Redoc/redoc-static.html floatplane-openapi-specification.json
|
||||
```
|
||||
|
||||
```sh
|
||||
ag -o Docs/AsyncAPIChat floatplane-asyncapi-chat-specification.json @asyncapi/html-template
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Working on This Repository
|
||||
|
||||
### Documentation Generation
|
||||
|
||||
In order to generate all of the documentation available at https://jamamp.github.io/FloatplaneAPIDocs automatically when testing changes to the OpenAPI file,
|
||||
1. Clone this repository
|
||||
2. Run `pnpm install`
|
||||
1. This will install all of the necessary tooling
|
||||
2. The OpenAPI Generator tooling requires Java to be installed, but will fail silently if it is not.
|
||||
3. Note that for AsyncAPI, it depends on installing `puppeteer`. If working on an M1 Apple device, you may run into issues with this dependency. Following [this article](https://linguinecode.com/post/how-to-fix-m1-mac-puppeteer-chromium-arm64-bug) may help.
|
||||
3. Make changes as necessary to `floatplane-openapi-specification.json` or the AsyncAPI specification files
|
||||
4. Run `make docs-all`. This will:
|
||||
1. Trim the spec into `floatplane-openapi-specification-trimmed.json`
|
||||
2. Generate documentation for the trimmed spec into the `/Docs` folder
|
||||
3. Generate documentation for the full spec into the `/Docs` folder
|
||||
4. Copy over the spec and some other files from `/static` into the `/Docs` folder
|
||||
|
||||
Then, open `/Docs/index.html` to view the changes. A Dockerfile is also available:
|
||||
|
||||
```sh
|
||||
docker build --tag fpapidocs:latest .
|
||||
```
|
||||
|
||||
### Analyze difference between Floatplane frontends
|
||||
|
||||
The list of APIs was generated from the Floatplane frontend files, available at https://frontend.floatplane.com/{version}/*.js
|
||||
|
||||
When a new version of the Floatplane frontend is released (which is done silently), we can analyze the differences between the files to find new endpoints being used. To do so more easily, some tools are included:
|
||||
|
||||
1. Clone this repository
|
||||
2. Change directory into the `/tools` folder: `cd tools`
|
||||
3. Fetch the frontend files for the **previous** version: `./fp-frontend-fetch-2.sh <previous version number>`
|
||||
1. E.g. `./fp-frontend-fetch-2.sh 4.0.12`
|
||||
2. This assumes that `wget` is installed on the system
|
||||
4. Fetch the frontend files for the **current** version: `./fp-frontend-fetch-2.sh <previous version number>`
|
||||
1. E.g. `./fp-frontend-fetch.sh 4.0.13`
|
||||
5. Un-minify the files: `prettier --write Frontend/4.0.12 && prettier --write Frontend/4.0.13`
|
||||
1. This assumes that [Prettier](https://prettier.io/) is installed on the system
|
||||
6. Perform a quick diff to clean up the files and see which files differ: `./fp-frontend-diff-2.sh <previous version> <current version>`
|
||||
1. E.g. `./fp-frontend-diff.sh 4.0.12 4.0.13`
|
||||
2. The cleanup replaces references of the version numbers in, specifically, `main.js` with a common piece of text in order to avoid many false-positives in the resulting diffs.
|
||||
7. Then, manually inspect the diff of the files listed to see what has changed.
|
||||
|
||||
The file `fp-frontend-version.txt` is a collection of recent version changes that Floatplane has made, starting with `3.5.1`. This may be updated irregularly.
|
||||
|
||||
### Integration Testing
|
||||
|
||||
After making changes to the `floatplane-openapi-specification.json`, run `make test` in order to run integration tests with the Floatplane API. This ensures that the specification and its models are aligned with the API correctly.
|
||||
|
||||
Integration test run requirements:
|
||||
- The environment variable "sails.sid" needs to be set to the value of the `sails.sid` HTTP Cookie for authentication in order for integration tests to run.
|
||||
- Python 3 must be installed.
|
||||
|
||||
Integration tests will test for:
|
||||
- Expected HTTP 200 responses for valid requests
|
||||
- Expected HTTP 400/401/403/404 responses for invalid requests
|
||||
- A strict match between the response data from FP and the response schema/model in the specification:
|
||||
- Case-sensitive property matching
|
||||
- Case-sensitive enumeration matching
|
||||
- No missing properties in the schema (if new properties begin appearing in FP response objects we want to begin tracking them)
|
||||
- No extraneous properties in schema (if a property isn't in the FP response then it shouldn't be in the schema)
|
||||
- Exact type matching (a string with a number in it should not be equivalent to a number)
|
||||
|
||||
Write integration/unit tests in the `tests/SchemaThesisTests` Python/Poetry project.
|
||||
|
||||
---
|
||||
|
||||
# Contributions
|
||||
|
||||
Anyone is free to help
|
||||
- Document undocumented API endpoints
|
||||
- Add more information to already-documented endpoints
|
||||
- Fix issues with documentation
|
||||
- Restructure models
|
||||
- Add more pre-generated docs
|
||||
- Etc.
|
||||
|
||||
# License
|
||||
|
||||
MIT License. See the LICENSE file.
|
||||
139
doc/research/reference-sources/fpdl_Attachment.ts
Normal file
139
doc/research/reference-sources/fpdl_Attachment.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import db from "@inrixia/db";
|
||||
import { nPad, ValueOfA } from "@inrixia/helpers";
|
||||
import sanitize from "sanitize-filename";
|
||||
import { args, settings } from "./helpers/index";
|
||||
|
||||
import { basename, dirname, extname } from "path";
|
||||
|
||||
import { readdir, rename, unlink } from "fs/promises";
|
||||
import { nll } from "./logging/ProgressLogger";
|
||||
|
||||
type AttachmentInfo = {
|
||||
partialBytes?: number;
|
||||
muxedBytes?: number;
|
||||
filePath: string;
|
||||
releaseDate: number;
|
||||
videoTitle: string;
|
||||
channelTitle: string;
|
||||
};
|
||||
|
||||
type AttachmentAttributes = {
|
||||
attachmentId: string;
|
||||
videoTitle: string;
|
||||
channelTitle: string;
|
||||
releaseDate: Date;
|
||||
};
|
||||
|
||||
enum Extensions {
|
||||
Muxed = ".mp4",
|
||||
Partial = ".partial",
|
||||
NFO = ".nfo",
|
||||
Thumbnail = ".png",
|
||||
Caption = ".vtt",
|
||||
}
|
||||
|
||||
export class Attachment implements AttachmentAttributes {
|
||||
private static readonly AttachmentsDB: Record<string, AttachmentInfo> = db<Record<string, AttachmentInfo>>(`${args.dbPath}/attachments.json`);
|
||||
public static readonly Extensions = Extensions;
|
||||
|
||||
public readonly attachmentId: string;
|
||||
public readonly channelTitle: string;
|
||||
public readonly videoTitle: string;
|
||||
public readonly releaseDate: Date;
|
||||
|
||||
public readonly filePath: string;
|
||||
public readonly folderPath: string;
|
||||
|
||||
public readonly artworkPath: string;
|
||||
public readonly nfoPath: string;
|
||||
public readonly partialPath: string;
|
||||
public readonly muxedPath: string;
|
||||
|
||||
constructor({ attachmentId, channelTitle, videoTitle, releaseDate }: AttachmentAttributes) {
|
||||
this.attachmentId = attachmentId;
|
||||
this.channelTitle = channelTitle;
|
||||
this.releaseDate = releaseDate;
|
||||
this.videoTitle = videoTitle;
|
||||
|
||||
this.filePath = this.formatFilePath(settings.filePathFormatting);
|
||||
|
||||
// Ensure filePath is not exceeding maximum length
|
||||
if (this.filePath.length > 250) this.filePath = this.filePath.substring(0, 250);
|
||||
|
||||
this.folderPath = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
|
||||
|
||||
this.artworkPath = `${this.filePath}${settings.artworkSuffix}`;
|
||||
this.nfoPath = `${this.filePath}${Extensions.NFO}`;
|
||||
this.partialPath = `${this.filePath}${Extensions.Partial}`;
|
||||
this.muxedPath = `${this.filePath}${Extensions.Muxed}`;
|
||||
|
||||
const attachmentInfo = (Attachment.AttachmentsDB[this.attachmentId] ??= {
|
||||
releaseDate: this.releaseDate.getTime(),
|
||||
filePath: this.filePath,
|
||||
videoTitle: this.videoTitle,
|
||||
channelTitle: this.channelTitle,
|
||||
});
|
||||
// If the attachment existed on another path then move it.
|
||||
if (attachmentInfo.filePath !== this.filePath) {
|
||||
rename(this.artworkPath.replace(this.filePath, attachmentInfo.filePath), this.artworkPath).catch(nll);
|
||||
rename(this.partialPath.replace(this.filePath, attachmentInfo.filePath), this.partialPath).catch(nll);
|
||||
rename(this.muxedPath.replace(this.filePath, attachmentInfo.filePath), this.muxedPath).catch(nll);
|
||||
rename(this.nfoPath.replace(this.filePath, attachmentInfo.filePath), this.nfoPath).catch(nll);
|
||||
attachmentInfo.filePath = this.filePath;
|
||||
}
|
||||
if (attachmentInfo.videoTitle !== this.videoTitle) attachmentInfo.videoTitle = this.videoTitle;
|
||||
}
|
||||
|
||||
public static find(filter: (attachment: AttachmentInfo) => boolean) {
|
||||
return Object.entries(this.AttachmentsDB)
|
||||
.map(([attachmentId, attachmentInfo]) => ({
|
||||
attachmentId,
|
||||
...attachmentInfo,
|
||||
}))
|
||||
.filter(filter);
|
||||
}
|
||||
|
||||
public attachmentInfo(): AttachmentInfo {
|
||||
return Attachment.AttachmentsDB[this.attachmentId];
|
||||
}
|
||||
public async delete() {
|
||||
await Promise.allSettled([unlink(this.partialPath), unlink(this.muxedPath), unlink(this.nfoPath), unlink(this.artworkPath)]);
|
||||
delete Attachment.AttachmentsDB[this.attachmentId];
|
||||
}
|
||||
|
||||
public static FilePathOptions = ["%channelTitle%", "%year%", "%month%", "%day%", "%hour%", "%minute%", "%second%", "%videoTitle%"] as const;
|
||||
protected formatFilePath(string: string): string {
|
||||
const formatLookup: Record<ValueOfA<typeof Attachment.FilePathOptions>, string> = {
|
||||
"%channelTitle%": sanitize(this.channelTitle),
|
||||
"%year%": this.releaseDate.getFullYear().toString(),
|
||||
"%month%": nPad(this.releaseDate.getMonth() + 1),
|
||||
"%day%": nPad(this.releaseDate.getDate()),
|
||||
"%hour%": nPad(this.releaseDate.getHours()),
|
||||
"%minute%": nPad(this.releaseDate.getMinutes()),
|
||||
"%second%": nPad(this.releaseDate.getSeconds()),
|
||||
"%videoTitle%": sanitize(this.videoTitle.replace(/ - /g, " ").replace(/\//g, " ").replace(/\\/g, " ")),
|
||||
};
|
||||
|
||||
for (const [match, value] of Object.entries(formatLookup)) {
|
||||
string = string.replace(new RegExp(match, "g"), value);
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
public async artworkFileExtension() {
|
||||
const fileDir = dirname(this.artworkPath);
|
||||
const fileName = basename(this.artworkPath);
|
||||
|
||||
const filesInDir = await readdir(fileDir);
|
||||
const matchingFile = filesInDir.find(
|
||||
(file) =>
|
||||
file.startsWith(fileName) &&
|
||||
!file.endsWith(Extensions.NFO) &&
|
||||
!file.endsWith(Extensions.Partial) &&
|
||||
!file.endsWith(Extensions.Muxed) &&
|
||||
!file.endsWith(Extensions.Caption)
|
||||
);
|
||||
if (matchingFile) return extname(matchingFile);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
61
doc/research/reference-sources/fpdl_Caches.ts
Normal file
61
doc/research/reference-sources/fpdl_Caches.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { JSONSafeValue } from "@inrixia/db";
|
||||
import { writeFile } from "fs/promises";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FetchItem<T> = (id: string, options?: any) => Promise<T>;
|
||||
|
||||
export class ItemCache<T extends JSONSafeValue> {
|
||||
private cache: Record<string, { i: T; t: number }>;
|
||||
private cachePath: string;
|
||||
private fetchItem: FetchItem<T>;
|
||||
private expireAgeMs: number;
|
||||
|
||||
constructor(cachePath: string, fetchItem: FetchItem<T>, expireAgeMins: number = 24 * 60) {
|
||||
this.cachePath = cachePath;
|
||||
this.fetchItem = fetchItem;
|
||||
this.expireAgeMs = expireAgeMins * 60 * 1000;
|
||||
|
||||
try {
|
||||
this.cache = JSON.parse(readFileSync(this.cachePath).toString());
|
||||
} catch {
|
||||
this.cache = {};
|
||||
}
|
||||
}
|
||||
|
||||
private async writeOut() {
|
||||
try {
|
||||
await writeFile(this.cachePath, JSON.stringify(this.cache));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async set(key: string, i: T) {
|
||||
this.cache[key] = { t: Date.now(), i };
|
||||
await this.writeOut();
|
||||
return i;
|
||||
}
|
||||
|
||||
private deepCopy(i: T): T {
|
||||
return JSON.parse(JSON.stringify(i));
|
||||
}
|
||||
|
||||
public async get(id: string, options?: unknown, noCache: boolean = false): Promise<T> {
|
||||
const key = options !== undefined ? JSON.stringify([id, options]) : id;
|
||||
if (noCache) {
|
||||
delete this.cache[key];
|
||||
return this.get(id, options);
|
||||
}
|
||||
const cacheItem = this.cache[key];
|
||||
if (cacheItem !== undefined) {
|
||||
// Remove expired entries older than expireAge
|
||||
if (Date.now() - cacheItem.t > this.expireAgeMs) {
|
||||
delete this.cache[key];
|
||||
return this.get(id, options);
|
||||
}
|
||||
return this.deepCopy(cacheItem.i);
|
||||
}
|
||||
return this.set(key, await this.fetchItem(id, options));
|
||||
}
|
||||
}
|
||||
143
doc/research/reference-sources/fpdl_Subscription.ts
Normal file
143
doc/research/reference-sources/fpdl_Subscription.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { fApi } from "./helpers/index";
|
||||
|
||||
import chalk from "chalk-template";
|
||||
|
||||
import type { ContentPost, VideoContent } from "floatplane/content";
|
||||
import type { ChannelOptions, SubscriptionSettings } from "./types";
|
||||
|
||||
import { Attachment } from "./Attachment";
|
||||
import { settings } from "./helpers/index";
|
||||
import { Video } from "./Video";
|
||||
|
||||
const removeRepeatedSentences = (postTitle: string, attachmentTitle: string) => {
|
||||
const separators = /(?:\s+|^)((?:[^.,;:!?-]+[\s]*[.,;:!?-]+)+)(?:\s+|$)/g;
|
||||
const postTitleSentences = postTitle.match(separators)?.map((sentence) => sentence.trim());
|
||||
const attachmentTitleSentences = attachmentTitle.match(separators)?.map((sentence) => sentence.trim());
|
||||
|
||||
const uniqueAttachmentTitleSentences = attachmentTitleSentences?.filter((sentence) => !postTitleSentences?.includes(sentence));
|
||||
|
||||
if (uniqueAttachmentTitleSentences === undefined) return `${postTitle.trim()} - ${attachmentTitle.trim()}`.trim();
|
||||
|
||||
// Remove trailing separator
|
||||
return `${postTitle.trim()} - ${uniqueAttachmentTitleSentences.join("").trim()}`.trim().replace(/[\s]*[.,;:!?-]+[\s]*$/, "");
|
||||
};
|
||||
|
||||
type isChannel = (post: ContentPost, video?: VideoContent) => boolean;
|
||||
export default class Subscription {
|
||||
public readonly creatorId: string;
|
||||
public readonly channels: SubscriptionSettings["channels"];
|
||||
public readonly plan: string;
|
||||
|
||||
constructor(subscription: SubscriptionSettings) {
|
||||
this.creatorId = subscription.creatorId;
|
||||
this.channels = subscription.channels;
|
||||
this.plan = subscription.plan;
|
||||
}
|
||||
|
||||
public deleteOldVideos = async () => {
|
||||
for (const channel of this.channels) {
|
||||
if (channel.daysToKeepVideos !== undefined) {
|
||||
const ignoreBeforeTimestamp = Subscription.getIgnoreBeforeTimestamp(channel);
|
||||
let deletedVideos = 0;
|
||||
|
||||
for (const video of Video.find((video) => {
|
||||
return video.releaseDate < ignoreBeforeTimestamp && video.channelTitle === channel.title;
|
||||
})) {
|
||||
deletedVideos++;
|
||||
await new Attachment({
|
||||
...video,
|
||||
releaseDate: new Date(video.releaseDate),
|
||||
}).delete();
|
||||
}
|
||||
console.log(chalk`[{yellow ${channel.title}}] deleted {redBright ${deletedVideos}} videos older than {cyanBright ${channel.daysToKeepVideos}} days`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static getIgnoreBeforeTimestamp = (channel: ChannelOptions) => Date.now() - (channel.daysToKeepVideos ?? 0) * 24 * 60 * 60 * 1000;
|
||||
private static isChannelCache: Record<string, isChannel> = {};
|
||||
private static isChannelHelper = `const isChannel = (post, channelId) => (typeof post.channel !== 'string' ? post.channel.id : post.channel) === channelId`;
|
||||
|
||||
private async *matchChannel(blogPost: ContentPost): AsyncGenerator<Video> {
|
||||
if (blogPost.videoAttachments === undefined) return;
|
||||
let dateOffset = 0;
|
||||
for (const videoAttachment of blogPost.videoAttachments.sort((a, b) => blogPost.attachmentOrder.indexOf(a.id) - blogPost.attachmentOrder.indexOf(b.id))) {
|
||||
// Make sure we have a unique object for each attachment
|
||||
const post = { ...blogPost };
|
||||
const video: VideoContent = await fApi.content.video(videoAttachment.id);
|
||||
// Skip videos with no levels
|
||||
if (video.levels.length === 0) continue;
|
||||
|
||||
if (blogPost.videoAttachments.length > 1) {
|
||||
dateOffset++;
|
||||
post.title = removeRepeatedSentences(post.title, video.title);
|
||||
}
|
||||
|
||||
for (const channel of this.channels) {
|
||||
if (channel.isChannel === undefined) continue;
|
||||
const isChannel =
|
||||
Subscription.isChannelCache[channel.isChannel] ??
|
||||
(Subscription.isChannelCache[channel.isChannel] = new Function(`${Subscription.isChannelHelper};return ${channel.isChannel};`)() as isChannel);
|
||||
|
||||
if (!isChannel(post, video)) continue;
|
||||
if (channel.skip) break;
|
||||
if (channel.daysToKeepVideos !== undefined && new Date(post.releaseDate).getTime() < Subscription.getIgnoreBeforeTimestamp(channel)) return;
|
||||
|
||||
// Remove the identifier from the video title if to give a nicer title
|
||||
if (settings.extras.stripSubchannelPrefix === true) {
|
||||
const replacers = [
|
||||
new RegExp(channel.title.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"), "i"),
|
||||
/MA: /i,
|
||||
/FP Exclusive: /i,
|
||||
/talklinked/i,
|
||||
/TL: /i,
|
||||
/TL Short: /i,
|
||||
/TQ: /i,
|
||||
/TJM: /i,
|
||||
/SC: /i,
|
||||
/CSF: /i,
|
||||
/Livestream VOD – /i,
|
||||
/ : /i,
|
||||
];
|
||||
for (const regIdCheck of replacers) {
|
||||
post.title = post.title.replace(regIdCheck, "");
|
||||
}
|
||||
|
||||
post.title = post.title.replaceAll(" ", " ");
|
||||
if (post.title.startsWith(": ")) post.title = post.title.replace(": ", "");
|
||||
|
||||
post.title = post.title.trim();
|
||||
}
|
||||
yield Video.getOrCreate({
|
||||
attachmentId: videoAttachment.id,
|
||||
description: post.text,
|
||||
artworkUrl: post.thumbnail?.path,
|
||||
channelTitle: channel.title,
|
||||
videoTitle: post.title,
|
||||
releaseDate: new Date(new Date(post.releaseDate).getTime() + dateOffset * 1000),
|
||||
textTracks: video.textTracks,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async *fetchNewVideos(): AsyncGenerator<Video> {
|
||||
if (settings.floatplane.videosToSearch === 0) return;
|
||||
let videosSearched = 0;
|
||||
console.log(chalk`Searching for new videos in {yellow ${this.plan}}`);
|
||||
for await (const blogPost of fApi.creator.blogPostsIterable(this.creatorId)) {
|
||||
if (!blogPost.metadata.hasVideo) continue;
|
||||
for await (const video of this.matchChannel(await fApi.content.post(blogPost.id))) {
|
||||
yield video;
|
||||
}
|
||||
|
||||
// Stop searching if we have looked through videosToSearch
|
||||
if (++videosSearched >= settings.floatplane.videosToSearch) break;
|
||||
}
|
||||
}
|
||||
|
||||
public async *seekAndDestroy(contentPost: ContentPost): AsyncGenerator<Video> {
|
||||
for await (const video of this.matchChannel(contentPost)) yield video;
|
||||
}
|
||||
}
|
||||
438
doc/research/reference-sources/fpdl_Video.ts
Normal file
438
doc/research/reference-sources/fpdl_Video.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { Counter, Gauge } from "@prometheus-io/client";
|
||||
import chalk from "chalk-template";
|
||||
import { exec as execCallback, execFile } from "child_process";
|
||||
import type { Progress } from "got";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import { extension } from "mime-types";
|
||||
import { ThrottleGroup, type ThrottleOptions } from "stream-throttle";
|
||||
import { promisify } from "util";
|
||||
import builder from "xmlbuilder2";
|
||||
|
||||
import { createWriteStream } from "fs";
|
||||
import { mkdir, stat, unlink, utimes, writeFile } from "fs/promises";
|
||||
|
||||
import { Attachment } from "./Attachment";
|
||||
|
||||
import { nPad, Semaphore } from "@inrixia/helpers";
|
||||
|
||||
import { args, fApi, settings } from "./helpers/index";
|
||||
import { Selector } from "./helpers/Selector";
|
||||
import { updatePlex } from "./helpers/updatePlex";
|
||||
|
||||
import { ProgressBars } from "./logging/ProgressBars";
|
||||
import { ProgressHeadless } from "./logging/ProgressConsole";
|
||||
import { nll, ProgressLogger, withContext } from "./logging/ProgressLogger";
|
||||
|
||||
import type { VideoContent } from "floatplane/content";
|
||||
import { ffmpegPath } from "./helpers/fetchFFMPEG";
|
||||
import { fileExists } from "./helpers/fileExists";
|
||||
import { discordEmbed, discordMessage } from "./notifications/discord";
|
||||
import { telegramMsg } from "./notifications/telegram";
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
const sleep = promisify(setTimeout);
|
||||
|
||||
const promQueued = new Gauge({
|
||||
name: "queued",
|
||||
help: "Videos waiting to download",
|
||||
});
|
||||
const pendingErrors = new Map<string, number>();
|
||||
const previouslyCollectedErrors = new Set<string>();
|
||||
const promErrors = new Gauge({
|
||||
name: "errors",
|
||||
help: "Video errors observed since the previous metrics collection",
|
||||
labelNames: ["message"],
|
||||
collect() {
|
||||
for (const message of previouslyCollectedErrors) this.remove({ message });
|
||||
previouslyCollectedErrors.clear();
|
||||
|
||||
for (const [message, count] of pendingErrors) {
|
||||
this.set({ message }, count);
|
||||
previouslyCollectedErrors.add(message);
|
||||
}
|
||||
pendingErrors.clear();
|
||||
},
|
||||
});
|
||||
const promDownloadedTotal = new Counter({
|
||||
name: "downloaded_total",
|
||||
help: "Videos downloaded",
|
||||
});
|
||||
const promDownloadedBytes = new Counter({
|
||||
name: "downloaded_bytes_total",
|
||||
help: "Video downloaded bytes",
|
||||
});
|
||||
|
||||
enum VideoState {
|
||||
Missing,
|
||||
Partial,
|
||||
Muxed,
|
||||
}
|
||||
export type VideoInfo = {
|
||||
description: string;
|
||||
artworkUrl?: string;
|
||||
attachmentId: string;
|
||||
channelTitle: string;
|
||||
videoTitle: string;
|
||||
releaseDate: Date;
|
||||
textTracks?: VideoContent["textTracks"];
|
||||
};
|
||||
|
||||
const byteToMbits = 131072;
|
||||
|
||||
export class Video extends Attachment {
|
||||
public readonly description: string;
|
||||
public readonly artworkUrl?: string;
|
||||
public readonly textTracks?: VideoContent["textTracks"];
|
||||
|
||||
public static State = VideoState;
|
||||
|
||||
private static readonly MaxRetries = 1;
|
||||
private static readonly DownloadThreads = 8;
|
||||
|
||||
private static readonly DownloadSemaphore = new Semaphore(this.DownloadThreads);
|
||||
|
||||
private static readonly ThrottleOptions: ThrottleOptions = { rate: settings.maxDownloadSpeed * byteToMbits };
|
||||
private static readonly ThrottleGroup = settings.maxDownloadSpeed > -1 ? new ThrottleGroup(Video.ThrottleOptions) : undefined;
|
||||
|
||||
private static readonly ProgressLogger: typeof ProgressHeadless | typeof ProgressBars = args.headless ? ProgressHeadless : ProgressBars;
|
||||
private readonly logger = new Video.ProgressLogger(this.videoTitle);
|
||||
|
||||
// Static cache of instances
|
||||
public static readonly Videos: Record<string, Video> = {};
|
||||
public static getOrCreate(videoInfo: VideoInfo): Video {
|
||||
if (this.Videos[videoInfo.attachmentId] !== undefined) return this.Videos[videoInfo.attachmentId];
|
||||
return (this.Videos[videoInfo.attachmentId] = new this(videoInfo));
|
||||
}
|
||||
|
||||
private constructor(videoInfo: VideoInfo) {
|
||||
super(videoInfo);
|
||||
|
||||
this.description = videoInfo.description;
|
||||
this.artworkUrl = videoInfo.artworkUrl;
|
||||
this.textTracks = videoInfo.textTracks;
|
||||
// Ensure onError is bound to this instance
|
||||
this.onError = this.onError.bind(this);
|
||||
}
|
||||
|
||||
public async download() {
|
||||
promQueued.inc();
|
||||
const release = await Video.DownloadSemaphore.obtain();
|
||||
try {
|
||||
// Make sure the folder for the video exists
|
||||
await mkdir(this.folderPath, { recursive: true }).catch((err) => this.onError(err, true));
|
||||
if (settings.extras.saveNfo) {
|
||||
await this.saveNfo().catch(withContext(`Saving .nfo file`)).catch(this.onError);
|
||||
}
|
||||
if (settings.extras.downloadArtwork) {
|
||||
await this.downloadArtwork().catch(withContext(`Downloading artwork`)).catch(this.onError);
|
||||
}
|
||||
if (settings.extras.downloadCaptions) {
|
||||
await this.downloadCaptions().catch(withContext(`Downloading captions`)).catch(this.onError);
|
||||
}
|
||||
if ((await this.getState()) === Video.State.Muxed) {
|
||||
this.logger.done(chalk`{green Exists! Skipping}`);
|
||||
return;
|
||||
}
|
||||
for (let retries = 0; retries < Video.MaxRetries; retries++) {
|
||||
try {
|
||||
switch (await this.getState()) {
|
||||
case Video.State.Missing: {
|
||||
await this.onMissing().catch(withContext(`Downloading missing video`));
|
||||
}
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case Video.State.Partial: {
|
||||
this.logger.log("Muxing ffmpeg metadata...");
|
||||
await this.muxffmpegMetadata().catch(withContext(`Muxing ffmpeg metadata`));
|
||||
|
||||
if (settings.postProcessingCommand !== "") {
|
||||
this.logger.log(`Running settings.postProcessingCommand...`);
|
||||
await this.postProcessingCommand().catch(withContext(`postProcessingCommand`));
|
||||
}
|
||||
|
||||
if (settings.plex.enabled) {
|
||||
await updatePlex().catch(withContext(`Updating plex`));
|
||||
}
|
||||
}
|
||||
}
|
||||
telegramMsg(`Downloaded ${this.videoTitle} from ${this.channelTitle}`);
|
||||
discordEmbed("New video downloaded", this);
|
||||
this.logger.done(chalk`{cyan Downloaded!}`);
|
||||
promDownloadedTotal.inc();
|
||||
break;
|
||||
} catch (err) {
|
||||
this.onError(err);
|
||||
if (retries < Video.MaxRetries) await sleep(5000);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
promQueued.dec();
|
||||
}
|
||||
}
|
||||
|
||||
private onError(err: unknown, throwAfterLog = false) {
|
||||
const errStatement = this.logger.error(err);
|
||||
console.error(`[${this.videoTitle}]`, errStatement);
|
||||
|
||||
const errStr = ProgressLogger.sanitizeError(err);
|
||||
pendingErrors.set(errStr, (pendingErrors.get(errStr) ?? 0) + 1);
|
||||
|
||||
const message = `Error downloading ${this.videoTitle} from ${this.channelTitle}: ${errStr}`;
|
||||
telegramMsg(message);
|
||||
discordMessage(message);
|
||||
|
||||
if (throwAfterLog) throw err;
|
||||
}
|
||||
|
||||
private async onMissing() {
|
||||
this.logger.log("Waiting on delivery cdn...");
|
||||
const downloadRequest = await this.getVideoStream(settings.floatplane.videoResolution);
|
||||
|
||||
// Pipe the download to the file once response starts
|
||||
const writeStream = createWriteStream(this.partialPath);
|
||||
|
||||
// Throttle if enabled
|
||||
if (Video.ThrottleGroup) {
|
||||
// @ts-expect-error Type is wrong, this needs to be called with no arguments
|
||||
const throttle = Video.ThrottleGroup.throttle();
|
||||
downloadRequest.pipe(throttle).pipe(writeStream);
|
||||
} else downloadRequest.pipe(writeStream);
|
||||
|
||||
let downloadedBytes = 0;
|
||||
const onDownloadProgress = (progress: Progress) => {
|
||||
const bytesSinceLast = progress.transferred - downloadedBytes;
|
||||
downloadedBytes = progress.transferred;
|
||||
promDownloadedBytes.inc(bytesSinceLast);
|
||||
this.logger.onDownloadProgress(downloadRequest.downloadProgress, bytesSinceLast);
|
||||
};
|
||||
|
||||
let downloadInterval: NodeJS.Timeout;
|
||||
downloadRequest.once("downloadProgress", (downloadProgress: Progress) => {
|
||||
this.logger.log("Download started!");
|
||||
downloadInterval = setInterval(() => onDownloadProgress(downloadRequest.downloadProgress), 250);
|
||||
onDownloadProgress(downloadProgress);
|
||||
});
|
||||
|
||||
await new Promise((res, rej) => {
|
||||
downloadRequest.once("end", res);
|
||||
downloadRequest.once("error", rej);
|
||||
}).finally(() => {
|
||||
clearInterval(downloadInterval);
|
||||
onDownloadProgress(downloadRequest.downloadProgress);
|
||||
});
|
||||
|
||||
this.logger.log("Download complete!");
|
||||
}
|
||||
|
||||
private static async pathBytes(path: string) {
|
||||
const { size } = await stat(path).catch(() => ({ size: -1 }));
|
||||
return size;
|
||||
}
|
||||
|
||||
public async getState() {
|
||||
const attrStore = await this.attachmentInfo();
|
||||
|
||||
const muxedBytes = await Video.pathBytes(this.muxedPath);
|
||||
// If considerAllNonPartialDownloaded is true, return true if the file exists. Otherwise check if the file is the correct size
|
||||
if (settings.extras.considerAllNonPartialDownloaded && muxedBytes !== -1) attrStore.muxedBytes = muxedBytes;
|
||||
if (attrStore.muxedBytes === muxedBytes) return VideoState.Muxed;
|
||||
|
||||
const partialBytes = await Video.pathBytes(this.partialPath);
|
||||
if (attrStore.partialBytes === partialBytes) return VideoState.Partial;
|
||||
|
||||
return VideoState.Missing;
|
||||
}
|
||||
|
||||
public async saveNfo() {
|
||||
if (await fileExists(this.nfoPath)) return;
|
||||
this.logger.log("Saving .nfo");
|
||||
|
||||
let season = "";
|
||||
let episode = "";
|
||||
const match = /S(\d+)E(\d+)/i.exec(this.nfoPath);
|
||||
if (match !== null) {
|
||||
season = match[1];
|
||||
episode = match[2];
|
||||
}
|
||||
const nfo = builder
|
||||
.create()
|
||||
.ele("episodedetails")
|
||||
.ele("title")
|
||||
.txt(this.videoTitle)
|
||||
.up()
|
||||
.ele("showtitle")
|
||||
.txt(this.channelTitle)
|
||||
.up()
|
||||
.ele("description")
|
||||
.txt(htmlToText(this.description))
|
||||
.up()
|
||||
.ele("plot") // Kodi/Plex NFO format uses `plot` as the episode description
|
||||
.txt(htmlToText(this.description))
|
||||
.up()
|
||||
.ele("aired") // format: yyyy-mm-dd required for Kodi/Plex
|
||||
.txt(this.releaseDate.getFullYear().toString() + "-" + nPad(this.releaseDate.getMonth() + 1) + "-" + nPad(this.releaseDate.getDate()))
|
||||
.up()
|
||||
.ele("season")
|
||||
.txt(season)
|
||||
.up()
|
||||
.ele("episode")
|
||||
.txt(episode)
|
||||
.up()
|
||||
.end({ prettyPrint: true });
|
||||
await writeFile(this.nfoPath, nfo, "utf8");
|
||||
await utimes(this.nfoPath, new Date(), this.releaseDate);
|
||||
this.logger.log("Saved .nfo");
|
||||
}
|
||||
|
||||
public async downloadArtwork() {
|
||||
if (!this.artworkUrl) return;
|
||||
// If the file already exists
|
||||
if (await this.artworkFileExtension()) return;
|
||||
this.logger.log("Saving artwork");
|
||||
|
||||
// Fetch the thumbnail and get its content type
|
||||
const response = await fApi.got(this.artworkUrl, { responseType: "buffer" });
|
||||
const contentType = response.headers["content-type"];
|
||||
|
||||
// Map the content type to a file extension
|
||||
const fileExtension = contentType ? `.${extension(contentType)}` : Attachment.Extensions.Thumbnail;
|
||||
|
||||
// Update the artworkPath with the correct file extension
|
||||
const artworkPathWithExtension = `${this.artworkPath}${fileExtension}`;
|
||||
|
||||
// Save the thumbnail with the correct file extension
|
||||
await writeFile(artworkPathWithExtension, Uint8Array.from(response.body));
|
||||
await utimes(artworkPathWithExtension, new Date(), this.releaseDate);
|
||||
this.logger.log("Saved artwork");
|
||||
}
|
||||
|
||||
private *captionsFiles() {
|
||||
if (this.textTracks === undefined) return;
|
||||
const captions = this.textTracks.filter((track) => track.kind === "captions");
|
||||
if (captions.length === 0) return;
|
||||
for (const caption of captions) {
|
||||
const path = `${this.filePath}${caption.language ? `.${caption.language}` : ""}.vtt`;
|
||||
yield { ...caption, path };
|
||||
}
|
||||
}
|
||||
private async downloadCaptions() {
|
||||
this.logger.log("Saving captions");
|
||||
for (const { path, src } of this.captionsFiles()) {
|
||||
if (await fileExists(path)) continue;
|
||||
const captionContent = await fetch(src).then((res) => res.text());
|
||||
await writeFile(path, captionContent, "utf8");
|
||||
}
|
||||
this.logger.log("Saved captions");
|
||||
}
|
||||
|
||||
// The number of available slots for making delivery requests,
|
||||
// limiting the rate of requests to avoid exceeding the API rate limit.
|
||||
private static DeliveryTimeout = 65000;
|
||||
private static DeliverySemaphore = new Semaphore(2);
|
||||
private async getDelivery() {
|
||||
// Ensure that we only call the delivery endpoint twice a minute at most
|
||||
const release = await Video.DeliverySemaphore.obtain();
|
||||
|
||||
// Send download request video, assume the first video attached is the actual video as most will not have more than one video
|
||||
const deliveryInfo = await fApi.cdn.delivery("download", this.attachmentId);
|
||||
|
||||
// Release the semaphore after DeliveryTimeout
|
||||
setTimeout(release, Video.DeliveryTimeout);
|
||||
|
||||
return deliveryInfo?.groups?.[0];
|
||||
}
|
||||
|
||||
protected async getVideoStream(quality: string): Promise<ReturnType<typeof fApi.got.stream>> {
|
||||
if ((await this.getState()) === VideoState.Muxed) throw new Error(`Attempting to download "${this.videoTitle}" video already downloaded!`);
|
||||
|
||||
const delivery = await this.getDelivery();
|
||||
if (delivery?.origins === undefined) throw new Error("Video has no origins to download from!");
|
||||
|
||||
// Round robin edges with download enabled
|
||||
const downloadOrigin = Selector.next(delivery.origins);
|
||||
|
||||
// Sort qualities from highest to smallest
|
||||
const availableVariants = delivery.variants.filter((variant) => variant.url !== "").sort((a, b) => (b.order || 0) - (a.order || 0));
|
||||
|
||||
if (availableVariants.length === 0) throw new Error("No variants available for download!");
|
||||
|
||||
// Set the quality to use based on whats given in the settings.json or the highest available
|
||||
const downloadVariant = availableVariants.find((variant) => variant.label.includes(quality)) ?? availableVariants[0];
|
||||
|
||||
const downloadRequest = fApi.got.stream(`${downloadOrigin.url}${downloadVariant.url}`);
|
||||
|
||||
// Set the videos expectedSize once we know how big it should be for download validation.
|
||||
downloadRequest.once("downloadProgress", async (progress) => ((await this.attachmentInfo()).partialBytes = progress.total));
|
||||
|
||||
return downloadRequest;
|
||||
}
|
||||
|
||||
public async muxffmpegMetadata(): Promise<void> {
|
||||
if ((await this.getState()) !== VideoState.Partial) throw new Error(`Cannot mux ffmpeg metadata! Video not downloaded.`);
|
||||
|
||||
let artworkEmbed: string[] = [];
|
||||
const artworkExtension = await this.artworkFileExtension();
|
||||
if (settings.extras.downloadArtwork && this.artworkUrl !== null && artworkExtension) {
|
||||
artworkEmbed = ["-i", `${this.artworkPath}${artworkExtension}`, "-map", "2", "-disposition:0", "attached_pic"];
|
||||
}
|
||||
|
||||
await unlink(this.muxedPath).catch(nll);
|
||||
|
||||
const description = htmlToText(this.description);
|
||||
const metadata = {
|
||||
title: this.videoTitle,
|
||||
AUTHOR: this.channelTitle,
|
||||
YEAR: this.releaseDate.getFullYear().toString(),
|
||||
date: `${this.releaseDate.getFullYear().toString()}${nPad(this.releaseDate.getMonth() + 1)}${nPad(this.releaseDate.getDate())}`,
|
||||
description: description,
|
||||
synopsis: description,
|
||||
};
|
||||
const metadataFilePath = `${this.folderPath}/${Math.random()}.ffmeta`;
|
||||
const metadataContent = Object.entries(metadata)
|
||||
.map(([key, value]) => `${key}=${value.replaceAll(/\n/g, "\\\n")}`)
|
||||
.join("\n");
|
||||
try {
|
||||
await writeFile(metadataFilePath, `;FFMETADATA\n${metadataContent}`);
|
||||
await new Promise((resolve, reject) =>
|
||||
execFile(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-i",
|
||||
this.partialPath,
|
||||
"-i",
|
||||
metadataFilePath, // Include the metadata file as an input
|
||||
...artworkEmbed,
|
||||
"-map",
|
||||
"0",
|
||||
"-map_metadata",
|
||||
"1",
|
||||
"-c",
|
||||
"copy",
|
||||
this.muxedPath,
|
||||
],
|
||||
(error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
error.message ??= "";
|
||||
error.message += stderr;
|
||||
reject(error);
|
||||
} else resolve(stdout);
|
||||
},
|
||||
),
|
||||
);
|
||||
// Remove the partial file when done
|
||||
await unlink(this.partialPath);
|
||||
// Set the files update time to when the video was released
|
||||
await utimes(this.muxedPath, new Date(), this.releaseDate);
|
||||
|
||||
(await this.attachmentInfo()).muxedBytes = await Video.pathBytes(this.muxedPath);
|
||||
} finally {
|
||||
// Ensure the metadata file is removed
|
||||
await unlink(metadataFilePath).catch(nll);
|
||||
}
|
||||
}
|
||||
|
||||
public async postProcessingCommand(): Promise<void> {
|
||||
const result = await exec(this.formatFilePath(settings.postProcessingCommand));
|
||||
if (result.stderr !== "") throw new Error(result.stderr);
|
||||
}
|
||||
}
|
||||
81
doc/research/reference-sources/fpdl_defaults.ts
Normal file
81
doc/research/reference-sources/fpdl_defaults.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Args, Channels, Resolutions, Settings } from "./types";
|
||||
|
||||
export const defaultResolutions: Resolutions = ["360", "720", "1080", "2160"];
|
||||
export const defaultSubChannels: Record<string, Channels> = {
|
||||
"59f94c0bdd241b70349eb72b": [
|
||||
{
|
||||
title: "TalkLinked",
|
||||
skip: false,
|
||||
isChannel: "(post) => post.title?.toLowerCase().includes('talklinked')",
|
||||
},
|
||||
{
|
||||
title: "TechLinked Shorts",
|
||||
skip: false,
|
||||
isChannel: "(post) => post.title?.toLowerCase().includes('tl short: ')",
|
||||
},
|
||||
{
|
||||
title: "The WAN Show",
|
||||
skip: false,
|
||||
isChannel: "(post) => isChannel(post, '63fe42c309e691e4e36de93d') && post.title?.toLowerCase().includes('- wan show')",
|
||||
},
|
||||
{
|
||||
title: "LMG Livestream VODs",
|
||||
skip: false,
|
||||
isChannel: "(post) => post.title?.toLowerCase().includes('livestream vod – ')",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const defaultArgs: Args = {
|
||||
headless: false,
|
||||
updateCheck: true,
|
||||
plexUsername: "",
|
||||
plexPassword: "",
|
||||
sanityCheck: false,
|
||||
dbPath: "./db",
|
||||
ffmpegPath: "",
|
||||
settingsPath: "",
|
||||
};
|
||||
|
||||
export const defaultSettings: Settings = {
|
||||
__SettingsWiki: "https://github.com/Inrixia/Floatplane-Downloader/blob/master/wiki/settings.md",
|
||||
runQuickstartPrompts: true,
|
||||
floatplane: {
|
||||
videosToSearch: 5,
|
||||
videoResolution: "1080",
|
||||
waitForNewVideos: true,
|
||||
seekAndDestroy: [],
|
||||
},
|
||||
maxDownloadSpeed: -1,
|
||||
plex: {
|
||||
sectionsToUpdate: [],
|
||||
enabled: false,
|
||||
token: "",
|
||||
},
|
||||
filePathFormatting: "./videos/%channelTitle%/%channelTitle% - S%year%E%month%%day%%hour%%minute%%second% - %videoTitle%",
|
||||
extras: {
|
||||
stripSubchannelPrefix: true,
|
||||
downloadArtwork: true,
|
||||
downloadCaptions: true,
|
||||
saveNfo: true,
|
||||
considerAllNonPartialDownloaded: false,
|
||||
},
|
||||
artworkSuffix: "",
|
||||
postProcessingCommand: "",
|
||||
subscriptions: {},
|
||||
metrics: {
|
||||
prometheusExporterPort: null,
|
||||
contributeMetrics: true,
|
||||
},
|
||||
notifications: {
|
||||
telegram: {
|
||||
enabled: false,
|
||||
token: "",
|
||||
chatId: "",
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
webhookUrl: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
109
doc/research/reference-sources/fpdl_float.ts
Normal file
109
doc/research/reference-sources/fpdl_float.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { defaultSettings } from "./lib/defaults";
|
||||
import { fetchFFMPEG } from "./lib/helpers/fetchFFMPEG";
|
||||
import { args, DownloaderVersion, fApi, settings } from "./lib/helpers/index";
|
||||
import { initProm } from "./lib/prometheus";
|
||||
import { quickStart, validatePlexSettings } from "./quickStart";
|
||||
|
||||
import chalk from "chalk-template";
|
||||
import { loginFloatplane, User } from "./logins";
|
||||
|
||||
import { fetchSubscriptions } from "./subscriptionFetching";
|
||||
|
||||
import semver from "semver";
|
||||
const { gt, diff } = semver;
|
||||
|
||||
import type { ContentPost } from "floatplane/content";
|
||||
import { Self } from "floatplane/user";
|
||||
|
||||
/**
|
||||
* Main function that triggeres everything else in the script
|
||||
*/
|
||||
const downloadNewVideos = async () => {
|
||||
const inProgress = [];
|
||||
|
||||
// Fetch content posts from seek and destroy guids
|
||||
const contentPosts: Promise<ContentPost>[] = [];
|
||||
while (settings.floatplane.seekAndDestroy.length > 0) {
|
||||
const guid = settings.floatplane.seekAndDestroy.pop();
|
||||
if (guid === undefined) continue;
|
||||
console.log(chalk`Seek and Destroy: {red ${guid}}`);
|
||||
contentPosts.push(fApi.content.post(guid));
|
||||
}
|
||||
|
||||
for await (const subscription of fetchSubscriptions()) {
|
||||
// Seek and destroy posts if the content post belongs to this subscription
|
||||
for await (const contentPost of contentPosts) {
|
||||
if (contentPost.creator.id === subscription.creatorId) {
|
||||
for await (const video of subscription.seekAndDestroy(contentPost)) {
|
||||
inProgress.push(video.download());
|
||||
}
|
||||
}
|
||||
}
|
||||
await subscription.deleteOldVideos();
|
||||
for await (const video of subscription.fetchNewVideos()) inProgress.push(video.download());
|
||||
}
|
||||
|
||||
console.log(chalk`Queued {green ${inProgress.length}} videos...`);
|
||||
await Promise.all(inProgress);
|
||||
|
||||
// Enforce search limits after searching once.
|
||||
settings.floatplane.videosToSearch = defaultSettings.floatplane.videosToSearch;
|
||||
|
||||
if (settings.floatplane.waitForNewVideos === true) {
|
||||
console.log(`Checking for new videos in 5 minutes...`);
|
||||
setTimeout(downloadNewVideos, 5 * 60 * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Fix for docker
|
||||
process.on("SIGTERM", () => process.exit(143));
|
||||
|
||||
(async () => {
|
||||
if (!args.headless) {
|
||||
console.log(chalk`\n{red ///}{grey ===} {cyan Console} {grey ===}{red \\\\\\}`);
|
||||
}
|
||||
|
||||
if (args.updateCheck) {
|
||||
const latest = await fApi
|
||||
.got("https://raw.githubusercontent.com/Inrixia/Floatplane-Downloader/master/package.json")
|
||||
.json<{ version: string }>()
|
||||
.catch(() => ({ version: DownloaderVersion }));
|
||||
|
||||
if (gt(latest.version, DownloaderVersion))
|
||||
console.log(
|
||||
chalk`There is a ${diff(latest.version, DownloaderVersion)} update available! ${DownloaderVersion} > ${
|
||||
latest.version
|
||||
}.\nHead to {cyanBright https://github.com/Inrixia/Floatplane-Downloader/releases} to update!\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (args.sanityCheck) {
|
||||
console.log("Sanity check passed!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Earlybird functions, these are run before script start and not run again if script repeating is enabled.
|
||||
await fetchFFMPEG();
|
||||
if (settings.runQuickstartPrompts) {
|
||||
if (args.headless) console.log("headless is set to true! Skipping quickstart prompts.");
|
||||
else await quickStart();
|
||||
}
|
||||
settings.runQuickstartPrompts = false;
|
||||
|
||||
// Get Plex details if not saved
|
||||
await validatePlexSettings();
|
||||
|
||||
// Get Floatplane credentials if not saved
|
||||
let user: Self | User;
|
||||
try {
|
||||
user = await fApi.user.self();
|
||||
} catch (err) {
|
||||
console.log(`Unable to authenticate with floatplane... ${(<Error>err).message}\nAttempting to re-authenticate...`);
|
||||
user = await loginFloatplane();
|
||||
}
|
||||
await initProm(user!.id);
|
||||
|
||||
console.log(chalk`Initalized! Running version {cyan ${DownloaderVersion}} instance {magenta ${user!.id}}`);
|
||||
|
||||
await downloadNewVideos();
|
||||
})();
|
||||
30
doc/research/reference-sources/fpdl_floatplane.ts
Normal file
30
doc/research/reference-sources/fpdl_floatplane.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import prompts from "prompts";
|
||||
import { type Channels } from "../types";
|
||||
|
||||
/**
|
||||
* Prompts user to set videos to search through for downloads.
|
||||
* @param {number} initial Default value
|
||||
* @returns {Promise<number>} Videos to search through.
|
||||
*/
|
||||
export const videosToSearch = async (initial: number): Promise<number> =>
|
||||
(
|
||||
await prompts({
|
||||
type: "number",
|
||||
name: "videosToSearch",
|
||||
message: "How many videos back from the latest do you want to search through for ones to download?",
|
||||
initial,
|
||||
min: 0,
|
||||
})
|
||||
).videosToSearch || initial;
|
||||
|
||||
export const channels = async (initial: Channels): Promise<string[]> => {
|
||||
return (
|
||||
await prompts({
|
||||
type: "multiselect",
|
||||
name: "downloadChannels",
|
||||
message: "Enable/Disable channels:",
|
||||
choices: initial.map((initial) => ({ title: initial.title, value: initial.title, selected: !initial.skip })),
|
||||
hint: "- Space to select. Return to submit",
|
||||
})
|
||||
).downloadChannels;
|
||||
};
|
||||
45
doc/research/reference-sources/fpdl_logins.ts
Normal file
45
doc/research/reference-sources/fpdl_logins.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { MyPlexAccount } from "@ctrl/plex";
|
||||
import { loopError } from "@inrixia/helpers";
|
||||
import { args, fApi } from "./lib/helpers/index";
|
||||
import { plex } from "./lib/prompts/index";
|
||||
|
||||
import type { Self } from "floatplane/user";
|
||||
export type User = Self;
|
||||
|
||||
export const loginFloatplane = async (): Promise<User> => {
|
||||
const loginResponse = await fApi.deviceLogin();
|
||||
console.log(`\nSigned in as \u001b[36m${loginResponse.username}\u001b[0m!\n`);
|
||||
return loginResponse;
|
||||
};
|
||||
|
||||
export const loginPlex = async (): Promise<string> => {
|
||||
let plexToken: string;
|
||||
if (args.headless === true) {
|
||||
if (args.plexUsername === undefined || args.plexPassword === undefined)
|
||||
throw new Error('Need plex username/password to login. Please pass them as --plexUsername="" --plexPassword="" or enviroment variables!');
|
||||
plexToken = (await loopError(
|
||||
async () =>
|
||||
(
|
||||
await new MyPlexAccount({
|
||||
username: args.plexUsername,
|
||||
password: args.plexPassword,
|
||||
}).connect()
|
||||
).token,
|
||||
async (err) => console.error(`\nLooks like those login details didnt work, Please try again... ${err}`),
|
||||
)) as string;
|
||||
} else {
|
||||
console.log("\n> Please enter your plex details. (Username and Password is not saved, only used to generate a token.)");
|
||||
plexToken = (await loopError(
|
||||
async () =>
|
||||
(
|
||||
await new MyPlexAccount({
|
||||
username: await plex.username(),
|
||||
password: await plex.password(),
|
||||
}).connect()
|
||||
).token,
|
||||
async (err) => console.error(`\nLooks like those login details didnt work, Please try again... ${err}`),
|
||||
)) as string;
|
||||
console.log(`> Fetched plex token: \u001b[36m${plexToken}\u001b[0m!\n`);
|
||||
}
|
||||
return plexToken;
|
||||
};
|
||||
20
doc/research/reference-sources/fpdl_onDeviceCode.ts
Normal file
20
doc/research/reference-sources/fpdl_onDeviceCode.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { OnDeviceCode } from "floatplane";
|
||||
import { args } from ".";
|
||||
import { requiredPrompts } from "../prompts/helpers";
|
||||
|
||||
import open from "open";
|
||||
|
||||
export const onDeviceCode: OnDeviceCode = async ({ verification_uri_complete, verification_uri }) => {
|
||||
const verifyUri = verification_uri_complete ?? verification_uri;
|
||||
const message = `Please login to Floatplane via ${verifyUri}`;
|
||||
if (args.headless) {
|
||||
console.log(message);
|
||||
} else {
|
||||
await open(verifyUri);
|
||||
await requiredPrompts({
|
||||
type: "confirm",
|
||||
name: "login",
|
||||
message,
|
||||
});
|
||||
}
|
||||
};
|
||||
58
doc/research/reference-sources/fpdl_package.json
Normal file
58
doc/research/reference-sources/fpdl_package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "floatplane-plex-downloader",
|
||||
"version": "5.19.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "pnpm run make && node ./dist/float.cjs",
|
||||
"startHeadless": "pnpm run make && node ./dist/float.cjs --headless",
|
||||
"make": "node ./make.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/plex": "^7.1.0",
|
||||
"@inrixia/db": "2.0.2",
|
||||
"@inrixia/helpers": "^3.21.2",
|
||||
"@npmcli/package-json": "^8.0.0",
|
||||
"@prometheus-io/client": "^0.16.1",
|
||||
"chalk-template": "^1.1.3",
|
||||
"default-import": "^3.0.1",
|
||||
"dotenv": "^18.0.6",
|
||||
"ffbinaries": "^1.1.6",
|
||||
"floatplane": "^5.2.8",
|
||||
"html-to-text": "^10.0.1",
|
||||
"json5": "^2.2.3",
|
||||
"mime-types": "^3.0.2",
|
||||
"multi-progress-bars": "^5.0.3",
|
||||
"open": "^11.0.4",
|
||||
"process.argv": "^1.0.0",
|
||||
"prompts": "^2.4.2",
|
||||
"sanitize-filename": "^1.6.4",
|
||||
"semver": "^7.8.5",
|
||||
"stream-throttle": "^0.1.3",
|
||||
"ws": "^8.22.0",
|
||||
"xmlbuilder2": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multi-progress": "^2.0.6",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/stream-throttle": "^0.1.4",
|
||||
"@types/ws": "^8.18.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.71.1",
|
||||
"@typescript-eslint/parser": "^8.71.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"got": "^16.0.0",
|
||||
"postject": "1.0.0-alpha.6",
|
||||
"rimraf": "^6.1.3",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
87
doc/research/reference-sources/fpdl_quickStart.ts
Normal file
87
doc/research/reference-sources/fpdl_quickStart.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { MyPlexAccount } from "@ctrl/plex";
|
||||
import { defaultResolutions } from "./lib/defaults";
|
||||
import { args, fApi, settings } from "./lib/helpers/index";
|
||||
import * as prompts from "./lib/prompts/index";
|
||||
import { loginFloatplane, loginPlex } from "./logins";
|
||||
|
||||
import { type Channels, type Extras } from "./lib/types";
|
||||
import { Video } from "./lib/Video";
|
||||
import { fetchSubscriptions } from "./subscriptionFetching";
|
||||
|
||||
export const promptPlexSections = async (): Promise<void> => {
|
||||
const plexApi = await new MyPlexAccount({ token: settings.plex.token }).connect();
|
||||
const servers = (await plexApi.resources()).filter((resource) => resource.provides.split(",").indexOf("server") !== -1);
|
||||
const serverSections = await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
const connectedServer = await server.connect();
|
||||
const library = await connectedServer.library();
|
||||
return (await library.sections()).filter((section) => section.type === "show");
|
||||
}),
|
||||
);
|
||||
settings.plex.sectionsToUpdate = await prompts.plex.sections(
|
||||
settings.plex.sectionsToUpdate,
|
||||
serverSections.flatMap((sections) => sections),
|
||||
);
|
||||
await validatePlexSettings();
|
||||
};
|
||||
|
||||
export const validatePlexSettings = async (): Promise<void> => {
|
||||
if (settings.plex.enabled) {
|
||||
if (settings.plex.token === "") {
|
||||
console.log("Missing plex token!");
|
||||
settings.plex.token = await loginPlex();
|
||||
}
|
||||
if (settings.plex.sectionsToUpdate.length === 0) {
|
||||
console.log("No sectionsToUpdate in config! Disabling plex integration...\n");
|
||||
settings.plex.enabled = false;
|
||||
}
|
||||
await new MyPlexAccount({ token: settings.plex.token }).connect().catch((err) => {
|
||||
console.error(`Failed to connect to plex, disabling plex refreshing! ${err.message}`);
|
||||
settings.plex.enabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const promptChannelSelection = async (): Promise<void> => {
|
||||
const channels: Channels = [];
|
||||
for await (const subscription of fetchSubscriptions()) {
|
||||
channels.push(...subscription.channels);
|
||||
}
|
||||
const desiredChannels = await prompts.floatplane.channels(channels);
|
||||
for (const channel of channels) {
|
||||
if (!desiredChannels.includes(channel.title)) channel.skip = true;
|
||||
}
|
||||
};
|
||||
|
||||
export const quickStart = async (): Promise<void> => {
|
||||
console.log("Welcome to Floatplane Downloader! Thanks for checking it out <3.");
|
||||
console.log("According to your settings.json this is your first launch! So lets go through the basic setup...\n");
|
||||
|
||||
console.log("\n== \u001b[38;5;208mFloatplane\u001b[0m ==\n");
|
||||
// Dont re-prompt for credentials if we are already logged in
|
||||
if ((await fApi.isAuthenticated()) !== true) {
|
||||
console.log("Please login to floatplane...");
|
||||
await loginFloatplane();
|
||||
} else console.log("Already logged in!");
|
||||
|
||||
console.log("\n== \u001b[38;5;208mGeneral\u001b[0m ==\n");
|
||||
settings.floatplane.videosToSearch = await prompts.floatplane.videosToSearch(settings.floatplane.videosToSearch);
|
||||
settings.floatplane.videoResolution = await prompts.settings.videoResolution(settings.floatplane.videoResolution, defaultResolutions);
|
||||
settings.filePathFormatting = await prompts.settings.fileFormatting(settings.filePathFormatting, Video.FilePathOptions);
|
||||
|
||||
const extras = await prompts.settings.extras(settings.extras);
|
||||
if (extras !== undefined) {
|
||||
for (const extra in settings.extras) settings.extras[extra as keyof Extras] = extras.indexOf(extra) > -1 ? true : false;
|
||||
}
|
||||
|
||||
console.log("\n== \u001b[38;5;208mSubscriptions\u001b[0m ==\n");
|
||||
await promptChannelSelection();
|
||||
|
||||
console.log("\n== \u001b[38;5;208mPlex\u001b[0m ==\n");
|
||||
settings.plex.enabled = await prompts.plex.usePlex(settings.plex.enabled);
|
||||
if (settings.plex.enabled) {
|
||||
if (settings.plex.token === "") settings.plex.token = await loginPlex();
|
||||
if (args.headless !== true) await promptPlexSections();
|
||||
}
|
||||
console.log("\n== \u001b[36mAll Setup!\u001b[0m ==\n");
|
||||
};
|
||||
274
doc/research/reference-sources/fpdl_settings.md
Normal file
274
doc/research/reference-sources/fpdl_settings.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Settings Wiki
|
||||
|
||||
Defaults for any setting is defined in the **[Defaults File](https://github.com/Inrixia/Floatplane-Downloader/blob/master/src/lib/defaults.ts)**.
|
||||
**You can find settings under /db/setttings.json**
|
||||
Note: Settings can be set in settings.json, using environment variables or passed in as arguments.
|
||||
For more info on how this work see the section in environment variables in the **[Docker Wiki](https://github.com/Inrixia/Floatplane-Downloader/blob/master/wiki/docker.md)** and **[Advanced Env Info](https://github.com/Inrixia/Floatplane-Downloader/blob/master/wiki/advenv.md)**
|
||||
<br><br>
|
||||
|
||||
**runQuickstartPrompts**:
|
||||
Setting this to true will cause the quickStartPrompts to run on startup.
|
||||
|
||||
```ts
|
||||
"runQuickstartPrompts": false
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Floatplane
|
||||
|
||||
**floatplane.videosToSearch**:
|
||||
Number of videos to search through when looking for undownloaded videos **per subscription**.
|
||||
|
||||
```json
|
||||
"floatplane": {
|
||||
"videosToSearch": 5
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**floatplane.videoResolution**:
|
||||
Resolution to download the videos in. See `_availableResolutions` for options.
|
||||
|
||||
```json
|
||||
"floatplane": {
|
||||
"videoResolution": 1080
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**floatplane.waitForNewVideos**:
|
||||
Controls if the downloader should wait for new videos to download after finishing or just exit
|
||||
|
||||
```json
|
||||
"floatplane": {
|
||||
"waitForNewVideos": true
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**floatplane.seekAndDestroy**:
|
||||
You can put video id's here and the downloader will find, sort and download them normally.
|
||||
|
||||
```json
|
||||
"floatplane": {
|
||||
"seekAndDestroy": ["xL64iWbreb"]
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## General Options
|
||||
|
||||
**filePathFormatting**:<br>
|
||||
This defined the path/filename formatting for downloaded videos...
|
||||
You can refer to the `Path Formatting Options` section in this wiki for what can be used.
|
||||
Strings surounded by % will be replaced with their respective values.
|
||||
|
||||
```json
|
||||
"filePathFormatting": "./videos/%channelTitle%/%channelTitle% - S%year%E%month%%day%%hour%%minute%%second% - %videoTitle%"
|
||||
```
|
||||
|
||||
#### Path Formatting Options
|
||||
|
||||
The following options are available to be used:
|
||||
|
||||
- **%channelTitle%** Title of the channel the video belongs to
|
||||
- **%year%** Year the video was released
|
||||
- **%month%** Month the video was released
|
||||
- **%day%** Day the video was released
|
||||
- **%hour%** Hour the video was released
|
||||
- **%minute%** Minute the video was released
|
||||
- **%second%** Second the video was released
|
||||
- **%videoTitle%** Title of the video
|
||||
<br>
|
||||
|
||||
**artworkSuffix**:
|
||||
Suffix appended to artwork filename.
|
||||
Added for Kodi support as Kodi looks for artwork in the format `VideoName-thumb.png`
|
||||
|
||||
```json
|
||||
"artworkSuffix": ""
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**maxDownloadSpeed**:
|
||||
The maximum speed to download at in mbps.
|
||||
|
||||
```json
|
||||
"maxDownloadSpeed": 8
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**postProcessingCommand**:
|
||||
A command to run after each video has sucessfully downloaded.
|
||||
You can refer to the `Path Formatting Options` section in this wiki for what can be used.
|
||||
Strings surounded by % will be replaced with their respective values.
|
||||
|
||||
```json
|
||||
"postProcessingCommand": "echo %videoTitle% > example.txt"
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Extras
|
||||
|
||||
**extras.stripSubchannelPrefix**:
|
||||
Removes the Subchannel prefix from the video title when a video is sorted into a subchannel.
|
||||
Note that this only works for videos that are sorted using title matching for channels. Newer channel id matching will not remove any prefix.
|
||||
For example:
|
||||
`TechLinked - SXXEXX - VideoTitle` - **true**
|
||||
vs
|
||||
`TechLinked - SXXEXX - TL: VideoTitle` - **false**
|
||||
|
||||
```json
|
||||
"extras": {
|
||||
"stripSubchannelPrefix": true
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**extras.downloadArtwork**:
|
||||
Saves video thubnails alongside each video. These are required for nice thumbnails in Plex.
|
||||
|
||||
```json
|
||||
"extras": {
|
||||
"downloadArtwork": true
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**extras.downloadCaptions**:
|
||||
Saves video captions alongside each video.
|
||||
|
||||
```json
|
||||
"extras": {
|
||||
"downloadCaptions": true
|
||||
}
|
||||
```
|
||||
|
||||
**extras.safeNfo**:
|
||||
Saves video metadata to nfo files alongside each video.
|
||||
|
||||
```json
|
||||
"extras": {
|
||||
"saveNfo": true
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**extras.considerAllNonPartialDownloaded**:
|
||||
When this is set to true the downloader will skip size checks for non `.mp4` files.
|
||||
This may result in files without muxed metadata and should only be used for recovery if your `db` is lost.
|
||||
|
||||
```json
|
||||
"extras": {
|
||||
"considerAllNonPartialDownloaded": true
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Plex
|
||||
|
||||
Use **quickstartPrompts** to easily set plex settings.
|
||||
|
||||
**plex.sectionsToUpdate**:
|
||||
Array of sections to update on refresh.
|
||||
Each "section" is a object containing the name of the section and the server it belongs to.
|
||||
|
||||
```json
|
||||
"plex": {
|
||||
"sectionsToUpdate": [
|
||||
{
|
||||
"server": "ServerA",
|
||||
"section": "Floatplane"
|
||||
},
|
||||
{
|
||||
"server": "ServerB",
|
||||
"section": "Floatplane"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**plex.token**:
|
||||
Plex token generated from your login details for updating remote servers.
|
||||
|
||||
```json
|
||||
"plex": {
|
||||
"token": "xM__2bulgyDf_wulgyE5owodds"
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Subscriptions:
|
||||
|
||||
All the Floatplane creators you are subscribed to.
|
||||

|
||||
At the creator level you can see the `creatorId` and `plan`. You can also choose to `skip` a creator and not download videos from them.
|
||||
You can add custom channels to a creator if you want.
|
||||
<br>
|
||||
|
||||
First come first served, the first channel a video matches to is what it goes into, channels are checked top to bottom in the config. Videos cannot be sorted into multiple channels.
|
||||
<br>
|
||||
|
||||
A **channel** is made up of a `title`, `skip`, `isChannel` and optionally `daysToKeepVideos`.
|
||||
`title` is the nice name used for the channel.
|
||||
`skip` can be set to true to skip downloading videos matched on the given channel.
|
||||
`isChannel` function that returns true or false if the video should be sorted into this channel (more on this further down).
|
||||
`daysToKeepVideos` is the optional number of days to keep videos for this channel. **2** would mean only videos released within the **last two days** are downloaded and any older will be **automatically deleted** if previously downloaded.
|
||||
|
||||
<br>
|
||||
|
||||
**isChannel** is a function that accepts a **[post](https://jamamp.github.io/FloatplaneAPIDocs/Redoc/redoc-static.html#tag/ContentV3/operation/getBlogPost)** which is the post the video belongs to and **[video](https://jamamp.github.io/FloatplaneAPIDocs/Redoc/redoc-static.html#tag/ContentV3/operation/getVideoContent)** which is one or more videos belonging to that post.
|
||||
|
||||
If it returns **true** the video is sorted into the channel, **false** and its not.
|
||||
This gives the flexibility to create completely custom channels based on any properties of a post or video.
|
||||
<br>
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Creators with Technology in their Description",
|
||||
"skip": false,
|
||||
"isChannel": "(post, video) => post.creator?.description?.toLowercase()?.includes('technology')",
|
||||
"daysToKeepVideos": 5
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Metrics:
|
||||
|
||||
**metrics.prometheusExporterPort**:
|
||||
Default is `null` if set to a number prometheus metrics will be made availible at that port.
|
||||
|
||||
```json
|
||||
"metrics": {
|
||||
"prometheusExporterPort": 8080,
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**metrics.contributeMetrics**:
|
||||
If true metrics will be included in the aggregate dashboard.
|
||||
|
||||
```json
|
||||
"metrics": {
|
||||
"contributeMetrics": true
|
||||
}
|
||||
```
|
||||
58
doc/research/reference-sources/fpdl_subscriptionFetching.ts
Normal file
58
doc/research/reference-sources/fpdl_subscriptionFetching.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { CreatorChannel } from "floatplane/creator";
|
||||
import { defaultSubChannels } from "./lib/defaults";
|
||||
import { fApi, settings } from "./lib/helpers/index";
|
||||
import Subscription from "./lib/Subscription";
|
||||
|
||||
import chalk from "chalk-template";
|
||||
|
||||
export async function* fetchSubscriptions() {
|
||||
const seenChannels = new Set<string>();
|
||||
console.log("Fetching user subscriptions...");
|
||||
for (const userSubscription of await fApi.user.subscriptions()) {
|
||||
// Add the subscription to settings if it doesnt exist
|
||||
settings.subscriptions[userSubscription.creator] ??= {
|
||||
creatorId: userSubscription.creator,
|
||||
plan: userSubscription.plan.title,
|
||||
skip: false,
|
||||
channels: defaultSubChannels[userSubscription.creator] ?? [],
|
||||
};
|
||||
const settingSubscription = settings.subscriptions[userSubscription.creator];
|
||||
|
||||
// Make sure that new subchannels from defaults are added to settings
|
||||
if (defaultSubChannels[userSubscription.creator] !== undefined) {
|
||||
const channelsToAdd = defaultSubChannels[userSubscription.creator].filter(
|
||||
(channel) => settingSubscription.channels.findIndex((chan) => chan.title === channel.title) === -1
|
||||
);
|
||||
if (channelsToAdd.length > 0) settingSubscription.channels = [...settingSubscription.channels, ...channelsToAdd];
|
||||
}
|
||||
|
||||
console.log(chalk`Fetching {yellow ${userSubscription.plan.title}'s} channels...`);
|
||||
const subChannels = await fApi.creator.channels([userSubscription.creator]);
|
||||
for (const channel of subChannels) {
|
||||
if (seenChannels.has(channel.id)) return;
|
||||
seenChannels.add(channel.id);
|
||||
const subChannel = settingSubscription.channels.find((chan) => chan.title === channel.title);
|
||||
const channelDefaults = subChannelDefaults(channel);
|
||||
if (subChannel === undefined) {
|
||||
settingSubscription.channels.push(channelDefaults);
|
||||
} else if (subChannel.isChannel === undefined) {
|
||||
// @ts-expect-error Identifiers have been replaced by isChannel
|
||||
delete subChannel.identifiers;
|
||||
subChannel.isChannel = channelDefaults.isChannel;
|
||||
}
|
||||
}
|
||||
|
||||
if (settingSubscription.skip === true) continue;
|
||||
|
||||
yield new Subscription(settings.subscriptions[userSubscription.creator]);
|
||||
}
|
||||
}
|
||||
|
||||
export const subChannelDefaults = (channel: CreatorChannel) => ({
|
||||
title: channel.title,
|
||||
skip: false,
|
||||
isChannel:
|
||||
channel.id === "6413623f5b12cca228a28e78"
|
||||
? `(post, video) => isChannel(post, '${channel.id}') && !video?.title?.toLowerCase().startsWith('caption')`
|
||||
: `(post) => isChannel(post, '${channel.id}')`,
|
||||
});
|
||||
88
doc/research/reference-sources/fpdl_types.ts
Normal file
88
doc/research/reference-sources/fpdl_types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type Resolutions = ["360", "720", "1080", "2160"];
|
||||
|
||||
import type { ValueOfA } from "@inrixia/helpers";
|
||||
|
||||
export type ChannelOptions = {
|
||||
title: string;
|
||||
skip: boolean;
|
||||
isChannel: string;
|
||||
daysToKeepVideos?: number;
|
||||
};
|
||||
|
||||
export type Channels = ChannelOptions[];
|
||||
|
||||
export type PlexSections = Array<{ server: string; section: string }>;
|
||||
export type PlexSettings = {
|
||||
sectionsToUpdate: PlexSections;
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type SubscriptionSettings = {
|
||||
creatorId: string;
|
||||
plan: string;
|
||||
skip: boolean;
|
||||
channels: Channels;
|
||||
};
|
||||
|
||||
export type Args = {
|
||||
headless: boolean;
|
||||
updateCheck: boolean;
|
||||
plexUsername: string;
|
||||
plexPassword: string;
|
||||
sanityCheck: boolean;
|
||||
dbPath: string;
|
||||
ffmpegPath: string;
|
||||
settingsPath: string;
|
||||
};
|
||||
|
||||
export type PartialArgs = Partial<Args & Settings>;
|
||||
|
||||
export interface Extras extends Record<string, boolean> {
|
||||
stripSubchannelPrefix: boolean;
|
||||
downloadArtwork: boolean;
|
||||
downloadCaptions: boolean;
|
||||
saveNfo: boolean;
|
||||
considerAllNonPartialDownloaded: boolean;
|
||||
}
|
||||
|
||||
export type Resolution = ValueOfA<Resolutions>;
|
||||
|
||||
export type Notification = {
|
||||
telegram?: {
|
||||
token: string;
|
||||
chatId: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
discord?: {
|
||||
enabled: boolean;
|
||||
webhookUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
__SettingsWiki: string;
|
||||
runQuickstartPrompts: boolean;
|
||||
floatplane: {
|
||||
videoResolution: Resolution;
|
||||
videosToSearch: number;
|
||||
waitForNewVideos: boolean;
|
||||
seekAndDestroy: string[];
|
||||
};
|
||||
maxDownloadSpeed: number;
|
||||
filePathFormatting: string;
|
||||
plex: PlexSettings;
|
||||
extras: Extras;
|
||||
artworkSuffix: string;
|
||||
postProcessingCommand: string;
|
||||
subscriptions: {
|
||||
[key: string]: SubscriptionSettings;
|
||||
};
|
||||
metrics: {
|
||||
prometheusExporterPort: number | null;
|
||||
contributeMetrics: boolean;
|
||||
};
|
||||
notifications: Notification;
|
||||
};
|
||||
|
||||
export const PROMPT_CONFIRM = "confirm";
|
||||
12
doc/research/reference-sources/fpts_Core.ts
Normal file
12
doc/research/reference-sources/fpts_Core.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import got from "got";
|
||||
|
||||
type Got = typeof got;
|
||||
export class Core {
|
||||
public got: Got;
|
||||
protected baseUrl;
|
||||
public static readonly BaseUrl = "https://www.floatplane.com";
|
||||
constructor(got: Got, baseUrl?: string) {
|
||||
this.got = got;
|
||||
this.baseUrl = baseUrl ?? Core.BaseUrl;
|
||||
}
|
||||
}
|
||||
67
doc/research/reference-sources/fpts_README.md
Normal file
67
doc/research/reference-sources/fpts_README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Unofficial Floatplane API
|
||||
|
||||
This library is not in any way related to LMG or Floatplane Media Inc.
|
||||
|
||||
Features/Endpoints are added as needed so if something is missing please make a [Issue](https://github.com/Inrixia/floatplaneAPI.ts/issues/new) or fork, add it yourself and [Pull Request](https://github.com/Inrixia/floatplaneAPI.ts/pulls)
|
||||
|
||||
### Install
|
||||
|
||||
`$ npm install floatplane`
|
||||
|
||||
### Types & Docs
|
||||
|
||||
This library uses & collaborates with the **[Floatplane API Specification](https://github.com/Jman012/FloatplaneAPI)** which has full **[documentation](https://jamamp.github.io/FloatplaneAPIDocs/)** for most Floatplane endpoints including ones this library has not yet implemented.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```ts
|
||||
import { Floatplane, type AuthToken, OnDeviceCode } from 'floatplane';
|
||||
|
||||
class AuthTokenStore {
|
||||
authToken?: AuthToken;
|
||||
}
|
||||
|
||||
const onDeviceCode: OnDeviceCode = async ({ verification_uri_complete, verification_uri }) => {
|
||||
const verifyUri = verification_uri_complete ?? verification_uri;
|
||||
console.log(`Please login to Floatplane via ${verifyUri}`);
|
||||
};
|
||||
|
||||
const authTokenStore = new AuthTokenStore();
|
||||
|
||||
const floatplane = new Floatplane({
|
||||
authConfig: {
|
||||
clientId: "example-client", // Has to be registered on FloatPlane
|
||||
authToken: authTokenStore.authToken,
|
||||
onAuthToken: (authToken) => {authTokenStore.authToken = authToken},
|
||||
onDeviceCode,
|
||||
},
|
||||
userAgent: `ExampleApp`, // Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent
|
||||
});
|
||||
|
||||
// Fetch User subscriptions
|
||||
const subs = await floatplane.user.subscriptions();
|
||||
|
||||
// 20 Latest videos from first subscription
|
||||
const videos = await floatplane.creator.blogPosts(subs[0].creator, { hasVideo: true });
|
||||
|
||||
// Fetch all videos asynchronously
|
||||
for await (const video of floatplane.creator.blogPostsIterable(subs[0].creator, { hasVideo: true })) {
|
||||
console.log(video);
|
||||
}
|
||||
```
|
||||
|
||||
Take a look at https://github.com/Inrixia/Floatplane-Downloader/blob/055e21e42c0af89a68cdf2f58a195cc81ad07ddf/src/lib/helpers/index.ts#L49-L58 for a reference implementation.
|
||||
|
||||
Individual classes can also be imported seperately:
|
||||
|
||||
```ts
|
||||
import { Auth } from "floatplane/auth";
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Projects
|
||||
|
||||
The following projects use this library:
|
||||
|
||||
### [Floatplane Downloader](https://github.com/Inrixia/Floatplane-Downloader)
|
||||
12
doc/research/reference-sources/fpts_api.ts
Normal file
12
doc/research/reference-sources/fpts_api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Core } from "./Core.js";
|
||||
|
||||
import { type operations, ApiPaths } from "./lib/apiSchema.js";
|
||||
|
||||
export type EdgesResponse = operations["getEdges"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
export class Api extends Core {
|
||||
/**
|
||||
* Fetch edges from the floatplane api
|
||||
*/
|
||||
edges = (): Promise<EdgesResponse> => this.got(this.baseUrl + ApiPaths.getEdges).json();
|
||||
}
|
||||
7683
doc/research/reference-sources/fpts_apiSchema.ts
Normal file
7683
doc/research/reference-sources/fpts_apiSchema.ts
Normal file
File diff suppressed because one or more lines are too long
33
doc/research/reference-sources/fpts_cdn.ts
Normal file
33
doc/research/reference-sources/fpts_cdn.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Core } from "./Core.js";
|
||||
|
||||
import type { Options } from "got";
|
||||
|
||||
import { type components, type operations, ApiPaths } from "./lib/apiSchema.js";
|
||||
|
||||
export type GotOptions = Options & { isStream: true };
|
||||
// V3
|
||||
export type DeliveryResponse = components["schemas"]["CdnDeliveryV3Response"];
|
||||
type QueryParams = operations["getDeliveryInfoV3"]["parameters"]["query"];
|
||||
|
||||
export class CDN extends Core {
|
||||
/**
|
||||
* Fetches resource information from cdn.
|
||||
*
|
||||
* @param scenario The scenario in which to consume the media.
|
||||
* - `onDemand` = stream a Video/Audio On Demand
|
||||
* - `download` = Download the content for the user to play later.
|
||||
* - `live` = Livestream the content
|
||||
* @param entityId The attachment or livestream identifier for the requested media. For video and audio, this would be from the `videoAttachments` or `audioAttachments` objects. For livestreams, this is the `liveStream.id` from the creator object.
|
||||
* @param outputKind Use `outputKind` to ensure the right vehicle is used for your client, e.g. `outputKind=hls.fmp4` is optimal for tvOS 10+.
|
||||
* @returns A promise that resolves to a `DeliveryResponse` object.
|
||||
*/
|
||||
delivery(scenario: QueryParams["scenario"], entityId: string, outputKind?: QueryParams["outputKind"]): Promise<DeliveryResponse> {
|
||||
const url = new URL(this.baseUrl + ApiPaths.getDeliveryInfoV3);
|
||||
|
||||
url.searchParams.set("scenario", scenario);
|
||||
url.searchParams.set("entityId", entityId);
|
||||
if (outputKind !== undefined) url.searchParams.set("outputKind", outputKind);
|
||||
|
||||
return this.got(url.href).json();
|
||||
}
|
||||
}
|
||||
21
doc/research/reference-sources/fpts_content.ts
Normal file
21
doc/research/reference-sources/fpts_content.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Core } from "./Core.js";
|
||||
|
||||
import { ApiPaths, type operations } from "./lib/apiSchema.js";
|
||||
|
||||
export type ContentPost = operations["getBlogPost"]["responses"][200]["content"]["application/json"];
|
||||
type PostParams = operations["getBlogPost"]["parameters"]["query"];
|
||||
|
||||
export type VideoContent = operations["getVideoContent"]["responses"][200]["content"]["application/json"];
|
||||
type VideoParams = operations["getVideoContent"]["parameters"]["query"];
|
||||
|
||||
export class Content extends Core {
|
||||
/**
|
||||
* A specific post from the floatplane api by id
|
||||
*/
|
||||
post = (id: PostParams["id"]): Promise<ContentPost> => this.got(`${this.baseUrl}${ApiPaths.getBlogPost}/?id=${id}`).json();
|
||||
|
||||
/**
|
||||
* A specific video attachment from the floatplane api by id
|
||||
*/
|
||||
video = (id: VideoParams["id"]): Promise<VideoContent> => this.got(`${this.baseUrl}${ApiPaths.getVideoContent}/?id=${id}`).json();
|
||||
}
|
||||
74
doc/research/reference-sources/fpts_creator.ts
Normal file
74
doc/research/reference-sources/fpts_creator.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Core } from "./Core.js";
|
||||
|
||||
import type { OptionalOnly } from "@inrixia/helpers";
|
||||
import { type components, type operations, ApiPaths } from "./lib/apiSchema.js";
|
||||
|
||||
export type BlogPost = components["schemas"]["BlogPostModelV3"];
|
||||
type BlogPostQueryParams = operations["getCreatorBlogPosts"]["parameters"]["query"];
|
||||
|
||||
export type CreatorInfo = components["schemas"]["CreatorModelV2"];
|
||||
type CreatorQueryParams = operations["getInfo"]["parameters"]["query"];
|
||||
|
||||
export type CreatorChannel = components["schemas"]["ChannelModel"];
|
||||
type CreatorChannelsQueryParams = operations["listCreatorChannelsV3"]["parameters"]["query"];
|
||||
export class Creator extends Core {
|
||||
/**
|
||||
* Fetch blogPosts from a creator, returns a Async Iterator.
|
||||
*/
|
||||
async *blogPostsIterable(creatorGUID: string, options?: Omit<OptionalOnly<BlogPostQueryParams>, "fetchAfter">): AsyncIterableIterator<BlogPost> {
|
||||
let fetchAfter = 0;
|
||||
let blogPosts = await this.blogPosts(creatorGUID, { ...options, fetchAfter });
|
||||
while (blogPosts.length > 0) {
|
||||
yield* blogPosts;
|
||||
fetchAfter += 20;
|
||||
blogPosts = await this.blogPosts(creatorGUID, { ...options, fetchAfter });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch blogPosts from a creator.
|
||||
*/
|
||||
blogPosts = (creatorGUID: BlogPostQueryParams["id"], options?: OptionalOnly<BlogPostQueryParams>): Promise<BlogPost[]> => {
|
||||
const url = new URL(this.baseUrl + ApiPaths.getCreatorBlogPosts);
|
||||
url.searchParams.append("id", creatorGUID);
|
||||
if (options !== undefined) {
|
||||
const { limit, fetchAfter, search, tags, hasVideo, hasAudio, hasPicture, hasText, sort, fromDuration, toDuration, fromDate, toDate, channel } = options;
|
||||
if (channel !== undefined) url.searchParams.append("channel", channel.toString());
|
||||
if (limit !== undefined) url.searchParams.append("limit", limit.toString());
|
||||
if (fetchAfter !== undefined) url.searchParams.append("fetchAfter", fetchAfter.toString());
|
||||
if (search !== undefined) url.searchParams.append("search", search);
|
||||
if (tags !== undefined) url.searchParams.append("tags", tags.join(","));
|
||||
|
||||
if (hasVideo !== undefined) url.searchParams.append("hasVideo", hasVideo.toString());
|
||||
if (hasAudio !== undefined) url.searchParams.append("hasAudio", hasAudio.toString());
|
||||
if (hasPicture !== undefined) url.searchParams.append("hasPicture", hasPicture.toString());
|
||||
if (hasText !== undefined) {
|
||||
if (hasVideo === true) throw new Error("hasVideo cannot be true if hasText is true");
|
||||
if (hasAudio === true) throw new Error("hasAudio cannot be true if hasText is true");
|
||||
if (hasPicture === true) throw new Error("hasPicture cannot be true if hasText is true");
|
||||
url.searchParams.append("hasText", hasText.toString());
|
||||
}
|
||||
|
||||
if (sort !== undefined) url.searchParams.append("sort", sort);
|
||||
|
||||
if (fromDuration !== undefined) {
|
||||
if (hasVideo !== true) throw new Error("hasVideo must be true if fromDuration is set");
|
||||
url.searchParams.append("fromDuration", fromDuration.toString());
|
||||
}
|
||||
if (toDuration !== undefined) {
|
||||
if (hasVideo !== true) throw new Error("hasVideo must be true if toDuration is set");
|
||||
url.searchParams.append("toDuration", toDuration.toString());
|
||||
}
|
||||
|
||||
if (fromDate !== undefined) url.searchParams.append("fromDate", fromDate.toString());
|
||||
if (toDate !== undefined) url.searchParams.append("toDate", toDate.toString());
|
||||
}
|
||||
return this.got(url.href).json();
|
||||
};
|
||||
|
||||
info = (creatorGUID: CreatorQueryParams["creatorGUID"]): Promise<CreatorInfo[]> =>
|
||||
this.got(`${this.baseUrl}${ApiPaths.getInfo}?creatorGUID=${creatorGUID}`).json();
|
||||
|
||||
channels = (creatorGUID: CreatorChannelsQueryParams["ids"]): Promise<CreatorChannel[]> =>
|
||||
this.got(`${this.baseUrl}${ApiPaths.listCreatorChannelsV3}?ids=${creatorGUID}`).json();
|
||||
}
|
||||
161
doc/research/reference-sources/fpts_index.ts
Normal file
161
doc/research/reference-sources/fpts_index.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import got from "got";
|
||||
|
||||
import * as client from "openid-client";
|
||||
import { Api } from "./api.js";
|
||||
import { CDN } from "./cdn.js";
|
||||
import { Creator } from "./creator.js";
|
||||
import { User } from "./user.js";
|
||||
|
||||
import { TokenEndpointResponse } from "openid-client";
|
||||
import { Content } from "./content.js";
|
||||
import { Core } from "./Core.js";
|
||||
|
||||
export const version = "5.2.8";
|
||||
|
||||
export type AuthToken = TokenEndpointResponse & { expiresEpoch?: number };
|
||||
export type OnDeviceCode = (response: client.DeviceAuthorizationResponse) => any;
|
||||
export type OnAuthToken = (authToken: AuthToken) => void;
|
||||
export type AuthConfig = {
|
||||
authToken?: AuthToken;
|
||||
onAuthToken?: OnAuthToken;
|
||||
onDeviceCode: OnDeviceCode;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
serverUrl?: string;
|
||||
};
|
||||
export type FloatplaneSettings = {
|
||||
authConfig: AuthConfig;
|
||||
userAgent?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
export class Floatplane {
|
||||
public got: typeof got;
|
||||
|
||||
public user: User;
|
||||
public api: Api;
|
||||
public creator: Creator;
|
||||
public cdn: CDN;
|
||||
public content: Content;
|
||||
|
||||
private oauthConfig?: client.Configuration;
|
||||
public readonly authConfig: AuthConfig;
|
||||
|
||||
public readonly userAgent: string = `FloatplaneAPI/${version} (Inrix, +https://github.com/Inrixia/floatplaneAPI.ts), CFNetwork`;
|
||||
|
||||
constructor({ authConfig, baseUrl, userAgent }: FloatplaneSettings) {
|
||||
this.authConfig = authConfig;
|
||||
|
||||
baseUrl ??= Core.BaseUrl;
|
||||
|
||||
const headers = {
|
||||
"User-Agent": userAgent ?? this.userAgent,
|
||||
accept: "application/json",
|
||||
connection: "keep-alive",
|
||||
};
|
||||
|
||||
this.got = got.extend({
|
||||
headers,
|
||||
retry: {
|
||||
limit: 5, // Maximum number of retries
|
||||
},
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
async ({ headers, url, prefixUrl }) => {
|
||||
if (!new URL(url ?? prefixUrl).href.startsWith(baseUrl)) return;
|
||||
await this.refreshAuthToken();
|
||||
if (this.authToken?.access_token) headers.authorization = `Bearer ${this.authToken.access_token}`;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
this.user = new User(this.got, baseUrl);
|
||||
this.api = new Api(this.got, baseUrl);
|
||||
this.creator = new Creator(this.got, baseUrl);
|
||||
this.cdn = new CDN(this.got, baseUrl);
|
||||
this.content = new Content(this.got, baseUrl);
|
||||
}
|
||||
|
||||
public extend(...params: Parameters<typeof got.extend>) {
|
||||
this.got = this.got.extend(...params);
|
||||
this.user.got = this.got;
|
||||
this.api.got = this.got;
|
||||
this.creator.got = this.got;
|
||||
this.cdn.got = this.got;
|
||||
this.content.got = this.got;
|
||||
}
|
||||
|
||||
private get authToken() {
|
||||
return this.authConfig.authToken;
|
||||
}
|
||||
private set authToken(authToken: AuthToken | null | undefined) {
|
||||
if (authToken) authToken.expiresEpoch = Date.now() + (authToken?.expires_in ?? 30) * 1000;
|
||||
this.authConfig.authToken = authToken ?? undefined;
|
||||
if (this.authConfig.onAuthToken && authToken) this.authConfig.onAuthToken(authToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time untl token expiry in ms
|
||||
*/
|
||||
public authTokenExpiresIn(authToken: AuthToken | null | undefined = this.authToken): number {
|
||||
const expiresEpoch = authToken?.expiresEpoch;
|
||||
if (!expiresEpoch) return -1;
|
||||
|
||||
return Math.max(expiresEpoch - Date.now(), 0);
|
||||
}
|
||||
|
||||
public refreshAuthToken = async () => {
|
||||
if (!this.authToken) return this.login();
|
||||
|
||||
if (this.authTokenExpiresIn() < 60000) {
|
||||
const refreshToken = this.authToken.refresh_token;
|
||||
if (!refreshToken) throw new Error("No refresh token available to refresh OAuth token!");
|
||||
|
||||
this.oauthConfig ??= await client.discovery(
|
||||
new URL(this.authConfig.serverUrl ?? "https://auth.floatplane.com/realms/floatplane"),
|
||||
this.authConfig.clientId,
|
||||
this.authConfig.clientSecret,
|
||||
);
|
||||
const refreshedTokenSet = await client.refreshTokenGrant(this.oauthConfig, refreshToken);
|
||||
|
||||
if (refreshedTokenSet.access_token === undefined) throw new Error("No access token received when refreshing token!");
|
||||
this.authToken = refreshedTokenSet;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Login to floatplane so future requests are authenticated using the Device flow
|
||||
* @returns {Promise<User>} User object.
|
||||
*/
|
||||
deviceLogin = async (onDeviceCode = this.authConfig.onDeviceCode) => {
|
||||
this.oauthConfig ??= await client.discovery(
|
||||
new URL(this.authConfig.serverUrl ?? "https://auth.floatplane.com/realms/floatplane"),
|
||||
this.authConfig.clientId,
|
||||
this.authConfig.clientSecret,
|
||||
);
|
||||
|
||||
if (!this.authToken) {
|
||||
const response = await client.initiateDeviceAuthorization(this.oauthConfig, { scope: "openid profile email offline_access" });
|
||||
await onDeviceCode(response);
|
||||
|
||||
const authToken = await client.pollDeviceAuthorizationGrant(this.oauthConfig, response);
|
||||
|
||||
if (authToken.access_token === undefined) throw new Error("No access token received from device authorization flow!");
|
||||
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
return this.user.self();
|
||||
};
|
||||
|
||||
private login = this.deviceLogin;
|
||||
|
||||
/**
|
||||
* Returns true if authenticated or Error if not.
|
||||
*/
|
||||
isAuthenticated = async (): Promise<Error | true> =>
|
||||
this.user
|
||||
.self()
|
||||
.then((): true => true)
|
||||
.catch((err: Error) => err);
|
||||
}
|
||||
19
doc/research/reference-sources/fpts_sanity.ts
Normal file
19
doc/research/reference-sources/fpts_sanity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Floatplane, version } from "./index.js";
|
||||
|
||||
const fApi = new Floatplane({
|
||||
authConfig: {
|
||||
serverUrl: "https://auth.floatplane.com/realms/floatplane-pp",
|
||||
clientId: "floatplane-downloader",
|
||||
onDeviceCode: ({ verification_uri_complete }) => console.log(`Go to ${verification_uri_complete} to complete login...`),
|
||||
},
|
||||
baseUrl: "https://pp.floatplane.com",
|
||||
}); // Create a new API instance.
|
||||
|
||||
// console.log("Logged in!", await fApi.refreshAuthToken());
|
||||
// console.log("Refreshed", await fApi.refreshAuthToken());
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Yes, package.json isnt under src, this is fine
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
if (pkg.version !== version) throw new Error(`Version mismatch! package.json says ${pkg.version} but index says ${version}`);
|
||||
17
doc/research/reference-sources/fpts_user.ts
Normal file
17
doc/research/reference-sources/fpts_user.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Core } from "./Core.js";
|
||||
import { ApiPaths, components } from "./lib/apiSchema.js";
|
||||
|
||||
export type UserSubscription = components["schemas"]["UserSubscriptionModel"];
|
||||
export type Self = components["schemas"]["UserSelfV3Response"];
|
||||
|
||||
export class User extends Core {
|
||||
/**
|
||||
* Fetch subscriptions for the logged in user.
|
||||
*/
|
||||
subscriptions = (): Promise<UserSubscription[]> => this.got(this.baseUrl + ApiPaths.listUserSubscriptionsV3).json<UserSubscription[]>();
|
||||
|
||||
/**
|
||||
* Fetch information about the logged in user.
|
||||
*/
|
||||
self = (): Promise<Self> => this.got(this.baseUrl + ApiPaths.getSelf).json<Self>();
|
||||
}
|
||||
31
doc/research/reference-sources/hydra_README.md
Normal file
31
doc/research/reference-sources/hydra_README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Hydravion
|
||||
**Unofficial Floatplane client for Roku devices. This project is NOT associated with Floatplane Media (floatplane.com).**
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/bmlzootown/Hydravion/master/images/channel-poster-mm_icon_focus_hd.png" width="200px">
|
||||
</p>
|
||||
|
||||
## Installing
|
||||
The certified version can be added via access code [TCXTKHH](https://my.roku.com/add/TCXTKHH), or you can sideload the source itself.
|
||||
</br></br>
|
||||
[**Now available on the Roku channel store!**](https://channelstore.roku.com/en-ot/details/96a633034fff363b2a194509d75cb966/hydravion)
|
||||
|
||||
## Issues/Feedback
|
||||
Having issues? Have feedback? Join the [Discord server](https://discord.gg/4xKDGz5M5B), or open an issue [here](https://github.com/bmlzootown/Hydravion/issues).
|
||||
|
||||
## Other Platforms
|
||||
- [Hydravion-AndroidTV](https://github.com/bmlzootown/Hydravion-AndroidTV) (bmlzootown, NickM-27)
|
||||
- [Wasserflug-tvOS](https://github.com/Jman012/Wasserflug-tvOS) (Jman012)
|
||||
|
||||
## Screenshots ([Imgur](https://imgur.com/a/mPSp50T))
|
||||
<p align="center">
|
||||
<img src="https://i.imgur.com/7lTgfeQ.png" width="500px">
|
||||
<img src="https://i.imgur.com/PgMGNG7.png" width="500px">
|
||||
<img src="https://i.imgur.com/Q8mTKCq.png" width="500px">
|
||||
<img src="https://i.imgur.com/Ucck3Es.png" width="500px">
|
||||
</p>
|
||||
|
||||
## Credits
|
||||
|
||||
### QR Code Generation
|
||||
This project uses the [QR-Code-generator-brightscript](https://github.com/paramount-engineering/QR-Code-generator-brightscript) library by Paramount, which is a derivative of the [QR Code generator](https://github.com/nayuki/QR-Code-generator) by Project Nayuki, for oauth flow. Both libraries are licensed under the MIT License.
|
||||
76
doc/research/reference-sources/hydra_apiConfig.brs
Normal file
76
doc/research/reference-sources/hydra_apiConfig.brs
Normal file
@@ -0,0 +1,76 @@
|
||||
' API Configuration Utility
|
||||
|
||||
function ApiConfig() as Object
|
||||
apiConfigObj = {
|
||||
environment: "prod"
|
||||
|
||||
' Base URLs for different environments
|
||||
baseUrls: {
|
||||
prod: "https://www.floatplane.com"
|
||||
preprod: "https://pp.floatplane.com"
|
||||
}
|
||||
|
||||
' Auth server URL
|
||||
authBaseUrl: "https://auth.floatplane.com"
|
||||
|
||||
' Realm names for different environments
|
||||
realms: {
|
||||
prod: "floatplane"
|
||||
preprod: "floatplane-pp"
|
||||
}
|
||||
|
||||
getApiBaseUrl: function() as String
|
||||
if m.environment = "preprod"
|
||||
return m.baseUrls.preprod
|
||||
else
|
||||
return m.baseUrls.prod
|
||||
end if
|
||||
end function
|
||||
|
||||
' Get the auth server base URL
|
||||
getAuthBaseUrl: function() as String
|
||||
return m.authBaseUrl
|
||||
end function
|
||||
|
||||
' Get the realm name for the current environment
|
||||
getRealm: function() as String
|
||||
if m.environment = "preprod"
|
||||
return m.realms.preprod
|
||||
else
|
||||
return m.realms.prod
|
||||
end if
|
||||
end function
|
||||
|
||||
' Build a full API URL from path
|
||||
buildApiUrl: function(path as String) as String
|
||||
baseUrl = m.getApiBaseUrl()
|
||||
if path.Left(1) <> "/"
|
||||
path = "/" + path
|
||||
end if
|
||||
return baseUrl + path
|
||||
end function
|
||||
|
||||
' Build a full auth URL from path
|
||||
buildAuthUrl: function(path as String) as String
|
||||
baseUrl = m.getAuthBaseUrl()
|
||||
if path.Left(1) <> "/"
|
||||
path = "/" + path
|
||||
end if
|
||||
return baseUrl + path
|
||||
end function
|
||||
|
||||
' Build a realm-specific auth URL (e.g., /realms/{realm}/protocol/...)
|
||||
buildRealmAuthUrl: function(path as String) as String
|
||||
realm = m.getRealm()
|
||||
' Remove leading slash from path if present, we'll add it after realm
|
||||
if path.Left(1) = "/"
|
||||
path = path.Mid(1)
|
||||
end if
|
||||
realmPath = "/realms/" + realm + "/protocol/" + path
|
||||
return m.buildAuthUrl(realmPath)
|
||||
end function
|
||||
}
|
||||
|
||||
return apiConfigObj
|
||||
end function
|
||||
|
||||
74
doc/research/reference-sources/hydra_category_screen.brs
Normal file
74
doc/research/reference-sources/hydra_category_screen.brs
Normal file
@@ -0,0 +1,74 @@
|
||||
function init()
|
||||
m.category_list = m.top.findNode("category_list")
|
||||
m.channel_list = m.top.findNode("channel_list")
|
||||
m.chevron_right = m.top.findNode("chevron-right")
|
||||
m.background = m.top.findNode("background")
|
||||
m.category_list.setFocus(true)
|
||||
m.top.observeField("visible", "onVisibleChange")
|
||||
m.category_list.observeField("itemFocused", "onFocusChanged")
|
||||
end function
|
||||
|
||||
sub onVisibleChange()
|
||||
if m.top.visible = true then
|
||||
m.category_list.setFocus(true)
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onFocusChanged()
|
||||
if m.category_list.visible = true
|
||||
index = m.category_list.itemFocused
|
||||
if index <> -1
|
||||
m.background.uri = m.category_list.content.getChild(index).HDPosterURL
|
||||
m.highlighted = m.category_list.content.getChild(index)
|
||||
getChannels(m.highlighted.creatorGUID)
|
||||
end if
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub getChannels(id)
|
||||
m.subs_task = CreateObject("roSGNode", "urlTask")
|
||||
apiConfigObj = ApiConfig()
|
||||
url = apiConfigObj.buildApiUrl("/api/v3/creator/info?id=" + id)
|
||||
m.subs_task.setField("url", url)
|
||||
m.subs_task.observeField("response", "gotChannels")
|
||||
m.subs_task.control = "RUN"
|
||||
end sub
|
||||
|
||||
sub gotChannels(obj)
|
||||
m.feed_task = createObject("roSGNode", "setupChannelsTask")
|
||||
m.feed_task.setField("unparsed", obj.getData())
|
||||
m.feed_task.observeField("category_node", "onChannelsSetup")
|
||||
m.feed_task.control = "RUN"
|
||||
end sub
|
||||
|
||||
sub onChannelsSetup(obj)
|
||||
m.channel_list.content = obj.getData()
|
||||
if obj.getData().getChildCount() < 2
|
||||
m.channel_list.visible = false
|
||||
m.chevron_right.visible = false
|
||||
else
|
||||
m.channel_list.visible = true
|
||||
m.chevron_right.visible = true
|
||||
m.channel_list.drawFocusFeedback = false
|
||||
m.channel_list.opacity = 0.5
|
||||
end if
|
||||
end sub
|
||||
|
||||
function onKeyEvent(key as string, press as boolean) as boolean
|
||||
if not press then return false
|
||||
|
||||
if key = "right" and m.category_list.hasFocus()
|
||||
m.channel_list.drawFocusFeedback = true
|
||||
m.channel_list.opacity = 1
|
||||
m.channel_list.setFocus(true)
|
||||
return true
|
||||
else if key = "left" OR key = "back"
|
||||
if m.channel_list.hasFocus()
|
||||
m.channel_list.opacity = 1
|
||||
m.category_list.setFocus(true)
|
||||
return true
|
||||
end if
|
||||
end if
|
||||
|
||||
return false
|
||||
end function
|
||||
71
doc/research/reference-sources/hydra_content_screen.brs
Normal file
71
doc/research/reference-sources/hydra_content_screen.brs
Normal file
@@ -0,0 +1,71 @@
|
||||
sub init()
|
||||
m.content_grid = m.top.FindNode("content_grid")
|
||||
'm.content_grid.caption1Font.size = 24
|
||||
'm.content_grid.caption2NumLines = 1
|
||||
'm.content_grid.caption2Font.size = 18
|
||||
'm.content_grid.captionLineSpacing = 10.0
|
||||
m.header = m.top.FindNode("header")
|
||||
m.hideHeader = m.top.FindNode("hideHeader")
|
||||
m.showHeader = m.top.FindNode("showHeader")
|
||||
m.cover = m.top.FindNode("cover")
|
||||
m.icon = m.top.FindNode("icon")
|
||||
'm.liveButton = m.top.FindNode("liveButton")
|
||||
m.top.observeField("visible", "onVisibleChange")
|
||||
m.top.observeField("feed_node", "onFeedChanged")
|
||||
m.content_grid.observeField("itemFocused", "OnFocusItem")
|
||||
m.top.observeField("jumpTo", "jumpTo")
|
||||
m.visibleCover = true
|
||||
end sub
|
||||
|
||||
sub onFeedChanged(obj)
|
||||
m.header.text = m.top.feed_name
|
||||
'm.cover.uri = m.creator.HDPOSTERURL
|
||||
showpostergrid(m.top.feed_node)
|
||||
end sub
|
||||
|
||||
sub showpostergrid(content)
|
||||
m.content_grid.content = content
|
||||
m.content_grid.visible = true
|
||||
if m.top.visible = true then
|
||||
m.content_grid.setFocus(true)
|
||||
end if
|
||||
m.cover.uri = m.top.category_node.HDPOSTERURL
|
||||
'm.icon.uri = m.top.category_node.icon
|
||||
m.icon.imageUri = m.top.category_node.icon
|
||||
m.icon.fixsize = true
|
||||
'm.content_grid.jumpToItem = m.top.jumpTo
|
||||
'? m.top.jumpTo
|
||||
end sub
|
||||
|
||||
sub OnFocusItem(event)
|
||||
m.top.itemIndex = event.getData()
|
||||
'? event.getData()
|
||||
if event.getData() > 2 and m.visibleCover = true
|
||||
m.hideHeader.control = "start"
|
||||
m.visibleCover = false
|
||||
else if event.getData() < 3 and m.visibleCover = false
|
||||
m.showHeader.control = "start"
|
||||
m.visibleCover = true
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub jumpTo(item)
|
||||
'? item.getData()
|
||||
'm.content_grid.jumpToItem = item.getData()
|
||||
end sub
|
||||
|
||||
sub onVisibleChange()
|
||||
if m.top.visible = true then
|
||||
m.content_grid.setFocus(true)
|
||||
end if
|
||||
end sub
|
||||
|
||||
function onKeyEvent(key as string, press as boolean) as boolean
|
||||
if not press then return false
|
||||
|
||||
if key = "up" and m.content_grid.hasFocus()
|
||||
return true
|
||||
end if
|
||||
|
||||
return false
|
||||
end function
|
||||
355
doc/research/reference-sources/hydra_details_screen.brs
Normal file
355
doc/research/reference-sources/hydra_details_screen.brs
Normal file
@@ -0,0 +1,355 @@
|
||||
sub init()
|
||||
m.title = m.top.FindNode("title")
|
||||
m.date = m.top.FindNode("date")
|
||||
m.layout_group = m.top.FindNode("layout_group")
|
||||
m.thumbnail = m.top.FindNode("thumbnail")
|
||||
m.postType = m.top.FindNode("postType")
|
||||
m.play_button = m.top.FindNode("play_button")
|
||||
m.resume_button = m.top.FindNode("resume_button")
|
||||
m.like_button = m.top.FindNode("like")
|
||||
m.dislike_button = m.top.FindNode("dislike")
|
||||
m.description = m.top.FindNode("description")
|
||||
m.attachmentsList = m.top.FindNode("attachmentsList")
|
||||
m.top.observeField("visible", "onVisibleChange")
|
||||
m.like_button.observeField("buttonSelected", "onLikeButtonPressed")
|
||||
m.dislike_button.observeField("buttonSelected", "onDislikeButtonPressed")
|
||||
end sub
|
||||
|
||||
sub fillAttachment(attachment as Object, raw as Dynamic, fallbackTitle as String, fallbackPoster as String)
|
||||
attachment.title = fallbackTitle
|
||||
attachment.HDPOSTERURL = fallbackPoster
|
||||
id = contentAttachmentId(raw)
|
||||
attachment.id = id
|
||||
attachment.guid = id
|
||||
if type(raw) = "roAssociativeArray"
|
||||
if contentIsString(raw.title) and raw.title <> "" then attachment.title = raw.title
|
||||
poster = contentImagePath(raw.thumbnail)
|
||||
if poster <> "" then attachment.HDPOSTERURL = poster
|
||||
if contentIsNumber(raw.duration) and raw.duration > 0 then attachment.duration = Int(raw.duration)
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onVisibleChange()
|
||||
if m.top.visible = true THEN
|
||||
if m.play_button.visible = false
|
||||
m.play_button.focusable = false
|
||||
m.like_button.setFocus(true)
|
||||
else
|
||||
m.play_button.focusable = true
|
||||
m.play_button.setFocus(true)
|
||||
if m.resume_button.focusable = true then
|
||||
m.resume_button.setFocus(true)
|
||||
end if
|
||||
end if
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub OnContentChange(obj)
|
||||
item = obj.getData()
|
||||
m.title.text = item.TITLE
|
||||
m.thumbnail.uri = item.HDPOSTERURL
|
||||
|
||||
'Scrolling description to bottom would hide other video descriptions, and with no way to programmatically scroll back to the beginning, we just have to manually create/destroy the ScrollingText object
|
||||
if m.description <> invalid
|
||||
m.layout_group.removeChild(m.description)
|
||||
end if
|
||||
m.description = CreateObject("roSGNode", "ScrollableText")
|
||||
m.description.id = "description"
|
||||
m.description.font = "font:MediumSystemFont"
|
||||
m.description.color = "0xFFFFFF"
|
||||
m.description.width = "1180"
|
||||
m.description.height = "260"
|
||||
m.description.lineSpacing="4.0"
|
||||
m.description.translation="[50,200]"
|
||||
m.description.text = item.DESCRIPTION
|
||||
|
||||
m.layout_group.appendChild(m.description)
|
||||
|
||||
dt = createObject("roDateTime")
|
||||
dt.FromISO8601String(item.id)
|
||||
m.date.text = dt.AsDateString("short-month-short-weekday")
|
||||
|
||||
m.postType.text = item.ShortDescriptionLine2
|
||||
|
||||
'Hide or show play button based on media type
|
||||
if item.hasPicture = true
|
||||
m.play_button.visible = false
|
||||
end if
|
||||
if item.hasAudio = true
|
||||
m.play_button.visible = true
|
||||
end if
|
||||
if item.hasVideo = true
|
||||
m.play_button.visible = true
|
||||
end if
|
||||
if item.hasAudio = false AND item.hasVideo = false
|
||||
m.play_button.visible = false
|
||||
end if
|
||||
|
||||
'Get rid of attachments row if it shouldn't exist but... does?
|
||||
if m.attachmentsList <> invalid
|
||||
if item.hasVideo = false AND item.hasAudio = false then
|
||||
m.layout_group.removeChild(m.attachmentsList)
|
||||
end if
|
||||
end if
|
||||
|
||||
'Add attachments to row. Detail payloads are objects; older list payloads were string ids.
|
||||
m.attachments = CreateObject("roSGNode", "ContentNode")
|
||||
if item.hasVideo = true and type(item.videoAttachments) = "roArray" then
|
||||
for each vid in item.videoAttachments
|
||||
attachment = CreateObject("roSGNode", "media_node")
|
||||
fillAttachment(attachment, vid, "Video", item.HDPOSTERURL)
|
||||
attachment.isVideo = true
|
||||
attachment.isAudio = false
|
||||
m.attachments.appendChild(attachment)
|
||||
end for
|
||||
end if
|
||||
if item.hasAudio = true and type(item.audioAttachments) = "roArray" then
|
||||
for each aud in item.audioAttachments
|
||||
attachment = CreateObject("roSGNode", "media_node")
|
||||
fillAttachment(attachment, aud, "Audio", "pkg:/images/sound-poster.png")
|
||||
attachment.isVideo = false
|
||||
attachment.isAudio = true
|
||||
attachment.HDPOSTERURL = "pkg:/images/sound-poster.png"
|
||||
m.attachments.appendChild(attachment)
|
||||
end for
|
||||
end if
|
||||
|
||||
if m.attachmentsList <> invalid
|
||||
m.layout_group.removeChild(m.attachmentsList)
|
||||
end if
|
||||
m.attachmentsList.content = m.attachments
|
||||
m.layout_group.appendChild(m.attachmentsList)
|
||||
|
||||
'Hide or show resume button based on progress
|
||||
m.resume_button.visible = false
|
||||
m.resume_button.focusable = false
|
||||
if item.hasVideo = true
|
||||
? "DURATION: " + item.duration.ToStr()
|
||||
? "PROGRESS: " + item.progress.ToStr()
|
||||
if (item.progress <> 0) AND (item.progress < item.duration)
|
||||
m.resume_button.visible = true
|
||||
m.resume_button.focusable = true
|
||||
m.play_button.text = "RESTART"
|
||||
m.play_button.maxWidth = 220
|
||||
m.play_button.minWidth = 220
|
||||
m.play_button.iconUri="pkg:/images/replay.png"
|
||||
m.play_button.focusedIconUri="pkg:/images/replay.png"
|
||||
m.resume_button.maxWidth = 200
|
||||
m.resume_button.minWidth = 200
|
||||
m.resume_button.setFocus(true)
|
||||
else
|
||||
m.play_button.text = "PLAY"
|
||||
m.play_button.maxWidth = 170
|
||||
m.play_button.minWidth = 170
|
||||
m.play_button.iconUri="pkg:/images/play.png"
|
||||
m.play_button.focusedIconUri="pkg:/images/play.png"
|
||||
m.resume_button.maxWidth = 0
|
||||
m.resume_button.minWidth = 0
|
||||
end if
|
||||
end if
|
||||
|
||||
'Hide or show date if media type is VOD or live
|
||||
if item.id = "live"
|
||||
m.date.visible = false
|
||||
else
|
||||
m.date.visible = true
|
||||
end if
|
||||
|
||||
'Set the number of current likes/dislikes for video
|
||||
m.like_button.text = item.likes
|
||||
m.dislike_button.text = item.dislikes
|
||||
m.likes = item.likes
|
||||
m.dislikes = item.dislikes
|
||||
m.postId = item.postId
|
||||
|
||||
if item.id = "live"
|
||||
m.like_button.visible = false
|
||||
m.dislike_button.visible = false
|
||||
m.like_button.focusable = false
|
||||
m.dislike_button.focusable = false
|
||||
else
|
||||
m.like_button.visible = true
|
||||
m.dislike_button.visible = true
|
||||
m.like_button.focusable = true
|
||||
m.dislike_button.focusable = true
|
||||
end if
|
||||
|
||||
'Set if user has liked or disliked video
|
||||
m.dislike_button.opacity = 0.5
|
||||
m.like_button.opacity = 0.5
|
||||
if item.userInteraction <> invalid
|
||||
for each interaction in item.userInteraction
|
||||
if interaction = "like"
|
||||
m.like_button.opacity = 1
|
||||
else if interaction = "dislike"
|
||||
m.dislike_button.opacity = 1
|
||||
end if
|
||||
end for
|
||||
end if
|
||||
end sub
|
||||
|
||||
'sub onItemSelected()
|
||||
' selected = m.attachmentsList.content.getChild(m.attachmentsList.itemSelected)
|
||||
' 'm.attachedMediaSelected = selected
|
||||
' m.top.addField("attachedMediaSelected","node", true)
|
||||
' m.top.setField("attachedMediaSelected", selected)
|
||||
'end sub
|
||||
|
||||
sub onLikeButtonPressed()
|
||||
if m.like_button.opacity = 0.5
|
||||
m.likes += 1
|
||||
m.like_button.opacity = 1
|
||||
m.like_button.text = m.likes
|
||||
if m.dislike_button.opacity = 1
|
||||
m.dislike_button.opacity = 0.5
|
||||
m.dislikes -= 1
|
||||
m.dislike_button.text = m.dislikes
|
||||
end if
|
||||
else if m.like_button.opacity = 1
|
||||
m.likes -= 1
|
||||
m.like_button.opacity = 0.5
|
||||
m.like_button.text = m.likes
|
||||
end if
|
||||
like_task = CreateObject("roSGNode", "likeTask")
|
||||
like_task.setField("do", "like")
|
||||
like_task.setField("id", m.postId)
|
||||
like_task.control = "RUN"
|
||||
end sub
|
||||
|
||||
sub onDislikeButtonPressed()
|
||||
if m.dislike_button.opacity = 0.5
|
||||
m.dislikes += 1
|
||||
m.dislike_button.opacity = 1
|
||||
m.dislike_button.text = m.dislikes
|
||||
if m.like_button.opacity = 1
|
||||
m.like_button.opacity = 0.5
|
||||
m.likes -= 1
|
||||
m.like_button.text = m.likes
|
||||
end if
|
||||
else if m.dislike_button.opacity = 1
|
||||
m.dislikes -= 1
|
||||
m.dislike_button.opacity = 0.5
|
||||
m.dislike_button.text = m.dislikes
|
||||
end if
|
||||
dislike_task = CreateObject("roSGNode", "likeTask")
|
||||
dislike_task.setField("do", "dislike")
|
||||
dislike_task.setField("id", m.postId)
|
||||
dislike_task.control = "RUN"
|
||||
end sub
|
||||
|
||||
function onKeyEvent(key as string, press as boolean) as boolean
|
||||
if not press then return false
|
||||
|
||||
if m.play_button.hasFocus()
|
||||
if key = "up"
|
||||
if m.attachmentsList <> Invalid AND m.attachments.getChildCount() > 0 then
|
||||
m.attachmentsList.setFocus(true)
|
||||
else
|
||||
m.description.setFocus(true)
|
||||
end if
|
||||
return true
|
||||
else if key = "left"
|
||||
if m.dislike_button.focusable = true
|
||||
m.dislike_button.setFocus(true)
|
||||
return true
|
||||
end if
|
||||
else if key = "right"
|
||||
if m.resume_button.focusable = true
|
||||
m.resume_button.setFocus(true)
|
||||
return true
|
||||
end if
|
||||
end if
|
||||
end if
|
||||
|
||||
if m.resume_button.hasFocus()
|
||||
if key = "up"
|
||||
if m.attachmentsList <> Invalid AND m.attachments.getChildCount() > 0 then
|
||||
m.attachmentsList.setFocus(true)
|
||||
else
|
||||
m.description.setFocus(true)
|
||||
end if
|
||||
return true
|
||||
else if key = "left"
|
||||
m.play_button.setFocus(true)
|
||||
end if
|
||||
end if
|
||||
|
||||
if m.dislike_button.hasFocus()
|
||||
if key = "left"
|
||||
m.like_button.setFocus(true)
|
||||
return true
|
||||
else if key = "right"
|
||||
if m.play_button.visible = false
|
||||
m.play_button.focusable = false
|
||||
else
|
||||
m.play_button.setFocus(true)
|
||||
end if
|
||||
return true
|
||||
else if key = "up"
|
||||
if m.attachmentsList <> Invalid AND m.attachments.getChildCount() > 0 then
|
||||
m.attachmentsList.setFocus(true)
|
||||
else
|
||||
m.description.setFocus(true)
|
||||
end if
|
||||
return true
|
||||
end if
|
||||
end if
|
||||
|
||||
if m.like_button.hasFocus()
|
||||
if key = "right"
|
||||
m.dislike_button.setFocus(true)
|
||||
return true
|
||||
else if key = "up"
|
||||
if m.attachmentsList <> Invalid AND m.attachments.getChildCount() > 0 then
|
||||
? "attachmentList found"
|
||||
m.attachmentsList.setFocus(true)
|
||||
else
|
||||
m.description.setFocus(true)
|
||||
end if
|
||||
return true
|
||||
end if
|
||||
end if
|
||||
|
||||
if m.description.hasFocus()
|
||||
if key = "down"
|
||||
if m.attachmentsList <> Invalid AND m.attachments.getChildCount() > 0 then
|
||||
m.attachmentsList.setFocus(true)
|
||||
else
|
||||
if m.play_button.focusable = true then
|
||||
m.play_button.setFocus(true)
|
||||
else
|
||||
m.like_button.setFocus(true)
|
||||
end if
|
||||
end if
|
||||
return true
|
||||
else if key = "back" or key = "left" or key = "right"
|
||||
if m.play_button.visible = false
|
||||
m.like_button.setFocus(true)
|
||||
else
|
||||
m.play_button.setFocus(true)
|
||||
end if
|
||||
return true
|
||||
end if
|
||||
end if
|
||||
|
||||
if m.attachmentsList.hasFocus()
|
||||
if key = "up"
|
||||
m.description.setFocus(true)
|
||||
else if key = "down"
|
||||
if m.play_button.focusable = true then
|
||||
m.play_button.setFocus(true)
|
||||
else
|
||||
m.like_button.setFocus(true)
|
||||
end if
|
||||
else if key = "back"
|
||||
if m.play_button.focusable = true then
|
||||
m.play_button.setFocus(true)
|
||||
else
|
||||
m.like_button.setFocus(true)
|
||||
end if
|
||||
end if
|
||||
return true
|
||||
end if
|
||||
|
||||
return false
|
||||
end function
|
||||
15
doc/research/reference-sources/hydra_getVideoTokenTask.brs
Normal file
15
doc/research/reference-sources/hydra_getVideoTokenTask.brs
Normal file
@@ -0,0 +1,15 @@
|
||||
sub init()
|
||||
m.top.functionName = "getToken"
|
||||
end sub
|
||||
|
||||
sub getToken()
|
||||
' Runs on task thread: safe to call getAccessToken(false) and thus refresh via roUrlTransfer
|
||||
tokenUtilObj = TokenUtil()
|
||||
accessToken = tokenUtilObj.getAccessToken(false)
|
||||
if accessToken <> invalid then
|
||||
m.top.accessToken = accessToken
|
||||
else
|
||||
m.top.error = "Not authenticated - please login"
|
||||
end if
|
||||
m.top.done = true
|
||||
end sub
|
||||
1592
doc/research/reference-sources/hydra_home_screen.brs
Normal file
1592
doc/research/reference-sources/hydra_home_screen.brs
Normal file
File diff suppressed because it is too large
Load Diff
22
doc/research/reference-sources/hydra_liveTask.brs
Normal file
22
doc/research/reference-sources/hydra_liveTask.brs
Normal file
@@ -0,0 +1,22 @@
|
||||
sub init()
|
||||
m.top.functionname = "request"
|
||||
end sub
|
||||
|
||||
function request()
|
||||
video = createObject("roUrlTransfer")
|
||||
video.SetCertificatesFile("common:/certs/ca-bundle.crt")
|
||||
video.InitClientCertificates()
|
||||
video.SetUrl(m.top.url)
|
||||
port = CreateObject("roMessagePort")
|
||||
video.SetMessagePort(port)
|
||||
if video.AsyncGetToString()
|
||||
event = wait(10000, port)
|
||||
if type(event) = "roUrlEvent"
|
||||
m3u8 = event.GetString()
|
||||
if m3u8 <> invalid and m3u8.Len() > 0
|
||||
WriteAsciiFile("tmp:/live.m3u8", m3u8)
|
||||
end if
|
||||
end if
|
||||
end if
|
||||
m.top.done = true
|
||||
end function
|
||||
393
doc/research/reference-sources/hydra_login_screen.brs
Normal file
393
doc/research/reference-sources/hydra_login_screen.brs
Normal file
@@ -0,0 +1,393 @@
|
||||
function init()
|
||||
m.login_text = m.top.findNode("login_text")
|
||||
if m.login_text <> invalid
|
||||
m.login_text.font.size = 50
|
||||
end if
|
||||
|
||||
m.qrCode = m.top.findNode("qrCode")
|
||||
m.expirationTimerLabel = m.top.findNode("expirationTimerLabel")
|
||||
m.expirationTimer = m.top.findNode("expirationTimer")
|
||||
m.orLabel = m.top.findNode("orLabel")
|
||||
m.orLine = m.top.findNode("orLine")
|
||||
m.orLine2 = m.top.findNode("orLine2")
|
||||
m.manualEntryLabel = m.top.findNode("manualEntryLabel")
|
||||
m.verificationUriBox = m.top.findNode("verificationUriBox")
|
||||
m.verificationUriLabel = m.top.findNode("verificationUriLabel")
|
||||
' Set smaller font for URL to prevent truncation
|
||||
if m.verificationUriLabel <> invalid
|
||||
m.verificationUriLabel.font.size = 20
|
||||
end if
|
||||
m.enterCodeLabel = m.top.findNode("enterCodeLabel")
|
||||
m.userCodeBox = m.top.findNode("userCodeBox")
|
||||
m.userCodeLabel = m.top.findNode("userCodeLabel")
|
||||
|
||||
' Set horizAlignment programmatically to ensure it's applied
|
||||
m.contentSectionWrapper = m.top.findNode("contentSectionWrapper")
|
||||
if m.contentSectionWrapper <> invalid
|
||||
m.contentSectionWrapper.horizAlignment = "center"
|
||||
end if
|
||||
m.contentSection = m.top.findNode("contentSection")
|
||||
if m.contentSection <> invalid
|
||||
m.contentSection.horizAlignment = "center"
|
||||
m.contentSection.vertAlignment = "center"
|
||||
end if
|
||||
|
||||
' Set vertAlignment and horizAlignment programmatically for dividerSection and manualEntrySection
|
||||
m.dividerSection = m.top.findNode("dividerSection")
|
||||
if m.dividerSection <> invalid
|
||||
m.dividerSection.horizAlignment = "center"
|
||||
m.dividerSection.vertAlignment = "center"
|
||||
end if
|
||||
m.manualEntrySection = m.top.findNode("manualEntrySection")
|
||||
if m.manualEntrySection <> invalid
|
||||
m.manualEntrySection.horizAlignment = "center"
|
||||
m.manualEntrySection.vertAlignment = "center"
|
||||
end if
|
||||
|
||||
' Center the labels inside manualEntrySection
|
||||
if m.manualEntryLabel <> invalid
|
||||
m.manualEntryLabel.horizAlign = "center"
|
||||
end if
|
||||
if m.enterCodeLabel <> invalid
|
||||
m.enterCodeLabel.horizAlign = "center"
|
||||
end if
|
||||
|
||||
m.showingManualEntry = false
|
||||
|
||||
' Timer to trigger next field change asynchronously
|
||||
m.nextTimer = createObject("roSGNode", "Timer")
|
||||
m.nextTimer.repeat = false
|
||||
m.nextTimer.duration = 0.1
|
||||
m.nextTimer.observeField("fire", "onNextTimerFire")
|
||||
|
||||
' Timer for countdown display
|
||||
m.countdownTimer = createObject("roSGNode", "Timer")
|
||||
m.countdownTimer.repeat = true
|
||||
m.countdownTimer.duration = 1.0
|
||||
m.countdownTimer.observeField("fire", "onCountdownTimerFire")
|
||||
|
||||
' Observe reset field to restart OAuth flow
|
||||
m.top.observeField("reset", "onReset")
|
||||
|
||||
' Observe visible field to start OAuth flow when screen becomes visible
|
||||
m.top.observeField("visible", "onVisibleChanged")
|
||||
|
||||
' Track if OAuth flow has been started to avoid starting it multiple times
|
||||
m.oauthFlowStarted = false
|
||||
|
||||
' Rate limiting for OAuth retries
|
||||
m.oauthRetryCount = 0
|
||||
m.lastOAuthErrorTime = invalid
|
||||
m.oauthRetryTimer = createObject("roSGNode", "Timer")
|
||||
m.oauthRetryTimer.repeat = false
|
||||
m.oauthRetryTimer.duration = 5.0 ' Wait 5 seconds before retrying
|
||||
m.oauthRetryTimer.observeField("fire", "onOAuthRetryTimerFire")
|
||||
|
||||
' Start OAuth flow if screen is already visible
|
||||
if m.top.visible = true
|
||||
startOAuthFlow()
|
||||
m.oauthFlowStarted = true
|
||||
end if
|
||||
end function
|
||||
|
||||
sub onVisibleChanged(obj)
|
||||
if m.top.visible = true and not m.oauthFlowStarted
|
||||
' Screen became visible and OAuth flow hasn't started yet
|
||||
' Reset retry counter when screen becomes visible
|
||||
m.oauthRetryCount = 0
|
||||
m.lastOAuthErrorTime = invalid
|
||||
m.oauthRetryTimer.control = "stop"
|
||||
startOAuthFlow()
|
||||
m.oauthFlowStarted = true
|
||||
else if m.top.visible = false
|
||||
' Screen hidden - stop OAuth flow and reset flag
|
||||
if m.oauthTask <> invalid
|
||||
m.oauthTask.control = "STOP"
|
||||
end if
|
||||
m.countdownTimer.control = "stop"
|
||||
m.oauthRetryTimer.control = "stop"
|
||||
m.oauthFlowStarted = false
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onNextTimerFire()
|
||||
' Set next field asynchronously to ensure observer fires
|
||||
time = CreateObject("roDateTime")
|
||||
m.top.next = "beep" + time.AsSeconds().ToStr()
|
||||
print "[PROGRESS] Set next field via timer to: " + m.top.next
|
||||
m.nextTimer.control = "stop"
|
||||
end sub
|
||||
|
||||
sub onReset()
|
||||
if m.top.reset = true
|
||||
' Reset state
|
||||
m.showingManualEntry = false
|
||||
m.field = ""
|
||||
|
||||
' Clear the next field so it can trigger again
|
||||
m.top.next = ""
|
||||
|
||||
' Stop existing OAuth task if running
|
||||
if m.oauthTask <> invalid
|
||||
m.oauthTask.control = "STOP"
|
||||
end if
|
||||
m.countdownTimer.control = "stop"
|
||||
m.oauthRetryTimer.control = "stop"
|
||||
|
||||
' Reset retry counter and error tracking
|
||||
m.oauthRetryCount = 0
|
||||
m.lastOAuthErrorTime = invalid
|
||||
|
||||
' Reset OAuth flow flag so it can start again
|
||||
m.oauthFlowStarted = false
|
||||
|
||||
' Restart OAuth flow
|
||||
startOAuthFlow()
|
||||
m.oauthFlowStarted = true
|
||||
|
||||
' Reset the field
|
||||
m.top.reset = false
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub refreshQRCode()
|
||||
' Stop existing OAuth task if running
|
||||
if m.oauthTask <> invalid
|
||||
m.oauthTask.control = "STOP"
|
||||
end if
|
||||
m.countdownTimer.control = "stop"
|
||||
m.oauthRetryTimer.control = "stop"
|
||||
|
||||
' Switch back to timer view
|
||||
m.expirationTimerLabel.text = "Time Remaining:"
|
||||
m.expirationTimer.text = "--:--"
|
||||
m.expirationTimerLabel.visible = true
|
||||
m.expirationTimer.visible = true
|
||||
|
||||
' Clear manual entry fields temporarily
|
||||
m.verificationUriLabel.text = ""
|
||||
m.userCodeLabel.text = ""
|
||||
m.orLabel.visible = false
|
||||
m.orLine.visible = false
|
||||
m.orLine2.visible = false
|
||||
m.manualEntryLabel.visible = false
|
||||
m.verificationUriBox.visible = false
|
||||
m.verificationUriLabel.visible = false
|
||||
m.enterCodeLabel.visible = false
|
||||
m.userCodeBox.visible = false
|
||||
m.userCodeLabel.visible = false
|
||||
|
||||
' Restart OAuth flow
|
||||
m.oauthFlowStarted = false
|
||||
startOAuthFlow()
|
||||
m.oauthFlowStarted = true
|
||||
end sub
|
||||
|
||||
sub startOAuthFlow()
|
||||
' Show QR code by default
|
||||
m.showingManualEntry = false
|
||||
m.qrCode.visible = true
|
||||
' Show timer
|
||||
m.expirationTimerLabel.text = "Time Remaining:"
|
||||
m.expirationTimerLabel.visible = true
|
||||
m.expirationTimer.visible = true
|
||||
m.expirationTimer.text = "--:--"
|
||||
m.orLabel.visible = false
|
||||
m.orLine.visible = false
|
||||
m.orLine2.visible = false
|
||||
m.manualEntryLabel.visible = false
|
||||
m.verificationUriBox.visible = false
|
||||
m.verificationUriLabel.visible = false
|
||||
m.enterCodeLabel.visible = false
|
||||
m.userCodeBox.visible = false
|
||||
m.userCodeLabel.visible = false
|
||||
|
||||
' Start OAuth device flow task
|
||||
m.oauthTask = createObject("roSGNode", "oauthDeviceTask")
|
||||
print "[PROGRESS] Setting up OAuth task observers..."
|
||||
m.oauthTask.observeField("userCode", "onUserCodeReady")
|
||||
m.oauthTask.observeField("verificationUri", "onVerificationUriReady")
|
||||
m.oauthTask.observeField("status", "onOAuthStatusChanged")
|
||||
m.oauthTask.observeField("error", "onOAuthError")
|
||||
m.oauthTask.observeField("expiresIn", "onExpiresInChanged")
|
||||
m.oauthTask.control = "RUN"
|
||||
print "[PROGRESS] OAuth task started"
|
||||
end sub
|
||||
|
||||
|
||||
sub onUserCodeReady(obj)
|
||||
userCode = obj.getData()
|
||||
if userCode <> invalid and userCode <> ""
|
||||
m.userCodeLabel.text = userCode
|
||||
print "[PROGRESS] User code: " + userCode
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onVerificationUriReady(obj)
|
||||
verificationUri = obj.getData()
|
||||
if verificationUri <> invalid and verificationUri <> ""
|
||||
m.verificationUriLabel.text = verificationUri
|
||||
m.orLabel.visible = true
|
||||
m.orLine.visible = true
|
||||
m.orLine2.visible = true
|
||||
m.manualEntryLabel.visible = true
|
||||
m.verificationUriBox.visible = true
|
||||
m.verificationUriLabel.visible = true
|
||||
m.enterCodeLabel.visible = true
|
||||
m.userCodeBox.visible = true
|
||||
m.userCodeLabel.visible = true
|
||||
print "[PROGRESS] Verification URI: " + verificationUri
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onOAuthStatusChanged(obj)
|
||||
status = obj.getData()
|
||||
print "[PROGRESS] OAuth status changed: " + status
|
||||
' If status is QR_CODE_READY, set the QR code text on the component
|
||||
if status = "QR_CODE_READY" and m.oauthTask <> invalid
|
||||
verificationUriComplete = m.oauthTask.verificationUriComplete
|
||||
if verificationUriComplete <> invalid and verificationUriComplete <> ""
|
||||
' The qrCode node is the library's QRCode component
|
||||
' Just set the text field - the component will automatically generate the QR code
|
||||
m.qrCode.text = verificationUriComplete
|
||||
end if
|
||||
end if
|
||||
status = obj.getData()
|
||||
if status = "QR_CODE_READY"
|
||||
m.countdownTimer.control = "start"
|
||||
' Show timer
|
||||
m.expirationTimerLabel.visible = true
|
||||
m.expirationTimer.visible = true
|
||||
else if status = "AUTHENTICATED"
|
||||
m.countdownTimer.control = "stop"
|
||||
m.expirationTimer.text = ""
|
||||
m.expirationTimerLabel.visible = false
|
||||
m.expirationTimer.visible = false
|
||||
' Proceed to main screen
|
||||
time = CreateObject("roDateTime")
|
||||
nextValue = "beep" + time.AsSeconds().ToStr()
|
||||
m.top.next = nextValue
|
||||
print "[PROGRESS] OAuth authentication complete, proceeding to main screen"
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onOAuthError(obj)
|
||||
error = obj.getData()
|
||||
|
||||
' Stop countdown timer
|
||||
m.countdownTimer.control = "stop"
|
||||
|
||||
' Check if this is a client credential error (401 - invalid_client)
|
||||
' These shouldn't be auto-retried as they indicate a configuration issue
|
||||
if error <> invalid and error.InStr("invalid_client") >= 0
|
||||
print "[ERROR] OAuth client credential error - not retrying automatically"
|
||||
' Show error to user instead of auto-retrying
|
||||
showErrorDialog("Authentication error: Invalid client credentials. Please check configuration.")
|
||||
return
|
||||
end if
|
||||
|
||||
' For timeout/expired errors, retry with rate limiting
|
||||
if error = "TIMEOUT" or error = "EXPIRED"
|
||||
scheduleOAuthRetry()
|
||||
else if error <> invalid and error <> ""
|
||||
' For other errors, check rate limit before retrying
|
||||
scheduleOAuthRetry()
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub scheduleOAuthRetry()
|
||||
' Rate limiting: prevent rapid retries
|
||||
currentTime = CreateObject("roDateTime")
|
||||
currentTimeSeconds = currentTime.AsSeconds()
|
||||
|
||||
' If we retried recently (within last 5 seconds), wait before retrying again
|
||||
if m.lastOAuthErrorTime <> invalid
|
||||
timeSinceLastError = currentTimeSeconds - m.lastOAuthErrorTime
|
||||
if timeSinceLastError < 5
|
||||
print "[OAUTH] Rate limiting: waiting before retry (last error was " + timeSinceLastError.ToStr() + " seconds ago)"
|
||||
' Start retry timer if not already running
|
||||
if not m.oauthRetryTimer.control = "start"
|
||||
m.oauthRetryTimer.control = "start"
|
||||
end if
|
||||
return
|
||||
end if
|
||||
end if
|
||||
|
||||
' Increment retry count
|
||||
m.oauthRetryCount = m.oauthRetryCount + 1
|
||||
m.lastOAuthErrorTime = currentTimeSeconds
|
||||
|
||||
' Limit maximum retries to prevent infinite loops
|
||||
if m.oauthRetryCount > 10
|
||||
print "[ERROR] OAuth retry limit reached (" + m.oauthRetryCount.ToStr() + " attempts)"
|
||||
showErrorDialog("Authentication failed after multiple attempts. Please try again later.")
|
||||
m.oauthRetryCount = 0 ' Reset counter
|
||||
return
|
||||
end if
|
||||
|
||||
print "[OAUTH] Scheduling retry #" + m.oauthRetryCount.ToStr()
|
||||
' Wait a bit before retrying (exponential backoff: 2^retryCount seconds, max 30 seconds)
|
||||
waitSeconds = 2 ^ m.oauthRetryCount
|
||||
if waitSeconds > 30
|
||||
waitSeconds = 30
|
||||
end if
|
||||
m.oauthRetryTimer.duration = waitSeconds
|
||||
m.oauthRetryTimer.control = "start"
|
||||
end sub
|
||||
|
||||
sub onOAuthRetryTimerFire()
|
||||
m.oauthRetryTimer.control = "stop"
|
||||
print "[OAUTH] Retrying OAuth flow after delay"
|
||||
refreshQRCode()
|
||||
end sub
|
||||
|
||||
sub onExpiresInChanged(obj)
|
||||
expiresIn = obj.getData()
|
||||
if expiresIn <> invalid and expiresIn <> ""
|
||||
m.expiresInSeconds = Val(expiresIn)
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub onCountdownTimerFire()
|
||||
if m.expiresInSeconds <> invalid
|
||||
if m.expiresInSeconds > 0
|
||||
minutes = m.expiresInSeconds \ 60
|
||||
seconds = m.expiresInSeconds mod 60
|
||||
timeText = minutes.ToStr() + ":" + Right("0" + seconds.ToStr(), 2)
|
||||
m.expirationTimer.text = timeText
|
||||
m.expiresInSeconds = m.expiresInSeconds - 1
|
||||
else
|
||||
m.countdownTimer.control = "stop"
|
||||
' Automatically refresh QR code when timer expires
|
||||
refreshQRCode()
|
||||
end if
|
||||
end if
|
||||
end sub
|
||||
|
||||
' Manual entry removed - OAuth device flow only
|
||||
|
||||
sub showErrorDialog(msg)
|
||||
m.top.getScene().dialog = createObject("roSGNode", "SimpleDialog")
|
||||
m.top.getScene().dialog.title = "Login Error"
|
||||
m.top.getScene().dialog.showCancel = false
|
||||
m.top.getScene().dialog.text = msg
|
||||
setupDialogPalette()
|
||||
m.top.getScene().dialog.observeField("buttonSelected","closeDialog")
|
||||
end sub
|
||||
|
||||
sub closeDialog()
|
||||
m.top.getScene().dialog.close = true
|
||||
end sub
|
||||
|
||||
sub setupDialogPalette()
|
||||
palette = createObject("roSGNode", "RSGPalette")
|
||||
palette.colors = { DialogBackgroundColor: "0x152130FF"}
|
||||
m.top.getScene().dialog.palette = palette
|
||||
end sub
|
||||
|
||||
function onKeyEvent(key as String, press as Boolean) as Boolean
|
||||
' No key handling needed - auto-refresh handles everything
|
||||
return false
|
||||
end function
|
||||
|
||||
' Old showInstructions function removed - no longer needed
|
||||
123
doc/research/reference-sources/hydra_main.brs
Normal file
123
doc/research/reference-sources/hydra_main.brs
Normal file
@@ -0,0 +1,123 @@
|
||||
sub Main(args as Dynamic)
|
||||
screen = createObject("roSGScreen")
|
||||
port = createObject("roMessagePort")
|
||||
screen.setMessagePort(port)
|
||||
scene = screen.createScene("home_screen")
|
||||
scene.observeField("exit", port)
|
||||
screen.Show()
|
||||
' vscode_rale_tracker_entry
|
||||
' vscode_rdb_on_device_component_entry
|
||||
scene.backgroundUri = ""
|
||||
scene.backgroundColor = "0x070b10"
|
||||
scene.setFocus(true)
|
||||
|
||||
'm.global = screen.getGlobalNode()
|
||||
'm.global.addFields( {scene: scene, deeplink: args} )
|
||||
|
||||
if (args.ContentId <> invalid) and (args.MediaType <> invalid)
|
||||
scene.setField("deepcontentid", args.ContentId)
|
||||
end if
|
||||
|
||||
setupMemoryMonitor(port)
|
||||
|
||||
while(true)
|
||||
msg = wait(0, port)
|
||||
msgType = type(msg)
|
||||
if msgType = "roSGScreenEvent"
|
||||
if msg.isScreenClosed() then return
|
||||
else if msgType = "roSGNodeEvent"
|
||||
field = msg.getField()
|
||||
data = msg.getData()
|
||||
if field = "exitChannel" and data = true
|
||||
END
|
||||
end if
|
||||
else if msgType = "roInputEvent"
|
||||
scene.setField("roInputData", msg.getInfo())
|
||||
else if msgType = "roAppMemoryNotificationEvent" or msgType = "roAppMemoryMonitorEvent"
|
||||
handleAppMemoryEvent(scene, msg)
|
||||
else if msgType = "roDeviceInfoEvent"
|
||||
handleDeviceMemoryEvent(scene, msg)
|
||||
end if
|
||||
end while
|
||||
end sub
|
||||
|
||||
' roAppMemoryMonitor exists on OS 10.5+. Newer queries were added later.
|
||||
' Devices without it use the older low-general-memory event instead.
|
||||
sub setupMemoryMonitor(port as Object)
|
||||
m.memoryMonitor = invalid
|
||||
if osAtLeast(10, 5)
|
||||
m.memoryMonitor = CreateObject("roAppMemoryMonitor")
|
||||
end if
|
||||
if m.memoryMonitor <> invalid
|
||||
m.memoryMonitor.SetMessagePort(port)
|
||||
warningsOn = m.memoryMonitor.EnableMemoryWarningEvent(true)
|
||||
limitPercent = m.memoryMonitor.GetMemoryLimitPercent()
|
||||
availableKb = invalid
|
||||
if osAtLeast(12, 5) then availableKb = m.memoryMonitor.GetChannelAvailableMemory()
|
||||
limits = invalid
|
||||
if osAtLeast(13, 0) then limits = m.memoryMonitor.GetChannelMemoryLimit()
|
||||
print "[MEMORY] warnings="; warningsOn; " usagePercent="; limitPercent; " availableKb="; availableKb; " limits="; limits
|
||||
if warningsOn <> true then enableLowGeneralMemory(port)
|
||||
else
|
||||
enableLowGeneralMemory(port)
|
||||
end if
|
||||
end sub
|
||||
|
||||
sub enableLowGeneralMemory(port as Object)
|
||||
m.deviceInfo = CreateObject("roDeviceInfo")
|
||||
m.deviceInfo.SetMessagePort(port)
|
||||
m.deviceInfo.EnableLowGeneralMemoryEvent(true)
|
||||
print "[MEMORY] general level="; m.deviceInfo.GetGeneralMemoryLevel()
|
||||
end sub
|
||||
|
||||
sub handleAppMemoryEvent(scene as Object, msg as Object)
|
||||
percent = memoryEventPercent(msg)
|
||||
availableKb = invalid
|
||||
if m.memoryMonitor <> invalid and osAtLeast(12, 5)
|
||||
availableKb = m.memoryMonitor.GetChannelAvailableMemory()
|
||||
end if
|
||||
print "[MEMORY] app warning percent="; percent; " availableKb="; availableKb
|
||||
' 15.2+ also notifies when usage falls back under a threshold.
|
||||
if percent <> invalid and percent < 80 then return
|
||||
level = "low"
|
||||
if percent <> invalid and percent >= 90 then level = "critical"
|
||||
pressure = { level: level }
|
||||
if percent <> invalid then pressure.percent = percent
|
||||
if availableKb <> invalid then pressure.availableKb = availableKb
|
||||
scene.memorypressure = pressure
|
||||
end sub
|
||||
|
||||
sub handleDeviceMemoryEvent(scene as Object, msg as Object)
|
||||
info = msg.getInfo()
|
||||
if type(info) <> "roAssociativeArray" then return
|
||||
level = info.generalMemoryLevel
|
||||
if level = invalid or level = "normal" then return
|
||||
print "[MEMORY] general level="; level
|
||||
scene.memorypressure = { level: level }
|
||||
end sub
|
||||
|
||||
function memoryEventPercent(msg as Object) as Dynamic
|
||||
info = msg.getInfo()
|
||||
if type(info) = "roAssociativeArray" and info.MemoryUsagePercent <> invalid
|
||||
return info.MemoryUsagePercent
|
||||
end if
|
||||
data = msg.getData()
|
||||
if type(data) = "Integer" or type(data) = "Float" or type(data) = "Double" or type(data) = "LongInteger"
|
||||
return data
|
||||
end if
|
||||
if type(data) = "roAssociativeArray" and data.MemoryUsagePercent <> invalid
|
||||
return data.MemoryUsagePercent
|
||||
end if
|
||||
return invalid
|
||||
end function
|
||||
|
||||
function osAtLeast(major as Integer, minor as Integer) as Boolean
|
||||
version = CreateObject("roDeviceInfo").GetOSVersion()
|
||||
if version = invalid or version.major = invalid then return false
|
||||
verMajor = Val(version.major.ToStr())
|
||||
verMinor = 0
|
||||
if version.minor <> invalid then verMinor = Val(version.minor.ToStr())
|
||||
if verMajor > major then return true
|
||||
if verMajor = major and verMinor >= minor then return true
|
||||
return false
|
||||
end function
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user