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:
parent
112a10991d
commit
6f2376932a
1 changed files with 209 additions and 0 deletions
|
|
@ -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.
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue