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:
parent
be3271ab32
commit
db0c74ffca
4 changed files with 130 additions and 27 deletions
40
src/app.rs
40
src/app.rs
|
|
@ -260,8 +260,8 @@ pub struct App {
|
||||||
/// Auto-tag grouping suggestions, recomputed when the Maintenance screen
|
/// Auto-tag grouping suggestions, recomputed when the Maintenance screen
|
||||||
/// opens and after a group is applied. Empty when there's nothing to suggest.
|
/// opens and after a group is applied. Empty when there's nothing to suggest.
|
||||||
autotag_suggestions: Vec<crate::autotag::GroupSuggestion>,
|
autotag_suggestions: Vec<crate::autotag::GroupSuggestion>,
|
||||||
// Federation (read-only remote libraries).
|
// Federation (read-only remote libraries — catacomb + PeerTube).
|
||||||
remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>>,
|
remotes: Vec<std::sync::Arc<crate::remote::RemoteClientKind>>,
|
||||||
/// Currently-selected peer index, if the Remotes screen is showing one.
|
/// Currently-selected peer index, if the Remotes screen is showing one.
|
||||||
remote_selected: Option<usize>,
|
remote_selected: Option<usize>,
|
||||||
/// Last fetched remote library; `None` until a peer is loaded.
|
/// Last fetched remote library; `None` until a peer is loaded.
|
||||||
|
|
@ -547,8 +547,8 @@ impl App {
|
||||||
downloader.fetch_comments = config.backup.fetch_comments;
|
downloader.fetch_comments = config.backup.fetch_comments;
|
||||||
downloader.dedup_enabled = config.backup.dedup_enabled;
|
downloader.dedup_enabled = config.backup.dedup_enabled;
|
||||||
// Federation peers, built once from config (read-only remote libraries).
|
// Federation peers, built once from config (read-only remote libraries).
|
||||||
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>> = config.remotes.iter()
|
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();
|
.collect();
|
||||||
downloader.convert_defaults = config.convert.clone();
|
downloader.convert_defaults = config.convert.clone();
|
||||||
let config_bind = config.web.bind.clone();
|
let config_bind = config.web.bind.clone();
|
||||||
|
|
@ -2524,15 +2524,27 @@ impl App {
|
||||||
let Some(client) = self.remotes.get(idx).cloned() else { return };
|
let Some(client) = self.remotes.get(idx).cloned() else { return };
|
||||||
self.remote_selected = Some(idx);
|
self.remote_selected = Some(idx);
|
||||||
self.remote_library = None;
|
self.remote_library = None;
|
||||||
self.remote_status = format!("Connecting to {}…", client.name);
|
match client.as_ref() {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
crate::remote::RemoteClientKind::Catacomb(_) => {
|
||||||
self.remote_rx = Some(rx);
|
self.remote_status = format!("Connecting to {}…", client.name());
|
||||||
let repaint_ctx = self.egui_ctx.clone();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
self.remote_rx = Some(rx);
|
||||||
let _ = tx.send(client.library());
|
let repaint_ctx = self.egui_ctx.clone();
|
||||||
// Wake the egui loop so update() drains the result promptly.
|
std::thread::spawn(move || {
|
||||||
repaint_ctx.request_repaint();
|
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.
|
/// Launch the configured player on a remote (absolute, tokenized) URL.
|
||||||
|
|
@ -2567,7 +2579,7 @@ impl App {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
for (i, r) in self.remotes.iter().enumerate() {
|
for (i, r) in self.remotes.iter().enumerate() {
|
||||||
let sel = self.remote_selected == Some(i);
|
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);
|
select_remote = Some(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
/// Canonical watch URL for a video, handed to the downloader (phase 3).
|
||||||
pub fn watch_url(&self, uuid: &str) -> String {
|
pub fn watch_url(&self, uuid: &str) -> String {
|
||||||
format!("{}/w/{}", self.api_base, uuid)
|
format!("{}/w/{}", self.api_base, uuid)
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,42 @@ impl RemoteClient {
|
||||||
let v = self.library_json()?;
|
let v = self.library_json()?;
|
||||||
Ok(parse_library(&v))
|
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
|
/// 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.len(), 2);
|
||||||
assert_eq!(lib.channels[0].videos[1].id, "b");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
src/web.rs
51
src/web.rs
|
|
@ -148,10 +148,10 @@ pub struct WebState {
|
||||||
/// GET access to `/feed*` and the media mounts even when a password is
|
/// GET access to `/feed*` and the media mounts even when a password is
|
||||||
/// set. Persisted in the `feed_token` setting; stable until regenerated.
|
/// set. Persisted in the `feed_token` setting; stable until regenerated.
|
||||||
pub feed_token: String,
|
pub feed_token: String,
|
||||||
/// Federation peers (read-only remote libraries), built once from
|
/// Federation peers (catacomb + PeerTube). Rebuilt wholesale by the editor's
|
||||||
/// `config.remotes` at startup. Indexed by position for the `/api/remotes`
|
/// `PUT /api/remotes` (behind the `RwLock` for live-apply). Indexed by
|
||||||
/// endpoints. Empty when no `[[remote]]` is configured.
|
/// position for the `/api/remotes` endpoints.
|
||||||
pub remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>>,
|
pub remotes: std::sync::RwLock<Vec<std::sync::Arc<crate::remote::RemoteClientKind>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live state for the background perceptual-dedup job (see
|
/// 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 {
|
async fn get_remotes(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
let list: Vec<_> = state
|
let list: Vec<_> = state
|
||||||
.remotes
|
.remotes
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.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();
|
.collect();
|
||||||
Json(list)
|
Json(list)
|
||||||
}
|
}
|
||||||
|
|
@ -2265,13 +2277,26 @@ async fn get_remote_library(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Path(id): Path<usize>,
|
Path(id): Path<usize>,
|
||||||
) -> Response {
|
) -> 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();
|
return (StatusCode::NOT_FOUND, "no such remote").into_response();
|
||||||
};
|
};
|
||||||
match tokio::task::spawn_blocking(move || remote.library_json()).await {
|
match remote.as_ref() {
|
||||||
Ok(Ok(v)) => Json(v).into_response(),
|
crate::remote::RemoteClientKind::Catacomb(_) => {
|
||||||
Ok(Err(e)) => (StatusCode::BAD_GATEWAY, format!("remote error: {e}")).into_response(),
|
match tokio::task::spawn_blocking(move || match remote.as_ref() {
|
||||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "remote task failed").into_response(),
|
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).
|
// get a Lagged error and just resubscribe).
|
||||||
let (progress_tx, _initial_rx) = tokio::sync::broadcast::channel::<String>(16);
|
let (progress_tx, _initial_rx) = tokio::sync::broadcast::channel::<String>(16);
|
||||||
// Build the federation peers from config before `config` is moved in.
|
// 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
|
.remotes
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let state = Arc::new(WebState {
|
let state = Arc::new(WebState {
|
||||||
library: Mutex::new(library),
|
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),
|
library_body_cache: Mutex::new(None),
|
||||||
dedup: std::sync::Arc::new(DedupState::default()),
|
dedup: std::sync::Arc::new(DedupState::default()),
|
||||||
feed_token,
|
feed_token,
|
||||||
remotes,
|
remotes: std::sync::RwLock::new(remotes),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue