feat(peertube): OAuth2 + list channels / videos / media over REST
Completes the phase-1 backend client: password-grant OAuth with refresh-token renewal (falling back to a full re-grant), anonymous public browsing when no creds are set, 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. The client is dead-code-allowed until the editor (phase 2) and browse UI (phase 3) construct it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f7f36d9bca
commit
00ae19e666
1 changed files with 155 additions and 0 deletions
155
src/peertube.rs
155
src/peertube.rs
|
|
@ -25,6 +25,7 @@ struct OAuthTokens {
|
|||
|
||||
/// One channel in a PeerTube target's channel list.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[allow(dead_code)] // consumed by the browse UI in phase 3
|
||||
pub struct RemoteChannelInfo {
|
||||
pub handle: String,
|
||||
pub display_name: String,
|
||||
|
|
@ -32,6 +33,7 @@ pub struct RemoteChannelInfo {
|
|||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // constructed + driven by the editor (phase 2) and browse UI (phase 3)
|
||||
pub struct PeerTubeClient {
|
||||
pub name: String,
|
||||
api_base: String,
|
||||
|
|
@ -73,6 +75,7 @@ fn parse_target(url: &str) -> (String, Target) {
|
|||
(api_base, target)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // public API consumed by phases 2-3 (editor + browse UI)
|
||||
impl PeerTubeClient {
|
||||
pub fn new(cfg: &RemoteSection) -> Self {
|
||||
let (api_base, target) = parse_target(&cfg.url);
|
||||
|
|
@ -97,6 +100,158 @@ impl PeerTubeClient {
|
|||
pub fn watch_url(&self, uuid: &str) -> String {
|
||||
format!("{}/w/{}", self.api_base, uuid)
|
||||
}
|
||||
|
||||
/// Fetch the instance's local OAuth client id/secret (needed for every
|
||||
/// token grant).
|
||||
fn oauth_client(&self) -> Result<(String, String), String> {
|
||||
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 id = oc.get("client_id").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let secret = oc.get("client_secret").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
Ok((id, secret))
|
||||
}
|
||||
|
||||
/// POST a token grant (`form` fields already include client id/secret and
|
||||
/// grant-specific params), cache the result, and return the access token.
|
||||
fn token_grant(&self, form: &[(&str, &str)]) -> Result<String, String> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/v1/users/token", self.api_base))
|
||||
.form(form)
|
||||
.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(access)
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
let (id, secret) = self.oauth_client()?;
|
||||
let access = self.token_grant(&[
|
||||
("client_id", &id),
|
||||
("client_secret", &secret),
|
||||
("grant_type", "password"),
|
||||
("username", user.as_str()),
|
||||
("password", pass.as_str()),
|
||||
])?;
|
||||
Ok(Some(access))
|
||||
}
|
||||
|
||||
/// Exchange the cached refresh token for a fresh access token. Returns
|
||||
/// `Ok(None)` when there's no usable refresh token or the grant is rejected,
|
||||
/// so the caller can fall back to a full password re-grant.
|
||||
fn try_refresh(&self) -> Result<Option<String>, String> {
|
||||
let refresh = match self.tokens.lock().unwrap().as_ref() {
|
||||
Some(t) if !t.refresh.is_empty() => t.refresh.clone(),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let (id, secret) = self.oauth_client()?;
|
||||
match self.token_grant(&[
|
||||
("client_id", &id),
|
||||
("client_secret", &secret),
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", &refresh),
|
||||
]) {
|
||||
Ok(access) => Ok(Some(access)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET an API path, adding a Bearer token when credentials are set. On a
|
||||
/// 401, refresh via the refresh token, falling back to a full re-grant.
|
||||
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() {
|
||||
// Try refresh first; on failure clear the cache and re-grant.
|
||||
let next = match self.try_refresh()? {
|
||||
Some(access) => Some(access),
|
||||
None => {
|
||||
*self.tokens.lock().unwrap() = None;
|
||||
self.ensure_token()?
|
||||
}
|
||||
};
|
||||
send(&next).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))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolutify a PeerTube relative path (`/lazy-static/…`) against the API base.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue