Skip to main content

kget/ytdlp/
mod.rs

1//! yt-dlp integration for downloading video content.
2//!
3//! Routes YouTube, Vimeo, and other video platform URLs through the `yt-dlp`
4//! command-line tool (or `youtube-dl` as a fallback) when available.
5
6use std::error::Error;
7use std::process::{Command, Stdio};
8use std::io::{BufRead, BufReader};
9
10/// Known video platform URL patterns.
11const 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
30/// Returns `true` if the URL points to a known video platform.
31pub fn is_video_url(url: &str) -> bool {
32    let lower = url.to_lowercase();
33    VIDEO_HOSTS.iter().any(|h| lower.contains(h))
34}
35
36/// Returns the name of the yt-dlp binary found in PATH, or `None`.
37pub 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    // Also try running the binary directly (in case `which` isn't available)
51    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
66/// Returns `true` if yt-dlp or youtube-dl is available in PATH.
67pub fn ytdlp_available() -> bool {
68    ytdlp_binary().is_some()
69}
70
71/// Quality preset for video downloads.
72///
73/// Passed to yt-dlp via `-f <format>`.
74#[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
115/// Download a video URL using yt-dlp.
116///
117/// Streams yt-dlp's progress lines through `status_cb` so callers can
118/// forward them to a progress bar or GUI.
119pub 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");   // one progress line per update
144
145    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    // Stream stdout lines (progress)
158    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}