simple_editor\media/
ytdlp.rs

1//! URL import: download media from a link with `yt-dlp.exe`, the same child-process pattern
2//! `ffpipe.rs` uses for ffmpeg. Entirely optional — `exe()` returns None when yt-dlp is not installed
3//! and the Library then hides its "Import URL…" button.
4//!
5//! yt-dlp is driven with `--print after_move:filepath` (prints the final file, and because a later
6//! stage is named it does not imply `--simulate`) plus `--progress --newline --progress-template` so
7//! progress and the resulting path both arrive as lines on stdout.
8
9use 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());
17// checking every candidate's --version (below) is a handful of process spawns, so the result is cached
18// until set_dir invalidates it — cheap enough at start-up, too slow to redo on every download.
19static CACHE: Mutex<Option<Option<PathBuf>>> = Mutex::new(None);
20
21/// Set the user-configured yt-dlp directory ("" = app dir, then PATH).
22pub 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
27/// The best working `yt-dlp.exe`, or None when none is installed (the URL import is hidden then).
28///
29/// Each candidate is verified with `--version`, because a PATH entry can hold a broken pip shim (one
30/// left behind by an uninstalled Python exits 1 and prints nothing). Machines with more than one Python
31/// install commonly have several *working* copies at very different ages — yt-dlp ships near-weekly to
32/// keep up with site changes, so picking merely the first one found can land on a copy too stale to get
33/// past current extractor/bot-check logic ("Sign in to confirm you're not a bot" and friends). The
34/// version string sorts as "YYYY.MM.DD[.N]", so the newest wins.
35pub 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
49/// This exe's version string ("2026.08.19"), or None if it does not actually run (a broken shim exits
50/// non-zero and prints nothing).
51fn 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
57/// Where downloads go by default: the user's Videos folder, else the temp dir.
58pub 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    /// Extract the audio only (needs ffmpeg, which yt-dlp is pointed at when we have it).
71    pub audio_only: bool,
72}
73
74/// A running download. `progress` drives the UI (and `progress.cancel` kills yt-dlp); `path()` is the
75/// downloaded file once `progress.is_done()` with no error.
76pub 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
88/// Percent from a `dl:` progress line ("dl:  45.2%" → 0.452). None for any other line.
89fn parse_percent(line: &str) -> Option<f32> {
90    let v = line.strip_prefix("dl:")?.trim().trim_end_matches('%').trim();
91    // yt-dlp paints "  N/A%" before the size is known
92    v.parse::<f32>().ok().map(|p| (p / 100.0).clamp(0.0, 1.0))
93}
94
95/// yt-dlp's own stderr, tacking on an update hint when the message is one it (or YouTube's bot-check)
96/// prints for a stale extractor — these are real yt-dlp errors, not ours, but "install a newer yt-dlp"
97/// is the actual fix often enough that it is worth saying plainly instead of leaving the user to guess.
98fn annotate_if_outdated(err: &str) -> String {
99    if err == export::CANCELLED {
100        return err.to_string(); // not a failure — the caller reports it as a cancel, not an error
101    }
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",          // a link inside a playlist must not pull the whole playlist
134        "--newline",              // one progress update per line
135        "--progress",             // --print implies --quiet; keep the progress lines
136        "--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    // let yt-dlp merge/extract with the ffmpeg we already located, even when it is not on PATH
143    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; // wait_ffmpeg kills the child and reports CANCELLED
158            }
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                // any other line is the `--print` filepath (the last one wins: audio extraction
163                // prints the container first, then the extracted file)
164                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        // yt-dlp exited 0 but printed no usable path (e.g. the file was already there under another name)
181        _ => 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        // a printed path is not progress
195        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        // unrelated failures are passed through untouched
207        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    /// The whole download path against a LOCAL http server — no third-party site is contacted.
213    /// Skips itself when yt-dlp, python or the test media are unavailable, so it is never flaky.
214    #[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; // `--selftest` has not generated the media on this machine
220        }
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; // no python on this machine
230        };
231        // wait for the port to accept connections (and give up rather than fail if it never does)
232        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        // an .exe that is not a program at all must not count as "installed"
277        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; // yt-dlp not installed on this machine: nothing to check
297        }
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}