Federation: read-only browsing of a peer instance's library (3.5)
Configure peers as [[remote]] entries (name, url, optional password) and browse their libraries from this instance, read-only. - remote.rs: RemoteClient (blocking reqwest + rustls + cookie jar). Fetches the peer's /api/library, logging in first if the peer has a password and retrying on a 401. Media is not proxied: the library JSON's media URLs (/files/, /music-files/, /api/transcode/, /api/sub-vtt/) are rewritten to absolute peer URLs with the peer's read-only feed token appended, so video streams straight from the peer to the browser/mpv while only the small JSON passes through us. - web.rs: auth_middleware now also accepts the feed token for GET /api/transcode/ and /api/sub-vtt/ (read-only media, same class as /files/), so a transcoded peer streams remotely. New GET /api/remotes and /api/remotes/:id/library (the RemoteClient runs under spawn_blocking). - web UI: a 🌐 Remotes sidebar switcher; remote mode is read-only (hides the download bar + per-card mutating actions, keeps Play). - desktop: a 🌐 Remotes screen that fetches a peer's library off-thread and plays via mpv on the tokenized URL. - config.rs: [[remote]] section list; reqwest dep added (rustls/blocking/ cookies, no system OpenSSL). Verified end-to-end against a second local instance, including a transcoded stream returning HTTP 200 via the token. ROADMAP 3.5 + CLAUDE.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d797f2a698
commit
033572d3bd
10 changed files with 899 additions and 13 deletions
51
src/web.rs
51
src/web.rs
|
|
@ -143,6 +143,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>>,
|
||||
}
|
||||
|
||||
/// Live state for the background perceptual-dedup job (see
|
||||
|
|
@ -1165,7 +1169,14 @@ async fn auth_middleware(
|
|||
// read-only feed token. Scoped to reads of feeds + media only — never
|
||||
// `/api/*` mutations.
|
||||
if req.method().as_str() == "GET"
|
||||
&& (path.starts_with("/feed") || path.starts_with("/files/") || path.starts_with("/music-files/"))
|
||||
&& (path.starts_with("/feed")
|
||||
|| path.starts_with("/files/")
|
||||
|| path.starts_with("/music-files/")
|
||||
// Read-only media streams a federated peer needs when transcode is
|
||||
// on (/api/transcode) or for subtitle playback (/api/sub-vtt). Both
|
||||
// are GET-only re-reads of already-token-readable media.
|
||||
|| path.starts_with("/api/transcode/")
|
||||
|| path.starts_with("/api/sub-vtt/"))
|
||||
&& req.uri().query().is_some_and(|q| query_has_token(q, &state.feed_token))
|
||||
{
|
||||
return next.run(req).await;
|
||||
|
|
@ -2112,6 +2123,35 @@ async fn get_maintenance_scan(State(state): State<Arc<WebState>>) -> impl IntoRe
|
|||
Json(report)
|
||||
}
|
||||
|
||||
/// `GET /api/remotes` — list configured federation peers for the UI switcher.
|
||||
async fn get_remotes(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let list: Vec<_> = state
|
||||
.remotes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| serde_json::json!({ "id": i, "name": r.name, "url": r.base_url() }))
|
||||
.collect();
|
||||
Json(list)
|
||||
}
|
||||
|
||||
/// `GET /api/remotes/:id/library` — proxy a peer's library (read-only). Media
|
||||
/// URLs come back absolute + token-bearing so the browser loads them straight
|
||||
/// from the peer; only this JSON travels through us. The blocking
|
||||
/// [`crate::remote::RemoteClient`] runs on a blocking task off the async pool.
|
||||
async fn get_remote_library(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<usize>,
|
||||
) -> Response {
|
||||
let Some(remote) = state.remotes.get(id).cloned() 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/autotag/suggest` — heuristic folder-grouping suggestions for
|
||||
/// unfiled channels (see [`crate::autotag`]). Pure arithmetic over the
|
||||
/// in-memory library, so it's computed on demand rather than as a job.
|
||||
|
|
@ -3033,6 +3073,12 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
// subscribe and the broadcast is lossy by design (subscribers that lag
|
||||
// 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
|
||||
.remotes
|
||||
.iter()
|
||||
.map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r)))
|
||||
.collect();
|
||||
let state = Arc::new(WebState {
|
||||
library: Mutex::new(library),
|
||||
downloader: Mutex::new(downloader),
|
||||
|
|
@ -3054,6 +3100,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,
|
||||
});
|
||||
|
||||
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
||||
|
|
@ -3169,6 +3216,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.route("/api/maintenance/remove", post(post_maintenance_remove))
|
||||
.route("/api/autotag/suggest", get(get_autotag_suggest))
|
||||
.route("/api/autotag/apply", post(post_autotag_apply))
|
||||
.route("/api/remotes", get(get_remotes))
|
||||
.route("/api/remotes/:id/library", get(get_remote_library))
|
||||
.route("/api/maintenance/dedup/scan", post(post_dedup_scan))
|
||||
.route("/api/maintenance/dedup/status", get(get_dedup_status))
|
||||
.route("/api/maintenance/repair/:id", post(post_maintenance_repair))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue