9.8 KiB
PeerTube Browse + Archive — Design (Federation Phase 3)
Third and final phase of the federation/PeerTube project. Phase 1 shipped the backend
PeerTubeClient(docs/superpowers/specs/2026-07-10-peertube-client-backend-design.md); phase 2 shipped the kind-aware remote editor (docs/superpowers/specs/2026-07-10-federation-editor-phase2-kind-aware-design.md). This phase adds the browse UI + per-video archive action to both front-ends.
Goal
Let a user browse a configured PeerTube peer's channels and videos from inside Catacomb (both the web SPA and the desktop GUI), play a video inline when the instance exposes a direct MP4, and archive any video into the local library with one click. Read-only browsing, one-video-at-a-time archiving.
Locked decisions (from brainstorming)
- Lazy two-level navigation — list channels, then click a channel to load its videos a page at a time. Not a one-shot whole-library load.
- Per-video archive only — each video row has an Archive button. No bulk/whole-channel archive in this phase.
- Archive destination = existing
Otherplatform — PeerTube is federated with no fixed domain, soPlatform::from_urlclassifies awatch_urlasOther, landing archived videos inother/<uploader>/. No newPlatformvariant, no download-path override. - Both front-ends in one spec — web + desktop at parity, shared backend endpoints.
- On-demand media resolution — resolve a video's playable MP4 only when Play is clicked, not eagerly for every listed video (eager would fire one extra HTTP call per video per page).
Background: what already exists
PeerTubeClient (phase 1, src/peertube.rs) is a blocking client with:
list_channels() -> Result<Vec<RemoteChannelInfo>, String>—RemoteChannelInfo { handle, display_name, video_count: Option<u64>, avatar_url: Option<String> }.channel_videos(handle, page) -> Result<Vec<RemoteVideo>, String>— page size 24, newest first. Returnscrate::remote::RemoteVideo { id (uuid), title, channel, video_url: None, thumb_url, duration_secs }.video_urlis deliberatelyNonehere — the playable URL is resolved separately.video_media(uuid) -> Result<Option<String>, String>— the direct MP4 URL, orNonewhen the video is HLS-only.watch_url(uuid) -> String—{api_base}/w/{uuid}, the canonical page URL handed to yt-dlp.
RemoteClientKind (phase 2, src/remote.rs) wraps Catacomb(RemoteClient) /
Peertube(PeerTubeClient). Both front-ends hold Vec<Arc<RemoteClientKind>>
(web behind a RwLock). Browsing dispatches on kind; the phase-2 stopgap for a
Peertube remote is a "browsing arrives in a later update" message, replaced here.
The existing catacomb browse path is untouched: GET /api/remotes/:id/library
returns the whole peer library and the SPA swaps its library array to reuse the
normal grid (enterRemote/exitRemote in index.html); desktop uses
start_remote_fetch → remote_library.
Architecture
Browsing remains kind-dispatched. Catacomb peers keep the one-shot /library
path. Peertube peers use new lazy endpoints and a new two-level browse view in
each UI. The new endpoints are Peertube-only: called against a catacomb remote
they return 400 Bad Request ("not a PeerTube remote").
RemoteClientKind gains thin passthroughs used by the new web handlers and the
desktop threads:
impl RemoteClientKind {
// Returns Err for the Catacomb arm ("not a PeerTube remote").
pub fn pt_channels(&self) -> Result<Vec<crate::peertube::RemoteChannelInfo>, String>;
pub fn pt_channel_videos(&self, handle: &str, page: usize) -> Result<Vec<RemoteVideo>, String>;
pub fn pt_video_media(&self, uuid: &str) -> Result<Option<String>, String>;
pub fn pt_watch_url(&self, uuid: &str) -> Result<String, String>;
}
(Alternatively the handlers match on the arm directly; the passthroughs keep the
unreachable!() noise out of both front-ends and give one kind-guard site.)
Backend endpoints (src/web.rs)
All run the blocking PeerTube calls on tokio::task::spawn_blocking (as the
existing get_remote_library does) and 400 on a catacomb remote.
| Method / path | Returns |
|---|---|
GET /api/remotes/:id/channels |
[{handle, display_name, video_count, avatar_url}] |
GET /api/remotes/:id/channels/:handle/videos?page=N |
[{id, title, channel, thumb_url, duration_secs}] (page 24; N defaults 0) |
GET /api/remotes/:id/videos/:uuid/media |
{url} (200) or 204 No Content when HLS-only |
POST /api/remotes/:id/archive {uuid} |
202 "ok" after downloader.start(watch_url, …); 404 unknown remote, 400 catacomb remote |
:handle may contain @host for a federated channel, so it is a path segment
that is percent-decoded by axum; the handler passes it verbatim to
channel_videos. The archive handler resolves watch_url(uuid), builds the
UrlInfo with the existing synchronous classify_url(&url) (no network probe;
info.platform == Other for a PeerTube URL), and calls the shared
Downloader::start. It mirrors post_download's shape: start returns (),
so the response is 202 "ok" and the job then appears in the normal downloads
panel via the existing progress stream (no job id is returned).
Web UI (src/web_ui/index.html)
A new PeerTube browse mode, distinct from the existing flat-library
remoteMode (the PeerTube view is a two-level nav, not the reused grid):
- Enter: clicking a remote whose
kind === 'peertube'in the sidebar enters PeerTube mode and callsGET …/channels. (A catacomb remote still callsenterRemote.) State:ptRemoteId,ptChannel,ptPage,ptVideos. - Channel list: rows with avatar, display name, and video count. Click → load that channel's videos.
- Video grid: cards (thumbnail, title, duration) with ▶ Play and ⬇ Archive, plus a [Load more] button that fetches the next page and appends (hidden when a page returns < 24).
- Play:
GET …/videos/:uuid/media; on 200 open the existing custom player (playVideo) with the returned URL; on 204 the Play button is disabled with an "HLS-only — archive to watch" note. - Archive:
POST …/archive {uuid}; toast on success; the job shows up in the normal downloads panel via the existing progress stream. - Back navigation: video grid → channel list → "Back to my library"
(
exitRemote-style reset). PeerTube mode reuses the sidebar remotes list for peer switching.
Sidebar: the existing 🌐 Remotes block already lists every peer with
enterRemote(id). Dispatch there on r.kind — peertube → enterPeertube(id).
Desktop UI (src/app.rs)
remotes_screen dispatches on RemoteClientKind::kind(). The Peertube arm
replaces the phase-2 stopgap with the same two-level nav in egui:
- New
Appstate:pt_channels: Option<Vec<RemoteChannelInfo>>,pt_selected_channel: Option<String>,pt_videos: Vec<RemoteVideo>,pt_page: usize, and mpsc receivers for the background channel/video fetches (pt_channels_rx,pt_videos_rx) drained inupdate()— mirroring the existingremote_rxpattern (fetch on a thread,request_repaint). - Channel list (selectable rows) → click loads page 0 of that channel's videos; a Load more button appends the next page.
- Per video row: Play — resolve
video_mediaon a thread, then hand the URL to the existingplay_remote_url(mpv); greyed when the resolve returnsNone. Archive —self.downloader.start(watch_url, …)(probe →Other). - Status/errors surface on the existing
remote_statusline.
Archive action (shared)
Both UIs route through the same Downloader::start a manual download uses, so
auto-retry, post-download transcode, and the hang watchdog all apply unchanged.
The archived video lands in other/<uploader>/ and appears in the local library
after the next scan (the download pipeline already triggers this). No new
download settings, no per-remote archive options.
HLS-only & error handling
video_media == None(HLS-only): Play disabled, Archive still works (yt-dlp downloads HLS fine). The disabled Play carries an explanatory tooltip.- Network / auth / not-found failures from the PeerTube client surface as an
inline error line in the browse view (web: a status/toast; desktop:
remote_status) — never a panic or a stuck "loading…". - An empty channel (zero videos) shows an empty-state row, not a blank grid.
- Calling a new endpoint on a catacomb remote →
400with a clear message; the UIs never do this (they dispatch on kind) but the guard documents intent and protects the API.
Testing
- Unit (web.rs): the kind-guard — a new endpoint handler (or the
RemoteClientKindpassthrough) returnsErr/400 for aCatacombarm. An archive-path unit asserting a givenuuidmaps to the expectedwatch_urlbefore hand-off. - Integration (
tests/api.rs):PUTa peertube remote, thenGET …/channelsagainst an unreachable host — asserts the route exists and returns a client/gateway error (not 404-route-missing), and thatGET …/channelson a catacomb remote returns 400. No network required (unreachable host → fast connection error); skip-if-curl-absent like the file's other tests. - Mapping: already fixture-tested in phase 1 (
map_channel,map_video,pick_media) — no change. - Manual: against
https://framatube.org(public instance): browse channels, open one, Load more, play a direct-MP4 video inline (web) / via mpv (desktop), confirm an HLS-only video disables Play, archive one video and confirm it lands underOtherand appears after a rescan.
Out of scope (possible later)
- Bulk / whole-channel archive.
- A dedicated
Platform::Peertubelibrary section. - Search within a peer, subscriptions/feeds, comments, or write actions.
- Caching channel/video lists (each browse is a live fetch).