docs: spec for PeerTube client + multi-kind config (phase 1)

First of three phased sub-projects for PeerTube federation. Backend-only:
RemoteKind enum + kind/username config fields (non-breaking), and a blocking
PeerTubeClient (OAuth2, list channels, paginated channel videos, media
resolution, watch URL) mapped into the existing remote types. Headless,
fixture-tested. Editor (phase 2) and browse UI (phase 3) follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-10 04:23:02 -07:00
parent 112a10991d
commit 6f2376932a
No known key found for this signature in database

View file

@ -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<String>, // NEW — PeerTube OAuth username
#[serde(default)]
pub password: Option<String>,
}
```
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<Option<OAuthTokens>>` 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<String>,
password: Option<String>,
client: reqwest::blocking::Client,
tokens: std::sync::Mutex<Option<OAuthTokens>>,
}
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<u64>,
pub avatar_url: Option<String>,
}
// 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<Vec<RemoteChannelInfo>, String>;
pub fn channel_videos(&self, handle: &str, page: usize) -> Result<Vec<crate::remote::RemoteVideo>, String>;
pub fn video_media(&self, uuid: &str) -> Result<Option<String>, 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: <handle's
display>, 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.
```