From db0c74ffca2531bee98bfd38bbd77d4da6a36e0b Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 06:56:26 -0700 Subject: [PATCH] feat(remote): RemoteClientKind enum + kind-aware live remotes list Both front-ends hold Vec> (web behind RwLock). Catacomb browse unchanged; PeerTube remotes report a phase-3 stopgap when browsed. Co-Authored-By: Claude Opus 4.8 --- src/app.rs | 40 ++++++++++++++++++++++------------- src/peertube.rs | 11 ++++++++++ src/remote.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ src/web.rs | 51 +++++++++++++++++++++++++++++++++------------ 4 files changed, 130 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index 709b78f..7c947cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -260,8 +260,8 @@ pub struct App { /// Auto-tag grouping suggestions, recomputed when the Maintenance screen /// opens and after a group is applied. Empty when there's nothing to suggest. autotag_suggestions: Vec, - // Federation (read-only remote libraries). - remotes: Vec>, + // Federation (read-only remote libraries — catacomb + PeerTube). + remotes: Vec>, /// Currently-selected peer index, if the Remotes screen is showing one. remote_selected: Option, /// Last fetched remote library; `None` until a peer is loaded. @@ -547,8 +547,8 @@ impl App { downloader.fetch_comments = config.backup.fetch_comments; downloader.dedup_enabled = config.backup.dedup_enabled; // Federation peers, built once from config (read-only remote libraries). - let remotes: Vec> = config.remotes.iter() - .map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r))) + let remotes: Vec> = config.remotes.iter() + .map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r))) .collect(); downloader.convert_defaults = config.convert.clone(); let config_bind = config.web.bind.clone(); @@ -2524,15 +2524,27 @@ impl App { let Some(client) = self.remotes.get(idx).cloned() else { return }; self.remote_selected = Some(idx); self.remote_library = None; - self.remote_status = format!("Connecting to {}…", client.name); - let (tx, rx) = std::sync::mpsc::channel(); - self.remote_rx = Some(rx); - let repaint_ctx = self.egui_ctx.clone(); - std::thread::spawn(move || { - let _ = tx.send(client.library()); - // Wake the egui loop so update() drains the result promptly. - repaint_ctx.request_repaint(); - }); + match client.as_ref() { + crate::remote::RemoteClientKind::Catacomb(_) => { + self.remote_status = format!("Connecting to {}…", client.name()); + let (tx, rx) = std::sync::mpsc::channel(); + self.remote_rx = Some(rx); + let repaint_ctx = self.egui_ctx.clone(); + std::thread::spawn(move || { + let res = match client.as_ref() { + crate::remote::RemoteClientKind::Catacomb(c) => c.library(), + _ => unreachable!(), + }; + let _ = tx.send(res); + // Wake the egui loop so update() drains the result promptly. + repaint_ctx.request_repaint(); + }); + } + crate::remote::RemoteClientKind::Peertube(_) => { + self.remote_status = + "PeerTube browsing arrives in a later update".to_string(); + } + } } /// Launch the configured player on a remote (absolute, tokenized) URL. @@ -2567,7 +2579,7 @@ impl App { ui.horizontal_wrapped(|ui| { for (i, r) in self.remotes.iter().enumerate() { let sel = self.remote_selected == Some(i); - if ui.selectable_label(sel, format!("🌐 {}", r.name)).clicked() { + if ui.selectable_label(sel, format!("🌐 {}", r.name())).clicked() { select_remote = Some(i); } } diff --git a/src/peertube.rs b/src/peertube.rs index 96c2f36..2adeb74 100644 --- a/src/peertube.rs +++ b/src/peertube.rs @@ -96,6 +96,17 @@ impl PeerTubeClient { } } + /// API base (`scheme://host`), for the editor's remote listing. + pub fn base_url(&self) -> &str { + &self.api_base + } + + /// Whether OAuth credentials are configured (editor `has_password` + /// indicator; never echoes the secret). + pub fn has_password(&self) -> bool { + self.password.is_some() + } + /// 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) diff --git a/src/remote.rs b/src/remote.rs index 58f2321..57eb9ac 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -151,6 +151,42 @@ impl RemoteClient { let v = self.library_json()?; Ok(parse_library(&v)) } + + /// Whether a password is configured (used by the editor's `has_password` + /// indicator; never echoes the secret itself). + pub fn has_password(&self) -> bool { + self.password.is_some() + } +} + +/// A live federation client of either kind. Held by both front-ends' remotes +/// lists so catacomb peers and PeerTube targets coexist. +pub enum RemoteClientKind { + Catacomb(RemoteClient), + Peertube(crate::peertube::PeerTubeClient), +} + +impl RemoteClientKind { + pub fn from_section(cfg: &crate::config::RemoteSection) -> Self { + match cfg.kind { + crate::config::RemoteKind::Catacomb => Self::Catacomb(RemoteClient::new(cfg)), + crate::config::RemoteKind::Peertube => { + Self::Peertube(crate::peertube::PeerTubeClient::new(cfg)) + } + } + } + pub fn name(&self) -> &str { + match self { + Self::Catacomb(c) => &c.name, + Self::Peertube(p) => &p.name, + } + } + pub fn kind(&self) -> crate::config::RemoteKind { + match self { + Self::Catacomb(_) => crate::config::RemoteKind::Catacomb, + Self::Peertube(_) => crate::config::RemoteKind::Peertube, + } + } } /// Whether a relative URL points at peer media the read-only feed token grants @@ -274,4 +310,23 @@ mod tests { assert_eq!(lib.channels[0].videos.len(), 2); assert_eq!(lib.channels[0].videos[1].id, "b"); } + + #[test] + fn client_kind_from_section_dispatches() { + use crate::config::{RemoteKind, RemoteSection}; + let cat = RemoteSection { + name: "c".into(), url: "http://p:8081".into(), + kind: RemoteKind::Catacomb, username: None, password: None, + }; + let pt = RemoteSection { + name: "p".into(), url: "https://framatube.org".into(), + kind: RemoteKind::Peertube, username: None, password: None, + }; + let a = RemoteClientKind::from_section(&cat); + let b = RemoteClientKind::from_section(&pt); + assert_eq!(a.kind(), RemoteKind::Catacomb); + assert_eq!(a.name(), "c"); + assert_eq!(b.kind(), RemoteKind::Peertube); + assert_eq!(b.name(), "p"); + } } diff --git a/src/web.rs b/src/web.rs index d674a85..caaf4ad 100644 --- a/src/web.rs +++ b/src/web.rs @@ -148,10 +148,10 @@ pub struct WebState { /// GET access to `/feed*` and the media mounts even when a password is /// set. Persisted in the `feed_token` setting; stable until regenerated. pub feed_token: String, - /// Federation peers (read-only remote libraries), built once from - /// `config.remotes` at startup. Indexed by position for the `/api/remotes` - /// endpoints. Empty when no `[[remote]]` is configured. - pub remotes: Vec>, + /// Federation peers (catacomb + PeerTube). Rebuilt wholesale by the editor's + /// `PUT /api/remotes` (behind the `RwLock` for live-apply). Indexed by + /// position for the `/api/remotes` endpoints. + pub remotes: std::sync::RwLock>>, } /// Live state for the background perceptual-dedup job (see @@ -2250,9 +2250,21 @@ async fn get_maintenance_scan(State(state): State>) -> impl IntoRe async fn get_remotes(State(state): State>) -> impl IntoResponse { let list: Vec<_> = state .remotes + .read() + .unwrap() .iter() .enumerate() - .map(|(i, r)| serde_json::json!({ "id": i, "name": r.name, "url": r.base_url() })) + .map(|(i, r)| { + let (url, has_password) = match r.as_ref() { + crate::remote::RemoteClientKind::Catacomb(c) => (c.base_url().to_string(), c.has_password()), + crate::remote::RemoteClientKind::Peertube(p) => (p.base_url().to_string(), p.has_password()), + }; + let kind = match r.kind() { + crate::config::RemoteKind::Catacomb => "catacomb", + crate::config::RemoteKind::Peertube => "peertube", + }; + serde_json::json!({ "id": i, "name": r.name(), "url": url, "kind": kind, "has_password": has_password }) + }) .collect(); Json(list) } @@ -2265,13 +2277,26 @@ async fn get_remote_library( State(state): State>, Path(id): Path, ) -> Response { - let Some(remote) = state.remotes.get(id).cloned() else { + let remote = state.remotes.read().unwrap().get(id).cloned(); + let Some(remote) = remote else { return (StatusCode::NOT_FOUND, "no such remote").into_response(); }; - match tokio::task::spawn_blocking(move || remote.library_json()).await { - Ok(Ok(v)) => Json(v).into_response(), - Ok(Err(e)) => (StatusCode::BAD_GATEWAY, format!("remote error: {e}")).into_response(), - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "remote task failed").into_response(), + match remote.as_ref() { + crate::remote::RemoteClientKind::Catacomb(_) => { + match tokio::task::spawn_blocking(move || match remote.as_ref() { + crate::remote::RemoteClientKind::Catacomb(c) => c.library_json(), + _ => unreachable!(), + }) + .await + { + Ok(Ok(v)) => Json(v).into_response(), + Ok(Err(e)) => (StatusCode::BAD_GATEWAY, format!("remote error: {e}")).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "remote task failed").into_response(), + } + } + crate::remote::RemoteClientKind::Peertube(_) => { + (StatusCode::NOT_IMPLEMENTED, "PeerTube browsing arrives in a later update").into_response() + } } } @@ -3211,10 +3236,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { // get a Lagged error and just resubscribe). let (progress_tx, _initial_rx) = tokio::sync::broadcast::channel::(16); // Build the federation peers from config before `config` is moved in. - let remotes: Vec> = config + let remotes: Vec> = config .remotes .iter() - .map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r))) + .map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r))) .collect(); let state = Arc::new(WebState { library: Mutex::new(library), @@ -3237,7 +3262,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { library_body_cache: Mutex::new(None), dedup: std::sync::Arc::new(DedupState::default()), feed_token, - remotes, + remotes: std::sync::RwLock::new(remotes), }); // Broadcast progress snapshots to WebSocket subscribers. Ticks fast