docs: implementation plan for PeerTube client backend (phase 1)
Four TDD tasks: config RemoteKind/username, peertube module + URL parsing, pure JSON mapping fns (fixture-tested), and the OAuth2 + REST HTTP methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6f2376932a
commit
96965aaa32
1 changed files with 686 additions and 0 deletions
686
docs/superpowers/plans/2026-07-10-peertube-client-backend.md
Normal file
686
docs/superpowers/plans/2026-07-10-peertube-client-backend.md
Normal file
|
|
@ -0,0 +1,686 @@
|
||||||
|
# PeerTube Client + Multi-Kind Config — Implementation Plan (Phase 1)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a `RemoteKind` config model and a headless blocking `PeerTubeClient` that lists a PeerTube target's channels, fetches a channel's videos paginated, resolves a video's playable MP4 on demand, and builds its watch URL — all mapped into the existing `remote` types.
|
||||||
|
|
||||||
|
**Architecture:** A new `src/peertube.rs` module holds pure mapping/parse functions (URL→target, JSON→`RemoteChannelInfo`/`RemoteVideo`, media pick, OAuth token parse) that are unit-tested against fixtures, plus a thin blocking-`reqwest` `PeerTubeClient` that wires those pure fns to PeerTube's public REST API (with optional OAuth2). No UI (phases 2–3).
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, `reqwest` (blocking, json, cookies — already a dep), `serde`/`serde_json`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Backend only — no UI, no changes to the existing `remote::RemoteClient` behavior.
|
||||||
|
- Config additions are `#[serde(default)]` and non-breaking: an existing `[[remote]]` with no `kind` deserializes as `RemoteKind::Catacomb`, `username = None`.
|
||||||
|
- Every `PeerTubeClient` method returns `Result<_, String>`; no panics. `new` is total.
|
||||||
|
- Pure mapping/parse fns take `&serde_json::Value` (or `&str`) so tests need no network. HTTP round-trips are verified manually against a real instance during phase 3.
|
||||||
|
- No new `RemoteSource` trait this phase (YAGNI until phase 2's shared UI).
|
||||||
|
- `.form()` and `.json()` on the blocking client are available (reqwest features `blocking`, `json` present). Cookies enabled.
|
||||||
|
- Commits are SSH-signed: `export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock`. End messages with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||||
|
- Spec: `docs/superpowers/specs/2026-07-10-peertube-client-backend-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Multi-kind config model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.rs` — add `RemoteKind` enum; add `kind` + `username` fields to `RemoteSection`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `pub enum RemoteKind { Catacomb, Peertube }` (`Default = Catacomb`); `RemoteSection.kind: RemoteKind`, `RemoteSection.username: Option<String>`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `src/config.rs` in a `#[cfg(test)] mod tests` block (create it at end of file with `use super::*;` if absent):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn remote_kind_defaults_to_catacomb() {
|
||||||
|
// A legacy [[remote]] with no `kind`/`username` still parses. `[backup]`
|
||||||
|
// has no serde default (directory is required), so include a minimal one.
|
||||||
|
let toml = r#"
|
||||||
|
[backup]
|
||||||
|
directory = "/tmp/lib"
|
||||||
|
|
||||||
|
[[remote]]
|
||||||
|
name = "peer"
|
||||||
|
url = "http://peer:8081"
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(cfg.remotes.len(), 1);
|
||||||
|
assert_eq!(cfg.remotes[0].kind, RemoteKind::Catacomb);
|
||||||
|
assert!(cfg.remotes[0].username.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_kind_parses_peertube_and_username() {
|
||||||
|
let toml = r#"
|
||||||
|
[backup]
|
||||||
|
directory = "/tmp/lib"
|
||||||
|
|
||||||
|
[[remote]]
|
||||||
|
name = "frama"
|
||||||
|
url = "https://framatube.org"
|
||||||
|
kind = "peertube"
|
||||||
|
username = "alice"
|
||||||
|
password = "secret"
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(cfg.remotes[0].kind, RemoteKind::Peertube);
|
||||||
|
assert_eq!(cfg.remotes[0].username.as_deref(), Some("alice"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test --release remote_kind 2>&1 | grep -E "error\[|cannot find|RemoteKind"`
|
||||||
|
Expected: FAIL — `RemoteKind` and the `kind`/`username` fields don't exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the enum and fields**
|
||||||
|
|
||||||
|
In `src/config.rs`, add the enum above `RemoteSection`:
|
||||||
|
|
||||||
|
```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,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the two fields to `RemoteSection` (after `url`, before `password`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Peer kind — catacomb federation peer (default) or a PeerTube target.
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: RemoteKind,
|
||||||
|
/// Username for PeerTube OAuth2. `None` for catacomb peers / public
|
||||||
|
/// PeerTube.
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: Option<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cargo test --release remote_kind 2>&1 | grep "test result"`
|
||||||
|
Expected: `test result: ok. 2 passed`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
|
||||||
|
git add src/config.rs
|
||||||
|
git commit -m "feat(config): RemoteKind + username for PeerTube remotes
|
||||||
|
|
||||||
|
Non-breaking: existing [[remote]] entries default to kind=catacomb.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: peertube module — target parsing + client skeleton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/peertube.rs`
|
||||||
|
- Modify: `src/main.rs` — add `mod peertube;` (between `mod maintenance;` and `mod platform;`).
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `crate::config::RemoteSection` (Task 1).
|
||||||
|
- Produces:
|
||||||
|
- `pub struct PeerTubeClient { pub name: String, api_base: String, target: Target, username: Option<String>, password: Option<String>, client: reqwest::blocking::Client, tokens: std::sync::Mutex<Option<OAuthTokens>> }`
|
||||||
|
- `PeerTubeClient::new(cfg: &RemoteSection) -> Self`
|
||||||
|
- `pub fn watch_url(&self, uuid: &str) -> String`
|
||||||
|
- internal `enum Target { Instance, Account(String), Channel(String) }`, `struct OAuthTokens { access: String, refresh: String }`, `fn parse_target(url: &str) -> (String, Target)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the module with parsing + skeleton**
|
||||||
|
|
||||||
|
Create `src/peertube.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! Read-only PeerTube client: lists a target's channels and their videos over
|
||||||
|
//! PeerTube's public REST API (with optional OAuth2), mapped into the existing
|
||||||
|
//! `crate::remote` types. Blocking, like `crate::remote::RemoteClient`.
|
||||||
|
//! Phase 1 of the PeerTube federation work (backend only).
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::config::RemoteSection;
|
||||||
|
use crate::remote::RemoteVideo;
|
||||||
|
|
||||||
|
/// What a PeerTube remote points at.
|
||||||
|
enum Target {
|
||||||
|
Instance,
|
||||||
|
Account(String),
|
||||||
|
Channel(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OAuthTokens {
|
||||||
|
access: String,
|
||||||
|
refresh: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel in a PeerTube target's channel list.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct RemoteChannelInfo {
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub video_count: Option<u64>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PeerTubeClient {
|
||||||
|
pub name: String,
|
||||||
|
api_base: String,
|
||||||
|
target: Target,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
client: reqwest::blocking::Client,
|
||||||
|
tokens: Mutex<Option<OAuthTokens>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the API base (`scheme://host[:port]`) and the target from a remote URL.
|
||||||
|
/// `/(c|video-channels)/{h}` → Channel; `/(a|accounts)/{n}` → Account; else
|
||||||
|
/// Instance. The handle/name is the segment after the marker, kept verbatim
|
||||||
|
/// (may include `@host` for a federated channel).
|
||||||
|
fn parse_target(url: &str) -> (String, Target) {
|
||||||
|
let trimmed = url.trim().trim_end_matches('/');
|
||||||
|
// Split scheme://host from the path.
|
||||||
|
let (scheme_host, path) = match trimmed.find("://") {
|
||||||
|
Some(i) => {
|
||||||
|
let after = &trimmed[i + 3..];
|
||||||
|
match after.find('/') {
|
||||||
|
Some(j) => (&trimmed[..i + 3 + j], &after[j..]),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
let api_base = scheme_host.to_string();
|
||||||
|
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
let target = match segs.as_slice() {
|
||||||
|
[marker, handle, ..] if *marker == "c" || *marker == "video-channels" => {
|
||||||
|
Target::Channel((*handle).to_string())
|
||||||
|
}
|
||||||
|
[marker, name, ..] if *marker == "a" || *marker == "accounts" => {
|
||||||
|
Target::Account((*name).to_string())
|
||||||
|
}
|
||||||
|
_ => Target::Instance,
|
||||||
|
};
|
||||||
|
(api_base, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PeerTubeClient {
|
||||||
|
pub fn new(cfg: &RemoteSection) -> Self {
|
||||||
|
let (api_base, target) = parse_target(&cfg.url);
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.user_agent("catacomb-peertube")
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::blocking::Client::new());
|
||||||
|
PeerTubeClient {
|
||||||
|
name: cfg.name.clone(),
|
||||||
|
api_base,
|
||||||
|
target,
|
||||||
|
username: cfg.username.clone().filter(|u| !u.is_empty()),
|
||||||
|
password: cfg.password.clone().filter(|p| !p.is_empty()),
|
||||||
|
client,
|
||||||
|
tokens: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical watch URL for a video, handed to the downloader (phase 3).
|
||||||
|
pub fn watch_url(&self, uuid: &str) -> String {
|
||||||
|
format!("{}/w/{}", self.api_base, uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn target_str(t: &Target) -> String {
|
||||||
|
match t {
|
||||||
|
Target::Instance => "instance".into(),
|
||||||
|
Target::Account(n) => format!("account:{n}"),
|
||||||
|
Target::Channel(h) => format!("channel:{h}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_target_variants() {
|
||||||
|
let cases = [
|
||||||
|
("https://framatube.org", "https://framatube.org", "instance"),
|
||||||
|
("https://framatube.org/", "https://framatube.org", "instance"),
|
||||||
|
("https://framatube.org/c/blender", "https://framatube.org", "channel:blender"),
|
||||||
|
("https://framatube.org/video-channels/blender", "https://framatube.org", "channel:blender"),
|
||||||
|
("https://framatube.org/a/framasoft", "https://framatube.org", "account:framasoft"),
|
||||||
|
("https://framatube.org/accounts/framasoft", "https://framatube.org", "account:framasoft"),
|
||||||
|
("https://framatube.org/c/foo@other.tld", "https://framatube.org", "channel:foo@other.tld"),
|
||||||
|
("http://peer:9000/c/x", "http://peer:9000", "channel:x"),
|
||||||
|
];
|
||||||
|
for (url, base, tgt) in cases {
|
||||||
|
let (b, t) = parse_target(url);
|
||||||
|
assert_eq!(b, base, "base for {url}");
|
||||||
|
assert_eq!(target_str(&t), tgt, "target for {url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_url_built_from_base() {
|
||||||
|
let cfg = RemoteSection {
|
||||||
|
name: "f".into(),
|
||||||
|
url: "https://framatube.org/c/blender".into(),
|
||||||
|
kind: crate::config::RemoteKind::Peertube,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
};
|
||||||
|
let c = PeerTubeClient::new(&cfg);
|
||||||
|
assert_eq!(c.watch_url("abc-123"), "https://framatube.org/w/abc-123");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the module**
|
||||||
|
|
||||||
|
In `src/main.rs`, add `mod peertube;` between `mod maintenance;` and `mod platform;`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cargo test --release peertube 2>&1 | grep -E "^error|test result"`
|
||||||
|
Expected: compiles; `test result: ok. 2 passed`. (A `dead_code` warning for the not-yet-used `target`/`tokens`/`OAuthTokens` fields is fine — they're used in Task 4.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
|
||||||
|
git add src/peertube.rs src/main.rs
|
||||||
|
git commit -m "feat(peertube): module skeleton + URL/target parsing
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Pure JSON mapping + parse functions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/peertube.rs` — add `map_channel`, `map_video`, `pick_media`, `parse_tokens`; tests.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `RemoteChannelInfo`, `OAuthTokens`, `RemoteVideo` (Tasks 2 / `remote.rs`).
|
||||||
|
- Produces (module-private):
|
||||||
|
- `fn map_channel(v: &Value, api_base: &str) -> Option<RemoteChannelInfo>`
|
||||||
|
- `fn map_video(v: &Value, api_base: &str, channel: &str) -> RemoteVideo`
|
||||||
|
- `fn pick_media(detail: &Value) -> Option<String>`
|
||||||
|
- `fn parse_tokens(v: &Value) -> Option<OAuthTokens>`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to the `#[cfg(test)] mod tests` block in `src/peertube.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_channel_maps_fields_and_absolutifies_avatar() {
|
||||||
|
let v = json!({
|
||||||
|
"name": "blender", "displayName": "Blender Open Movies",
|
||||||
|
"videosCount": 12, "avatars": [{ "path": "/lazy-static/avatars/x.png" }]
|
||||||
|
});
|
||||||
|
let c = map_channel(&v, "https://framatube.org").unwrap();
|
||||||
|
assert_eq!(c.handle, "blender");
|
||||||
|
assert_eq!(c.display_name, "Blender Open Movies");
|
||||||
|
assert_eq!(c.video_count, Some(12));
|
||||||
|
assert_eq!(c.avatar_url.as_deref(), Some("https://framatube.org/lazy-static/avatars/x.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_channel_federated_handle_includes_host() {
|
||||||
|
let v = json!({ "name": "foo", "displayName": "Foo", "host": "other.tld" });
|
||||||
|
let c = map_channel(&v, "https://framatube.org").unwrap();
|
||||||
|
assert_eq!(c.handle, "foo@other.tld");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_video_maps_and_absolutifies_thumb() {
|
||||||
|
let v = json!({
|
||||||
|
"uuid": "abc-123", "name": "My Vid", "duration": 61,
|
||||||
|
"thumbnailPath": "/lazy-static/thumbnails/abc.jpg"
|
||||||
|
});
|
||||||
|
let vid = map_video(&v, "https://framatube.org", "Blender");
|
||||||
|
assert_eq!(vid.id, "abc-123");
|
||||||
|
assert_eq!(vid.title, "My Vid");
|
||||||
|
assert_eq!(vid.channel, "Blender");
|
||||||
|
assert_eq!(vid.duration_secs, Some(61.0));
|
||||||
|
assert_eq!(vid.thumb_url.as_deref(), Some("https://framatube.org/lazy-static/thumbnails/abc.jpg"));
|
||||||
|
assert!(vid.video_url.is_none()); // resolved later via video_media
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_media_prefers_direct_mp4() {
|
||||||
|
let detail = json!({
|
||||||
|
"files": [
|
||||||
|
{ "resolution": { "id": 480 }, "fileUrl": "https://f/480.mp4" },
|
||||||
|
{ "resolution": { "id": 1080 }, "fileUrl": "https://f/1080.mp4" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert_eq!(pick_media(&detail).as_deref(), Some("https://f/1080.mp4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_media_hls_only_is_none() {
|
||||||
|
let detail = json!({ "files": [], "streamingPlaylists": [{ "playlistUrl": "https://f/master.m3u8" }] });
|
||||||
|
assert_eq!(pick_media(&detail), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_tokens_reads_access_and_refresh() {
|
||||||
|
let v = json!({ "access_token": "AAA", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "RRR" });
|
||||||
|
let t = parse_tokens(&v).unwrap();
|
||||||
|
assert_eq!(t.access, "AAA");
|
||||||
|
assert_eq!(t.refresh, "RRR");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cargo test --release peertube 2>&1 | grep -E "cannot find|test result"`
|
||||||
|
Expected: FAIL — `map_channel`/`map_video`/`pick_media`/`parse_tokens` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the pure functions**
|
||||||
|
|
||||||
|
Add to `src/peertube.rs` (module scope, not inside `impl`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Absolutify a PeerTube relative path (`/lazy-static/…`) against the API base.
|
||||||
|
fn absolutify(api_base: &str, path: &str) -> String {
|
||||||
|
if path.starts_with("http://") || path.starts_with("https://") {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{api_base}{path}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_channel(v: &Value, api_base: &str) -> Option<RemoteChannelInfo> {
|
||||||
|
let name = v.get("name").and_then(Value::as_str)?;
|
||||||
|
let handle = match v.get("host").and_then(Value::as_str) {
|
||||||
|
Some(host) if !host.is_empty() => format!("{name}@{host}"),
|
||||||
|
_ => name.to_string(),
|
||||||
|
};
|
||||||
|
let display_name = v
|
||||||
|
.get("displayName")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(name)
|
||||||
|
.to_string();
|
||||||
|
let video_count = v.get("videosCount").and_then(Value::as_u64);
|
||||||
|
// Newer PeerTube: `avatars: [{path}]`; older: `avatar: {path}`.
|
||||||
|
let avatar_path = v
|
||||||
|
.get("avatars")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.and_then(|a| a.get("path"))
|
||||||
|
.or_else(|| v.get("avatar").and_then(|a| a.get("path")))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
let avatar_url = avatar_path.map(|p| absolutify(api_base, p));
|
||||||
|
Some(RemoteChannelInfo { handle, display_name, video_count, avatar_url })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_video(v: &Value, api_base: &str, channel: &str) -> RemoteVideo {
|
||||||
|
let id = v.get("uuid").and_then(Value::as_str).unwrap_or("").to_string();
|
||||||
|
let title = v
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("(untitled)")
|
||||||
|
.to_string();
|
||||||
|
let duration_secs = v.get("duration").and_then(Value::as_f64);
|
||||||
|
let thumb_url = v
|
||||||
|
.get("thumbnailPath")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|p| absolutify(api_base, p));
|
||||||
|
RemoteVideo {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
channel: channel.to_string(),
|
||||||
|
video_url: None,
|
||||||
|
thumb_url,
|
||||||
|
duration_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a directly-playable MP4 from a video detail object. Prefers the highest
|
||||||
|
/// resolution ≤ 1080; falls back to the highest available. HLS-only (empty
|
||||||
|
/// `files`) → None.
|
||||||
|
fn pick_media(detail: &Value) -> Option<String> {
|
||||||
|
let files = detail.get("files").and_then(Value::as_array)?;
|
||||||
|
if files.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let res = |f: &Value| f.get("resolution").and_then(|r| r.get("id")).and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
// Highest res ≤ 1080, else highest overall.
|
||||||
|
let best = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| res(f) <= 1080)
|
||||||
|
.max_by_key(|f| res(f))
|
||||||
|
.or_else(|| files.iter().max_by_key(|f| res(f)))?;
|
||||||
|
best.get("fileUrl").and_then(Value::as_str).map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tokens(v: &Value) -> Option<OAuthTokens> {
|
||||||
|
let access = v.get("access_token").and_then(Value::as_str)?.to_string();
|
||||||
|
let refresh = v.get("refresh_token").and_then(Value::as_str).unwrap_or("").to_string();
|
||||||
|
Some(OAuthTokens { access, refresh })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cargo test --release peertube 2>&1 | grep "test result"`
|
||||||
|
Expected: `test result: ok. 8 passed` (2 from Task 2 + 6 here).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
|
||||||
|
git add src/peertube.rs
|
||||||
|
git commit -m "feat(peertube): pure JSON mapping (channels, videos, media, tokens)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: HTTP methods (OAuth + list/videos/media)
|
||||||
|
|
||||||
|
Wires the pure fns to PeerTube's REST API. Not unit-tested (network); verified by build + an optional manual smoke against a public instance. Correctness rests on Task 3's tested mapping fns.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/peertube.rs` — add OAuth (`ensure_token`/`authed_get`) and `list_channels`, `channel_videos`, `video_media`.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 3 pure fns; Task 2 `Target`/`OAuthTokens`/`api_base`/`client`.
|
||||||
|
- Produces:
|
||||||
|
- `pub fn list_channels(&self) -> Result<Vec<RemoteChannelInfo>, String>`
|
||||||
|
- `pub fn channel_videos(&self, handle: &str, page: usize) -> Result<Vec<RemoteVideo>, String>`
|
||||||
|
- `pub fn video_media(&self, uuid: &str) -> Result<Option<String>, String>`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement OAuth + authed_get**
|
||||||
|
|
||||||
|
Add to `impl PeerTubeClient` in `src/peertube.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Obtain (and cache) an access token via the OAuth2 password grant. No-op /
|
||||||
|
/// returns None when no credentials are configured (public browsing).
|
||||||
|
fn ensure_token(&self) -> Result<Option<String>, String> {
|
||||||
|
let (Some(user), Some(pass)) = (&self.username, &self.password) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if let Some(t) = self.tokens.lock().unwrap().as_ref() {
|
||||||
|
return Ok(Some(t.access.clone()));
|
||||||
|
}
|
||||||
|
// 1. oauth client id/secret.
|
||||||
|
let oc: Value = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/v1/oauth-clients/local", self.api_base))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("peertube {}: oauth-clients: {e}", self.name))?
|
||||||
|
.json()
|
||||||
|
.map_err(|e| format!("peertube {}: oauth-clients parse: {e}", self.name))?;
|
||||||
|
let client_id = oc.get("client_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let client_secret = oc.get("client_secret").and_then(Value::as_str).unwrap_or("");
|
||||||
|
// 2. password grant.
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/v1/users/token", self.api_base))
|
||||||
|
.form(&[
|
||||||
|
("client_id", client_id),
|
||||||
|
("client_secret", client_secret),
|
||||||
|
("grant_type", "password"),
|
||||||
|
("username", user.as_str()),
|
||||||
|
("password", pass.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("peertube {}: token: {e}", self.name))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("peertube {}: token HTTP {}", self.name, resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let v: Value = resp.json().map_err(|e| format!("peertube {}: token parse: {e}", self.name))?;
|
||||||
|
let toks = parse_tokens(&v).ok_or_else(|| format!("peertube {}: token missing", self.name))?;
|
||||||
|
let access = toks.access.clone();
|
||||||
|
*self.tokens.lock().unwrap() = Some(toks);
|
||||||
|
Ok(Some(access))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET an API path, adding a Bearer token when credentials are set, retrying
|
||||||
|
/// once after clearing a stale token on 401.
|
||||||
|
fn authed_get(&self, path: &str) -> Result<Value, String> {
|
||||||
|
let url = format!("{}{}", self.api_base, path);
|
||||||
|
let send = |token: &Option<String>| {
|
||||||
|
let mut req = self.client.get(&url);
|
||||||
|
if let Some(t) = token {
|
||||||
|
req = req.bearer_auth(t);
|
||||||
|
}
|
||||||
|
req.send()
|
||||||
|
};
|
||||||
|
let token = self.ensure_token()?;
|
||||||
|
let resp = send(&token).map_err(|e| format!("peertube {}: {path}: {e}", self.name))?;
|
||||||
|
let resp = if resp.status().as_u16() == 401 && token.is_some() {
|
||||||
|
*self.tokens.lock().unwrap() = None; // force re-grant
|
||||||
|
let fresh = self.ensure_token()?;
|
||||||
|
send(&fresh).map_err(|e| format!("peertube {}: {path}: {e}", self.name))?
|
||||||
|
} else {
|
||||||
|
resp
|
||||||
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("peertube {}: {path}: HTTP {}", self.name, resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
resp.json().map_err(|e| format!("peertube {}: {path}: parse {e}", self.name))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement list_channels / channel_videos / video_media**
|
||||||
|
|
||||||
|
Add to the same `impl PeerTubeClient`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// List the target's video channels.
|
||||||
|
pub fn list_channels(&self) -> Result<Vec<RemoteChannelInfo>, String> {
|
||||||
|
let path = match &self.target {
|
||||||
|
Target::Instance => "/api/v1/video-channels?start=0&count=100".to_string(),
|
||||||
|
Target::Account(n) => format!("/api/v1/accounts/{n}/video-channels?start=0&count=100"),
|
||||||
|
Target::Channel(h) => {
|
||||||
|
// Single channel — fetch its object directly and map it alone.
|
||||||
|
let v = self.authed_get(&format!("/api/v1/video-channels/{h}"))?;
|
||||||
|
return Ok(map_channel(&v, &self.api_base).into_iter().collect());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let v = self.authed_get(&path)?;
|
||||||
|
let data = v.get("data").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||||
|
Ok(data.iter().filter_map(|c| map_channel(c, &self.api_base)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch one page (24) of a channel's videos, newest first.
|
||||||
|
pub fn channel_videos(&self, handle: &str, page: usize) -> Result<Vec<RemoteVideo>, String> {
|
||||||
|
let start = page * 24;
|
||||||
|
let path = format!(
|
||||||
|
"/api/v1/video-channels/{handle}/videos?start={start}&count=24&sort=-publishedAt"
|
||||||
|
);
|
||||||
|
let v = self.authed_get(&path)?;
|
||||||
|
let data = v.get("data").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||||
|
Ok(data
|
||||||
|
.iter()
|
||||||
|
.map(|vid| {
|
||||||
|
let channel = vid
|
||||||
|
.get("channel")
|
||||||
|
.and_then(|c| c.get("displayName"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(handle)
|
||||||
|
.to_string();
|
||||||
|
map_video(vid, &self.api_base, &channel)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a video's directly-playable MP4 URL (None if HLS-only).
|
||||||
|
pub fn video_media(&self, uuid: &str) -> Result<Option<String>, String> {
|
||||||
|
let v = self.authed_get(&format!("/api/v1/videos/{uuid}"))?;
|
||||||
|
Ok(pick_media(&v))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify it compiles clean**
|
||||||
|
|
||||||
|
Run: `cargo build --release 2>&1 | grep -E "^error|^warning: unused" | head`
|
||||||
|
Expected: no `error` lines. The `dead_code` warnings from Task 2 (`target`, `tokens`, `OAuthTokens`) are now gone (all used).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Full test suite**
|
||||||
|
|
||||||
|
Run: `cargo test --release 2>&1 | grep "test result"`
|
||||||
|
Expected: all `ok`, 0 failed (unit count +8 vs the pre-Task-1 baseline).
|
||||||
|
|
||||||
|
- [ ] **Step 5 (optional): Manual smoke against a public instance**
|
||||||
|
|
||||||
|
If online, in a throwaway `src/main.rs`-style scratch or a `#[ignore]` test, construct a client for `https://framatube.org` and confirm `list_channels()` returns entries and `channel_videos(<handle>, 0)` returns videos with absolutified thumbs. This is bring-up confidence only; not required to land the phase.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SSH_AUTH_SOCK=/tmp/luna-ssh-agent.sock
|
||||||
|
git add src/peertube.rs
|
||||||
|
git commit -m "feat(peertube): OAuth2 + list channels / videos / media over REST
|
||||||
|
|
||||||
|
Completes the phase-1 backend client: password-grant OAuth (public browsing
|
||||||
|
when unset), list_channels, paginated channel_videos, and on-demand video_media
|
||||||
|
(direct MP4, None for HLS-only). Wires the tested pure mapping fns to the API.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Config model (`RemoteKind` + `kind`/`username`, non-breaking) → Task 1. ✓
|
||||||
|
- URL/handle parsing (instance / `/c/` / `/video-channels/` / `/a/` / `/accounts/` / federated `@host`) → Task 2 `parse_target` + test. ✓
|
||||||
|
- OAuth2 (client id/secret → password grant → Bearer → 401 re-grant), anonymous when unset → Task 4 `ensure_token`/`authed_get`. ✓
|
||||||
|
- `list_channels` (instance/account/single-channel) → Task 4. ✓
|
||||||
|
- `channel_videos(handle, page)` paginated, newest-first, mapped → Task 4 + Task 3 `map_video`. ✓
|
||||||
|
- `video_media(uuid)` direct MP4 / None for HLS → Task 4 + Task 3 `pick_media`. ✓
|
||||||
|
- `watch_url(uuid)` → Task 2. ✓
|
||||||
|
- Error handling: every method `Result<_, String>`, no panics → Tasks 2/4. ✓
|
||||||
|
- Testing: parse_target, channel/video/media/token mapping fixtures → Tasks 2/3. ✓
|
||||||
|
- Non-goals honored: no UI, no `RemoteSource` trait, no `RemoteClient` change. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** none — every step has concrete code/commands. Step 5 of Task 4 is explicitly optional bring-up, not a gap.
|
||||||
|
|
||||||
|
**Type consistency:** `RemoteChannelInfo { handle, display_name, video_count: Option<u64>, avatar_url: Option<String> }`, `map_channel(&Value, &str) -> Option<RemoteChannelInfo>`, `map_video(&Value, &str, &str) -> RemoteVideo`, `pick_media(&Value) -> Option<String>`, `parse_tokens(&Value) -> Option<OAuthTokens>`, and the three `pub fn` HTTP methods are named/typed identically where defined (Tasks 2/3) and used (Task 4). `RemoteVideo` fields (`id/title/channel/video_url/thumb_url/duration_secs`) match `src/remote.rs`. ✓
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue