diff --git a/docs/superpowers/specs/2026-07-10-peertube-client-backend-design.md b/docs/superpowers/specs/2026-07-10-peertube-client-backend-design.md new file mode 100644 index 0000000..08d735a --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-peertube-client-backend-design.md @@ -0,0 +1,209 @@ +# PeerTube client + multi-kind config (backend foundation) + +**Date:** 2026-07-10 +**Status:** Approved design, ready for implementation plan +**Scope:** Backend only — no UI. Sub-project 1 of the PeerTube federation work. + +## Context + +This is the first of three phased sub-projects that together let catacomb +federate with PeerTube instances/channels in addition to catacomb peers: + +1. **PeerTube client + multi-kind config (this spec)** — the backend foundation: + a `RemoteKind`, config fields, and a `PeerTubeClient` that lists channels and + fetches paginated videos, mapped into the existing remote types. Headless, + unit-tested. +2. **Kind-aware remote editor** — the editor from + `2026-07-10-federation-remote-editor-design.md`, extended with a kind + selector + username field, both UIs. +3. **PeerTube browse UI + archive action** — two-level lazy navigation (list + channels → click → load that channel's videos) + a per-video "Archive" + button wiring the PeerTube watch URL into the downloader, both UIs. + +Each phase ships independently. This spec covers phase 1 only. + +## Problem + +catacomb federation (`src/remote.rs`) can only browse *other catacomb +instances*. PeerTube is a large federated video network catacomb cannot +currently browse read-only. yt-dlp already downloads PeerTube URLs, but there is +no way to browse a PeerTube instance/channel inside catacomb. Phase 1 builds the +backend client and the config model the later UI phases consume. + +## Goal + +A blocking `PeerTubeClient` that, given a `RemoteSection` of kind `peertube`, +can: authenticate (OAuth2, if credentials are set), list the target's video +channels, fetch a channel's videos paginated, resolve a video's playable media +URL on demand, and produce the canonical watch URL for archiving — all mapped +into the existing `remote` types. Plus the non-breaking config model. + +## Non-goals (this phase) + +- No UI (editor and browse are phases 2 and 3). +- No `RemoteSource` trait / catacomb-PeerTube polymorphism yet — introduced in + phase 2 when a shared UI consumes both kinds (YAGNI until a second consumer). +- No changes to the existing catacomb `RemoteClient` behavior. +- No live-network integration test (needs a real instance; covered by + fixture-based unit tests + manual verification). + +## Config model + +`src/config.rs` — additive, `#[serde(default)]` so existing configs are +untouched: + +```rust +/// Which kind of peer a `[[remote]]` is. Defaults to a catacomb peer so +/// pre-existing config.toml entries keep working unchanged. +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum RemoteKind { + #[default] + Catacomb, + Peertube, +} + +pub struct RemoteSection { + pub name: String, + pub url: String, + #[serde(default)] + pub kind: RemoteKind, // NEW + #[serde(default)] + pub username: Option, // NEW — PeerTube OAuth username + #[serde(default)] + pub password: Option, +} +``` + +A catacomb entry (no `kind`) deserializes as `RemoteKind::Catacomb`, +`username = None`. No migration needed. + +## `PeerTubeClient` (`src/peertube.rs`) + +New module. Blocking `reqwest` client with a cookie/connection pool, mirroring +`RemoteClient`'s construction. Holds the parsed API base, the target, optional +credentials, and a `Mutex>` cache. + +```rust +pub struct PeerTubeClient { + pub name: String, + api_base: String, // scheme://host (no trailing slash) + target: Target, // Instance | Account(String) | Channel(String) + username: Option, + password: Option, + client: reqwest::blocking::Client, + tokens: std::sync::Mutex>, +} + +enum Target { Instance, Account(String), Channel(String) } + +struct OAuthTokens { access: String, refresh: String } + +pub struct RemoteChannelInfo { + pub handle: String, // e.g. "blender_open_movies" or "foo@other.tld" + pub display_name: String, + pub video_count: Option, + pub avatar_url: Option, +} +// Videos map into the existing remote::RemoteVideo. +``` + +### URL / handle parsing (`fn parse_target(url) -> (api_base, Target)`) + +From the remote's `url`: +- `api_base` = `scheme://host[:port]`. +- Path `/(c|video-channels)/{handle}` → `Target::Channel(handle)`. +- Path `/(a|accounts)/{name}` → `Target::Account(name)`. +- Bare host / `/` → `Target::Instance`. + +`handle`/`name` is the last non-empty path segment (may contain `@host` for a +federated channel, kept verbatim). + +### OAuth2 (only when both `username` and non-empty `password` are set) + +Anonymous (public) mode otherwise — plain GETs, no `Authorization`. + +1. `GET {api_base}/api/v1/oauth-clients/local` → `{ client_id, client_secret }`. +2. `POST {api_base}/api/v1/users/token` (form-encoded): `client_id`, + `client_secret`, `grant_type=password`, `username`, `password` → + `{ access_token, refresh_token, expires_in }`. Cache both tokens. +3. Authenticated requests send `Authorization: Bearer {access}`. +4. On a `401`, refresh via `grant_type=refresh_token`; if refresh fails, redo the + password grant once. A second failure surfaces as an error. + +`authed_get(path)` centralises this (parallels `RemoteClient::authed_get`). + +### Methods + +```rust +pub fn list_channels(&self) -> Result, String>; +pub fn channel_videos(&self, handle: &str, page: usize) -> Result, String>; +pub fn video_media(&self, uuid: &str) -> Result, String>; +pub fn watch_url(&self, uuid: &str) -> String; +``` + +- **`list_channels`**: + - `Instance` → `GET /api/v1/video-channels?start=0&count=100` (paginate until + `total` consumed or a sane cap). + - `Account(n)` → `GET /api/v1/accounts/{n}/video-channels`. + - `Channel(h)` → one `GET /api/v1/video-channels/{h}` mapped to a single + `RemoteChannelInfo`. + - Map each PeerTube channel object → `RemoteChannelInfo { handle: name (+@host + if remote), display_name: displayName, video_count: videosCount, avatar_url: + api_base + avatars[…].path }`. +- **`channel_videos(handle, page)`**: `GET + /api/v1/video-channels/{handle}/videos?start={page*24}&count=24&sort=-publishedAt`. + Each list object → `RemoteVideo { id: uuid, title: name, channel: , video_url: None, thumb_url: Some(api_base + thumbnailPath), + duration_secs: Some(duration) }`. `video_url` is `None` because list objects + omit `files`; it is resolved on demand by `video_media`. +- **`video_media(uuid)`**: `GET /api/v1/videos/{uuid}` → choose a direct MP4 from + `files[].fileUrl` (prefer the highest resolution ≤ 1080p; any if none match). + For a private video, append `?videoFileToken={t}` obtained from + `POST /api/v1/videos/{uuid}/token` (only when authenticated). HLS-only (empty + `files`, non-empty `streamingPlaylists`) → `Ok(None)`. +- **`watch_url(uuid)`**: `format!("{api_base}/w/{uuid}")`. + +### Media / playback constraint + +Direct-MP4 (`files[].fileUrl`) videos stream inline. HLS-only videos return +`video_url = None`; phase 3's UI will show them as browse-only (no inline +player) while the Archive action still works (yt-dlp handles HLS). Documenting +this limitation here so phase 3 doesn't treat it as a bug. + +## Error handling + +Every method returns `Result<_, String>`. Network, JSON-parse, non-2xx, and auth +failures become descriptive error strings (`"peertube {name}: HTTP 404"`, +`"oauth token: …"`). `PeerTubeClient::new` is total (parsing a malformed URL +still constructs a client whose first request fails with a clear error) — no +panics. A missing/renamed JSON field maps to `None`/skips the item rather than +erroring the whole list. + +## Testing (headless, fixture-based) + +Unit tests in `src/peertube.rs`: +- **`parse_target`**: instance root, `/c/{h}`, `/video-channels/{h}`, `/a/{n}`, + `/accounts/{n}`, and a federated `/c/foo@other.tld` handle → correct + `(api_base, Target)`. +- **channel mapping**: fixture `/api/v1/video-channels` JSON → `RemoteChannelInfo` + (display name, count, absolutified avatar). +- **video mapping**: fixture channel-videos JSON → `RemoteVideo` (uuid, title, + duration, `thumb_url` absolutified, `video_url == None`). +- **media pick**: fixture video-detail JSON with several `files[]` → the chosen + MP4 `fileUrl`; HLS-only fixture (`files: []`) → `None`. +- **oauth parse**: fixture token response → `OAuthTokens { access, refresh }`. + +Pure mapping/parse fns take `&serde_json::Value` (or `&str`) so tests need no +network. The actual HTTP round-trips are verified manually against a real +public instance (e.g. framatube.org) during phase 3 bring-up. + +## What phase 2 / 3 consume + +- Phase 2 (editor): the `RemoteKind` + `username` config fields, and a + `RemoteSource` trait extracted then so the editor and browse treat both kinds + uniformly. +- Phase 3 (browse UI): `list_channels` → channel list; `channel_videos` → + per-channel grid (lazy on click, paginated); `video_media` → inline play; + `watch_url` → the Archive action's downloader input. +```