From f5d208324820c215aad606d74fe02456bd8811ad Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 22 Mar 2026 12:19:46 +0100 Subject: [PATCH] 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(()) + } }