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:
parent
84c5730b6a
commit
b8e496457f
13 changed files with 689 additions and 89 deletions
|
|
@ -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, ×tamp) {
|
||||
Ok(h) => h,
|
||||
match downloader::ytdlp::download(path.clone(), store_path, ×tamp, 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, ×tamp) {
|
||||
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(×tamp)
|
||||
|
|
|
|||
|
|
@ -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("{}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue