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>
8.9 KiB
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:
- PeerTube client + multi-kind config (this spec) — the backend foundation:
a
RemoteKind, config fields, and aPeerTubeClientthat lists channels and fetches paginated videos, mapped into the existing remote types. Headless, unit-tested. - Kind-aware remote editor — the editor from
2026-07-10-federation-remote-editor-design.md, extended with a kind selector + username field, both UIs. - 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
RemoteSourcetrait / 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
RemoteClientbehavior. - 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:
/// 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.
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.
GET {api_base}/api/v1/oauth-clients/local→{ client_id, client_secret }.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.- Authenticated requests send
Authorization: Bearer {access}. - On a
401, refresh viagrant_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
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 untiltotalconsumed or a sane cap).Account(n)→GET /api/v1/accounts/{n}/video-channels.Channel(h)→ oneGET /api/v1/video-channels/{h}mapped to a singleRemoteChannelInfo.- 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_urlisNonebecause list objects omitfiles; it is resolved on demand byvideo_media.video_media(uuid):GET /api/v1/videos/{uuid}→ choose a direct MP4 fromfiles[].fileUrl(prefer the highest resolution ≤ 1080p; any if none match). For a private video, append?videoFileToken={t}obtained fromPOST /api/v1/videos/{uuid}/token(only when authenticated). HLS-only (emptyfiles, non-emptystreamingPlaylists) →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.tldhandle → correct(api_base, Target).- channel mapping: fixture
/api/v1/video-channelsJSON →RemoteChannelInfo(display name, count, absolutified avatar). - video mapping: fixture channel-videos JSON →
RemoteVideo(uuid, title, duration,thumb_urlabsolutified,video_url == None). - media pick: fixture video-detail JSON with several
files[]→ the chosen MP4fileUrl; 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+usernameconfig fields, and aRemoteSourcetrait 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.