use std::{
process::Stdio,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex, OnceLock,
},
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use super::{run_command_with_timeout, ClientEvent};
const DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(10);
pub(crate) const PLAYER_CLIENTS: &[&str] = &["ios", "android", "web", "tv", "mweb", "web_embedded"];
pub(crate) const STREAM_FORMAT: &str = "bestaudio[ext=m4a]/bestaudio/best[ext=mp4]/best";
pub(crate) const DOWNLOAD_FORMAT: &str = "bestaudio/best";
const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
pub(crate) fn is_youtube_url(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
lower.contains("youtube.com") || lower.contains("youtu.be") || lower.contains("music.youtube")
}
pub(crate) fn pick_player_client(url: &str, format_selector: &str) -> Option<String> {
if !is_youtube_url(url) {
return None;
}
let yt_dlp = crate::deps::resolve_yt_dlp()?;
let done = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel::<String>();
for &client in PLAYER_CLIENTS {
let tx = tx.clone();
let done = done.clone();
let path = yt_dlp.clone();
let url = url.to_string();
let format = format_selector.to_string();
let client = client.to_string();
std::thread::spawn(move || {
if done.load(Ordering::SeqCst) {
return;
}
if probe_client_has_format(&path, &url, &format, &client, &done) {
done.store(true, Ordering::SeqCst);
let _ = tx.send(client);
}
});
}
drop(tx);
let winner = rx.recv_timeout(PROBE_TIMEOUT + Duration::from_secs(5)).ok();
done.store(true, Ordering::SeqCst);
winner
}
static CACHED_CLIENT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
pub(crate) fn cached_client() -> Option<String> {
CACHED_CLIENT
.get_or_init(|| Mutex::new(None))
.lock()
.ok()
.and_then(|cached| cached.clone())
}
fn remember_client(client: &str) {
if let Ok(mut cached) = CACHED_CLIENT.get_or_init(|| Mutex::new(None)).lock() {
*cached = Some(client.to_string());
}
}
pub(crate) fn forget_client() {
if let Ok(mut cached) = CACHED_CLIENT.get_or_init(|| Mutex::new(None)).lock() {
*cached = None;
}
}
pub(crate) fn is_client_failure(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
[
"requested format is not available",
"video unavailable",
"video is unavailable",
"sign in to confirm",
"not a bot",
"po token",
"login required",
]
.iter()
.any(|marker| lower.contains(marker))
}
pub(crate) fn resolve_player_client(
url: &str,
format_selector: &str,
emit: &dyn Fn(ClientEvent),
) -> (Option<String>, bool) {
if !is_youtube_url(url) {
return (None, false);
}
if let Some(cached) = cached_client() {
return (Some(cached), false);
}
if crate::deps::resolve_yt_dlp().is_none() {
return (None, false);
}
emit(ClientEvent::Resolving);
let winner = pick_player_client(url, format_selector);
match &winner {
Some(client) => {
remember_client(client);
emit(ClientEvent::Resolved(client.clone()));
}
None => emit(ClientEvent::Unavailable),
}
(winner, true)
}
fn probe_client_has_format(
yt_dlp: &std::path::Path,
url: &str,
format_selector: &str,
client: &str,
done: &AtomicBool,
) -> bool {
let extractor_arg = format!("youtube:player_client={client}");
let cookie_args = crate::deps::cookie_args();
let mut args = vec![
"--skip-download",
"--no-warnings",
"--no-playlist",
"--socket-timeout",
"10",
"-f",
format_selector,
"--print",
"format_id",
];
args.extend(cookie_args.iter().map(String::as_str));
args.extend(["--extractor-args", extractor_arg.as_str(), url]);
let Ok(mut child) = std::process::Command::new(yt_dlp)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let started = Instant::now();
loop {
if done.load(Ordering::SeqCst) {
let _ = child.kill();
let _ = child.wait();
return false;
}
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {
if started.elapsed() >= PROBE_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
return false;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => return false,
}
}
}
pub(crate) fn download_audio(url: &str, output_path: &str, extra_args: &[&str]) -> Result<String> {
let ext = "mp3";
let mut cmd = crate::deps::yt_dlp_command().context("Failed to download audio")?;
cmd.args([
"--extract-audio",
"--audio-format",
ext,
"--audio-quality",
"0",
"--output",
output_path,
"--no-warnings",
])
.args(extra_args)
.arg(url);
let output =
run_command_with_timeout(&mut cmd, DOWNLOAD_TIMEOUT).context("Failed to download audio")?;
if !output.status.success() {
anyhow::bail!(
"yt-dlp download failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output_path.replace("%(ext)s", ext))
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn lock_tests() -> std::sync::MutexGuard<'static, ()> {
TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn detects_youtube_urls() {
assert!(is_youtube_url("https://www.youtube.com/watch?v=abc123"));
assert!(is_youtube_url("https://youtu.be/abc123"));
assert!(is_youtube_url("https://music.youtube.com/watch?v=abc123"));
assert!(!is_youtube_url("https://soundcloud.com/artist/track"));
assert!(!is_youtube_url("https://example.com/audio.mp3"));
}
#[test]
fn skips_probe_for_non_youtube_urls() {
assert!(pick_player_client("https://soundcloud.com/a/b", STREAM_FORMAT).is_none());
}
#[test]
fn winner_cache_remembers_and_forgets() {
let _guard = lock_tests();
forget_client();
assert!(cached_client().is_none());
remember_client("ios");
assert_eq!(cached_client().as_deref(), Some("ios"));
forget_client();
assert!(cached_client().is_none());
}
#[test]
fn resolve_stays_silent_for_non_youtube_urls() {
let _guard = lock_tests();
forget_client();
let events = std::cell::RefCell::new(Vec::new());
let (winner, raced) =
resolve_player_client("https://soundcloud.com/a/b", STREAM_FORMAT, &|e| {
events.borrow_mut().push(format!("{e:?}"));
});
assert!(winner.is_none() && !raced);
assert!(events.borrow().is_empty());
}
#[test]
fn detects_client_failures() {
assert!(is_client_failure(
"ERROR: [youtube] abc: Requested format is not available"
));
assert!(is_client_failure("ERROR: This video is unavailable"));
assert!(is_client_failure("Sign in to confirm you're not a bot"));
assert!(is_client_failure("Failed to fetch PO Token for web client"));
assert!(is_client_failure(
"ERROR: [youtube] abc: Video is unavailable"
));
assert!(!is_client_failure("ERROR: Service unavailable (HTTP 503)"));
assert!(!is_client_failure(
"ERROR: Unable to download webpage: HTTP Error 500"
));
assert!(!is_client_failure(
"ERROR: HTTP Error 500: Internal Server Error"
));
assert!(!is_client_failure(""));
}
}