From f102f338706d8b3f96d28b95d725da17130c9204 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 14 Jul 2026 09:48:51 -0700 Subject: [PATCH] feat(web): PeerTube browse + archive endpoints (kind-guarded) GET channels / channel videos (paged) / video media (204 = HLS-only); POST archive routes watch_url through the shared downloader (lands in Other). Co-Authored-By: Claude Opus 4.8 --- src/web.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/api.rs | 30 +++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/web.rs b/src/web.rs index fdb2a50..c8e8d5c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2412,6 +2412,97 @@ async fn get_remote_library( } } +#[derive(Deserialize)] +struct PageQuery { + #[serde(default)] + page: usize, +} + +#[derive(Deserialize)] +struct ArchiveBody { + uuid: String, +} + +/// Guard: fetch the remote and require it be a PeerTube kind. Returns the +/// cloned Arc on success, or the ready-made error Response. +fn require_peertube( + state: &Arc, + id: usize, +) -> Result, Response> { + let remote = state.remotes.read().unwrap().get(id).cloned(); + let Some(remote) = remote else { + return Err((StatusCode::NOT_FOUND, "no such remote").into_response()); + }; + if remote.kind() != crate::config::RemoteKind::Peertube { + return Err((StatusCode::BAD_REQUEST, "not a PeerTube remote").into_response()); + } + Ok(remote) +} + +/// `GET /api/remotes/:id/channels` — a PeerTube peer's channel list. +async fn get_remote_channels( + State(state): State>, + Path(id): Path, +) -> Response { + let remote = match require_peertube(&state, id) { Ok(r) => r, Err(e) => return e }; + match tokio::task::spawn_blocking(move || remote.pt_channels()).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/remotes/:id/channels/:handle/videos?page=N` — one page (24) of a +/// channel's videos. `:handle` may be `name@host` (axum percent-decodes it). +async fn get_remote_channel_videos( + State(state): State>, + Path((id, handle)): Path<(usize, String)>, + Query(q): Query, +) -> Response { + let remote = match require_peertube(&state, id) { Ok(r) => r, Err(e) => return e }; + let page = q.page; + match tokio::task::spawn_blocking(move || remote.pt_channel_videos(&handle, page)).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/remotes/:id/videos/:uuid/media` — resolve a directly-playable MP4. +/// `204 No Content` when the video is HLS-only. +async fn get_remote_video_media( + State(state): State>, + Path((id, uuid)): Path<(usize, String)>, +) -> Response { + let remote = match require_peertube(&state, id) { Ok(r) => r, Err(e) => return e }; + match tokio::task::spawn_blocking(move || remote.pt_video_media(&uuid)).await { + Ok(Ok(Some(url))) => Json(serde_json::json!({ "url": url })).into_response(), + Ok(Ok(None)) => StatusCode::NO_CONTENT.into_response(), + Ok(Err(e)) => (StatusCode::BAD_GATEWAY, format!("remote error: {e}")).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "remote task failed").into_response(), + } +} + +/// `POST /api/remotes/:id/archive` `{uuid}` — queue a PeerTube video into the +/// local library via the shared downloader (lands in `Other`). +async fn post_remote_archive( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Response { + let remote = match require_peertube(&state, id) { Ok(r) => r, Err(e) => return e }; + let watch_url = match remote.pt_watch_url(&body.uuid) { + Ok(u) => u, + Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(), + }; + let info = classify_url(&watch_url); + { + let mut dl = state.downloader.lock_recover(); + dl.start(watch_url, &info, false, DownloadQuality::Best, false, None); + } + (StatusCode::ACCEPTED, "ok").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. @@ -3501,6 +3592,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/remotes", get(get_remotes).put(put_remotes)) .route("/api/remotes/test", post(test_remote)) .route("/api/remotes/:id/library", get(get_remote_library)) + .route("/api/remotes/:id/channels", get(get_remote_channels)) + .route("/api/remotes/:id/channels/:handle/videos", get(get_remote_channel_videos)) + .route("/api/remotes/:id/videos/:uuid/media", get(get_remote_video_media)) + .route("/api/remotes/:id/archive", post(post_remote_archive)) .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)) diff --git a/tests/api.rs b/tests/api.rs index 5d12514..1c34685 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -561,3 +561,33 @@ fn remotes_editor_put_get_roundtrip() { // The blank-password edit of peerA kept its stored secret. assert!(list.contains("\"has_password\":true"), "peerA password preserved on blank edit: {list}"); } + +#[test] +fn peertube_browse_endpoints_kind_guarded() { + if !have_curl() { eprintln!("skip: no curl"); return; } + let s = Server::start(); + + // A peertube remote pointing at a dead port, and a catacomb remote. + let body = r#"[ + {"name":"pt","url":"http://127.0.0.1:59999","kind":"peertube"}, + {"name":"cat","url":"http://127.0.0.1:59998","kind":"catacomb"} + ]"#; + assert_eq!(s.put("/api/remotes", body).0, 200); + + // PeerTube channels endpoint on the peertube remote: route exists, but the + // host is unreachable → 502 (NOT 404-route-missing, NOT 400-wrong-kind). + let (code, _) = s.get("/api/remotes/0/channels"); + assert_eq!(code, 502, "unreachable peertube host → bad gateway"); + + // Same endpoint on the catacomb remote → 400 (kind guard). + let (code, _) = s.get("/api/remotes/1/channels"); + assert_eq!(code, 400, "channels on a catacomb remote is rejected"); + + // Archive on the catacomb remote is also kind-guarded. + let (code, _) = s.post("/api/remotes/1/archive", r#"{"uuid":"abc"}"#); + assert_eq!(code, 400, "archive on a catacomb remote is rejected"); + + // Unknown remote id → 404. + let (code, _) = s.get("/api/remotes/9/channels"); + assert_eq!(code, 404); +}