1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: add video quality selection for yt-dlp captures (#17)

- ytdlp::download() accepts quality: Option<&str>; quality_format()
  maps best/1080p/720p/480p/360p to yt-dlp -f format strings
- perform_capture() threads quality through to the downloader
- CaptureBody gains optional quality field; capture_handler validates
  it against the allowlist (400 on unknown values) before spawning
- CLI passes None (preserves existing best-quality behaviour)
- Frontend: isVideoSource() mirrors determine_source() exactly —
  shows quality picker only for yt-dlp-backed sources, excludes
  playlist/channel shorthands and tweet/thread paths
- submitCapture(archiveId, locator, quality) sends quality in POST body
- CSS: .capture-quality styles the inline select to fit the capture row
- Tests: quality_format unit tests in ytdlp.rs; two new route tests
  (valid quality accepted, invalid quality rejected with 400)
- Docs: video quality section added under Supported Platforms
This commit is contained in:
TheGeneralist 2026-07-05 20:42:41 +02:00 committed by GitHub
parent 84c5730b6a
commit b8e496457f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 689 additions and 89 deletions

View file

@ -29,7 +29,7 @@ use std::{
};
use parking_lot::Mutex;
use archivr_core::{archive, capture, database};
use archivr_core::{archive, capture, database, downloader};
use axum::{
Json, Router,
extract::{ConnectInfo, Path, Query, Request, State},
@ -231,6 +231,7 @@ pub fn app_with_state(state: AppState) -> Router {
)
.route("/api/archives/:archive_id/runs", get(list_runs))
.route("/api/archives/:archive_id/captures", post(capture_handler))
.route("/api/archives/:archive_id/captures/probe", get(probe_handler))
.route(
"/api/archives/:archive_id/capture_jobs/:job_uid",
get(get_capture_job_handler),
@ -637,6 +638,14 @@ async fn delete_entry_handler(
#[derive(Debug, serde::Deserialize)]
struct CaptureBody {
locator: String,
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
quality: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ProbeQuery {
locator: String,
}
#[derive(Debug, serde::Deserialize)]
@ -676,6 +685,18 @@ async fn capture_handler(
if body.locator.trim().is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
}
if let Some(q) = &body.quality {
let valid = q == "best"
|| q == "audio"
|| q.strip_suffix('p')
.and_then(|n| n.parse::<u32>().ok())
.is_some();
if !valid {
return Err(ApiError::bad_request(
"invalid quality: must be \"best\", \"audio\", or a height string like \"1080p\"",
));
}
}
let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
.map_err(ApiError::from)?;
@ -687,6 +708,7 @@ async fn capture_handler(
// Spawn background capture.
let locator = body.locator.trim().to_string();
let quality = body.quality.clone();
let archive_path = mounted.archive_path.clone();
let job_uid_bg = job_uid.clone();
let archive_id_bg = archive_id.clone();
@ -699,7 +721,7 @@ async fn capture_handler(
}
};
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None).ok();
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg)) {
match capture::perform_capture(&archive_paths, &locator, Some(&archive_id_bg), quality.as_deref()) {
Ok(result) => {
database::update_capture_job_status(
&conn,
@ -742,6 +764,64 @@ async fn get_capture_job_handler(
.ok_or_else(|| ApiError::not_found("capture job not found"))
}
/// `GET /api/archives/:id/captures/probe?locator=<url>`
///
/// Runs `yt-dlp --dump-json` (behind `spawn_blocking`) and returns the video
/// heights actually available at the given locator.
///
/// Response shapes:
/// - Locator is not a yt-dlp source (tweet, webpage, local, …):
/// `{ "has_video": false, "qualities": [] }` — 200
/// - yt-dlp ran and found no video tracks (e.g. tweet URL with no media):
/// `{ "has_video": false, "qualities": [] }` — 200
/// - yt-dlp ran and found video tracks:
/// `{ "has_video": true, "qualities": ["1080p", "720p", …] }` — 200
/// - yt-dlp itself failed (non-zero exit, network error, rate-limit, …):
/// 502 — caller should treat this as "probe inconclusive", not "no video"
async fn probe_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
Query(params): Query<ProbeQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
auth_user.require_role(ROLE_USER)?;
let locator = params.locator.trim().to_string();
if locator.is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
}
// Verify the archive exists but don't need the paths for probing.
let _ = mounted_archive(&state, &archive_id)?;
// Resolve to a yt-dlp URL; return empty result immediately for non-video sources.
let Some(ytdlp_url) = capture::locator_to_ytdlp_url(&locator) else {
return Ok(Json(serde_json::json!({ "has_video": false, "has_audio": false, "qualities": [] })));
};
// fetch_metadata shells out and can take several seconds — keep the async runtime free.
// Returns None when yt-dlp exits non-zero (transient error, rate-limit, unsupported
// extractor, etc.). That is distinct from "yt-dlp ran fine but found no video": we
// return 502 so the frontend treats it as inconclusive rather than showing
// "No video detected" for a URL that may well be downloadable.
let maybe_result = tokio::task::spawn_blocking(move || {
downloader::ytdlp::fetch_metadata(&ytdlp_url)
.map(|json| downloader::ytdlp::probe_result(&json))
})
.await
.map_err(|_| ApiError::internal("probe task panicked"))?;
let result = maybe_result.ok_or_else(|| ApiError {
status: StatusCode::BAD_GATEWAY,
message: "yt-dlp metadata fetch failed".to_string(),
})?;
let qualities: Vec<String> = result.video_heights.iter().map(|h| format!("{h}p")).collect();
Ok(Json(serde_json::json!({
"has_video": !qualities.is_empty(),
"qualities": qualities,
"has_audio": result.has_audio,
})))
}
async fn auth_setup_status(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
@ -2206,6 +2286,93 @@ mod tests {
assert_eq!(json["status"], "pending");
}
#[tokio::test]
async fn capture_with_valid_quality_is_accepted() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/captures")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"locator":"local:/nonexistent","quality":"720p"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["job_uid"].as_str().is_some(), "response must have job_uid");
assert_eq!(json["status"], "pending");
}
#[tokio::test]
async fn capture_with_invalid_quality_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/archives/test/captures")
.header("content-type", "application/json")
.header("cookie", &session_cookie)
.body(Body::from(r#"{"locator":"local:/nonexistent","quality":"4K"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().is_some_and(|e| e.contains("invalid quality")));
}
#[tokio::test]
async fn probe_requires_auth() {
let (test_app, _dir) = make_test_app();
let response = test_app
.oneshot(
Request::builder()
.uri("/api/archives/test/captures/probe?locator=local%3A%2Fnonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn probe_non_video_locator_returns_has_video_false() {
// local:/nonexistent is not a yt-dlp source — the handler returns
// immediately without spawning yt-dlp, so this is fast and deterministic.
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let session_cookie = make_test_session(&auth_path);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/archives/test/captures/probe?locator=local%3A%2Fnonexistent")
.header("cookie", &session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["has_video"], false);
assert_eq!(json["qualities"], serde_json::json!([]));
assert_eq!(json["has_audio"], false);
}
#[tokio::test]
async fn admin_users_requires_admin_role() {
let (test_app, _dir) = make_test_app();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-CTwpNgei.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZfMx3eM.css">
<script type="module" crossorigin src="/assets/index-BHg5-TAr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css">
</head>
<body>
<div id="root"></div>