use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::anyhow;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::core::log_hook;
use crate::core::traits::DownloadReporter;
use crate::models::media::{DownloadResult, FormatInfo};
type ExtCookiePathFn = Box<dyn Fn() -> PathBuf + Send + Sync>;
type GlobalCookieFileFn = Box<dyn Fn() -> Option<String> + Send + Sync>;
type CookiesFromBrowserFn = Box<dyn Fn() -> String + Send + Sync>;
type ManualCookieHeaderFn = Box<dyn Fn() -> String + Send + Sync>;
type ExtRefererFn = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
type IncludeAutoSubsFn = Box<dyn Fn() -> bool + Send + Sync>;
type TranslateMetadataFn = Box<dyn Fn() -> Option<String> + Send + Sync>;
type SponsorBlockFn = Box<dyn Fn() -> bool + Send + Sync>;
type SplitChaptersFn = Box<dyn Fn() -> bool + Send + Sync>;
static EXT_COOKIE_PATH_FN: OnceLock<ExtCookiePathFn> = OnceLock::new();
static GLOBAL_COOKIE_FILE_FN: OnceLock<GlobalCookieFileFn> = OnceLock::new();
static COOKIES_FROM_BROWSER_FN: OnceLock<CookiesFromBrowserFn> = OnceLock::new();
static MANUAL_COOKIE_HEADER_FN: OnceLock<ManualCookieHeaderFn> = OnceLock::new();
static EXT_REFERER_FN: OnceLock<ExtRefererFn> = OnceLock::new();
static INCLUDE_AUTO_SUBS_FN: OnceLock<IncludeAutoSubsFn> = OnceLock::new();
static TRANSLATE_METADATA_FN: OnceLock<TranslateMetadataFn> = OnceLock::new();
static SPONSORBLOCK_FN: OnceLock<SponsorBlockFn> = OnceLock::new();
static SPLIT_CHAPTERS_FN: OnceLock<SplitChaptersFn> = OnceLock::new();
pub fn set_ext_cookie_path_fn(f: impl Fn() -> PathBuf + Send + Sync + 'static) {
let _ = EXT_COOKIE_PATH_FN.set(Box::new(f));
}
pub fn set_global_cookie_file_fn(f: impl Fn() -> Option<String> + Send + Sync + 'static) {
let _ = GLOBAL_COOKIE_FILE_FN.set(Box::new(f));
}
pub fn set_cookies_from_browser_fn(f: impl Fn() -> String + Send + Sync + 'static) {
let _ = COOKIES_FROM_BROWSER_FN.set(Box::new(f));
}
pub fn set_manual_cookie_header_fn(f: impl Fn() -> String + Send + Sync + 'static) {
let _ = MANUAL_COOKIE_HEADER_FN.set(Box::new(f));
}
pub fn set_ext_referer_fn(f: impl Fn(&str) -> Option<String> + Send + Sync + 'static) {
let _ = EXT_REFERER_FN.set(Box::new(f));
}
pub fn set_include_auto_subs_fn(f: impl Fn() -> bool + Send + Sync + 'static) {
let _ = INCLUDE_AUTO_SUBS_FN.set(Box::new(f));
}
fn include_auto_subs_setting() -> bool {
INCLUDE_AUTO_SUBS_FN.get().map(|f| f()).unwrap_or(false)
}
pub fn set_translate_metadata_fn(f: impl Fn() -> Option<String> + Send + Sync + 'static) {
let _ = TRANSLATE_METADATA_FN.set(Box::new(f));
}
fn translate_metadata_lang() -> Option<String> {
TRANSLATE_METADATA_FN.get().and_then(|f| f())
}
pub fn set_sponsorblock_fn(f: impl Fn() -> bool + Send + Sync + 'static) {
let _ = SPONSORBLOCK_FN.set(Box::new(f));
}
fn sponsorblock_enabled() -> bool {
SPONSORBLOCK_FN.get().map(|f| f()).unwrap_or(false)
}
pub fn set_split_chapters_fn(f: impl Fn() -> bool + Send + Sync + 'static) {
let _ = SPLIT_CHAPTERS_FN.set(Box::new(f));
}
fn split_chapters_enabled() -> bool {
SPLIT_CHAPTERS_FN.get().map(|f| f()).unwrap_or(false)
}
static EXT_UA_MAP: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
fn ext_ua_map() -> &'static Mutex<HashMap<String, String>> {
EXT_UA_MAP.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_ext_user_agent(url: String, ua: String) {
if let Ok(mut map) = ext_ua_map().lock() {
map.insert(url, ua);
}
}
pub fn clear_ext_user_agent(url: &str) {
if let Ok(mut map) = ext_ua_map().lock() {
map.remove(url);
}
}
fn ext_user_agent_for_url(url: &str) -> Option<String> {
ext_ua_map().lock().ok().and_then(|m| m.get(url).cloned())
}
static ETA_BY_DOWNLOAD: OnceLock<Mutex<HashMap<u64, u64>>> = OnceLock::new();
fn eta_map() -> &'static Mutex<HashMap<u64, u64>> {
ETA_BY_DOWNLOAD.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn record_eta(download_id: u64, eta_seconds: u64) {
if let Ok(mut m) = eta_map().lock() {
m.insert(download_id, eta_seconds);
}
}
pub fn get_eta(download_id: u64) -> Option<u64> {
eta_map()
.lock()
.ok()
.and_then(|m| m.get(&download_id).copied())
}
pub fn clear_eta(download_id: u64) {
if let Ok(mut m) = eta_map().lock() {
m.remove(&download_id);
}
}
static EXT_HEADERS_MAP: OnceLock<Mutex<HashMap<String, HashMap<String, String>>>> = OnceLock::new();
fn ext_headers_map() -> &'static Mutex<HashMap<String, HashMap<String, String>>> {
EXT_HEADERS_MAP.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_ext_headers(url: String, headers: HashMap<String, String>) {
if let Ok(mut map) = ext_headers_map().lock() {
map.insert(url, headers);
}
}
pub fn clear_ext_headers(url: &str) {
if let Ok(mut map) = ext_headers_map().lock() {
map.remove(url);
}
}
fn ext_headers_for_url(url: &str) -> Option<HashMap<String, String>> {
ext_headers_map()
.lock()
.ok()
.and_then(|m| m.get(url).cloned())
}
fn ext_referer_for_url(url: &str) -> Option<String> {
EXT_REFERER_FN.get().and_then(|f| f(url))
}
fn cookies_from_browser_setting() -> String {
COOKIES_FROM_BROWSER_FN
.get()
.map(|f| f())
.unwrap_or_default()
}
fn manual_cookie_header_setting() -> Option<String> {
let raw = MANUAL_COOKIE_HEADER_FN
.get()
.map(|f| f())
.unwrap_or_default();
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let parsed = crate::core::cookie_parser::parse_cookie_input(trimmed, "");
if !parsed.cookie_string.trim().is_empty() {
Some(parsed.cookie_string)
} else {
Some(trimmed.to_string())
}
}
pub fn ext_cookie_path() -> PathBuf {
EXT_COOKIE_PATH_FN
.get()
.map(|f| f())
.unwrap_or_else(|| PathBuf::from(""))
}
pub fn ext_cookie_path_if_fresh() -> Option<PathBuf> {
let source = ext_cookie_path();
let metadata = std::fs::metadata(&source).ok()?;
let modified = metadata.modified().ok()?;
if modified.elapsed().unwrap_or_default() >= std::time::Duration::from_secs(604800) {
return None;
}
Some(source)
}
fn global_cookie_file() -> Option<String> {
GLOBAL_COOKIE_FILE_FN.get().and_then(|f| f())
}
static YTDLP_UPDATING: AtomicBool = AtomicBool::new(false);
static YTDLP_UPDATE_CHECKED: AtomicBool = AtomicBool::new(false);
static YTDLP_PATH_CACHE: std::sync::RwLock<Option<Option<PathBuf>>> = std::sync::RwLock::new(None);
static FFMPEG_LOCATION_CACHE: std::sync::RwLock<Option<Option<String>>> =
std::sync::RwLock::new(None);
static JS_RUNTIME_CACHE: std::sync::RwLock<Option<Option<String>>> = std::sync::RwLock::new(None);
static RATE_LIMIT_429_COUNT: AtomicU64 = AtomicU64::new(0);
static RATE_LIMIT_429_LAST_TS: AtomicU64 = AtomicU64::new(0);
static COOKIE_ERROR_FLAG: AtomicBool = AtomicBool::new(false);
pub fn has_cookie_error() -> bool {
COOKIE_ERROR_FLAG.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn clear_cookie_error() {
COOKIE_ERROR_FLAG.store(false, std::sync::atomic::Ordering::Relaxed);
}
fn rate_limit_429_count() -> u64 {
let last = RATE_LIMIT_429_LAST_TS.load(Ordering::Relaxed);
if last == 0 {
return 0;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(last) > 1800 {
RATE_LIMIT_429_COUNT.store(0, Ordering::Relaxed);
RATE_LIMIT_429_LAST_TS.store(0, Ordering::Relaxed);
return 0;
}
RATE_LIMIT_429_COUNT.load(Ordering::Relaxed)
}
fn rate_limit_429_increment() {
RATE_LIMIT_429_COUNT.fetch_add(1, Ordering::Relaxed);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
RATE_LIMIT_429_LAST_TS.store(now, Ordering::Relaxed);
}
pub fn reset_ytdlp_cache() {
if let Ok(mut cache) = YTDLP_PATH_CACHE.write() {
*cache = None;
}
}
pub fn reset_ffmpeg_location_cache() {
if let Ok(mut cache) = FFMPEG_LOCATION_CACHE.write() {
*cache = None;
}
}
pub fn reset_js_runtime_cache() {
if let Ok(mut cache) = JS_RUNTIME_CACHE.write() {
*cache = None;
}
}
pub async fn check_ytdlp_update(ytdlp: &Path) -> anyhow::Result<bool> {
if YTDLP_UPDATE_CHECKED.swap(true, Ordering::Relaxed) {
return Ok(false);
}
let ytdlp = ytdlp.to_path_buf();
let output = tokio::task::spawn_blocking(move || {
crate::core::process::std_command(&ytdlp)
.args(["--update-to", "nightly"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
})
.await??;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr);
if combined.contains("Updated yt-dlp") || combined.contains("Updating to") {
tracing::info!("[ytdlp] updated: {}", combined.trim());
reset_ytdlp_cache();
Ok(true)
} else {
Ok(false)
}
}
fn proxy_args() -> Vec<String> {
match crate::core::http_client::proxy_url() {
Some(url) => vec!["--proxy".to_string(), url],
None => Vec::new(),
}
}
fn has_explicit_cookie_header(args: &[String]) -> bool {
args.windows(2).any(|pair| {
pair[0] == "--add-headers" && pair[1].to_ascii_lowercase().starts_with("cookie:")
})
}
fn append_cookie_header(args: &mut Vec<String>, cookie_header: &str) {
args.push("--add-headers".to_string());
args.push(format!("Cookie:{}", cookie_header));
}
struct YtRateLimiter {
semaphore: tokio::sync::Semaphore,
last_request_ns: AtomicU64,
}
impl YtRateLimiter {
async fn acquire(&self) {
let _permit = self
.semaphore
.acquire()
.await
.unwrap_or_else(|_| panic!("semaphore closed"));
let min_interval_ns = 500_000_000u64;
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let last = self.last_request_ns.load(Ordering::Relaxed);
let elapsed = now_ns.saturating_sub(last);
if elapsed < min_interval_ns {
let wait_ns = min_interval_ns - elapsed;
let wait_duration = std::time::Duration::from_nanos(wait_ns);
tokio::time::sleep(wait_duration).await;
}
self.last_request_ns.store(now_ns, Ordering::Relaxed);
}
}
static YT_RATE_LIMITER: OnceLock<YtRateLimiter> = OnceLock::new();
fn yt_rate_limiter() -> &'static YtRateLimiter {
YT_RATE_LIMITER.get_or_init(|| YtRateLimiter {
semaphore: tokio::sync::Semaphore::new(3),
last_request_ns: AtomicU64::new(0),
})
}
const CHROME_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
pub async fn find_ytdlp() -> Option<PathBuf> {
let _timer_start = std::time::Instant::now();
let bin_name = if cfg!(target_os = "windows") {
"yt-dlp.exe"
} else {
"yt-dlp"
};
#[cfg(target_os = "linux")]
{
let flatpak_path = PathBuf::from("/app/bin").join(bin_name);
if flatpak_path.exists() {
tracing::debug!("[perf] find_ytdlp took {:?}", _timer_start.elapsed());
return Some(flatpak_path);
}
}
let managed = managed_ytdlp_path()?;
if managed.exists() {
tracing::debug!("[perf] find_ytdlp took {:?}", _timer_start.elapsed());
return Some(managed);
}
let bin_name_owned = bin_name.to_string();
let found = tokio::task::spawn_blocking(move || {
crate::core::process::std_command(&bin_name_owned)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()
.filter(|s| s.success())
})
.await
.ok()
.flatten();
if found.is_some() {
let abs = resolve_absolute_path(bin_name);
tracing::debug!("[perf] find_ytdlp took {:?}", _timer_start.elapsed());
return Some(abs);
}
tracing::debug!("[perf] find_ytdlp took {:?}", _timer_start.elapsed());
None
}
fn resolve_absolute_path(bin_name: &str) -> PathBuf {
let finder = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
if let Ok(output) = crate::core::process::std_command(finder)
.arg(bin_name)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
{
if output.status.success() {
if let Some(line) = String::from_utf8_lossy(&output.stdout).lines().next() {
let path = line.trim();
if !path.is_empty() {
return PathBuf::from(path);
}
}
}
}
PathBuf::from(bin_name)
}
pub async fn find_ytdlp_cached() -> Option<PathBuf> {
let _timer_start = std::time::Instant::now();
if let Ok(cache) = YTDLP_PATH_CACHE.read() {
if let Some(ref cached) = *cache {
if let Some(ref path) = cached {
if path.exists() {
tracing::debug!(
"[perf] find_ytdlp_cached (hit): {:?}",
_timer_start.elapsed()
);
return cached.clone();
}
tracing::warn!("[ytdlp] cached path no longer exists: {}", path.display());
} else {
return None;
}
}
}
let result = find_ytdlp().await;
if let Ok(mut cache) = YTDLP_PATH_CACHE.write() {
*cache = Some(result.clone());
}
tracing::debug!(
"[perf] find_ytdlp_cached (miss): {:?}",
_timer_start.elapsed()
);
result
}
fn managed_ytdlp_path() -> Option<PathBuf> {
let data = crate::core::paths::app_data_dir()?;
let bin_name = if cfg!(target_os = "windows") {
"yt-dlp.exe"
} else {
"yt-dlp"
};
Some(data.join("bin").join(bin_name))
}
pub async fn force_update_ytdlp(
reporter: Option<&dyn DownloadReporter>,
) -> anyhow::Result<PathBuf> {
YTDLP_UPDATING.store(true, Ordering::SeqCst);
let result = download_ytdlp_binary(reporter).await;
YTDLP_UPDATING.store(false, Ordering::SeqCst);
if result.is_ok() {
if let Ok(mut cache) = YTDLP_PATH_CACHE.write() {
*cache = Some(result.as_ref().ok().cloned());
}
}
result
}
pub async fn ensure_ytdlp(reporter: Option<&dyn DownloadReporter>) -> anyhow::Result<PathBuf> {
let _timer_start = std::time::Instant::now();
if !crate::core::dependencies::is_flatpak() {
let managed = managed_ytdlp_path();
if managed.as_ref().is_none_or(|p| !p.exists()) {
tracing::info!("[ytdlp] managed binary missing, downloading...");
match download_ytdlp_binary(reporter).await {
Ok(path) => {
reset_ytdlp_cache();
std::thread::Builder::new()
.name("js-runtime-check".into())
.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("js-runtime runtime");
rt.block_on(async {
crate::core::dependencies::ensure_js_runtime(None).await;
});
})
.ok();
tracing::debug!("[perf] ensure_ytdlp took {:?}", _timer_start.elapsed());
return Ok(path);
}
Err(e) => {
tracing::warn!(
"[ytdlp] failed to download managed binary, falling back to system: {}",
e
);
}
}
}
}
if let Some(path) = find_ytdlp_cached().await {
let path_clone = path.clone();
std::thread::Builder::new()
.name("ytdlp-freshness".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("freshness runtime");
rt.block_on(async move {
check_ytdlp_freshness(&path_clone).await;
});
})
.ok();
std::thread::Builder::new()
.name("js-runtime-check".into())
.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("js-runtime runtime");
rt.block_on(async {
crate::core::dependencies::ensure_js_runtime(None).await;
});
})
.ok();
tracing::debug!("[perf] ensure_ytdlp took {:?}", _timer_start.elapsed());
return Ok(path);
}
if crate::core::dependencies::is_flatpak() {
tracing::debug!("[perf] ensure_ytdlp took {:?}", _timer_start.elapsed());
return Err(anyhow!("yt-dlp not found in Flatpak sandbox"));
}
let path = download_ytdlp_binary(reporter).await?;
reset_ytdlp_cache();
std::thread::Builder::new()
.name("js-runtime-check".into())
.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("js-runtime runtime");
rt.block_on(async {
crate::core::dependencies::ensure_js_runtime(None).await;
});
})
.ok();
tracing::debug!("[perf] ensure_ytdlp took {:?}", _timer_start.elapsed());
Ok(path)
}
async fn download_ytdlp_binary(reporter: Option<&dyn DownloadReporter>) -> anyhow::Result<PathBuf> {
let target =
managed_ytdlp_path().ok_or_else(|| anyhow!("Could not determine data directory"))?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let download_url = if cfg!(target_os = "windows") {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
} else if cfg!(target_os = "macos") {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
} else if cfg!(target_arch = "aarch64") {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux_aarch64"
} else {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
};
let bytes = crate::core::http_client::download_with_progress(download_url, |percent| {
if let Some(r) = reporter {
r.on_system_progress("yt-dlp", percent, "Downloading yt-dlp...");
}
})
.await?;
let target_clone = target.clone();
tokio::task::spawn_blocking(move || std::fs::write(&target_clone, &bytes))
.await
.map_err(|e| anyhow!("spawn_blocking failed: {}", e))??;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&target, perms)?;
}
#[cfg(target_os = "macos")]
{
let target_mac = target.clone();
let _ = tokio::task::spawn_blocking(move || {
crate::core::process::std_command("xattr")
.args(["-d", "com.apple.quarantine"])
.arg(&target_mac)
.output()
})
.await;
}
Ok(target)
}
async fn check_ytdlp_freshness(path: &Path) {
if let Some(managed) = managed_ytdlp_path() {
if path != managed.as_path() {
return;
}
} else {
return;
}
if let Ok(meta) = std::fs::metadata(path) {
if let Ok(modified) = meta.modified() {
if let Ok(age) = modified.elapsed() {
if age > std::time::Duration::from_secs(2 * 24 * 60 * 60) {
if YTDLP_UPDATING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
tracing::info!("yt-dlp is older than 2 days, updating in background");
std::thread::Builder::new()
.name("ytdlp-update".into())
.spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("ytdlp-update runtime");
rt.block_on(async {
match download_ytdlp_binary(None).await {
Ok(_) => tracing::info!("yt-dlp updated successfully"),
Err(e) => tracing::warn!("Failed to update yt-dlp: {}", e),
}
YTDLP_UPDATING.store(false, Ordering::SeqCst);
});
})
.ok();
}
}
}
}
}
async fn find_ffmpeg_location() -> Option<String> {
let _timer_start = std::time::Instant::now();
let result = if let Some(path) = crate::core::dependencies::find_tool("ffmpeg").await {
path.parent()
.and_then(|dir| dir.to_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
} else {
None
};
tracing::debug!(
"[perf] find_ffmpeg_location took {:?}",
_timer_start.elapsed()
);
result
}
async fn find_ffmpeg_location_cached() -> Option<String> {
if let Ok(cache) = FFMPEG_LOCATION_CACHE.read() {
if let Some(ref cached) = *cache {
if let Some(ref dir) = cached {
let check_path =
std::path::Path::new(dir).join(crate::core::dependencies::bin_name("ffmpeg"));
if check_path.exists() {
return cached.clone();
}
tracing::warn!("[ffmpeg] cached location no longer valid: {}", dir);
} else {
return None;
}
}
}
let result = find_ffmpeg_location().await;
if let Ok(mut cache) = FFMPEG_LOCATION_CACHE.write() {
*cache = Some(result.clone());
}
result
}
async fn extension_cookie_file() -> Option<std::path::PathBuf> {
let source = ext_cookie_path();
let metadata = std::fs::metadata(&source).ok()?;
let modified = metadata.modified().ok()?;
if modified.elapsed().unwrap_or_default() >= std::time::Duration::from_secs(604800) {
return None;
}
let copy = source.with_file_name(format!(
"chrome-extension-cookies-{}.txt",
uuid::Uuid::new_v4()
));
tokio::fs::copy(&source, ©).await.ok()?;
Some(copy)
}
fn detect_js_runtime() -> Option<String> {
let runtimes: &[(&str, &str)] = if cfg!(target_os = "windows") {
&[
("node", "node.exe"),
("deno", "deno.exe"),
("bun", "bun.exe"),
]
} else {
&[("node", "node"), ("deno", "deno"), ("bun", "bun")]
};
for &(runtime, bin) in runtimes {
let finder = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
if let Ok(output) = crate::core::process::std_command(finder)
.arg(bin)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
{
if output.status.success() {
if let Some(line) = String::from_utf8_lossy(&output.stdout).lines().next() {
let path = line.trim();
if !path.is_empty() && std::path::Path::new(path).exists() {
return Some(format!("{}:{}", runtime, path));
}
}
}
}
}
if let Some(bin_dir) = crate::core::paths::app_data_dir().map(|d| d.join("bin")) {
for &(runtime, bin) in runtimes {
let managed = bin_dir.join(bin);
if managed.exists() {
return Some(format!("{}:{}", runtime, managed.display()));
}
}
}
#[cfg(target_os = "windows")]
{
let candidates = [
("node", r"C:\Program Files\nodejs\node.exe"),
("node", r"C:\Program Files (x86)\nodejs\node.exe"),
];
for (runtime, path) in &candidates {
if std::path::Path::new(path).exists() {
return Some(format!("{}:{}", runtime, path));
}
}
}
None
}
fn js_runtime_args() -> Vec<String> {
let cached = {
if let Ok(cache) = JS_RUNTIME_CACHE.read() {
cache.clone()
} else {
None
}
};
let runtime = match cached {
Some(val) => val,
None => {
let val = detect_js_runtime();
if let Ok(mut cache) = JS_RUNTIME_CACHE.write() {
*cache = Some(val.clone());
}
val
}
};
match runtime {
Some(rt) => vec!["--js-runtimes".to_string(), rt],
None => vec![],
}
}
fn is_youtube_url(url: &str) -> bool {
let lower = url.to_lowercase();
lower.contains("youtube.com") || lower.contains("youtu.be")
}
fn extract_error_message(stderr: &str) -> String {
let error_line = stderr
.lines()
.find(|l| l.to_uppercase().contains("ERROR:"))
.map(|l| l.trim().to_string());
if let Some(msg) = error_line {
return msg;
}
let warning_line = stderr
.lines()
.find(|l| l.to_uppercase().contains("WARNING:"))
.map(|l| l.trim().to_string());
if let Some(msg) = warning_line {
return msg;
}
stderr.trim().to_string()
}
pub async fn get_video_info(
ytdlp: &Path,
url: &str,
extra_flags: &[String],
) -> anyhow::Result<serde_json::Value> {
let _timer_start = std::time::Instant::now();
if is_youtube_url(url) {
yt_rate_limiter().acquire().await;
}
let is_yt = is_youtube_url(url);
let clients: &[Option<&str>] = if is_yt {
&[None, Some("youtube:player_client=default,mweb")]
} else {
&[None]
};
let mut last_error = String::new();
for (attempt, client) in clients.iter().enumerate() {
tracing::info!(
"[yt-dlp] info fetch attempt {}/{} for URL",
attempt + 1,
clients.len()
);
let mut args = vec![
"--dump-single-json".to_string(),
"--no-warnings".to_string(),
"--no-playlist".to_string(),
"--no-check-certificates".to_string(),
"--socket-timeout".to_string(),
"15".to_string(),
"--retries".to_string(),
"1".to_string(),
"--extractor-retries".to_string(),
"2".to_string(),
"--retry-sleep".to_string(),
"exp=1:30".to_string(),
"--user-agent".to_string(),
CHROME_UA.to_string(),
"--skip-download".to_string(),
];
args.extend(js_runtime_args());
if let Some(extractor_args) = client {
args.push("--extractor-args".to_string());
args.push(extractor_args.to_string());
}
let explicit_cookie_header = has_explicit_cookie_header(extra_flags);
let manual_cookie_header = if explicit_cookie_header {
None
} else {
manual_cookie_header_setting()
};
let extension_cookies = if manual_cookie_header.is_none() {
extension_cookie_file().await
} else {
None
};
let global_cf = if manual_cookie_header.is_none() {
global_cookie_file()
} else {
None
};
if let Some(ref cookie_header) = manual_cookie_header {
append_cookie_header(&mut args, cookie_header);
tracing::debug!("[yt-dlp] using manual cookie header from settings");
} else if let Some(ref cf) = extension_cookies {
args.push("--cookies".to_string());
args.push(cf.to_string_lossy().to_string());
} else if let Some(ref cf) = global_cf {
args.push("--cookies".to_string());
args.push(cf.clone());
} else if !explicit_cookie_header {
let cfb = cookies_from_browser_setting();
if !cfb.is_empty() {
args.push("--cookies-from-browser".to_string());
args.push(cfb);
}
} else {
tracing::debug!(
"[yt-dlp] skipping cookies-from-browser because explicit Cookie header was provided"
);
}
args.extend(proxy_args());
args.extend(extra_flags.iter().cloned());
args.push(url.to_string());
let child = crate::core::process::command(ytdlp)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow!("Failed to run yt-dlp: {}", e))?;
tracing::debug!(
"[perf] get_video_info: yt-dlp process spawned at {:?} (attempt {})",
_timer_start.elapsed(),
attempt + 1
);
let result =
tokio::time::timeout(std::time::Duration::from_secs(60), child.wait_with_output())
.await
.map_err(|_| {
tracing::debug!("[perf] get_video_info took {:?}", _timer_start.elapsed());
anyhow!("Timeout fetching video info (60s)")
})?
.map_err(|e| {
tracing::debug!("[perf] get_video_info took {:?}", _timer_start.elapsed());
anyhow!("Failed to run yt-dlp: {}", e)
})?;
tracing::debug!(
"[perf] get_video_info: yt-dlp process exited at {:?} (attempt {})",
_timer_start.elapsed(),
attempt + 1
);
if result.status.success() {
let json: serde_json::Value = serde_json::from_slice(&result.stdout)
.map_err(|e| anyhow!("yt-dlp returned invalid JSON: {}", e))?;
tracing::debug!("[perf] get_video_info took {:?}", _timer_start.elapsed());
return Ok(json);
}
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
tracing::debug!(
"[yt-dlp info] stderr ({} bytes): {}",
stderr.len(),
stderr.trim()
);
let stderr_lower = stderr.to_lowercase();
if stderr_lower.contains("http error 429") {
rate_limit_429_increment();
let sanitized_url = sanitize_log_line(url);
tracing::warn!(
"[yt-429] rate limit in get_video_info: url={} attempt={}/{}",
sanitized_url,
attempt + 1,
clients.len()
);
}
let is_retryable = is_yt
&& attempt < clients.len() - 1
&& (stderr_lower.contains("requested format")
|| stderr_lower.contains("not available")
|| stderr_lower.contains("http error 403")
|| stderr_lower.contains("nsig")
|| stderr_lower.contains("http error 429"));
if is_retryable {
tracing::warn!(
"[yt-dlp] info fetch attempt {} failed, retrying with fallback player_client: {}",
attempt + 1,
stderr.trim().lines().last().unwrap_or("")
);
if stderr_lower.contains("http error 429") {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
last_error = stderr;
continue;
}
tracing::debug!("[perf] get_video_info took {:?}", _timer_start.elapsed());
return Err(translate_ytdlp_error(&stderr));
}
tracing::debug!("[perf] get_video_info took {:?}", _timer_start.elapsed());
Err(translate_ytdlp_error(&last_error))
}
pub async fn get_playlist_info(
ytdlp: &Path,
url: &str,
extra_flags: &[String],
) -> anyhow::Result<(String, Vec<PlaylistEntry>)> {
if is_youtube_url(url) {
yt_rate_limiter().acquire().await;
}
let mut args = vec![
"--flat-playlist".to_string(),
"--dump-json".to_string(),
"--no-warnings".to_string(),
"--socket-timeout".to_string(),
"30".to_string(),
"--retries".to_string(),
"3".to_string(),
"--extractor-retries".to_string(),
"3".to_string(),
"--retry-sleep".to_string(),
"exp=1:60".to_string(),
"--user-agent".to_string(),
CHROME_UA.to_string(),
];
args.extend(js_runtime_args());
if is_youtube_url(url) {
args.push("--extractor-args".to_string());
args.push("youtube:player_client=default".to_string());
}
args.extend(proxy_args());
args.extend(extra_flags.iter().cloned());
args.push(url.to_string());
let output = tokio::time::timeout(
std::time::Duration::from_secs(120),
crate::core::process::command(ytdlp)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await
.map_err(|_| anyhow!("Timeout fetching playlist (120s)"))?
.map_err(|e| anyhow!("Failed to run yt-dlp: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_lower = stderr.to_lowercase();
if stderr_lower.contains("http error 429") {
rate_limit_429_increment();
let sanitized_url = sanitize_log_line(url);
let player_client = if is_youtube_url(url) {
"default"
} else {
"n/a"
};
tracing::warn!(
"[yt-429] rate limit in get_playlist_info: url={} player_client={} retries=3",
sanitized_url,
player_client
);
}
return Err(anyhow!(
"yt-dlp playlist failed: {}",
extract_error_message(&stderr)
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
let mut playlist_title = String::new();
for line in stdout.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
if playlist_title.is_empty() {
playlist_title = json
.get("playlist_title")
.or_else(|| json.get("playlist"))
.and_then(|v| v.as_str())
.unwrap_or("playlist")
.to_string();
}
let id = json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let title = json
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let url = json
.get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("https://www.youtube.com/watch?v={}", id));
let duration = json.get("duration").and_then(|v| v.as_f64());
if !id.is_empty() {
entries.push(PlaylistEntry {
id,
title,
url,
duration,
});
}
}
}
Ok((playlist_title, entries))
}
pub struct PlaylistEntry {
pub id: String,
pub title: String,
pub url: String,
pub duration: Option<f64>,
}
fn parse_destination_line(line: &str) -> Option<String> {
let line = line.trim();
if let Some(rest) = line.strip_prefix("[download] Destination:") {
let path = rest.trim();
if !path.is_empty() {
return Some(path.to_string());
}
}
if let Some(rest) = line.strip_prefix("[Merger] Merging formats into \"") {
let path = rest.trim_end_matches('"');
if !path.is_empty() {
return Some(path.to_string());
}
}
None
}
pub async fn write_netscape_cookie_file(
cookies: &[(String, String)],
domain: &str,
path: &Path,
) -> anyhow::Result<()> {
let mut content = String::from("# Netscape HTTP Cookie File\n");
for (name, value) in cookies {
content.push_str(&format!(
"{}\tTRUE\t/\tTRUE\t0\t{}\t{}\n",
domain, name, value
));
}
std::fs::write(path, content)?;
Ok(())
}
fn build_format_selector(
mode: &str,
format_id: Option<&str>,
quality_height: Option<u32>,
ffmpeg_available: bool,
) -> String {
if let Some(fid) = format_id {
return fid.to_string();
}
match mode {
"audio" => "ba/b".to_string(),
"mute" => match quality_height {
Some(h) if h > 0 => format!("bv*[height<={}]/bv*/b", h),
_ => "bv*/b".to_string(),
},
_ => {
if ffmpeg_available {
match quality_height {
Some(h) if h > 0 => format!(
"bv*[height<={}]+ba[ext=m4a]/bv*[height<={}]+ba/b[height<={}]/b",
h, h, h
),
_ => "bv*+ba[ext=m4a]/bv*+ba/b".to_string(),
}
} else {
tracing::warn!("[yt-dlp] ffmpeg not available, using fallback format selector");
match quality_height {
Some(h) if h > 0 => format!("b[height<={}]/bv*[height<={}]/b", h, h),
_ => "b/bv*".to_string(),
}
}
}
}
}
struct CookieConfig {
effective_cookie_file: Option<PathBuf>,
cfb_setting: String,
manual_cookie_header: Option<String>,
manual_cookie_enabled: bool,
explicit_cookie_header: bool,
ext_cookies: Option<PathBuf>,
}
async fn resolve_cookie_config(cookie_file: Option<&Path>, extra_flags: &[String]) -> CookieConfig {
let explicit_cookie_header = has_explicit_cookie_header(extra_flags);
let manual_cookie_header = if explicit_cookie_header || cookie_file.is_some() {
None
} else {
manual_cookie_header_setting()
};
let manual_cookie_enabled = manual_cookie_header.is_some();
let global_cookie_file = if manual_cookie_enabled {
None
} else {
global_cookie_file()
};
let ext_cookies =
if cookie_file.is_none() && global_cookie_file.is_none() && !manual_cookie_enabled {
extension_cookie_file().await
} else {
None
};
let effective_cookie_file = cookie_file
.map(|p| p.to_path_buf())
.or_else(|| global_cookie_file.map(std::path::PathBuf::from))
.or(ext_cookies.clone());
let cfb_setting = if manual_cookie_enabled || explicit_cookie_header {
String::new()
} else {
cookies_from_browser_setting()
};
CookieConfig {
effective_cookie_file,
cfb_setting,
manual_cookie_header,
manual_cookie_enabled,
explicit_cookie_header,
ext_cookies,
}
}
fn build_base_args(
url: &str,
mode: &str,
format_id: Option<&str>,
format_selector: String,
referer: Option<&str>,
cookie_config: &CookieConfig,
ffmpeg_available: bool,
ffmpeg_location: Option<String>,
concurrent_fragments: u32,
output_template: String,
max_name: usize,
extra_flags: &[String],
) -> Vec<String> {
let mut base_args = vec!["-f".to_string(), format_selector];
base_args.extend(js_runtime_args());
if format_id.is_none() && mode == "audio" {
base_args.push("-S".to_string());
base_args.push("+codec:aac:m4a".to_string());
}
if format_id.is_none() && mode != "audio" && ffmpeg_available {
base_args.push("--merge-output-format".to_string());
base_args.push("mp4".to_string());
}
if let Some(ref_url) = referer {
base_args.push("--referer".to_string());
base_args.push(ref_url.to_string());
base_args.push("--add-headers".to_string());
base_args.push(format!("Referer:{}", ref_url));
}
if let Some(ext_headers) = ext_headers_for_url(url) {
for (name, value) in ext_headers {
let lower = name.to_lowercase();
if lower == "referer" || lower == "cookie" || lower == "user-agent" {
continue;
}
base_args.push("--add-headers".to_string());
base_args.push(format!("{}:{}", name, value));
}
}
if let Some(ref cf) = cookie_config.effective_cookie_file {
base_args.push("--cookies".to_string());
base_args.push(cf.to_string_lossy().to_string());
}
if let Some(ref cookie_header) = cookie_config.manual_cookie_header {
append_cookie_header(&mut base_args, cookie_header);
}
if let Some(ref loc) = ffmpeg_location {
base_args.push("--ffmpeg-location".to_string());
base_args.push(loc.clone());
}
let effective_fragments = if is_youtube_url(url) {
let rate_limit_count = rate_limit_429_count();
let max_frags = if rate_limit_count >= 2 {
2
} else if rate_limit_count > 0 {
4
} else {
8
};
concurrent_fragments.min(max_frags)
} else {
concurrent_fragments
};
base_args.push("-N".to_string());
base_args.push(effective_fragments.to_string());
if is_youtube_url(url) {
base_args.push("--extractor-args".to_string());
base_args.push("youtube:player_client=default".to_string());
base_args.push("--throttled-rate".to_string());
base_args.push("100K".to_string());
base_args.push("--sleep-subtitles".to_string());
base_args.push("5".to_string());
}
base_args.extend(["--buffer-size".to_string(), "16M".to_string()]);
if !is_youtube_url(url) {
base_args.extend(["--http-chunk-size".to_string(), "10M".to_string()]);
}
let effective_ua = ext_user_agent_for_url(url).unwrap_or_else(|| CHROME_UA.to_string());
base_args.extend([
"--no-check-certificate".to_string(),
"--no-warnings".to_string(),
"--no-mtime".to_string(),
"--user-agent".to_string(),
effective_ua,
"--socket-timeout".to_string(),
"30".to_string(),
"--retries".to_string(),
"5".to_string(),
"--fragment-retries".to_string(),
"5".to_string(),
"--extractor-retries".to_string(),
"3".to_string(),
"--file-access-retries".to_string(),
"3".to_string(),
"--retry-sleep".to_string(),
"exp=1:30".to_string(),
"--trim-filenames".to_string(),
max_name.to_string(),
"--no-playlist".to_string(),
"--newline".to_string(),
"--progress-template".to_string(),
"download:%(progress._percent_str)s|eta:%(progress.eta)s".to_string(),
"-o".to_string(),
output_template,
"--skip-unavailable-fragments".to_string(),
]);
base_args.extend(proxy_args());
base_args.extend(extra_flags.iter().cloned());
if let Some(lang) = translate_metadata_lang() {
base_args.push("--extractor-args".to_string());
base_args.push(format!("youtube:lang={}", lang));
}
if sponsorblock_enabled() && is_youtube_url(url) {
base_args.push("--sponsorblock-remove".to_string());
base_args.push("default".to_string());
}
if split_chapters_enabled() {
base_args.push("--split-chapters".to_string());
}
if cfg!(target_os = "windows") {
base_args.push("--windows-filenames".to_string());
}
base_args
}
fn spawn_stdout_reader(
stdout: tokio::process::ChildStdout,
progress_tx: mpsc::Sender<f64>,
captured_path_writer: Arc<Mutex<Option<PathBuf>>>,
is_audio_only: bool,
timer_start: std::time::Instant,
) -> tokio::task::JoinHandle<()> {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
let log_id = log_hook::current_download_id();
tokio::spawn(async move {
let mut phase = 0u32;
let mut max_reported = 0.0f64;
let mut first_line_logged = false;
let mut first_progress_logged = false;
let mut last_send = std::time::Instant::now();
let throttle = std::time::Duration::from_millis(250);
while let Ok(Some(line)) = lines.next_line().await {
if let Some(id) = log_id {
log_hook::emit_log(id, &line);
}
if !first_line_logged {
first_line_logged = true;
tracing::debug!(
"[perf] download_video first_byte_time: {:?}",
timer_start.elapsed()
);
}
if let Some(dest) = parse_destination_line(&line) {
let dest_path = PathBuf::from(&dest);
let ext = dest_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let is_subtitle =
matches!(ext.as_str(), "vtt" | "srt" | "ass" | "ssa" | "sub" | "lrc");
if !is_subtitle {
phase += 1;
let mut guard = captured_path_writer.lock().unwrap();
*guard = Some(dest_path);
}
}
if line.contains("[Merger]") {
if 99.0 > max_reported {
max_reported = 99.0;
let _ = progress_tx.send(99.0).await;
last_send = std::time::Instant::now();
}
continue;
}
if let Some(pct) = parse_progress_line(&line) {
if !first_progress_logged && pct > 0.0 {
first_progress_logged = true;
tracing::debug!(
"[perf] download_video: first_progress > 0% at {:?}",
timer_start.elapsed()
);
}
if let Some(id) = log_id {
if let Some(eta) = parse_eta_line(&line) {
record_eta(id, eta);
}
}
if is_audio_only {
if pct >= 99.0 || last_send.elapsed() >= throttle {
let _ = progress_tx.send(pct).await;
last_send = std::time::Instant::now();
}
} else {
let adjusted = if phase <= 1 {
pct * 0.5
} else {
50.0 + pct * 0.5
};
if adjusted > max_reported
&& (adjusted >= 99.0 || last_send.elapsed() >= throttle)
{
max_reported = adjusted;
let _ = progress_tx.send(adjusted).await;
last_send = std::time::Instant::now();
}
}
}
}
})
}
fn spawn_stderr_reader(
stderr_pipe: tokio::process::ChildStderr,
) -> tokio::task::JoinHandle<String> {
let stderr_log_id = log_hook::current_download_id();
tokio::spawn(async move {
let mut buf = String::new();
let stderr_buf = BufReader::new(stderr_pipe);
let mut stderr_lines = stderr_buf.lines();
while let Ok(Some(line)) = stderr_lines.next_line().await {
if let Some(id) = stderr_log_id {
log_hook::emit_log(id, &line);
}
buf.push_str(&line);
buf.push('\n');
}
buf
})
}
struct ErrorHandlingResult {
should_break: bool,
}
#[allow(clippy::too_many_arguments)]
async fn handle_ytdlp_error(
last_error: &str,
url: &str,
attempt: usize,
max_attempts: usize,
referer: Option<&str>,
base_args: &mut Vec<String>,
extra_args: &mut Vec<String>,
use_aria2c: &mut bool,
last_was_429: &mut bool,
use_subtitles: &mut bool,
use_cfb: &mut bool,
format_already_simplified: &mut bool,
has_effective_cookie_file: bool,
) -> ErrorHandlingResult {
let stderr_lower = last_error.to_lowercase();
if attempt < max_attempts - 1 {
if *use_aria2c
&& (stderr_lower.contains("aria2") || stderr_lower.contains("external downloader"))
{
*use_aria2c = false;
tracing::warn!("[yt-dlp] aria2c failed, retrying with native downloader");
}
*last_was_429 = stderr_lower.contains("http error 429");
if *last_was_429 {
let is_subtitle_only_429 = last_error.lines().all(|line| {
let ll = line.to_lowercase();
!ll.contains("429") || ll.contains("subtitle")
});
if *use_subtitles {
*use_subtitles = false;
tracing::warn!(
"[yt-dlp] 429 detected, disabling subtitle download for remaining retries"
);
}
if is_subtitle_only_429 {
tracing::warn!("[yt-dlp] subtitle-only 429, retrying without subtitles (keeping current player_client)");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
} else {
rate_limit_429_increment();
let sanitized_url = sanitize_log_line(url);
let player_client = if is_youtube_url(url) {
"default"
} else {
"n/a"
};
let cookies_enabled = *use_cfb || has_effective_cookie_file;
tracing::warn!(
"[yt-429] rate limit in download_video: url={} attempt={}/{} player_client={} cookies={} aria2c={}",
sanitized_url,
attempt + 1,
max_attempts,
player_client,
cookies_enabled,
*use_aria2c
);
let base_secs = 10u64 * 2u64.pow(attempt as u32);
let jitter_secs = (attempt as u64 * 7 + url.len() as u64) % 5;
let wait_secs = base_secs + jitter_secs;
tracing::warn!(
"[yt-dlp] rate limited (429), waiting {}s (base={}s + jitter={}s)",
wait_secs,
base_secs,
jitter_secs
);
tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
if is_youtube_url(url) {
base_args.retain(|a| a != "--extractor-args" && !a.contains("player_client"));
extra_args.retain(|a| a != "--extractor-args" && !a.contains("player_client"));
let client = match attempt {
0 => "youtube:player_client=mweb",
1 => "youtube:player_client=ios",
_ => "youtube:player_client=ios",
};
extra_args.push("--extractor-args".to_string());
extra_args.push(client.to_string());
tracing::warn!(
"[yt-dlp] 429 detected, rotating player_client to {}",
client
);
}
}
}
if stderr_lower.contains("nsig") {
base_args.retain(|a| a != "--extractor-args" && !a.contains("player_client"));
extra_args.retain(|a| a != "--extractor-args" && !a.contains("player_client"));
let client = if attempt == 0 {
"youtube:player_client=ios"
} else {
"youtube:player_client=mweb"
};
extra_args.push("--extractor-args".to_string());
extra_args.push(client.to_string());
tracing::warn!("[yt-dlp] nsig error, switching to {}", client);
}
if (stderr_lower.contains("http error 403") || stderr_lower.contains("forbidden"))
&& !extra_args.contains(&"--force-ipv4".to_string())
{
extra_args.push("--force-ipv4".to_string());
tracing::warn!("[yt-dlp] 403 forbidden, adding --force-ipv4");
}
if stderr_lower.contains("subtitle") && *use_subtitles && !*last_was_429 {
tracing::warn!("[yt-dlp] subtitle error detected, disabling subtitles for retry");
*use_subtitles = false;
}
if stderr_lower.contains("timed out") || stderr_lower.contains("timeout") {
tracing::warn!("[yt-dlp] socket timeout on attempt {}", attempt + 1);
}
if stderr_lower.contains("certificate") || stderr_lower.contains("ssl") {
tracing::warn!("[yt-dlp] SSL/certificate error on attempt {}", attempt + 1);
}
if (stderr_lower.contains("invalid argument") || stderr_lower.contains("errno 22"))
&& !extra_args.contains(&"--restrict-filenames".to_string())
{
extra_args.push("--restrict-filenames".to_string());
tracing::warn!("[yt-dlp] Errno 22, adding --restrict-filenames");
}
if (stderr_lower.contains("403") || stderr_lower.contains("forbidden"))
&& referer.is_none()
&& !extra_args.contains(&"--referer".to_string())
{
let fallback = ext_referer_for_url(url).or_else(|| {
url::Url::parse(url).ok().and_then(|parsed| {
let host = parsed.host_str()?;
Some(format!("{}://{}/", parsed.scheme(), host))
})
});
if let Some(ref_url) = fallback {
tracing::info!("[yt-dlp] 403, adding fallback referer: {}", ref_url);
extra_args.push("--referer".to_string());
extra_args.push(ref_url.clone());
extra_args.push("--add-headers".to_string());
extra_args.push(format!("Referer:{}", ref_url));
}
}
if ((stderr_lower.contains("could not") && stderr_lower.contains("cookie"))
|| stderr_lower.contains("cookies-from-browser")
|| stderr_lower.contains("failed to decrypt")
|| stderr_lower.contains("keyring")
|| stderr_lower.contains("permission denied"))
&& *use_cfb
{
*use_cfb = false;
tracing::warn!("[yt-dlp] cookies-from-browser failed. Use the browser extension or set a cookie file in Settings.");
COOKIE_ERROR_FLAG.store(true, std::sync::atomic::Ordering::Relaxed);
}
if (stderr_lower.contains("sign in") || stderr_lower.contains("login required"))
&& !*use_cfb
&& !has_effective_cookie_file
{
tracing::warn!("[yt-dlp] login required. Install the browser extension and visit the site while logged in.");
}
if stderr_lower.contains("requested format") && stderr_lower.contains("not available")
|| stderr_lower.contains("ffmpeg") && stderr_lower.contains("not found")
|| stderr_lower.contains("postprocessing")
{
if *format_already_simplified {
tracing::warn!(
"[yt-dlp] format/postprocessing error after simplification, giving up"
);
return ErrorHandlingResult { should_break: true };
}
base_args.retain(|a| a != "--extractor-args" && !a.contains("player_client"));
extra_args.retain(|a| a != "--extractor-args" && !a.contains("player_client"));
base_args.retain(|a| a != "--merge-output-format" && a != "mp4");
if let Some(pos) = base_args.iter().position(|a| a == "-f") {
base_args.remove(pos + 1);
base_args.remove(pos);
}
tracing::warn!("[yt-dlp] format/postprocessing error on attempt {}, removed -f and player_client to use yt-dlp defaults", attempt + 1);
*format_already_simplified = true;
}
let last_line = last_error.lines().last().unwrap_or("unknown error").trim();
let sanitized = sanitize_log_line(last_line);
tracing::warn!(
"[yt-dlp] attempt {}/{} failed: {}",
attempt + 1,
max_attempts,
sanitized
);
}
ErrorHandlingResult {
should_break: false,
}
}
#[allow(clippy::too_many_arguments)]
pub async fn download_video(
ytdlp: &Path,
url: &str,
output_dir: &Path,
quality_height: Option<u32>,
progress: mpsc::Sender<f64>,
download_mode: Option<&str>,
format_id: Option<&str>,
filename_template: Option<&str>,
referer: Option<&str>,
cancel_token: CancellationToken,
cookie_file: Option<&Path>,
concurrent_fragments: u32,
download_subtitles: bool,
extra_flags: &[String],
) -> anyhow::Result<DownloadResult> {
let _timer_start = std::time::Instant::now();
if is_youtube_url(url) {
yt_rate_limiter().acquire().await;
}
let _ = progress.send(-1.0).await;
let mode = download_mode.unwrap_or("auto");
let is_audio_only = mode == "audio";
let (ffmpeg_available, ffmpeg_location, aria2c_path) = tokio::join!(
crate::core::ffmpeg::is_ffmpeg_available(),
find_ffmpeg_location_cached(),
crate::core::dependencies::ensure_aria2c(None),
);
let format_selector = build_format_selector(mode, format_id, quality_height, ffmpeg_available);
let dir_len = output_dir.to_string_lossy().len();
let max_name = if cfg!(target_os = "windows") {
250_usize.saturating_sub(dir_len).min(200)
} else {
200
};
let template = filename_template
.map(|t| t.to_string())
.unwrap_or_else(|| format!("%(title).{}s [%(id)s].%(ext)s", max_name));
let output_template = output_dir.join(&template).to_string_lossy().to_string();
std::fs::create_dir_all(output_dir)?;
let cookie_config = resolve_cookie_config(cookie_file, extra_flags).await;
let mut base_args = build_base_args(
url,
mode,
format_id,
format_selector,
referer,
&cookie_config,
ffmpeg_available,
ffmpeg_location,
concurrent_fragments,
output_template,
max_name,
extra_flags,
);
let mut use_aria2c = aria2c_path.is_some()
&& mode != "audio"
&& cookie_config.effective_cookie_file.is_none()
&& cookie_config.cfb_setting.is_empty()
&& !cookie_config.manual_cookie_enabled
&& !cookie_config.explicit_cookie_header;
let should_download_subs = download_subtitles && rate_limit_429_count() < 2;
let subtitle_args = if should_download_subs {
let mut args = vec!["--write-sub".to_string()];
if include_auto_subs_setting() {
args.push("--write-auto-sub".to_string());
}
args.extend([
"--sub-lang".to_string(),
"en,pt,es".to_string(),
"--sub-format".to_string(),
"best".to_string(),
"--convert-subs".to_string(),
"srt".to_string(),
]);
args
} else {
Vec::new()
};
let max_attempts: usize = 3;
let mut extra_args: Vec<String> = Vec::new();
let mut last_error = String::new();
let mut use_subtitles = should_download_subs;
let mut use_cfb = !cookie_config.cfb_setting.is_empty()
&& !cookie_config.explicit_cookie_header
&& !cookie_config.manual_cookie_enabled;
let mut format_already_simplified = false;
let mut last_was_429 = false;
for attempt in 0..max_attempts {
tracing::info!("[yt-dlp] download attempt {}/{}", attempt + 1, max_attempts);
if cancel_token.is_cancelled() {
tracing::debug!("[perf] download_video took {:?}", _timer_start.elapsed());
anyhow::bail!("Download cancelled");
}
if attempt > 0 {
let wait: u64 = if last_was_429 {
match attempt {
1 => 3,
2 => 8,
_ => 15,
}
} else {
1
};
tracing::info!(
"[yt-dlp] retry {}/{} after {}s (429={})",
attempt,
max_attempts - 1,
wait,
last_was_429
);
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
cleanup_part_files(output_dir).await;
}
let mut args = base_args.clone();
if use_subtitles {
args.extend(subtitle_args.iter().cloned());
}
if use_cfb {
args.push("--cookies-from-browser".to_string());
args.push(cookie_config.cfb_setting.clone());
}
if use_aria2c && !use_cfb {
if let Some(ref a2_path) = aria2c_path {
let effective_fragments = if is_youtube_url(url) {
let rate_limit_count = rate_limit_429_count();
concurrent_fragments.min(if rate_limit_count >= 2 {
2
} else if rate_limit_count > 0 {
4
} else {
8
})
} else {
concurrent_fragments
};
let conns = if is_youtube_url(url) {
effective_fragments.max(1)
} else {
effective_fragments.clamp(8, 16)
};
args.push("--downloader".to_string());
args.push(a2_path.to_string_lossy().to_string());
args.push("--downloader-args".to_string());
let aria2c_proxy = match crate::core::http_client::proxy_url() {
Some(url) => format!(" --all-proxy={}", url),
None => String::new(),
};
args.push(format!("aria2c:-x {} -k 1M -j {} --min-split-size=1M --file-allocation=none --optimize-concurrent-downloads=true --auto-file-renaming=false --summary-interval=0{}", conns, conns, aria2c_proxy));
}
}
args.extend(extra_args.iter().cloned());
args.push(url.to_string());
let mut child = crate::core::process::command(ytdlp)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow!("Failed to start yt-dlp: {}", e))?;
tracing::debug!(
"[perf] download_video: yt-dlp process spawned at {:?} (attempt {})",
_timer_start.elapsed(),
attempt + 1
);
let _ = progress.send(-2.0).await;
let stdout = child.stdout.take().ok_or_else(|| anyhow!("No stdout"))?;
let stderr_pipe = child.stderr.take().ok_or_else(|| anyhow!("No stderr"))?;
let progress_tx = progress.clone();
let captured_path: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
let line_reader = spawn_stdout_reader(
stdout,
progress_tx.clone(),
captured_path.clone(),
is_audio_only,
_timer_start,
);
let stderr_reader = spawn_stderr_reader(stderr_pipe);
let status = tokio::select! {
s = child.wait() => s.map_err(|e| anyhow!("yt-dlp process failed: {}", e))?,
_ = cancel_token.cancelled() => {
let _ = child.kill().await;
let _ = line_reader.await;
let _ = stderr_reader.await;
cleanup_part_files(output_dir).await;
if let Some(ref path) = cookie_config.ext_cookies {
let _ = tokio::fs::remove_file(path).await;
}
tracing::debug!("[perf] download_video took {:?}", _timer_start.elapsed());
anyhow::bail!("Download cancelled");
}
};
let _ = line_reader.await;
let stderr_content = stderr_reader.await.unwrap_or_default();
if let Some(ref path) = cookie_config.ext_cookies {
let _ = tokio::fs::remove_file(path).await;
}
if status.success() {
let _ = progress.send(100.0).await;
let file_path = {
let guard = captured_path.lock().unwrap();
guard.clone()
};
let file_path = match file_path {
Some(p) if p.exists() => {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_audio_ext = matches!(
ext.to_lowercase().as_str(),
"m4a" | "mp3" | "ogg" | "opus" | "flac" | "aac" | "wav"
);
if is_audio_ext && !is_audio_only {
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let mp4_candidate = p.with_file_name(format!("{}.mp4", stem));
if mp4_candidate.exists() {
mp4_candidate
} else {
find_downloaded_file(output_dir, url).await.unwrap_or(p)
}
} else {
p
}
}
_ => find_downloaded_file(output_dir, url).await?,
};
let meta = std::fs::metadata(&file_path)?;
tracing::debug!("[perf] download_video took {:?}", _timer_start.elapsed());
return Ok(DownloadResult {
file_path,
file_size_bytes: meta.len(),
duration_seconds: 0.0,
torrent_id: None,
});
}
last_error = stderr_content;
let err_res = handle_ytdlp_error(
&last_error,
url,
attempt,
max_attempts,
referer,
&mut base_args,
&mut extra_args,
&mut use_aria2c,
&mut last_was_429,
&mut use_subtitles,
&mut use_cfb,
&mut format_already_simplified,
cookie_config.effective_cookie_file.is_some(),
)
.await;
if err_res.should_break {
break;
}
continue;
}
if let Some(ref path) = cookie_config.ext_cookies {
let _ = tokio::fs::remove_file(path).await;
}
tracing::debug!("[perf] download_video took {:?}", _timer_start.elapsed());
Err(translate_ytdlp_error(&last_error))
}
async fn cleanup_part_files(dir: &Path) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.ends_with(".part") || name.ends_with(".ytdl") {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
fn sanitize_log_line(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut remaining = line;
loop {
let found = remaining
.find("https://")
.or_else(|| remaining.find("http://"));
match found {
Some(start) => {
result.push_str(&remaining[..start]);
let url_part = &remaining[start..];
let url_end = url_part
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '>')
.unwrap_or(url_part.len());
let url = &url_part[..url_end];
if let Some(q) = url.find('?') {
result.push_str(&url[..q]);
} else {
result.push_str(url);
}
remaining = &url_part[url_end..];
}
None => {
result.push_str(remaining);
break;
}
}
}
result
}
fn translate_ytdlp_error(stderr: &str) -> anyhow::Error {
let lower = stderr.to_lowercase();
if lower.contains("errno 22")
&& (lower.contains("textiowrapper")
|| lower.contains("encoding=")
|| lower.contains("exception ignored"))
{
return anyhow!(
"Console encoding error (non-UTF-8 locale). Update yt-dlp in Settings → Dependencies, or run `chcp 65001` in a terminal and reopen the app."
);
}
if lower.contains("http error 429") {
return anyhow!("Server returned error 429 (too many requests). Try again later.");
}
if lower.contains("http error 403") || lower.contains("forbidden") {
return anyhow!("Access denied (403). The video may be private or region-restricted.");
}
if lower.contains("sign in to confirm") || lower.contains("login required") {
return anyhow!("Video requires login. Use browser cookies or try another URL.");
}
if lower.contains("nsig extraction failed") || lower.contains("nsig") {
return anyhow!("Video extraction failed. Update yt-dlp or try again.");
}
if lower.contains("cannot parse data")
|| lower.contains("please report this issue")
|| (lower.contains("confirm you are on the latest version") && lower.contains("yt-dlp"))
{
return anyhow!(
"yt-dlp extractor is broken for this site. Update yt-dlp in Settings → Dependencies, then retry."
);
}
if lower.contains("requested format") && lower.contains("not available") {
return anyhow!(
"Requested format is not available. The download will retry with a compatible format."
);
}
if lower.contains("video unavailable") || lower.contains("not available") {
return anyhow!("Video unavailable or removed.");
}
if lower.contains("private video") {
return anyhow!("This video is private.");
}
if lower.contains("copyright") {
return anyhow!("Video blocked due to copyright.");
}
if lower.contains("geo") && lower.contains("block") {
return anyhow!("Video restricted in your region.");
}
if lower.contains("timed out") || lower.contains("timeout") {
return anyhow!("Connection timed out. Check your internet and try again.");
}
if lower.contains("ffmpeg") && (lower.contains("not found") || lower.contains("no such file")) {
return anyhow!("FFmpeg not found. Install FFmpeg to download this format.");
}
if lower.contains("unsupported url") || lower.contains("no suitable infojson") {
return anyhow!("Unsupported URL. Check that the link is correct.");
}
if lower.contains("unable to download") && lower.contains("webpage") {
return anyhow!("Failed to access the page. Check the link and your connection.");
}
if lower.contains("is not a valid url") || lower.contains("no video formats") {
return anyhow!("No video formats found for this link.");
}
let last_error_line = stderr
.lines()
.rev()
.find(|l| {
let t = l.trim().to_lowercase();
t.starts_with("error:") || t.starts_with("error ")
})
.unwrap_or("")
.trim();
let msg = if !last_error_line.is_empty() {
last_error_line
.strip_prefix("ERROR: ")
.or_else(|| last_error_line.strip_prefix("ERROR:"))
.or_else(|| last_error_line.strip_prefix("error: "))
.unwrap_or(last_error_line)
} else {
let trimmed = stderr.trim();
if trimmed.len() > 300 {
&trimmed[..300]
} else {
trimmed
}
};
anyhow!("yt-dlp: {}", msg)
}
pub fn get_rate_limit_stats() -> serde_json::Value {
serde_json::json!({
"rate_limit_429_count": RATE_LIMIT_429_COUNT.load(Ordering::Relaxed)
})
}
fn parse_progress_line(line: &str) -> Option<f64> {
let line = line.trim();
let body = if let Some(rest) = line.strip_prefix("download:") {
rest
} else if line.ends_with('%') {
line
} else {
return None;
};
let pct_part = body.split('|').next()?.trim().trim_end_matches('%');
let pct_str = pct_part.split_whitespace().last()?;
pct_str.parse::<f64>().ok()
}
fn parse_eta_line(line: &str) -> Option<u64> {
let body = line.trim().strip_prefix("download:")?;
for part in body.split('|') {
let part = part.trim();
if let Some(rest) = part.strip_prefix("eta:") {
let rest = rest.trim();
if rest.is_empty() || rest.eq_ignore_ascii_case("na") {
return None;
}
return rest.parse::<u64>().ok();
}
}
None
}
async fn find_downloaded_file(output_dir: &Path, url: &str) -> anyhow::Result<PathBuf> {
let video_id = extract_id_from_url(url).unwrap_or_default();
let output_dir_owned = output_dir.to_path_buf();
tokio::task::spawn_blocking(move || {
let media_extensions: &[&str] = &[
"mp4", "mkv", "webm", "m4a", "mp3", "ogg", "opus", "flac", "avi", "mov", "ts", "m4v",
"3gp", "aac", "wav",
];
let now = std::time::SystemTime::now();
let recency_limit = std::time::Duration::from_secs(1800);
std::fs::create_dir_all(&output_dir_owned)?;
let read_dir = std::fs::read_dir(&output_dir_owned)?;
let mut candidates: Vec<(PathBuf, std::time::SystemTime, bool)> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.ends_with(".part") || name.ends_with(".ytdl") || name.starts_with('.') {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_media = media_extensions
.iter()
.any(|&e| ext.eq_ignore_ascii_case(e));
if !is_media {
continue;
}
if let Ok(meta) = entry.metadata() {
if meta.len() == 0 {
continue;
}
if let Ok(modified) = meta.modified() {
let is_recent =
now.duration_since(modified).unwrap_or_default() < recency_limit;
let matches_id = !video_id.is_empty() && name.contains(&video_id);
if matches_id || is_recent {
candidates.push((path, modified, matches_id));
}
}
}
}
candidates.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| b.1.cmp(&a.1)));
if let Some((p, _, _)) = candidates.into_iter().next() {
return Ok(p);
}
let fallback_limit = std::time::Duration::from_secs(120);
let mut newest: Option<(PathBuf, std::time::SystemTime)> = None;
if let Ok(entries) = std::fs::read_dir(&output_dir_owned) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.ends_with(".part") || name.ends_with(".ytdl") || name.starts_with('.') {
continue;
}
if let Ok(meta) = entry.metadata() {
if meta.len() == 0 {
continue;
}
if let Ok(modified) = meta.modified() {
if now.duration_since(modified).unwrap_or_default() < fallback_limit
&& newest.as_ref().is_none_or(|(_, t)| modified > *t)
{
newest = Some((path, modified));
}
}
}
}
}
newest
.map(|(p, _)| p)
.ok_or_else(|| anyhow!("Downloaded file not found in {:?}", output_dir_owned))
})
.await?
}
pub fn parse_formats(json: &serde_json::Value) -> Vec<FormatInfo> {
let formats = match json.get("formats").and_then(|v| v.as_array()) {
Some(f) => f,
None => return Vec::new(),
};
let mut result = Vec::new();
for f in formats {
let format_id = match f.get("format_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => continue,
};
let ext = f
.get("ext")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let width = f.get("width").and_then(|v| v.as_u64()).map(|v| v as u32);
let height = f.get("height").and_then(|v| v.as_u64()).map(|v| v as u32);
let fps = f.get("fps").and_then(|v| v.as_f64());
let vcodec = f
.get("vcodec")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let acodec = f
.get("acodec")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let filesize = f
.get("filesize")
.or_else(|| f.get("filesize_approx"))
.and_then(|v| v.as_u64());
let tbr = f.get("tbr").and_then(|v| v.as_f64());
let format_note = f
.get("format_note")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let has_video = vcodec.as_deref().map(|v| v != "none").unwrap_or(false);
let has_audio = acodec.as_deref().map(|v| v != "none").unwrap_or(false);
if !has_video && !has_audio {
continue;
}
let resolution = match (width, height) {
(Some(w), Some(h)) if w > 0 && h > 0 => Some(format!("{}x{}", w, h)),
_ => f
.get("resolution")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
result.push(FormatInfo {
format_id,
ext,
resolution,
width,
height,
fps,
vcodec,
acodec,
filesize,
tbr,
has_video,
has_audio,
format_note,
});
}
result
}
fn extract_id_from_url(url: &str) -> Option<String> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?.to_lowercase();
if host.contains("youtu.be") {
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
return segments.first().map(|s| s.to_string());
}
if host.contains("youtube.com") && parsed.path().starts_with("/embed/") {
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
return segments.last().map(|s| s.to_string());
}
if host.contains("youtube.com") {
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
if segments.first() == Some(&"shorts") {
return segments.get(1).map(|s| s.to_string());
}
return parsed
.query_pairs()
.find(|(k, _)| k == "v")
.map(|(_, v)| v.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_progress_download_prefix() {
assert_eq!(parse_progress_line("download: 45.2%"), Some(45.2));
}
#[test]
fn parse_progress_100_percent() {
assert_eq!(parse_progress_line("download:100.0%"), Some(100.0));
}
#[test]
fn parse_progress_bare_percent() {
assert_eq!(parse_progress_line(" 92.5%"), Some(92.5));
}
#[test]
fn parse_progress_integer() {
assert_eq!(parse_progress_line("download:100%"), Some(100.0));
}
#[test]
fn parse_progress_with_eta_field() {
assert_eq!(parse_progress_line("download: 45.2%|eta:30"), Some(45.2));
}
#[test]
fn parse_eta_extracts_seconds() {
assert_eq!(parse_eta_line("download: 45.2%|eta:30"), Some(30));
}
#[test]
fn parse_eta_na_returns_none() {
assert_eq!(parse_eta_line("download: 45.2%|eta:NA"), None);
}
#[test]
fn parse_eta_missing_returns_none() {
assert_eq!(parse_eta_line("download: 45.2%"), None);
}
#[test]
fn parse_eta_no_prefix_returns_none() {
assert_eq!(parse_eta_line(" 45.2%|eta:30"), None);
}
#[test]
fn parse_progress_garbage_returns_none() {
assert_eq!(parse_progress_line("[info] Writing video subtitles"), None);
}
#[test]
fn parse_progress_empty_returns_none() {
assert_eq!(parse_progress_line(""), None);
}
#[test]
fn parse_destination_standard() {
assert_eq!(
parse_destination_line("[download] Destination: /tmp/video.mp4"),
Some("/tmp/video.mp4".to_string())
);
}
#[test]
fn parse_destination_merger() {
assert_eq!(
parse_destination_line("[Merger] Merging formats into \"/tmp/video.mp4\""),
Some("/tmp/video.mp4".to_string())
);
}
#[test]
fn parse_destination_no_match() {
assert_eq!(parse_destination_line("[download] 100% of 50.0MiB"), None);
}
#[test]
fn parse_destination_empty_path_returns_none() {
assert_eq!(parse_destination_line("[download] Destination:"), None);
}
#[test]
fn is_youtube_url_standard() {
assert!(is_youtube_url(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
));
}
#[test]
fn is_youtube_url_short() {
assert!(is_youtube_url("https://youtu.be/dQw4w9WgXcQ"));
}
#[test]
fn is_youtube_url_case_insensitive() {
assert!(is_youtube_url("https://www.YouTube.com/watch?v=test"));
}
#[test]
fn is_youtube_url_other_site() {
assert!(!is_youtube_url("https://vimeo.com/123456"));
}
#[test]
fn sanitize_strips_query_params() {
let input = "Error downloading https://example.com/video?token=secret&key=123 failed";
let result = sanitize_log_line(input);
assert_eq!(result, "Error downloading https://example.com/video failed");
}
#[test]
fn sanitize_preserves_url_without_query() {
let input = "Error downloading https://example.com/video failed";
let result = sanitize_log_line(input);
assert_eq!(result, input);
}
#[test]
fn sanitize_multiple_urls() {
let input = "from https://a.com/x?s=1 to https://b.com/y?t=2 done";
let result = sanitize_log_line(input);
assert_eq!(result, "from https://a.com/x to https://b.com/y done");
}
#[test]
fn sanitize_no_urls() {
let input = "plain error message";
let result = sanitize_log_line(input);
assert_eq!(result, input);
}
#[test]
fn extract_id_youtube_standard() {
assert_eq!(
extract_id_from_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
Some("dQw4w9WgXcQ".to_string())
);
}
#[test]
fn extract_id_youtu_be() {
assert_eq!(
extract_id_from_url("https://youtu.be/dQw4w9WgXcQ"),
Some("dQw4w9WgXcQ".to_string())
);
}
#[test]
fn extract_id_youtube_embed() {
assert_eq!(
extract_id_from_url("https://www.youtube.com/embed/dQw4w9WgXcQ"),
Some("dQw4w9WgXcQ".to_string())
);
}
#[test]
fn extract_id_shorts() {
assert_eq!(
extract_id_from_url("https://www.youtube.com/shorts/abc123"),
Some("abc123".to_string())
);
}
#[test]
fn extract_id_non_youtube() {
assert_eq!(extract_id_from_url("https://vimeo.com/123456"), None);
}
#[test]
fn translate_error_429() {
let err = translate_ytdlp_error("HTTP Error 429: Too Many Requests");
assert!(err.to_string().contains("429"));
}
#[test]
fn translate_error_403() {
let err = translate_ytdlp_error("HTTP Error 403: Forbidden");
assert!(err.to_string().contains("403"));
}
#[test]
fn translate_error_nsig() {
let err = translate_ytdlp_error("nsig extraction failed");
assert!(err.to_string().contains("extraction"));
}
#[test]
fn translate_error_unavailable() {
let err = translate_ytdlp_error("Video unavailable");
assert!(err.to_string().contains("unavailable"));
}
#[test]
fn translate_error_requested_format() {
let err = translate_ytdlp_error("ERROR: Requested format is not available. Use --list-formats for a list of available formats");
assert!(err.to_string().contains("Requested format"), "Got: {}", err);
assert!(
!err.to_string().contains("Video unavailable"),
"Should not contain 'Video unavailable', got: {}",
err
);
}
#[test]
fn translate_error_private() {
let err = translate_ytdlp_error("This is a private video");
assert!(err.to_string().contains("private"));
}
#[test]
fn translate_error_timeout() {
let err = translate_ytdlp_error("Connection timed out");
assert!(err.to_string().contains("timed out"));
}
#[test]
fn translate_error_unknown_falls_through() {
let err = translate_ytdlp_error("ERROR: some unknown thing happened");
assert!(err.to_string().contains("yt-dlp"));
}
#[test]
fn parse_formats_empty_json() {
let json = serde_json::json!({});
assert!(parse_formats(&json).is_empty());
}
#[test]
fn parse_formats_extracts_fields() {
let json = serde_json::json!({
"formats": [
{
"format_id": "22",
"ext": "mp4",
"width": 1280,
"height": 720,
"fps": 30.0,
"vcodec": "avc1.64001F",
"acodec": "mp4a.40.2",
"filesize": 50_000_000u64,
"tbr": 2500.0,
"format_note": "720p"
}
]
});
let formats = parse_formats(&json);
assert_eq!(formats.len(), 1);
assert_eq!(formats[0].format_id, "22");
assert_eq!(formats[0].height, Some(720));
assert!(formats[0].has_video);
assert!(formats[0].has_audio);
assert_eq!(formats[0].resolution, Some("1280x720".to_string()));
}
#[test]
fn parse_formats_video_only() {
let json = serde_json::json!({
"formats": [
{
"format_id": "137",
"ext": "mp4",
"width": 1920,
"height": 1080,
"vcodec": "avc1.640028",
"acodec": "none"
}
]
});
let formats = parse_formats(&json);
assert_eq!(formats.len(), 1);
assert!(formats[0].has_video);
assert!(!formats[0].has_audio);
}
}