diff --git a/src/video.rs b/src/video.rs index d09bd5b4..443b773d 100644 --- a/src/video.rs +++ b/src/video.rs @@ -916,6 +916,31 @@ pub(crate) fn cookies_browser_spec(value: &str) -> Option { Some(value.to_string()) } +/// Shared trailing argv for every yt-dlp spawn: browser cookies, user +/// agent, then the page URL behind `--`. One helper so identity flags +/// can never drift between extraction, parts, HLS and live-resolve +/// spawns (or let a hostile URL parse as a flag). `None` user agent +/// keeps today's extraction behavior (yt-dlp default UA there). +pub(crate) fn ytdlp_identity_args( + cookies_browser: &str, + user_agent: Option<&str>, + page_url: &str, +) -> Vec { + let mut args = Vec::new(); + if let Some(spec) = cookies_browser_spec(cookies_browser) { + args.push(format!("--cookies-from-browser={spec}")); + } + if let Some(ua) = user_agent.map(str::trim).filter(|s| !s.is_empty()) { + args.push("--user-agent".to_string()); + args.push(ua.to_string()); + } + // `--` before the page URL: option parsing ends here, so a hostile + // or malformed URL can never be read as a flag. + args.push("--".to_string()); + args.push(page_url.to_string()); + args +} + /// Shared root for extraction scratch space. pub fn staging_root() -> PathBuf { std::env::temp_dir().join("grab-video") @@ -1054,13 +1079,7 @@ async fn fetch_video_page( "--no-progress".to_string(), "--dump-single-json".to_string(), ]; - if let Some(spec) = cookies_browser_spec(cookies_browser) { - args.push(format!("--cookies-from-browser={spec}")); - } - // `--` before the page URL: option parsing ends here, so a hostile - // or malformed URL can never be read as a flag. - args.push("--".to_string()); - args.push(url.to_string()); + args.extend(ytdlp_identity_args(cookies_browser, None, url)); // Spawned directly (tokio + timeout) rather than through the // crate's executor: same semantics — concurrent pipe drain, // timeout kill, nonzero exit as error — with the failure detail @@ -1887,15 +1906,7 @@ async fn resolve_hls_input_ytdlp( "--no-progress".to_string(), "--dump-single-json".to_string(), ]; - if let Some(spec) = cookies_browser_spec(cookies_browser) { - args.push(format!("--cookies-from-browser={spec}")); - } - if !user_agent.trim().is_empty() { - args.push("--user-agent".to_string()); - args.push(user_agent.trim().to_string()); - } - args.push("--".to_string()); - args.push(url.to_string()); + args.extend(ytdlp_identity_args(cookies_browser, Some(user_agent), url)); let mut cmd = tokio::process::Command::new(youtube_bin); cmd.args(&args); cmd.stdin(std::process::Stdio::null()) @@ -2140,7 +2151,6 @@ struct StreamSel { format_id: String, ext: String, url: String, - headers: HttpHeaders, size: Option, /// Whether the stream carries an audio track (muxed files do). has_audio: bool, @@ -2167,7 +2177,6 @@ impl StreamSel { .url .clone() .ok_or_else(VideoError::unavailable)?, - headers: f.download_info.http_headers.clone(), size: filesize_of(f), has_audio: f .codec_info @@ -3013,15 +3022,11 @@ fn part_download_argv(job: &VideoJob, spec: &str, out: &Path) -> Vec { "--retries".to_string(), job.tries.max(1).to_string(), ]; - if let Some(spec) = cookies_browser_spec(&job.cookies_browser) { - args.push(format!("--cookies-from-browser={spec}")); - } - if !job.user_agent.is_empty() { - args.push("--user-agent".to_string()); - args.push(job.user_agent.clone()); - } - args.push("--".to_string()); - args.push(job.page_url.clone()); + args.extend(ytdlp_identity_args( + &job.cookies_browser, + Some(job.user_agent.as_str()), + &job.page_url, + )); args } @@ -3543,16 +3548,13 @@ async fn run_hls_ytdlp( } else { cmd.arg("--merge-output-format").arg("mp4"); } - if let Some(spec) = cookies_browser_spec(&job.cookies_browser) { - cmd.arg(format!("--cookies-from-browser={spec}")); - } - if !job.user_agent.is_empty() { - cmd.arg("--user-agent").arg(&job.user_agent); + for arg in ytdlp_identity_args( + &job.cookies_browser, + Some(job.user_agent.as_str()), + &job.page_url, + ) { + cmd.arg(arg); } - // `--` before the page URL: option parsing ends here, so a hostile - // or malformed URL can never be read as a flag. - cmd.arg("--"); - cmd.arg(&job.page_url); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); diff --git a/src/video_tests.rs b/src/video_tests.rs index 40a6a4a3..992515cd 100644 --- a/src/video_tests.rs +++ b/src/video_tests.rs @@ -2405,6 +2405,29 @@ fn part_binary_retries_stale_id_with_fallback_spec() { let _ = std::fs::remove_dir_all(&dir); } +// ── shared yt-dlp identity argv ────────────────────────────────────── + +#[test] +fn identity_args_order_and_trim() { + // Cookies, trimmed UA, then `--` + page: identical for every spawn. + let argv = ytdlp_identity_args("none", Some(" Grab/1 "), "https://x.com/u/status/1"); + assert_eq!( + argv, + vec![ + "--user-agent".to_string(), + "Grab/1".to_string(), + "--".to_string(), + "https://x.com/u/status/1".to_string(), + ] + ); + // Blank UA is omitted, never sent empty. + let argv = ytdlp_identity_args("none", None, "https://x.com/u/status/1"); + assert_eq!( + argv, + vec!["--".to_string(), "https://x.com/u/status/1".to_string()] + ); +} + // ── live resolution via yt-dlp dump ────────────────────────────────── /// Canned `--dump-single-json` over an HLS master: two variants, a