feat(remote): RemoteClientKind enum + kind-aware live remotes list

Both front-ends hold Vec<Arc<RemoteClientKind>> (web behind RwLock). Catacomb
browse unchanged; PeerTube remotes report a phase-3 stopgap when browsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-10 06:56:26 -07:00
parent be3271ab32
commit db0c74ffca
No known key found for this signature in database
4 changed files with 130 additions and 27 deletions

View file

@ -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<crate::autotag::GroupSuggestion>,
// Federation (read-only remote libraries).
remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>>,
// Federation (read-only remote libraries — catacomb + PeerTube).
remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>>,
/// Currently-selected peer index, if the Remotes screen is showing one.
remote_selected: Option<usize>,
/// 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<std::sync::Arc<crate::remote::RemoteClient>> = config.remotes.iter()
.map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r)))
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>> = 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);
}
}

View file

@ -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)

View file

@ -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");
}
}

View file

@ -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<std::sync::Arc<crate::remote::RemoteClient>>,
/// 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<Vec<std::sync::Arc<crate::remote::RemoteClientKind>>>,
}
/// Live state for the background perceptual-dedup job (see
@ -2250,9 +2250,21 @@ async fn get_maintenance_scan(State(state): State<Arc<WebState>>) -> impl IntoRe
async fn get_remotes(State(state): State<Arc<WebState>>) -> 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<Arc<WebState>>,
Path(id): Path<usize>,
) -> 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::<String>(16);
// Build the federation peers from config before `config` is moved in.
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>> = config
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>> = 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