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>
209 lines
8.9 KiB
Markdown
209 lines
8.9 KiB
Markdown
# 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.
|
|
```
|