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

@ -331,6 +331,26 @@ fn determine_source(path: &str) -> Source {
Source::Other
}
/// Resolves `locator` to the canonical URL that yt-dlp should receive, or
/// returns `None` if the locator does not map to a yt-dlp-downloadable source
/// (e.g. tweet/thread shorthands, web pages, local files, playlists/channels).
///
/// Use this to gate the probe endpoint: only call `fetch_metadata` when this
/// returns `Some`.
pub fn locator_to_ytdlp_url(locator: &str) -> Option<String> {
let source = determine_source(locator);
match source {
Source::YouTubeVideo
| Source::X
| Source::Instagram
| Source::Facebook
| Source::TikTok
| Source::Reddit
| Source::Snapchat => Some(expand_shorthand_to_url(locator, &source)),
_ => None,
}
}
fn hash_exists(hash: &str, file_extension: &str, store_path: &Path) -> Result<bool> {
let path = store_path.join(raw_relative_path_from_hash(hash, file_extension)?);
Ok(path.exists())
@ -427,20 +447,6 @@ fn local_file_extension(path: &str) -> String {
.map_or(String::new(), |ext| format!(".{}", ext.to_string_lossy()))
}
fn media_file_extension(source: Source, path: &str) -> String {
match source {
Source::YouTubeVideo
| Source::X
| Source::Instagram
| Source::Facebook
| Source::TikTok
| Source::Reddit
| Source::Snapchat => ".mp4".to_string(),
Source::Local => local_file_extension(path),
_ => String::new(),
}
}
fn tweet_id_from_archive_path(path: &str) -> Option<String> {
path.split(':').next_back().and_then(parse_tweet_id)
}
@ -677,6 +683,7 @@ pub fn perform_capture(
archive_paths: &ArchivePaths,
locator: &str,
archive_id: Option<&str>,
quality: Option<&str>,
) -> Result<CaptureResult> {
// Append a UUID so parallel captures starting in the same millisecond
// never collide on the staging directory or file names.
@ -1015,7 +1022,7 @@ pub fn perform_capture(
_ => None,
};
let hash = match source {
let (hash, file_extension) = match source {
Source::YouTubeVideo
| Source::X
| Source::Instagram
@ -1023,8 +1030,8 @@ pub fn perform_capture(
| Source::TikTok
| Source::Reddit
| Source::Snapchat => {
match downloader::ytdlp::download(path.clone(), store_path, &timestamp) {
Ok(h) => h,
match downloader::ytdlp::download(path.clone(), store_path, &timestamp, quality) {
Ok(result) => result,
Err(e) => {
return Err(fail_run(
&conn,
@ -1037,7 +1044,7 @@ pub fn perform_capture(
}
Source::Local => {
match downloader::local::save(path.clone(), store_path, &timestamp) {
Ok(h) => h,
Ok(h) => (h, local_file_extension(&path)),
Err(e) => {
return Err(fail_run(
&conn,
@ -1058,8 +1065,6 @@ pub fn perform_capture(
}
_ => unreachable!(),
};
let file_extension = media_file_extension(source, &path);
let temp_file = store_path
.join("temp")
.join(&timestamp)

View file

@ -1,26 +1,126 @@
use anyhow::{Context, Result, bail};
use std::{env, path::Path, process::Command};
use anyhow::{bail, Context, Result};
use std::{env, path::{Path, PathBuf}, process::Command};
use crate::hash::hash_file;
pub fn download(path: String, store_path: &Path, timestamp: &String) -> Result<String> {
/// Returns the yt-dlp `-f` format selector for `quality`.
///
/// - `"audio"` → prefers native Opus/WebM (most efficient), then native
/// AAC/M4A, then any best-audio fallback — no transcoding, smallest file
/// at equivalent perceptual quality.
/// - `"NNNp"` (e.g. `"1080p"`) → height-capped selector with `/best` fallback
/// - `None` / `"best"` / anything else → highest-quality video+audio
pub fn quality_format(quality: Option<&str>) -> String {
if quality == Some("audio") {
// Opus (WebM) is more efficient than AAC (M4A) at the same perceptual
// quality, so prefer it first. Both are taken natively — no transcode.
return "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best".to_string();
}
if let Some(q) = quality {
if let Some(h) = q.strip_suffix('p').and_then(|n| n.parse::<u32>().ok()) {
return format!("bestvideo[height<={h}]+bestaudio/best[height<={h}]/best");
}
}
"bestvideo+bestaudio/best".to_string()
}
/// Combined result of a yt-dlp metadata probe.
pub struct ProbeResult {
/// Distinct video heights available, sorted highest-first (e.g. `[1080, 720, 480]`).
pub video_heights: Vec<u32>,
/// True when at least one format with a real audio codec exists.
pub has_audio: bool,
}
/// Parses a yt-dlp `--dump-json` response into a `ProbeResult`.
pub fn probe_result(json: &str) -> ProbeResult {
ProbeResult {
video_heights: available_video_heights(json),
has_audio: has_audio_track(json),
}
}
/// Distinct video heights from a yt-dlp `--dump-json` response, sorted highest-first.
/// Audio-only formats (`vcodec == "none"`) and zero-height entries are excluded.
pub fn available_video_heights(json: &str) -> Vec<u32> {
let v: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(_) => return vec![],
};
let Some(formats) = v.get("formats").and_then(|f| f.as_array()) else {
return vec![];
};
let mut heights: Vec<u32> = formats
.iter()
.filter_map(|f| {
let vcodec = f.get("vcodec")?.as_str()?;
if vcodec == "none" {
return None;
}
let h = f.get("height")?.as_u64()?;
if h == 0 { None } else { Some(h as u32) }
})
.collect();
heights.sort_unstable_by(|a, b| b.cmp(a));
heights.dedup();
heights
}
/// Returns true when the yt-dlp `--dump-json` response contains at least one
/// format with a real audio codec (i.e. `acodec != "none"`).
pub fn has_audio_track(json: &str) -> bool {
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
return false;
};
let Some(formats) = v.get("formats").and_then(|f| f.as_array()) else {
return false;
};
formats.iter().any(|f| {
f.get("acodec")
.and_then(|a| a.as_str())
.is_some_and(|a| a != "none")
})
}
/// Downloads `path` via yt-dlp and returns `(hash, file_extension_with_dot)`.
///
/// For video the extension is always `.mp4` (forced via `--merge-output-format`).
/// For audio (`quality == Some("audio")`) `-x` is passed to guarantee audio-only
/// output even when only combined A/V formats exist (yt-dlp strips the video
/// track). `--audio-format` is intentionally omitted so the audio stream is
/// remuxed into its native container without re-encoding — no lossy transcode,
/// no size inflation. The actual output extension is discovered by globbing.
pub fn download(
path: String,
store_path: &Path,
timestamp: &String,
quality: Option<&str>,
) -> Result<(String, String)> {
println!("Downloading with yt-dlp: {path}");
let ytdlp = env::var("ARCHIVR_YT_DLP").unwrap_or_else(|_| "yt-dlp".to_string());
let is_audio = quality == Some("audio");
let temp_dir = store_path.join("temp").join(timestamp);
std::fs::create_dir_all(&temp_dir)?;
let out_file = temp_dir.join(format!("{timestamp}.mp4"));
// %(ext)s lets yt-dlp write the correct extension for the chosen format.
let out_template = temp_dir.join(format!("{timestamp}.%(ext)s"));
let out = Command::new(&ytdlp)
.arg(&path)
.arg("-f")
.arg("bestvideo+bestaudio/best")
.arg("--merge-output-format")
.arg("mp4")
let mut cmd = Command::new(&ytdlp);
cmd.arg(&path).arg("-f").arg(quality_format(quality));
if is_audio {
// -x guarantees audio-only even when /best falls back to a combined
// A/V format. No --audio-format → native remux only, no re-encode.
cmd.arg("-x");
} else {
// Force the video container to mp4 so we always have a known extension.
cmd.arg("--merge-output-format").arg("mp4");
}
let out = cmd
.arg("-o")
.arg(&out_file)
.arg(&out_template)
.output()
.with_context(|| format!("failed to spawn {ytdlp} process"))?;
@ -29,7 +129,31 @@ pub fn download(path: String, store_path: &Path, timestamp: &String) -> Result<S
bail!("yt-dlp failed: {stderr}");
}
hash_file(&out_file)
let actual_file = find_downloaded_file(&temp_dir, timestamp)?;
let ext = actual_file
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let hash = hash_file(&actual_file)?;
Ok((hash, ext))
}
/// Finds the file yt-dlp wrote to `temp_dir` whose stem is `timestamp`.
/// Ignores `.part` files (incomplete downloads).
fn find_downloaded_file(temp_dir: &Path, timestamp: &str) -> Result<PathBuf> {
let entries = std::fs::read_dir(temp_dir)
.with_context(|| format!("failed to read temp dir {}", temp_dir.display()))?;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(timestamp) && !name_str.ends_with(".part") {
return Ok(entry.path());
}
}
bail!(
"yt-dlp output file not found in {}",
temp_dir.display()
)
}
/// Fetches metadata JSON for `path` via `yt-dlp --dump-json`.
@ -48,10 +172,90 @@ pub fn fetch_metadata(path: &str) -> Option<String> {
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
eprintln!("yt-dlp --dump-json failed for {path} (status {:?}): {stderr}", out.status);
eprintln!(
"yt-dlp --dump-json failed for {path} (status {:?}): {stderr}",
out.status
);
return None;
}
let json = String::from_utf8(out.stdout).ok()?;
if json.trim().is_empty() { None } else { Some(json) }
}
#[cfg(test)]
mod tests {
use super::{available_video_heights, has_audio_track, quality_format};
#[test]
fn quality_format_audio() {
assert_eq!(quality_format(Some("audio")), "bestaudio[ext=webm]/bestaudio[ext=m4a]/bestaudio/best");
}
#[test]
fn quality_format_known_heights() {
assert_eq!(
quality_format(Some("1080p")),
"bestvideo[height<=1080]+bestaudio/best[height<=1080]/best"
);
assert_eq!(
quality_format(Some("720p")),
"bestvideo[height<=720]+bestaudio/best[height<=720]/best"
);
assert_eq!(
quality_format(Some("2160p")),
"bestvideo[height<=2160]+bestaudio/best[height<=2160]/best"
);
}
#[test]
fn quality_format_defaults_to_best() {
assert_eq!(quality_format(None), "bestvideo+bestaudio/best");
assert_eq!(quality_format(Some("best")), "bestvideo+bestaudio/best");
assert_eq!(quality_format(Some("bogus")), "bestvideo+bestaudio/best");
}
#[test]
fn available_video_heights_parses_formats() {
let json = r#"{
"formats": [
{"height": 1080, "vcodec": "avc1.640028", "acodec": "none"},
{"height": 720, "vcodec": "avc1.4d401f", "acodec": "none"},
{"height": 1080, "vcodec": "avc1.640028", "acodec": "mp4a.40.2"},
{"height": null, "vcodec": "none", "acodec": "mp4a.40.2"},
{"height": 360, "vcodec": "none", "acodec": "mp4a.40.2"}
]
}"#;
assert_eq!(available_video_heights(json), vec![1080, 720]);
}
#[test]
fn available_video_heights_empty_on_audio_only() {
let json = r#"{"formats": [{"height": null, "vcodec": "none", "acodec": "mp4a.40.2"}]}"#;
assert_eq!(available_video_heights(json), vec![0u32; 0]);
}
#[test]
fn available_video_heights_empty_on_bad_json() {
assert_eq!(available_video_heights("not json"), vec![0u32; 0]);
assert_eq!(available_video_heights("{}"), vec![0u32; 0]);
}
#[test]
fn has_audio_track_detects_audio() {
let with_audio = r#"{"formats": [
{"vcodec": "avc1", "acodec": "mp4a.40.2"},
{"vcodec": "none", "acodec": "mp4a.40.2"}
]}"#;
assert!(has_audio_track(with_audio));
let video_only = r#"{"formats": [
{"vcodec": "avc1", "acodec": "none"}
]}"#;
assert!(!has_audio_track(video_only));
assert!(!has_audio_track("not json"));
assert!(!has_audio_track("{}"));
}
}