simple_editor\media/
ytdlp.rs1use crate::engine::export::{self, Progress};
10use crate::media::ffpipe;
11use std::io::{BufRead, BufReader};
12use std::path::PathBuf;
13use std::process::Stdio;
14use std::sync::{Arc, Mutex};
15
16static DIR: Mutex<String> = Mutex::new(String::new());
17static CACHE: Mutex<Option<Option<PathBuf>>> = Mutex::new(None);
20
21pub fn set_dir(dir: &str) {
23 *DIR.lock().unwrap_or_else(|e| e.into_inner()) = dir.to_string();
24 *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = None;
25}
26
27pub fn exe() -> Option<PathBuf> {
36 if let Some(cached) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).clone() {
37 return cached;
38 }
39 let dir = DIR.lock().unwrap_or_else(|e| e.into_inner()).clone();
40 let found = ffpipe::find_all_exe("yt-dlp.exe", &dir)
41 .into_iter()
42 .filter_map(|p| version(&p).map(|v| (p, v)))
43 .max_by(|a, b| a.1.cmp(&b.1))
44 .map(|(p, _)| p);
45 *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some(found.clone());
46 found
47}
48
49fn version(exe: &std::path::Path) -> Option<String> {
52 let out = ffpipe::command(exe).arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
53 let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
54 (out.status.success() && !v.is_empty()).then_some(v)
55}
56
57pub fn default_dir() -> PathBuf {
59 let videos = std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("Videos"));
60 match videos {
61 Some(v) if v.is_dir() => v,
62 _ => std::env::temp_dir(),
63 }
64}
65
66#[derive(Clone, Debug)]
67pub struct DownloadOptions {
68 pub url: String,
69 pub dir: PathBuf,
70 pub audio_only: bool,
72}
73
74pub struct Download {
77 pub progress: Arc<Progress>,
78 pub url: String,
79 path: Arc<Mutex<Option<PathBuf>>>,
80}
81
82impl Download {
83 pub fn path(&self) -> Option<PathBuf> {
84 self.path.lock().ok().and_then(|p| p.clone())
85 }
86}
87
88fn parse_percent(line: &str) -> Option<f32> {
90 let v = line.strip_prefix("dl:")?.trim().trim_end_matches('%').trim();
91 v.parse::<f32>().ok().map(|p| (p / 100.0).clamp(0.0, 1.0))
93}
94
95fn annotate_if_outdated(err: &str) -> String {
99 if err == export::CANCELLED {
100 return err.to_string(); }
102 let lower = err.to_ascii_lowercase();
103 const SIGNS: [&str; 4] =
104 ["yt-dlp -u", "confirm you are on the latest version", "sign in to confirm", "please reload"];
105 if SIGNS.iter().any(|s| lower.contains(s)) {
106 let hint = "your yt-dlp looks outdated — update it and try again: \
107 yt-dlp -U (or `pip install -U yt-dlp` if it came from pip)";
108 format!("{err}\n\n{hint}")
109 } else {
110 err.to_string()
111 }
112}
113
114pub fn start_download(opts: DownloadOptions) -> Download {
115 let path: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
116 let url = opts.url.clone();
117 let out = path.clone();
118 let progress = export::spawn_job("ytdlp", move |prog| run(&opts, prog, &out));
119 Download { progress, url, path }
120}
121
122fn run(opts: &DownloadOptions, prog: &Progress, out: &Mutex<Option<PathBuf>>) -> Result<(), String> {
123 let exe = exe().ok_or("yt-dlp.exe not found")?;
124 if opts.url.trim().is_empty() {
125 return Err("No URL".into());
126 }
127 std::fs::create_dir_all(&opts.dir).map_err(|e| format!("{}: {e}", opts.dir.display()))?;
128 prog.set(0.0, "Starting…");
129
130 let mut cmd = ffpipe::command(&exe);
131 #[rustfmt::skip]
132 cmd.args([
133 "--no-playlist", "--newline", "--progress", "--no-warnings",
137 "--progress-template", "dl:%(progress._percent_str)s",
138 "--print", "after_move:filepath",
139 "-o", "%(title).100B [%(id)s].%(ext)s",
140 ]);
141 cmd.arg("-P").arg(&opts.dir);
142 if let Some(dir) = ffpipe::ffmpeg_exe().and_then(|p| p.parent().map(PathBuf::from)) {
144 cmd.arg("--ffmpeg-location").arg(dir);
145 }
146 if opts.audio_only {
147 cmd.arg("-x");
148 }
149 cmd.arg(&opts.url);
150 cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
151
152 let mut child = cmd.spawn().map_err(|e| format!("yt-dlp: {e}"))?;
153 let tail = export::stderr_tail(&mut child);
154 if let Some(stdout) = child.stdout.take() {
155 for line in BufReader::new(stdout).lines().map_while(Result::ok) {
156 if prog.is_cancelled() {
157 break; }
159 let line = line.trim().to_string();
160 match parse_percent(&line) {
161 Some(f) => prog.set(f, format!("Downloading… {:.0}%", f * 100.0)),
162 None if !line.is_empty() => {
165 prog.set(prog.fraction().max(0.99), "Finishing…");
166 *out.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(line));
167 }
168 None => {}
169 }
170 }
171 }
172 export::wait_ffmpeg(&mut child, tail, prog).map_err(|e| annotate_if_outdated(&e))?;
173
174 let file = out.lock().unwrap_or_else(|e| e.into_inner()).clone();
175 match file {
176 Some(f) if f.is_file() => {
177 prog.set(1.0, "Done");
178 Ok(())
179 }
180 _ => Err("yt-dlp finished but reported no output file".into()),
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn progress_lines_parse() {
191 assert!((parse_percent("dl: 45.2%").unwrap() - 0.452).abs() < 1e-6);
192 assert!((parse_percent("dl:100.0%").unwrap() - 1.0).abs() < 1e-6);
193 assert_eq!(parse_percent("dl: N/A%"), None);
194 assert_eq!(parse_percent(r"C:\Users\me\Videos\clip [abc].mp4"), None);
196 assert_eq!(parse_percent(""), None);
197 }
198
199 #[test]
200 fn outdated_yt_dlp_gets_an_update_hint() {
201 let bot_check = "ERROR: [youtube] abc123: Sign in to confirm you're not a bot.";
202 assert!(annotate_if_outdated(bot_check).contains("yt-dlp -U"));
203 let stale_extractor =
204 "ERROR: [youtube] abc123: some error; Confirm you are on the latest version using yt-dlp -U";
205 assert!(annotate_if_outdated(stale_extractor).contains("pip install -U yt-dlp"));
206 let unrelated = "ERROR: [youtube] abc123: Video unavailable";
208 assert_eq!(annotate_if_outdated(unrelated), unrelated);
209 assert_eq!(annotate_if_outdated(export::CANCELLED), export::CANCELLED);
210 }
211
212 #[test]
215 fn local_http_download() {
216 let Some(_) = exe() else { return };
217 let media = std::env::temp_dir().join("simple-editor-selftest");
218 if !media.join("test.mp4").is_file() {
219 return; }
221 let port = 8700 + (std::process::id() % 200) as u16;
222 let Ok(mut server) = std::process::Command::new("python")
223 .args(["-m", "http.server", &port.to_string(), "--bind", "127.0.0.1"])
224 .current_dir(&media)
225 .stdout(Stdio::null())
226 .stderr(Stdio::null())
227 .spawn()
228 else {
229 return; };
231 let addr = format!("127.0.0.1:{port}");
233 let mut up = false;
234 for _ in 0..50 {
235 if std::net::TcpStream::connect(&addr).is_ok() {
236 up = true;
237 break;
238 }
239 std::thread::sleep(std::time::Duration::from_millis(100));
240 }
241 if !up {
242 let _ = server.kill();
243 return;
244 }
245 let dir = std::env::temp_dir().join("se-ytdlp-test");
246 let _ = std::fs::remove_dir_all(&dir);
247 let d = start_download(DownloadOptions {
248 url: format!("http://{addr}/test.mp4"),
249 dir: dir.clone(),
250 audio_only: false,
251 });
252 let mut seen_progress = false;
253 for _ in 0..600 {
254 if d.progress.fraction() > 0.0 {
255 seen_progress = true;
256 }
257 if d.progress.is_done() {
258 break;
259 }
260 std::thread::sleep(std::time::Duration::from_millis(50));
261 }
262 let _ = server.kill();
263 let err = d.progress.error();
264 let path = d.path();
265 println!("done={} err={:?} path={:?} progress={}", d.progress.is_done(), err, path, seen_progress);
266 assert!(d.progress.is_done() && err.is_none(), "err {err:?}");
267 let p = path.expect("output path");
268 assert!(p.is_file(), "{} missing", p.display());
269 assert!(p.starts_with(&dir), "wrote outside the chosen folder: {}", p.display());
270 println!("downloaded {} bytes to {}", std::fs::metadata(&p).unwrap().len(), p.display());
271 let _ = std::fs::remove_dir_all(&dir);
272 }
273
274 #[test]
275 fn broken_shim_is_rejected() {
276 let dir = std::env::temp_dir().join(format!("se-ytdlp-broken-{}", std::process::id()));
278 std::fs::create_dir_all(&dir).unwrap();
279 let fake = dir.join("yt-dlp.exe");
280 std::fs::write(&fake, b"not an executable").unwrap();
281 assert!(version(&fake).is_none());
282 set_dir(&dir.to_string_lossy());
283 assert!(exe().is_none_or(|p| p != fake), "a broken shim was accepted");
284 set_dir("");
285 let _ = std::fs::remove_dir_all(&dir);
286 }
287
288 #[test]
289 fn default_dir_exists() {
290 assert!(default_dir().is_dir());
291 }
292
293 #[test]
294 fn missing_url_fails_without_spawning() {
295 if exe().is_none() {
296 return; }
298 let opts = DownloadOptions { url: " ".into(), dir: std::env::temp_dir(), audio_only: false };
299 let d = start_download(opts);
300 for _ in 0..200 {
301 if d.progress.is_done() {
302 break;
303 }
304 std::thread::sleep(std::time::Duration::from_millis(10));
305 }
306 assert!(d.progress.is_done());
307 assert_eq!(d.progress.error().as_deref(), Some("No URL"));
308 assert!(d.path().is_none());
309 }
310}