#[path = "yt_engine.rs"]
mod yt_engine;
use yt_engine::{Client, Format, Kind, Plan, Solver};
struct Args {
url: String,
quality: String,
output: Option<String>,
list: bool,
get_url: bool,
client: Client,
audio_only: bool,
no_merge: bool,
pot: String,
browser_pot: bool,
proxy: Option<String>,
}
fn parse_args() -> Option<Args> {
let mut a = Args {
url: String::new(),
quality: "best".into(),
output: None,
list: false,
get_url: false,
client: Client::Tv,
audio_only: false,
no_merge: false,
pot: String::new(),
browser_pot: false,
proxy: std::env::var("YT_PROXY")
.ok()
.filter(|s| !s.trim().is_empty()),
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"-q" | "--quality" => a.quality = it.next()?,
"-o" | "--output" => a.output = it.next(),
"--list" => a.list = true,
"-g" | "--get-url" | "--url-only" => a.get_url = true,
"-x" | "--proxy" => a.proxy = it.next().filter(|s| !s.trim().is_empty()),
"--client" => {
a.client = if it.next().as_deref() == Some("web") {
Client::Web
} else {
Client::Tv
}
}
"--audio-only" => a.audio_only = true,
"--no-merge" => a.no_merge = true,
"--pot" => a.pot = it.next().unwrap_or_default(),
"--browser-pot" => a.browser_pot = true,
s if !s.starts_with('-') => a.url = s.to_string(),
_ => {}
}
}
if a.url.is_empty() { None } else { Some(a) }
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let Some(mut args) = parse_args() else {
eprintln!(
"用法:cargo run --example yt_dl --features signer,cdp -- <URL|videoId> [-q best|1080|audio|itag:N] [-o name] [--list] [-g|--get-url] [-x|--proxy URL] [--client tv|web] [--audio-only] [--no-merge] [--pot TOKEN] [--browser-pot]"
);
std::process::exit(2);
};
if args.audio_only {
args.quality = "audio".into();
}
let Some(video_id) = yt_engine::extract_video_id(&args.url) else {
eprintln!("[!] 无法从输入解析 videoId: {}", args.url);
std::process::exit(2);
};
let client = build_client(args.proxy.as_deref());
let watch_url = format!("https://www.youtube.com/watch?v={video_id}");
eprintln!("[*] 抓取与解析(无浏览器)…");
let Some(html) = yt_engine::fetch_text(&client, &watch_url, yt_engine::UA_WEB).await else {
eprintln!("[!] 抓 watch 页失败");
std::process::exit(1);
};
let Some(mut meta) = yt_engine::parse_watch_meta(&html) else {
eprintln!("[!] 解析 watch 元数据失败");
std::process::exit(1);
};
let Some(base_js) = yt_engine::fetch_text(&client, &meta.base_js_url, yt_engine::UA_WEB).await
else {
eprintln!("[!] 抓 base.js 失败");
std::process::exit(1);
};
let sts = yt_engine::parse_sts(&base_js).unwrap_or(0);
eprintln!(
" base.js={} | sts={sts}",
meta.base_js_url.rsplit('/').nth(3).unwrap_or("?")
);
let mut kind = args.client;
if args.browser_pot {
match capture_browser_pot(&watch_url, args.proxy.as_deref()).await {
Some((pot, visitor)) => {
eprintln!(
" [pot] 浏览器捕获 pot({}…) + visitor 透传,切 WEB 客户端",
&pot[..pot.len().min(12)]
);
args.pot = pot;
if !visitor.is_empty() {
meta.visitor_data = visitor;
}
kind = Client::Web;
}
None => eprintln!(" [pot] 浏览器捕获失败,退回默认客户端"),
}
}
let mut pr =
match yt_engine::innertube_player(&client, &video_id, kind, &meta, sts, &args.pot).await {
Some(p) => p,
None => {
eprintln!("[!] InnerTube player 请求失败");
std::process::exit(1);
}
};
let mut formats = yt_engine::list_formats(&pr);
for attempt in 1..=4 {
if args.quality == "audio" || formats.iter().any(|f| f.kind == yt_engine::Kind::Video) {
break;
}
eprintln!(
" [~] InnerTube 降级(可用格式={},无 adaptive 分轨;TV progressive 可能串视频)→ 第 {attempt} 次重试…",
formats.len()
);
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
if let Some(p2) =
yt_engine::innertube_player(&client, &video_id, kind, &meta, sts, &args.pot).await
{
pr = p2;
formats = yt_engine::list_formats(&pr);
}
}
let status = pr
.pointer("/playabilityStatus/status")
.and_then(|v| v.as_str())
.unwrap_or("?");
let title = pr
.pointer("/videoDetails/title")
.and_then(|v| v.as_str())
.unwrap_or("video")
.to_string();
eprintln!(
" playability={status} | 标题={} | 可用格式={}",
trunc(&title, 48),
formats.len()
);
if formats.is_empty() {
eprintln!("[!] 无可用(带 url/cipher)格式——该视频可能需要 pot/登录;试 --browser-pot。");
std::process::exit(1);
}
if args.list {
print_table(&formats);
return;
}
let Some(plan) = yt_engine::select_plan(&formats, &args.quality) else {
eprintln!("[!] 没有匹配清晰度 {} 的格式", args.quality);
std::process::exit(1);
};
eprintln!("[*] 内嵌 QuickJS 跑 base.js,自算 sig/n 直链 …");
let Some(solver) = Solver::new(&base_js) else {
eprintln!("[!] QuickJS solver 初始化失败");
std::process::exit(1);
};
if !solver.is_ready() {
eprintln!("[!] solver 未就位(base.js 结构变化?)");
std::process::exit(1);
}
if args.get_url {
match &plan {
Plan::Single(f) => {
let url = yt_engine::with_pot(&solver.url_for(f), &args.pot);
if bad_url(&url) {
eprintln!("[!] 直链计算失败:{url}");
std::process::exit(1);
}
eprintln!(
"[*] itag={} {} {}(单条直链)",
f.itag,
kindlabel(f),
f.quality
);
show_bound_ip(&url);
println!("{url}");
}
Plan::Mux(v, a) => {
let vurl = yt_engine::with_pot(&solver.url_for(v), &args.pot);
let aurl = yt_engine::with_pot(&solver.url_for(a), &args.pot);
if bad_url(&vurl) || bad_url(&aurl) {
eprintln!(
"[!] 直链计算失败 v={} a={}",
trunc(&vurl, 40),
trunc(&aurl, 40)
);
std::process::exit(1);
}
show_bound_ip(&vurl);
eprintln!(
"[*] 分轨直链:第1行=视频 itag={} {} {};第2行=音频 itag={} {}kbps {}(高清本就分轨;要单条可直接播放的直链用 -q 18)",
v.itag,
v.quality,
v.codec_short(),
a.itag,
a.bitrate / 1000,
a.codec_short()
);
println!("{vurl}");
println!("{aurl}");
}
}
return;
}
let rm = Remint {
video_id: video_id.clone(),
kind,
meta: &meta,
sts,
pot: args.pot.clone(),
};
let stem = args.output.unwrap_or_else(|| sanitize(&title));
match plan {
Plan::Single(f) => {
let url = yt_engine::with_pot(&solver.url_for(&f), &args.pot);
if bad_url(&url) {
eprintln!("[!] 直链计算失败:{url}");
std::process::exit(1);
}
show_bound_ip(&url);
let out = format!("{stem}.{}", f.ext());
println!(
"[*] 下载 itag={} {} {} → {out}",
f.itag,
kindlabel(&f),
f.quality
);
dl_retry(&client, &solver, &rm, f.itag, url, &out, "媒体").await;
println!("[✅] 完成:{out}\n {}", yt_engine::ffprobe_brief(&out));
}
Plan::Mux(v, a) => {
let vurl = yt_engine::with_pot(&solver.url_for(&v), &args.pot);
let aurl = yt_engine::with_pot(&solver.url_for(&a), &args.pot);
if std::env::var("YT_DEBUG").is_ok() {
println!("[debug] vurl={vurl}");
}
if bad_url(&vurl) || bad_url(&aurl) {
eprintln!(
"[!] 直链计算失败 v={} a={}",
trunc(&vurl, 40),
trunc(&aurl, 40)
);
std::process::exit(1);
}
show_bound_ip(&vurl);
let vtmp = format!("{stem}.v.{}", v.ext());
let atmp = format!("{stem}.a.{}", a.ext());
println!(
"[*] 视频 itag={} {}{} ({}) + 音频 itag={} {}kbps ({})",
v.itag,
v.quality,
if v.fps > 30 {
format!("{}fps", v.fps)
} else {
String::new()
},
v.codec_short(),
a.itag,
a.bitrate / 1000,
a.codec_short()
);
dl_retry(&client, &solver, &rm, v.itag, vurl, &vtmp, "视频").await;
dl_retry(&client, &solver, &rm, a.itag, aurl, &atmp, "音频").await;
if args.no_merge {
println!("[✅] 已分开保存:{vtmp} + {atmp}(--no-merge)");
return;
}
println!("[*] ffmpeg 合并(copy,不转码)…");
match yt_engine::ffmpeg_merge(&vtmp, &atmp, &stem, &v, &a) {
Ok(out) => {
let _ = std::fs::remove_file(&vtmp);
let _ = std::fs::remove_file(&atmp);
println!("[✅] 完成:{out}\n {}", yt_engine::ffprobe_brief(&out));
}
Err(e) => eprintln!("[!] 合并失败:{e}(分轨保留:{vtmp} + {atmp})"),
}
}
}
}
fn build_client(proxy: Option<&str>) -> reqwest::Client {
struct Ipv4Only;
impl reqwest::dns::Resolve for Ipv4Only {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
Box::pin(async move {
let host = format!("{}:0", name.as_str());
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(host)
.await?
.filter(std::net::SocketAddr::is_ipv4)
.collect();
Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
})
}
}
let mut b = reqwest::Client::builder();
if std::env::var("YT_IPV4")
.ok()
.filter(|s| !s.trim().is_empty())
.is_some()
{
b = b.dns_resolver(std::sync::Arc::new(Ipv4Only));
eprintln!(
"[*] 强制 IPv4 出站(YT_IPV4):只解析 A 记录,治 WARP IPv6 出口抖动导致的 adaptive 403"
);
}
if let Some(p) = proxy.filter(|s| !s.trim().is_empty()) {
match reqwest::Proxy::all(p) {
Ok(px) => {
eprintln!("[*] 出口代理:{p}(铸链与下载全程同一出口 → 直链 ip= 即该出口)");
b = b.proxy(px);
}
Err(e) => eprintln!("[!] 代理地址无效 {p}: {e} —— 忽略代理,走默认路由"),
}
}
b.build().expect("reqwest")
}
fn show_bound_ip(url: &str) {
if let Some(ip) = yt_engine::param_of(url, "ip") {
eprintln!(
" [出口] 直链绑定 ip={ip} —— 仅该出口可用;换设备/网络须用同一出口(同一代理)重铸"
);
}
}
struct Remint<'a> {
video_id: String,
kind: Client,
meta: &'a yt_engine::WatchMeta,
sts: u64,
pot: String,
}
async fn dl_retry(
client: &reqwest::Client,
solver: &Solver,
rm: &Remint<'_>,
itag: i64,
first_url: String,
path: &str,
label: &str,
) {
const TRIES: usize = 4;
let mut url = first_url;
for attempt in 1..=TRIES {
match yt_engine::download_with_progress(client, &url, path, label).await {
Ok(_) => return,
Err(e) if e.contains("403") && attempt < TRIES => {
eprintln!(
"\n[~] {label} 第 {attempt} 次 HTTP 403(代理出口与铸链 ip= 不一致?WARP/轮换代理常见)→ 退避后重新铸链重试…"
);
tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await;
match solve_fresh_url(client, solver, rm, itag).await {
Some(u) => url = u,
None => {
eprintln!("[!] 重新铸链失败(InnerTube 无该 itag?)");
std::process::exit(1);
}
}
}
Err(e) => {
eprintln!(
"\n[!] 下载失败({label}):{e}\n 受限视频可试 --browser-pot;若走 WARP/轮换代理建议固定出口或直连(403 多因出口 IP 与直链 ip= 不符)"
);
std::process::exit(1);
}
}
}
}
async fn solve_fresh_url(
client: &reqwest::Client,
solver: &Solver,
rm: &Remint<'_>,
itag: i64,
) -> Option<String> {
let pr = yt_engine::innertube_player(client, &rm.video_id, rm.kind, rm.meta, rm.sts, &rm.pot)
.await?;
let f = yt_engine::list_formats(&pr)
.into_iter()
.find(|f| f.itag == itag)?;
let u = yt_engine::with_pot(&solver.url_for(&f), &rm.pot);
u.starts_with("http").then_some(u)
}
#[cfg(feature = "cdp")]
async fn capture_browser_pot(watch_url: &str, proxy: Option<&str>) -> Option<(String, String)> {
use drission::prelude::*;
let mut opts = ChromiumOptions::new().headless(true);
if let Some(p) = proxy.filter(|s| !s.trim().is_empty()) {
opts = opts.proxy(p);
}
let browser = ChromiumBrowser::launch(opts).await.ok()?;
let tab = browser.new_tab(Some("about:blank")).await.ok()?;
let listener = tab.listen();
listener.start(&["videoplayback"]).await.ok()?;
tab.get(watch_url).await.ok()?;
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tab
.run_js("setTimeout(function(){try{var v=document.querySelector('video');if(v){v.muted=true;v.play&&v.play()}}catch(e){}},0);1")
.await;
let visitor = tab
.run_js("(window.ytcfg&&ytcfg.get&&ytcfg.get('VISITOR_DATA'))||(window.ytInitialPlayerResponse&&ytInitialPlayerResponse.responseContext&&ytInitialPlayerResponse.responseContext.visitorData)||''")
.await
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
let mut pot = String::new();
for _ in 0..6 {
let packets = listener
.wait_count(10, Some(std::time::Duration::from_secs(2)))
.await
.unwrap_or_default();
if let Some(p) = packets
.iter()
.find_map(|p| yt_engine::param_of(&p.url, "pot"))
{
pot = p;
break;
}
}
let _ = browser.quit().await;
if pot.is_empty() {
None
} else {
Some((pot, visitor))
}
}
#[cfg(not(feature = "cdp"))]
async fn capture_browser_pot(_watch_url: &str, _proxy: Option<&str>) -> Option<(String, String)> {
eprintln!(" [pot] --browser-pot 需要 cdp feature(--features signer,cdp)");
None
}
fn print_table(formats: &[Format]) {
println!("\n itag 类型 清晰度 编码 码率 体积");
println!(" ---- ----- ------------ ----------- -------- ---------");
let mut fs: Vec<&Format> = formats.iter().collect();
fs.sort_by_key(|f| std::cmp::Reverse((f.height, f.bitrate)));
for f in fs {
println!(
" {:<4} {:<5} {:<12} {:<11} {:>7}k {:>9}",
f.itag,
kindlabel(f),
if f.quality.is_empty() {
format!("{}p", f.height)
} else {
f.quality.clone()
},
f.codec_short(),
f.bitrate / 1000,
f.human_size()
);
}
}
fn kindlabel(f: &Format) -> &'static str {
match f.kind {
Kind::Muxed => "音视",
Kind::Video => "视频",
Kind::Audio => "音频",
}
}
fn bad_url(u: &str) -> bool {
u.is_empty() || u.starts_with("ERR") || !u.starts_with("http")
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') {
c
} else {
'_'
}
})
.collect::<String>()
.trim()
.replace(' ', "_")
.chars()
.take(60)
.collect()
}
fn trunc(s: &str, n: usize) -> String {
let t: String = s.chars().take(n).collect();
if s.chars().count() > n {
format!("{t}…")
} else {
t
}
}