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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-14 09:48:51 -07:00
parent 095f1d48b0
commit f102f33870
No known key found for this signature in database
2 changed files with 125 additions and 0 deletions

View file

@ -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<WebState>,
id: usize,
) -> Result<std::sync::Arc<crate::remote::RemoteClientKind>, 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<Arc<WebState>>,
Path(id): Path<usize>,
) -> 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<Arc<WebState>>,
Path((id, handle)): Path<(usize, String)>,
Query(q): Query<PageQuery>,
) -> 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<Arc<WebState>>,
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<Arc<WebState>>,
Path(id): Path<usize>,
Json(body): Json<ArchiveBody>,
) -> 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))

View file

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