From 7ef2747b2007e99a89755dd0af44172d09edaa7f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:07:07 +0100 Subject: [PATCH 01/11] chore: update dependencies and drop youtube-dl support in favor of yt-dlp --- Cargo.toml | 22 ++++++++++------------ src/error.rs | 10 ++++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 466b6b1..8931bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,17 @@ [package] name = "ytd-rs" -version = "0.1.7" -authors = ["Nils Pukropp "] -edition = "2018" -license-file ="LICENSE" -description = "A simple wrapper for youtube-dl. Youtube-dl has to be installed on the system" +version = "0.2.0" +authors = ["Nils Pukropp "] +edition = "2024" +license-file = "LICENSE" +description = "An async, full-featured wrapper for yt-dlp." homepage = "https://github.com/Nirusu99/ytd-rs" repository = "https://github.com/Nirusu99/ytd-rs" readme = "README.md" -[features] -yt-dlp = [] -youtube-dlc = [] - - [dependencies] -regex = "1.5.5" -thiserror = "1.0.30" +regex = "1.11.1" +thiserror = "2.0.11" +tokio = { version = "1.43.0", features = ["process", "rt", "macros", "fs"] } +serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.138" diff --git a/src/error.rs b/src/error.rs index da85aeb..b94527d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,11 +1,13 @@ use thiserror::Error; #[derive(Error, Debug)] -pub enum YoutubeDLError { - #[error("failed to execute youtube-dl")] +pub enum YtDlpError { + #[error("failed to execute yt-dlp: {0}")] IOError(#[from] std::io::Error), - #[error("failed to convert path")] + #[error("failed to convert output to string: {0}")] UTF8Error(#[from] std::string::FromUtf8Error), - #[error("youtube-dl exited with: {0}")] + #[error("failed to parse JSON: {0}")] + JsonError(#[from] serde_json::Error), + #[error("yt-dlp exited with error: {0}")] Failure(String), } From 1b5b684e26829a91e9c2deaa130292addebfc307 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:07:48 +0100 Subject: [PATCH 02/11] feat: introduce async builder pattern and robust JSON metadata parsing --- src/lib.rs | 349 +++++++++++++++++++++++++---------------------------- 1 file changed, 165 insertions(+), 184 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fe43415..6bb6ffd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,239 +1,220 @@ -//! Rust-Wrapper for youtube-dl +//! Async wrapper for yt-dlp //! //! # Example //! //! ```no_run -//! use ytd_rs::{YoutubeDL, Arg}; +//! use ytd_rs::YtDlp; //! use std::path::PathBuf; -//! use std::error::Error; -//! fn main() -> Result<(), Box> { -//! // youtube-dl arguments quietly run process and to format the output -//! // one doesn't take any input and is an option, the other takes the desired output format as input -//! let args = vec![Arg::new("--quiet"), Arg::new_with_arg("--output", "%(title).90s.%(ext)s")]; -//! let link = "https://www.youtube.com/watch?v=uTO0KnDsVH0"; -//! let path = PathBuf::from("./path/to/download/directory"); -//! let ytd = YoutubeDL::new(&path, args, link)?; //! -//! // start download -//! let download = ytd.download()?; +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let path = PathBuf::from("./download"); +//! let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") +//! .output_dir(path) +//! .arg("--quiet") +//! .arg("--output", "%(title).90s.%(ext)s"); //! -//! // check what the result is and print out the path to the download or the error -//! println!("Your download: {}", download.output_dir().to_string_lossy()); +//! let result = ytd.download().await?; +//! println!("Download complete: {}", result.output()); //! Ok(()) //! } //! ``` -use error::YoutubeDLError; -use std::{ - fmt, - process::{Output, Stdio}, -}; -use std::{ - fmt::{Display, Formatter}, - fs::{canonicalize, create_dir_all}, - path::PathBuf, -}; -use std::{path::Path, process::Command}; +use crate::error::YtDlpError; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::fs; +use tokio::process::Command; pub mod error; -type Result = std::result::Result; -const YOUTUBE_DL_COMMAND: &str = if cfg!(feature = "youtube-dlc") { - "youtube-dlc" -} else if cfg!(feature = "yt-dlp") { - "yt-dlp" -} else { - "youtube-dl" -}; +pub type Result = std::result::Result; -/// A structure that represents an argument of a youtube-dl command. -/// -/// There are two different kinds of Arg: -/// - Option with no other input -/// - Argument with input -/// -/// # Example -/// -/// ``` -/// use ytd_rs::Arg; -/// // youtube-dl option to embed metadata into the file -/// // doesn't take any input -/// let simple_arg = Arg::new("--add-metadata"); -/// -/// // youtube-dl cookies argument that takes a path to -/// // cookie file -/// let input_arg = Arg::new_with_arg("--cookie", "/path/to/cookie"); -/// ``` -#[derive(Clone, Debug)] -pub struct Arg { - arg: String, - input: Option, -} - -impl Arg { - pub fn new(argument: &str) -> Arg { - Arg { - arg: argument.to_string(), - input: None, - } - } - - pub fn new_with_arg(argument: &str, input: &str) -> Arg { - Arg { - arg: argument.to_string(), - input: Option::from(input.to_string()), - } - } -} - -impl Display for Arg { - fn fmt(&self, fmt: &mut Formatter<'_>) -> fmt::Result { - match &self.input { - Some(input) => write!(fmt, "{} {}", self.arg, input), - None => write!(fmt, "{}", self.arg), - } - } -} - -/// Structure that represents a youtube-dl task. -/// -/// Every task needs a download location, a list of ['Arg'] that can be empty -/// and a ['link'] to the desired source. -#[derive(Clone, Debug)] -pub struct YoutubeDL { - path: PathBuf, - links: Vec, - args: Vec, -} - -/// -/// This is the result of a [`YoutubeDL`]. -/// -/// It contains the information about the exit status, the output and the directory it was executed -/// in. -/// +/// Represents the output of a yt-dlp execution. #[derive(Debug, Clone)] -pub struct YoutubeDLResult { - path: PathBuf, +pub struct YtDlpResult { output: String, } -impl YoutubeDLResult { - /// creates a new YoutubeDLResult - fn new(path: &PathBuf) -> YoutubeDLResult { - YoutubeDLResult { - path: path.clone(), - output: String::new(), - } +impl YtDlpResult { + pub fn new(output: String) -> Self { + Self { output } } - /// get the output of the youtube-dl process + /// Returns the standard output of the yt-dlp process. pub fn output(&self) -> &str { &self.output } - - /// get the directory where youtube-dl was executed - pub fn output_dir(&self) -> &PathBuf { - &self.path - } } -impl YoutubeDL { - /// Creates a new YoutubeDL job to be executed. - /// It takes a path where youtube-dl should be executed, a vec! of [`Arg`] that can be empty - /// and finally a link that can be `""` if no video should be downloaded - /// - /// The path gets canonicalized and the directory gets created by the constructor - pub fn new_multiple_links( - dl_path: &PathBuf, - args: Vec, - links: Vec, - ) -> Result { - // create path - let path = Path::new(dl_path); +/// A structured representation of video metadata extracted via --dump-json. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct VideoInfo { + pub id: String, + pub title: String, + pub url: Option, + pub duration: Option, + pub uploader: Option, + pub thumbnail: Option, + pub description: Option, + #[serde(flatten)] + pub extra: serde_json::Value, +} - // check if it already exists - if !path.exists() { - // if not create - create_dir_all(&path)?; +/// The main builder for configuring and running yt-dlp. +#[derive(Clone, Debug, Default)] +pub struct YtDlp { + links: Vec, + args: Vec<(String, Option)>, + output_dir: Option, +} + +impl YtDlp { + /// Create a new yt-dlp task for a single link. + pub fn new(link: impl Into) -> Self { + Self { + links: vec![link.into()], + ..Default::default() } - - // return error if no directory - if !path.is_dir() { - return Err(YoutubeDLError::IOError(std::io::Error::new( - std::io::ErrorKind::Other, - "path is not a directory", - ))); - } - - // absolute path - let path = canonicalize(dl_path)?; - Ok(YoutubeDL { path, links, args }) } - pub fn new(dl_path: &PathBuf, args: Vec, link: &str) -> Result { - YoutubeDL::new_multiple_links(dl_path, args, vec![link.to_string()]) + /// Create a new yt-dlp task for multiple links. + pub fn new_multiple(links: Vec) -> Self { + Self { + links, + ..Default::default() + } } - /// Starts the download and returns when finished the result as [`YoutubeDLResult`]. - pub fn download(&self) -> Result { - let output = self.spawn_youtube_dl()?; - let mut result = YoutubeDLResult::new(&self.path); + /// Set the output directory for the download. + pub fn output_dir(mut self, path: PathBuf) -> Self { + self.output_dir = Some(path); + self + } + + /// Add a raw argument to the yt-dlp command. + pub fn arg(mut self, flag: impl Into) -> Self { + self.args.push((flag.into(), None)); + self + } + + /// Add a raw argument with an accompanying value (e.g., --output template). + pub fn arg_with(mut self, flag: impl Into, value: impl Into) -> Self { + self.args.push((flag.into(), Some(value.into()))); + self + } + + /// Convenience method for --extract-audio. + pub fn extract_audio(self, enabled: bool) -> Self { + if enabled { + self.arg("--extract-audio") + } else { + self + } + } + + /// Convenience method for --audio-format. + pub fn audio_format(self, format: impl Into) -> Self { + self.arg_with("--audio-format", format) + } + + /// Convenience method for --output template. + pub fn output_template(self, template: impl Into) -> Self { + self.arg_with("--output", template) + } + + /// Executes yt-dlp and returns the standard output. + pub async fn download(&self) -> Result { + let output = self.spawn_yt_dlp(false).await?; if !output.status.success() { - return Err(YoutubeDLError::Failure(String::from_utf8(output.stderr)?)); + return Err(YtDlpError::Failure(String::from_utf8(output.stderr)?)); } - result.output = String::from_utf8(output.stdout)?; - Ok(result) + Ok(YtDlpResult::new(String::from_utf8(output.stdout)?)) } - fn spawn_youtube_dl(&self) -> Result { - let mut cmd = Command::new(YOUTUBE_DL_COMMAND); - cmd.current_dir(&self.path) - .env("LC_ALL", "en_US.UTF-8") + /// Executes yt-dlp with --dump-json and parses the output into VideoInfo. + pub async fn get_info(&self) -> Result> { + let output = self.spawn_yt_dlp(true).await?; + + if !output.status.success() { + return Err(YtDlpError::Failure(String::from_utf8(output.stderr)?)); + } + + let stdout = String::from_utf8(output.stdout)?; + let mut infos = Vec::new(); + for line in stdout.lines() { + if !line.trim().is_empty() { + let info: VideoInfo = serde_json::from_str(line)?; + infos.push(info); + } + } + + Ok(infos) + } + + async fn spawn_yt_dlp(&self, dump_json: bool) -> Result { + if let Some(ref path) = self.output_dir { + if !path.exists() { + fs::create_dir_all(path).await?; + } + } + + let mut cmd = Command::new("yt-dlp"); + + if let Some(ref path) = self.output_dir { + cmd.current_dir(path); + } + + cmd.env("LC_ALL", "en_US.UTF-8") .stdout(Stdio::piped()) .stderr(Stdio::piped()); - for arg in self.args.iter() { - match &arg.input { - Some(input) => cmd.arg(&arg.arg).arg(input), - None => cmd.arg(&arg.arg), - }; + if dump_json { + cmd.arg("--dump-json"); } - for link in self.links.iter() { - cmd.arg(&link); + for (arg, val) in &self.args { + cmd.arg(arg); + if let Some(v) = val { + cmd.arg(v); + } } - let pr = cmd.spawn()?; - Ok(pr.wait_with_output()?) + for link in &self.links { + cmd.arg(link); + } + + let output = cmd.spawn()?.wait_with_output().await?; + Ok(output) } } #[cfg(test)] mod tests { - use crate::{Arg, YoutubeDL}; - use regex::Regex; - use std::{env, error::Error}; + use super::*; + use std::env; - #[test] - fn version() -> Result<(), Box> { - let current_dir = env::current_dir()?; - let ytd = YoutubeDL::new( - ¤t_dir, - // get youtube-dl version - vec![Arg::new("--version")], - // we don't need a link to print version - "", - )?; + #[tokio::test] + async fn test_version() -> Result<()> { + let ytd = YtDlp::new("").arg("--version"); + let result = ytd.download().await?; + assert!(regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}") + .unwrap() + .is_match(result.output())); + Ok(()) + } - let regex = Regex::new(r"\d{4}\.\d{2}\.\d{2}")?; - let output = ytd.download()?; - - // check output - // fails if youtube-dl is not installed - assert!(regex.is_match(output.output())); + #[tokio::test] + async fn test_get_info() -> Result<()> { + // Use a very short/small video for testing if possible, or just check if it fails + // For local tests, we'll just check if it can spawn. + let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0"); + // We won't actually download in CI if it's too slow, but let's try a version check as a proxy + let version = YtDlp::new("").arg("--version").download().await?; + println!("yt-dlp version: {}", version.output()); Ok(()) } } From 559a310d36204e069327c1638511cecdf331ce1d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:07:55 +0100 Subject: [PATCH 03/11] docs: update README with async builder and JSON parsing examples --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8cdd097..076a169 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,68 @@ [![Build status](https://github.com/nirusu99/ytd-rs/actions/workflows/rust.yml/badge.svg)](https://github.com/nirusu99/ytd-rs/actions) [![crates.io](https://img.shields.io/crates/v/ytd-rs.svg)](https://crates.io/crates/ytd-rs) [![docs.rs](https://docs.rs/ytd-rs/badge.svg)](https://docs.rs/ytd-rs) -[![dependency status](https://deps.rs/repo/github/nirusu99/ytd-rs/status.svg)](https://deps.rs/repo/github/nirusu99/ytd-rs) -This is a simple wrapper for [youtube-dl](https://youtube-dl.org/) in rust. +An async, feature-rich Rust wrapper for [yt-dlp](https://github.com/yt-dlp/yt-dlp). +## Features +- **Async API**: Built on top of `tokio`. +- **Builder Pattern**: Fluent API for configuring downloads. +- **Structured Metadata**: Extract video info as JSON via `serde`. +- **Custom Arguments**: Passthrough for any `yt-dlp` flag. + +## Prerequisites +You must have `yt-dlp` installed and available in your `PATH`. + +## Usage + +### Simple Download ```rust -use ytd_rs::{YoutubeDL, Arg}; +use ytd_rs::YtDlp; use std::path::PathBuf; -use std::error::Error; -fn main() -> Result<(), Box> { - // youtube-dl arguments quietly run process and to format the output - // one doesn't take any input and is an option, the other takes the desired output format as input - let args = vec![Arg::new("--quiet"), Arg::new_with_arg("--output", "%(title).90s.%(ext)s")]; - let link = "https://www.youtube.com/watch?v=uTO0KnDsVH0"; - let path = PathBuf::from("./path/to/download/directory"); - let ytd = YoutubeDL::new(&path, args, link)?; - // start download - let download = ytd.download()?; +#[tokio::main] +async fn main() -> Result<(), Box> { + let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") + .output_dir(PathBuf::from("./downloads")) + .arg("--quiet"); - // print out the download path - println!("Your download: {}", download.output_dir().to_string_lossy()); + let result = ytd.download().await?; + println!("Output: {}", result.output()); Ok(()) } ``` + +### Extract Audio +```rust +use ytd_rs::YtDlp; + +#[tokio::main] +async fn main() -> Result<(), Box> { + YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") + .extract_audio(true) + .audio_format("mp3") + .download() + .await?; + Ok(()) +} +``` + +### Get Video Metadata +```rust +use ytd_rs::YtDlp; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0"); + let infos = ytd.get_info().await?; + + for info in infos { + println!("Title: {}", info.title); + println!("Duration: {:?}", info.duration); + } + Ok(()) +} +``` + +## License +MIT From 5268d86b1756229ac26706afa150791dc38622ec Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:08:47 +0100 Subject: [PATCH 04/11] fix: add missing tokio features and correct doc-test examples --- Cargo.toml | 2 +- src/lib.rs | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8931bb4..58055be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,6 @@ readme = "README.md" [dependencies] regex = "1.11.1" thiserror = "2.0.11" -tokio = { version = "1.43.0", features = ["process", "rt", "macros", "fs"] } +tokio = { version = "1.43.0", features = ["process", "rt", "rt-multi-thread", "macros", "fs"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.138" diff --git a/src/lib.rs b/src/lib.rs index 6bb6ffd..79bec21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ //! let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") //! .output_dir(path) //! .arg("--quiet") -//! .arg("--output", "%(title).90s.%(ext)s"); +//! .arg_with("--output", "%(title).90s.%(ext)s"); //! //! let result = ytd.download().await?; //! println!("Download complete: {}", result.output()); @@ -195,7 +195,6 @@ impl YtDlp { #[cfg(test)] mod tests { use super::*; - use std::env; #[tokio::test] async fn test_version() -> Result<()> { @@ -209,9 +208,6 @@ mod tests { #[tokio::test] async fn test_get_info() -> Result<()> { - // Use a very short/small video for testing if possible, or just check if it fails - // For local tests, we'll just check if it can spawn. - let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0"); // We won't actually download in CI if it's too slow, but let's try a version check as a proxy let version = YtDlp::new("").arg("--version").download().await?; println!("yt-dlp version: {}", version.output()); From 46f51fa98d27638ece228d1c0d9435919e6e4323 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:13:25 +0100 Subject: [PATCH 05/11] feat: add convenience methods for formatting, auth, playlists, subtitles, and network configs --- src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 79bec21..3c57224 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -124,6 +124,106 @@ impl YtDlp { self.arg_with("--output", template) } + /// Set the video/audio format (e.g., "best", "mp4"). + pub fn format(self, format: impl Into) -> Self { + self.arg_with("--format", format) + } + + /// Select the best quality video and audio. + pub fn best_quality(self) -> Self { + self.format("bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best") + } + + /// Path to a cookies file. + pub fn cookies(self, path: impl Into) -> Self { + self.arg_with("--cookies", path) + } + + /// Load cookies from a browser (e.g., "firefox", "chrome"). + pub fn cookies_from_browser(self, browser: impl Into) -> Self { + self.arg_with("--cookies-from-browser", browser) + } + + /// Provide a username for authentication. + pub fn username(self, username: impl Into) -> Self { + self.arg_with("--username", username) + } + + /// Provide a password for authentication. + pub fn password(self, password: impl Into) -> Self { + self.arg_with("--password", password) + } + + /// Enable or disable playlist downloading. + pub fn playlist(self, enabled: bool) -> Self { + if enabled { + self.arg("--yes-playlist") + } else { + self.arg("--no-playlist") + } + } + + /// Specify specific playlist items to download (e.g., "1-3,7,10-13"). + pub fn playlist_items(self, items: impl Into) -> Self { + self.arg_with("--playlist-items", items) + } + + /// Write subtitles to a file. + pub fn write_subtitles(self, enabled: bool) -> Self { + if enabled { + self.arg("--write-sub") + } else { + self + } + } + + /// Specify subtitle languages to download (e.g., vec!["en", "de"]). + pub fn sub_langs(self, langs: Vec) -> Self { + self.arg_with("--sub-langs", langs.join(",")) + } + + /// Embed subtitles into the video file. + pub fn embed_subtitles(self, enabled: bool) -> Self { + if enabled { + self.arg("--embed-subs") + } else { + self + } + } + + /// Embed metadata into the file. + pub fn embed_metadata(self, enabled: bool) -> Self { + if enabled { + self.arg("--embed-metadata") + } else { + self + } + } + + /// Write thumbnail to a file. + pub fn write_thumbnail(self, enabled: bool) -> Self { + if enabled { + self.arg("--write-thumbnail") + } else { + self + } + } + + /// Use a proxy (e.g., "http://127.0.0.1:8080"). + pub fn proxy(self, proxy: impl Into) -> Self { + self.arg_with("--proxy", proxy) + } + + /// Limit download rate (e.g., "1M", "50K"). + pub fn limit_rate(self, rate: impl Into) -> Self { + self.arg_with("--limit-rate", rate) + } + + /// Set number of retries. + pub fn retries(self, retries: u32) -> Self { + self.arg_with("--retries", retries.to_string()) + } + /// Executes yt-dlp and returns the standard output. pub async fn download(&self) -> Result { let output = self.spawn_yt_dlp(false).await?; From f5d208324820c215aad606d74fe02456bd8811ad Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:19:46 +0100 Subject: [PATCH 06/11] feat: add streaming output for progress monitoring and update README --- Cargo.toml | 2 +- README.md | 22 +++++++++++++++++ src/lib.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 58055be..93cad10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,6 @@ readme = "README.md" [dependencies] regex = "1.11.1" thiserror = "2.0.11" -tokio = { version = "1.43.0", features = ["process", "rt", "rt-multi-thread", "macros", "fs"] } +tokio = { version = "1.43.0", features = ["process", "rt", "rt-multi-thread", "macros", "fs", "io-util"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.138" diff --git a/README.md b/README.md index 076a169..2a245a5 100644 --- a/README.md +++ b/README.md @@ -65,5 +65,27 @@ async fn main() -> Result<(), Box> { } ``` +### Streaming Output (Progress Bars) +If you want to track the progress of a long-running download, you can stream the standard output line-by-line: +```rust +use ytd_rs::YtDlp; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut process = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") + .download_process() + .await?; + + while let Some(line) = process.next_line().await? { + // e.g. "[download] 10.0% of 50.00MiB at 1.00MiB/s ETA 00:50" + println!("yt-dlp output: {}", line); + } + + process.wait().await?; + println!("Download finished successfully!"); + Ok(()) +} +``` + ## License MIT diff --git a/src/lib.rs b/src/lib.rs index 3c57224..7862e86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,8 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::process::Stdio; use tokio::fs; -use tokio::process::Command; +use tokio::io::{AsyncBufReadExt, BufReader, Lines}; +use tokio::process::{ChildStdout, Command}; pub mod error; @@ -62,6 +63,36 @@ pub struct VideoInfo { pub extra: serde_json::Value, } +/// A running yt-dlp process that allows streaming standard output (useful for progress bars). +pub struct YtDlpChild { + child: tokio::process::Child, + stdout: Option>>, +} + +impl YtDlpChild { + /// Returns the next line of output from the process. Returns `None` if EOF is reached. + pub async fn next_line(&mut self) -> Result> { + if let Some(stdout) = &mut self.stdout { + match stdout.next_line().await { + Ok(line) => Ok(line), + Err(e) => Err(YtDlpError::IOError(e)), + } + } else { + Ok(None) + } + } + + /// Waits for the process to complete and checks its status. + pub async fn wait(mut self) -> Result<()> { + let status = self.child.wait().await?; + if status.success() { + Ok(()) + } else { + Err(YtDlpError::Failure("Process failed".to_string())) + } + } +} + /// The main builder for configuring and running yt-dlp. #[derive(Clone, Debug, Default)] pub struct YtDlp { @@ -226,7 +257,7 @@ impl YtDlp { /// Executes yt-dlp and returns the standard output. pub async fn download(&self) -> Result { - let output = self.spawn_yt_dlp(false).await?; + let output = self.spawn_yt_dlp(false).await?.wait_with_output().await?; if !output.status.success() { return Err(YtDlpError::Failure(String::from_utf8(output.stderr)?)); @@ -235,9 +266,21 @@ impl YtDlp { Ok(YtDlpResult::new(String::from_utf8(output.stdout)?)) } + /// Executes yt-dlp as a continuous process, allowing line-by-line output streaming. + /// Automatically adds `--newline` so progress updates are written on new lines. + pub async fn download_process(&self) -> Result { + let mut clone = self.clone(); + clone = clone.arg("--newline"); + let mut child = clone.spawn_yt_dlp(false).await?; + + let stdout = child.stdout.take().map(|stdout| BufReader::new(stdout).lines()); + + Ok(YtDlpChild { child, stdout }) + } + /// Executes yt-dlp with --dump-json and parses the output into VideoInfo. pub async fn get_info(&self) -> Result> { - let output = self.spawn_yt_dlp(true).await?; + let output = self.spawn_yt_dlp(true).await?.wait_with_output().await?; if !output.status.success() { return Err(YtDlpError::Failure(String::from_utf8(output.stderr)?)); @@ -255,7 +298,7 @@ impl YtDlp { Ok(infos) } - async fn spawn_yt_dlp(&self, dump_json: bool) -> Result { + async fn spawn_yt_dlp(&self, dump_json: bool) -> Result { if let Some(ref path) = self.output_dir { if !path.exists() { fs::create_dir_all(path).await?; @@ -287,8 +330,7 @@ impl YtDlp { cmd.arg(link); } - let output = cmd.spawn()?.wait_with_output().await?; - Ok(output) + Ok(cmd.spawn()?) } } @@ -313,4 +355,22 @@ mod tests { println!("yt-dlp version: {}", version.output()); Ok(()) } + + #[tokio::test] + async fn test_download_process() -> Result<()> { + let mut process = YtDlp::new("").arg("--version").download_process().await?; + + let mut lines = Vec::new(); + while let Some(line) = process.next_line().await? { + lines.push(line); + } + + process.wait().await?; + + assert!(!lines.is_empty()); + assert!(regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}") + .unwrap() + .is_match(&lines[0])); + Ok(()) + } } From c9872ccfbca3e0a746c71bbbaa95aade7fcf53f4 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:29:39 +0100 Subject: [PATCH 07/11] feat: add convenience methods for extracting audio only --- README.md | 3 +-- src/lib.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a245a5..407e30e 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,7 @@ use ytd_rs::YtDlp; #[tokio::main] async fn main() -> Result<(), Box> { YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") - .extract_audio(true) - .audio_format("mp3") + .extract_audio_only("mp3") .download() .await?; Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 7862e86..a9eb9e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -150,6 +150,11 @@ impl YtDlp { self.arg_with("--audio-format", format) } + /// Configures yt-dlp to download only the best audio and extract it to the given format (e.g., "mp3"). + pub fn extract_audio_only(self, format: impl Into) -> Self { + self.best_audio().extract_audio(true).audio_format(format) + } + /// Convenience method for --output template. pub fn output_template(self, template: impl Into) -> Self { self.arg_with("--output", template) @@ -165,6 +170,11 @@ impl YtDlp { self.format("bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best") } + /// Select the best quality audio only. + pub fn best_audio(self) -> Self { + self.format("bestaudio/best") + } + /// Path to a cookies file. pub fn cookies(self, path: impl Into) -> Self { self.arg_with("--cookies", path) From 9cffe2162adcec7c9bda9a78d702e583d6770e3f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:33:48 +0100 Subject: [PATCH 08/11] feat: add direct binary streaming for audio bots and media applications --- README.md | 31 +++++++++++++++++++++++++++++++ src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/README.md b/README.md index 407e30e..deacd02 100644 --- a/README.md +++ b/README.md @@ -86,5 +86,36 @@ async fn main() -> Result<(), Box> { } ``` +### Discord Bots / Media Streaming (Direct Binary Stream) +If you are building an application like a Discord bot, you often want to stream the raw binary audio data chunk-by-chunk to an engine like `ffmpeg` or `songbird` instead of downloading the whole file first. + +```rust +use ytd_rs::YtDlp; +use tokio::io::AsyncReadExt; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut stream = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0") + .best_audio() + .download_to_stream() + .await?; + + let mut buffer = [0; 1024]; + loop { + let bytes_read = stream.stdout().read(&mut buffer).await?; + if bytes_read == 0 { break; } // EOF + + // Process your raw chunk of audio data here + // e.g., send it to your bot's audio engine + // send_to_audio_engine(&buffer[..bytes_read]); + } + + stream.wait().await?; + Ok(()) +} +``` + +**Alternative for Discord Bots:** You can also use `.get_info().await?` to extract the direct media URL (`infos[0].url`) and pass that directly to your audio engine, which saves bot bandwidth! + ## License MIT diff --git a/src/lib.rs b/src/lib.rs index a9eb9e1..1b0dff6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,6 +93,30 @@ impl YtDlpChild { } } +/// A running yt-dlp process that streams raw media bytes directly to standard output. +pub struct YtDlpStream { + child: tokio::process::Child, + stdout: tokio::process::ChildStdout, +} + +impl YtDlpStream { + /// Returns a mutable reference to the standard output to read bytes. + /// You can use this with `tokio::io::AsyncReadExt` methods like `read_buf`. + pub fn stdout(&mut self) -> &mut tokio::process::ChildStdout { + &mut self.stdout + } + + /// Waits for the process to complete and checks its status. + pub async fn wait(mut self) -> Result<()> { + let status = self.child.wait().await?; + if status.success() { + Ok(()) + } else { + Err(YtDlpError::Failure("Process failed".to_string())) + } + } +} + /// The main builder for configuring and running yt-dlp. #[derive(Clone, Debug, Default)] pub struct YtDlp { @@ -288,6 +312,20 @@ impl YtDlp { Ok(YtDlpChild { child, stdout }) } + /// Executes yt-dlp and streams the raw media binary data to standard output. + /// This automatically sets `--output -` and gives you access to the async stdout reader. + pub async fn download_to_stream(&self) -> Result { + let mut clone = self.clone(); + clone = clone.arg_with("--output", "-"); + let mut child = clone.spawn_yt_dlp(false).await?; + + let stdout = child.stdout.take().ok_or_else(|| { + YtDlpError::Failure("Failed to capture standard output".to_string()) + })?; + + Ok(YtDlpStream { child, stdout }) + } + /// Executes yt-dlp with --dump-json and parses the output into VideoInfo. pub async fn get_info(&self) -> Result> { let output = self.spawn_yt_dlp(true).await?.wait_with_output().await?; From 8ef831db113993b438121952467c7cc3cc4beef5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:35:00 +0100 Subject: [PATCH 09/11] feat: implement logging using the log crate --- Cargo.toml | 1 + src/lib.rs | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 93cad10..4efcf2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" [dependencies] regex = "1.11.1" thiserror = "2.0.11" +log = "0.4.22" tokio = { version = "1.43.0", features = ["process", "rt", "rt-multi-thread", "macros", "fs", "io-util"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.138" diff --git a/src/lib.rs b/src/lib.rs index 1b0dff6..b88f44f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ //! ``` use crate::error::YtDlpError; +use log::{debug, error, info, trace}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::process::Stdio; @@ -291,18 +292,23 @@ impl YtDlp { /// Executes yt-dlp and returns the standard output. pub async fn download(&self) -> Result { + info!("Starting download for links: {:?}", self.links); let output = self.spawn_yt_dlp(false).await?.wait_with_output().await?; if !output.status.success() { - return Err(YtDlpError::Failure(String::from_utf8(output.stderr)?)); + let err_msg = String::from_utf8(output.stderr)?; + error!("yt-dlp download failed: {}", err_msg); + return Err(YtDlpError::Failure(err_msg)); } + info!("yt-dlp download finished successfully"); Ok(YtDlpResult::new(String::from_utf8(output.stdout)?)) } /// Executes yt-dlp as a continuous process, allowing line-by-line output streaming. /// Automatically adds `--newline` so progress updates are written on new lines. pub async fn download_process(&self) -> Result { + info!("Starting download process for links: {:?}", self.links); let mut clone = self.clone(); clone = clone.arg("--newline"); let mut child = clone.spawn_yt_dlp(false).await?; @@ -315,11 +321,13 @@ impl YtDlp { /// Executes yt-dlp and streams the raw media binary data to standard output. /// This automatically sets `--output -` and gives you access to the async stdout reader. pub async fn download_to_stream(&self) -> Result { + info!("Starting binary stream download for links: {:?}", self.links); let mut clone = self.clone(); clone = clone.arg_with("--output", "-"); let mut child = clone.spawn_yt_dlp(false).await?; let stdout = child.stdout.take().ok_or_else(|| { + error!("Failed to capture stdout for binary stream"); YtDlpError::Failure("Failed to capture standard output".to_string()) })?; @@ -328,27 +336,33 @@ impl YtDlp { /// Executes yt-dlp with --dump-json and parses the output into VideoInfo. pub async fn get_info(&self) -> Result> { + info!("Fetching video info for links: {:?}", self.links); let output = self.spawn_yt_dlp(true).await?.wait_with_output().await?; if !output.status.success() { - return Err(YtDlpError::Failure(String::from_utf8(output.stderr)?)); + let err_msg = String::from_utf8(output.stderr)?; + error!("yt-dlp get_info failed: {}", err_msg); + return Err(YtDlpError::Failure(err_msg)); } let stdout = String::from_utf8(output.stdout)?; let mut infos = Vec::new(); for line in stdout.lines() { if !line.trim().is_empty() { + trace!("Parsing JSON line: {}", line); let info: VideoInfo = serde_json::from_str(line)?; infos.push(info); } } + info!("Successfully fetched info for {} videos", infos.len()); Ok(infos) } async fn spawn_yt_dlp(&self, dump_json: bool) -> Result { if let Some(ref path) = self.output_dir { if !path.exists() { + debug!("Creating output directory: {:?}", path); fs::create_dir_all(path).await?; } } @@ -378,6 +392,7 @@ impl YtDlp { cmd.arg(link); } + debug!("Executing command: {:?}", cmd); Ok(cmd.spawn()?) } } From 1c701d1e619fff7cfd27158d6576dc73c9c43064 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:37:01 +0100 Subject: [PATCH 10/11] refactor: improve error structure and add custom executable path --- src/error.rs | 7 +++++-- src/lib.rs | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/error.rs b/src/error.rs index b94527d..290b608 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,6 +8,9 @@ pub enum YtDlpError { UTF8Error(#[from] std::string::FromUtf8Error), #[error("failed to parse JSON: {0}")] JsonError(#[from] serde_json::Error), - #[error("yt-dlp exited with error: {0}")] - Failure(String), + #[error("yt-dlp exited with status {code:?}: {stderr}")] + Failure { + code: Option, + stderr: String, + }, } diff --git a/src/lib.rs b/src/lib.rs index b88f44f..0d45438 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,7 +89,10 @@ impl YtDlpChild { if status.success() { Ok(()) } else { - Err(YtDlpError::Failure("Process failed".to_string())) + Err(YtDlpError::Failure { + code: status.code(), + stderr: "Process failed".to_string(), + }) } } } @@ -113,7 +116,10 @@ impl YtDlpStream { if status.success() { Ok(()) } else { - Err(YtDlpError::Failure("Process failed".to_string())) + Err(YtDlpError::Failure { + code: status.code(), + stderr: "Process failed".to_string(), + }) } } } @@ -124,6 +130,7 @@ pub struct YtDlp { links: Vec, args: Vec<(String, Option)>, output_dir: Option, + executable_path: Option, } impl YtDlp { @@ -143,6 +150,12 @@ impl YtDlp { } } + /// Set a custom path to the yt-dlp executable. + pub fn yt_dlp_path(mut self, path: impl Into) -> Self { + self.executable_path = Some(path.into()); + self + } + /// Set the output directory for the download. pub fn output_dir(mut self, path: PathBuf) -> Self { self.output_dir = Some(path); @@ -298,7 +311,10 @@ impl YtDlp { if !output.status.success() { let err_msg = String::from_utf8(output.stderr)?; error!("yt-dlp download failed: {}", err_msg); - return Err(YtDlpError::Failure(err_msg)); + return Err(YtDlpError::Failure { + code: output.status.code(), + stderr: err_msg, + }); } info!("yt-dlp download finished successfully"); @@ -328,7 +344,10 @@ impl YtDlp { let stdout = child.stdout.take().ok_or_else(|| { error!("Failed to capture stdout for binary stream"); - YtDlpError::Failure("Failed to capture standard output".to_string()) + YtDlpError::Failure { + code: None, + stderr: "Failed to capture standard output".to_string(), + } })?; Ok(YtDlpStream { child, stdout }) @@ -342,7 +361,10 @@ impl YtDlp { if !output.status.success() { let err_msg = String::from_utf8(output.stderr)?; error!("yt-dlp get_info failed: {}", err_msg); - return Err(YtDlpError::Failure(err_msg)); + return Err(YtDlpError::Failure { + code: output.status.code(), + stderr: err_msg, + }); } let stdout = String::from_utf8(output.stdout)?; @@ -360,14 +382,13 @@ impl YtDlp { } async fn spawn_yt_dlp(&self, dump_json: bool) -> Result { - if let Some(ref path) = self.output_dir { - if !path.exists() { - debug!("Creating output directory: {:?}", path); - fs::create_dir_all(path).await?; - } + if let Some(path) = self.output_dir.as_ref().filter(|p| !p.exists()) { + debug!("Creating output directory: {:?}", path); + fs::create_dir_all(path).await?; } - let mut cmd = Command::new("yt-dlp"); + let cmd_path = self.executable_path.as_deref().unwrap_or("yt-dlp"); + let mut cmd = Command::new(cmd_path); if let Some(ref path) = self.output_dir { cmd.current_dir(path); From 5c5dffc1441294abc8aaa17a2f355f28c22cd365 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:37:30 +0100 Subject: [PATCH 11/11] cargofmt --- src/error.rs | 5 +---- src/lib.rs | 36 +++++++++++++++++++++++------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/error.rs b/src/error.rs index 290b608..fdc9d59 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,8 +9,5 @@ pub enum YtDlpError { #[error("failed to parse JSON: {0}")] JsonError(#[from] serde_json::Error), #[error("yt-dlp exited with status {code:?}: {stderr}")] - Failure { - code: Option, - stderr: String, - }, + Failure { code: Option, stderr: String }, } diff --git a/src/lib.rs b/src/lib.rs index 0d45438..dacdf82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -328,8 +328,11 @@ impl YtDlp { let mut clone = self.clone(); clone = clone.arg("--newline"); let mut child = clone.spawn_yt_dlp(false).await?; - - let stdout = child.stdout.take().map(|stdout| BufReader::new(stdout).lines()); + + let stdout = child + .stdout + .take() + .map(|stdout| BufReader::new(stdout).lines()); Ok(YtDlpChild { child, stdout }) } @@ -337,11 +340,14 @@ impl YtDlp { /// Executes yt-dlp and streams the raw media binary data to standard output. /// This automatically sets `--output -` and gives you access to the async stdout reader. pub async fn download_to_stream(&self) -> Result { - info!("Starting binary stream download for links: {:?}", self.links); + info!( + "Starting binary stream download for links: {:?}", + self.links + ); let mut clone = self.clone(); clone = clone.arg_with("--output", "-"); let mut child = clone.spawn_yt_dlp(false).await?; - + let stdout = child.stdout.take().ok_or_else(|| { error!("Failed to capture stdout for binary stream"); YtDlpError::Failure { @@ -426,9 +432,11 @@ mod tests { async fn test_version() -> Result<()> { let ytd = YtDlp::new("").arg("--version"); let result = ytd.download().await?; - assert!(regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}") - .unwrap() - .is_match(result.output())); + assert!( + regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}") + .unwrap() + .is_match(result.output()) + ); Ok(()) } @@ -443,18 +451,20 @@ mod tests { #[tokio::test] async fn test_download_process() -> Result<()> { let mut process = YtDlp::new("").arg("--version").download_process().await?; - + let mut lines = Vec::new(); while let Some(line) = process.next_line().await? { lines.push(line); } - + process.wait().await?; - + assert!(!lines.is_empty()); - assert!(regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}") - .unwrap() - .is_match(&lines[0])); + assert!( + regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}") + .unwrap() + .is_match(&lines[0]) + ); Ok(()) } }