1use std::error::Error;
7use std::process::{Command, Stdio};
8use std::io::{BufRead, BufReader};
9
10const VIDEO_HOSTS: &[&str] = &[
12 "youtube.com/watch",
13 "youtube.com/shorts",
14 "youtu.be/",
15 "vimeo.com/",
16 "dailymotion.com/video",
17 "twitch.tv/",
18 "twitter.com/i/status",
19 "x.com/i/status",
20 "instagram.com/reel",
21 "instagram.com/p/",
22 "tiktok.com/",
23 "facebook.com/watch",
24 "facebook.com/videos",
25 "bilibili.com/video",
26 "rumble.com/v",
27 "odysee.com/@",
28];
29
30pub fn is_video_url(url: &str) -> bool {
32 let lower = url.to_lowercase();
33 VIDEO_HOSTS.iter().any(|h| lower.contains(h))
34}
35
36pub fn ytdlp_binary() -> Option<String> {
38 for candidate in &["yt-dlp", "youtube-dl"] {
39 let ok = Command::new("which")
40 .arg(candidate)
41 .stdout(Stdio::null())
42 .stderr(Stdio::null())
43 .status()
44 .map(|s| s.success())
45 .unwrap_or(false);
46 if ok {
47 return Some(candidate.to_string());
48 }
49 }
50 for candidate in &["yt-dlp", "youtube-dl"] {
52 let ok = Command::new(candidate)
53 .arg("--version")
54 .stdout(Stdio::null())
55 .stderr(Stdio::null())
56 .status()
57 .map(|s| s.success())
58 .unwrap_or(false);
59 if ok {
60 return Some(candidate.to_string());
61 }
62 }
63 None
64}
65
66pub fn ytdlp_available() -> bool {
68 ytdlp_binary().is_some()
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum VideoQuality {
76 Best,
77 P1080,
78 P720,
79 P480,
80 P360,
81 AudioOnly,
82 Custom(String),
83}
84
85impl VideoQuality {
86 pub fn from_str(s: &str) -> Self {
87 match s {
88 "1080p" | "1080" => VideoQuality::P1080,
89 "720p" | "720" => VideoQuality::P720,
90 "480p" | "480" => VideoQuality::P480,
91 "360p" | "360" => VideoQuality::P360,
92 "audio" => VideoQuality::AudioOnly,
93 "best" | "" => VideoQuality::Best,
94 other => VideoQuality::Custom(other.to_string()),
95 }
96 }
97
98 pub fn yt_dlp_format(&self) -> &str {
99 match self {
100 VideoQuality::Best => "bestvideo+bestaudio/best",
101 VideoQuality::P1080 => "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best",
102 VideoQuality::P720 => "bestvideo[height<=720]+bestaudio/best[height<=720]/best",
103 VideoQuality::P480 => "bestvideo[height<=480]+bestaudio/best[height<=480]/best",
104 VideoQuality::P360 => "bestvideo[height<=360]+bestaudio/best[height<=360]/best",
105 VideoQuality::AudioOnly => "bestaudio/best",
106 VideoQuality::Custom(f) => f,
107 }
108 }
109}
110
111impl Default for VideoQuality {
112 fn default() -> Self { VideoQuality::Best }
113}
114
115pub fn download_video<F>(
120 url: &str,
121 output_dir: &str,
122 quality: &VideoQuality,
123 quiet: bool,
124 status_cb: Option<F>,
125) -> Result<(), Box<dyn Error + Send + Sync>>
126where
127 F: Fn(String) + Send + Sync + 'static,
128{
129 let bin = ytdlp_binary().ok_or(
130 "yt-dlp not found. Install with: brew install yt-dlp (or pip install yt-dlp)"
131 )?;
132
133 let output_template = format!(
134 "{}/%(title)s.%(ext)s",
135 output_dir.trim_end_matches('/')
136 );
137
138 let mut cmd = Command::new(&bin);
139 cmd.arg("-f").arg(quality.yt_dlp_format())
140 .arg("-o").arg(&output_template)
141 .arg("--merge-output-format").arg("mp4")
142 .arg("--no-playlist")
143 .arg("--newline"); if quiet {
146 cmd.arg("--quiet").arg("--no-warnings");
147 } else {
148 cmd.arg("--progress");
149 }
150
151 cmd.arg(url);
152 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
153
154 let mut child = cmd.spawn()
155 .map_err(|e| format!("Failed to start {bin}: {e}"))?;
156
157 if let Some(stdout) = child.stdout.take() {
159 let reader = BufReader::new(stdout);
160 for line in reader.lines().map_while(Result::ok) {
161 if let Some(ref cb) = status_cb {
162 cb(line);
163 }
164 }
165 }
166
167 let status = child.wait()
168 .map_err(|e| format!("yt-dlp process error: {e}"))?;
169
170 if status.success() {
171 Ok(())
172 } else {
173 Err(format!(
174 "yt-dlp exited with code {}",
175 status.code().unwrap_or(-1)
176 ).into())
177 }
178}