mirror of
https://github.com/narrrl/ytd-rs.git
synced 2026-11-03 16:41:27 +01:00
4 files changed
+539
-214
No files matched your search
+11
-12
@@ -1,19 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ytd-rs"
|
name = "ytd-rs"
|
||||||
version = "0.1.7"
|
version = "0.2.0"
|
||||||
authors = ["Nils Pukropp <nils.pukropp@outlook.de>"]
|
authors = ["Nils Pukropp <nils@narl.io>"]
|
||||||
edition = "2018"
|
edition = "2024"
|
||||||
license-file ="LICENSE"
|
license-file = "LICENSE"
|
||||||
description = "A simple wrapper for youtube-dl. Youtube-dl has to be installed on the system"
|
description = "An async, full-featured wrapper for yt-dlp."
|
||||||
homepage = "https://github.com/Nirusu99/ytd-rs"
|
homepage = "https://github.com/Nirusu99/ytd-rs"
|
||||||
repository = "https://github.com/Nirusu99/ytd-rs"
|
repository = "https://github.com/Nirusu99/ytd-rs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
[features]
|
|
||||||
yt-dlp = []
|
|
||||||
youtube-dlc = []
|
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
regex = "1.5.5"
|
regex = "1.11.1"
|
||||||
thiserror = "1.0.30"
|
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"
|
||||||
@@ -2,27 +2,120 @@
|
|||||||
[](https://github.com/nirusu99/ytd-rs/actions)
|
[](https://github.com/nirusu99/ytd-rs/actions)
|
||||||
[](https://crates.io/crates/ytd-rs)
|
[](https://crates.io/crates/ytd-rs)
|
||||||
[](https://docs.rs/ytd-rs)
|
[](https://docs.rs/ytd-rs)
|
||||||
[](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
|
```rust
|
||||||
use ytd_rs::{YoutubeDL, Arg};
|
use ytd_rs::YtDlp;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::error::Error;
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
// 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
|
#[tokio::main]
|
||||||
let download = ytd.download()?;
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0")
|
||||||
|
.output_dir(PathBuf::from("./downloads"))
|
||||||
|
.arg("--quiet");
|
||||||
|
|
||||||
// print out the download path
|
let result = ytd.download().await?;
|
||||||
println!("Your download: {}", download.output_dir().to_string_lossy());
|
println!("Output: {}", result.output());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Extract Audio
|
||||||
|
```rust
|
||||||
|
use ytd_rs::YtDlp;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0")
|
||||||
|
.extract_audio_only("mp3")
|
||||||
|
.download()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Video Metadata
|
||||||
|
```rust
|
||||||
|
use ytd_rs::YtDlp;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<dyn std::error::Error>> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<dyn std::error::Error>> {
|
||||||
|
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
|
||||||
+7
-5
@@ -1,11 +1,13 @@
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum YoutubeDLError {
|
pub enum YtDlpError {
|
||||||
#[error("failed to execute youtube-dl")]
|
#[error("failed to execute yt-dlp: {0}")]
|
||||||
IOError(#[from] std::io::Error),
|
IOError(#[from] std::io::Error),
|
||||||
#[error("failed to convert path")]
|
#[error("failed to convert output to string: {0}")]
|
||||||
UTF8Error(#[from] std::string::FromUtf8Error),
|
UTF8Error(#[from] std::string::FromUtf8Error),
|
||||||
#[error("youtube-dl exited with: {0}")]
|
#[error("failed to parse JSON: {0}")]
|
||||||
Failure(String),
|
JsonError(#[from] serde_json::Error),
|
||||||
|
#[error("yt-dlp exited with status {code:?}: {stderr}")]
|
||||||
|
Failure { code: Option<i32>, stderr: String },
|
||||||
}
|
}
|
||||||
+413
-182
@@ -1,239 +1,470 @@
|
|||||||
//! Rust-Wrapper for youtube-dl
|
//! Async wrapper for yt-dlp
|
||||||
//!
|
//!
|
||||||
//! # Example
|
//! # Example
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use ytd_rs::{YoutubeDL, Arg};
|
//! use ytd_rs::YtDlp;
|
||||||
//! use std::path::PathBuf;
|
//! use std::path::PathBuf;
|
||||||
//! use std::error::Error;
|
|
||||||
//! fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
//! // 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
|
//! #[tokio::main]
|
||||||
//! let download = ytd.download()?;
|
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let path = PathBuf::from("./download");
|
||||||
|
//! let ytd = YtDlp::new("https://www.youtube.com/watch?v=uTO0KnDsVH0")
|
||||||
|
//! .output_dir(path)
|
||||||
|
//! .arg("--quiet")
|
||||||
|
//! .arg_with("--output", "%(title).90s.%(ext)s");
|
||||||
//!
|
//!
|
||||||
//! // check what the result is and print out the path to the download or the error
|
//! let result = ytd.download().await?;
|
||||||
//! println!("Your download: {}", download.output_dir().to_string_lossy());
|
//! println!("Download complete: {}", result.output());
|
||||||
//! Ok(())
|
//! Ok(())
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use error::YoutubeDLError;
|
use crate::error::YtDlpError;
|
||||||
use std::{
|
use log::{debug, error, info, trace};
|
||||||
fmt,
|
use serde::{Deserialize, Serialize};
|
||||||
process::{Output, Stdio},
|
use std::path::PathBuf;
|
||||||
};
|
use std::process::Stdio;
|
||||||
use std::{
|
use tokio::fs;
|
||||||
fmt::{Display, Formatter},
|
use tokio::io::{AsyncBufReadExt, BufReader, Lines};
|
||||||
fs::{canonicalize, create_dir_all},
|
use tokio::process::{ChildStdout, Command};
|
||||||
path::PathBuf,
|
|
||||||
};
|
|
||||||
use std::{path::Path, process::Command};
|
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
type Result<T> = std::result::Result<T, YoutubeDLError>;
|
|
||||||
|
|
||||||
const YOUTUBE_DL_COMMAND: &str = if cfg!(feature = "youtube-dlc") {
|
pub type Result<T> = std::result::Result<T, YtDlpError>;
|
||||||
"youtube-dlc"
|
|
||||||
} else if cfg!(feature = "yt-dlp") {
|
|
||||||
"yt-dlp"
|
|
||||||
} else {
|
|
||||||
"youtube-dl"
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A structure that represents an argument of a youtube-dl command.
|
/// Represents the output of a yt-dlp execution.
|
||||||
///
|
|
||||||
/// 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String>,
|
|
||||||
args: Vec<Arg>,
|
|
||||||
}
|
|
||||||
|
|
||||||
///
|
|
||||||
/// This is the result of a [`YoutubeDL`].
|
|
||||||
///
|
|
||||||
/// It contains the information about the exit status, the output and the directory it was executed
|
|
||||||
/// in.
|
|
||||||
///
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct YoutubeDLResult {
|
pub struct YtDlpResult {
|
||||||
path: PathBuf,
|
|
||||||
output: String,
|
output: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YoutubeDLResult {
|
impl YtDlpResult {
|
||||||
/// creates a new YoutubeDLResult
|
pub fn new(output: String) -> Self {
|
||||||
fn new(path: &PathBuf) -> YoutubeDLResult {
|
Self { output }
|
||||||
YoutubeDLResult {
|
|
||||||
path: path.clone(),
|
|
||||||
output: String::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// get the output of the youtube-dl process
|
/// Returns the standard output of the yt-dlp process.
|
||||||
pub fn output(&self) -> &str {
|
pub fn output(&self) -> &str {
|
||||||
&self.output
|
&self.output
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// get the directory where youtube-dl was executed
|
/// A structured representation of video metadata extracted via --dump-json.
|
||||||
pub fn output_dir(&self) -> &PathBuf {
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
&self.path
|
pub struct VideoInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub uploader: Option<String>,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
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<Lines<BufReader<ChildStdout>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<String>> {
|
||||||
|
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 {
|
||||||
|
code: status.code(),
|
||||||
|
stderr: "Process failed".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YoutubeDL {
|
/// A running yt-dlp process that streams raw media bytes directly to standard output.
|
||||||
/// Creates a new YoutubeDL job to be executed.
|
pub struct YtDlpStream {
|
||||||
/// It takes a path where youtube-dl should be executed, a vec! of [`Arg`] that can be empty
|
child: tokio::process::Child,
|
||||||
/// and finally a link that can be `""` if no video should be downloaded
|
stdout: tokio::process::ChildStdout,
|
||||||
///
|
}
|
||||||
/// The path gets canonicalized and the directory gets created by the constructor
|
|
||||||
pub fn new_multiple_links(
|
|
||||||
dl_path: &PathBuf,
|
|
||||||
args: Vec<Arg>,
|
|
||||||
links: Vec<String>,
|
|
||||||
) -> Result<YoutubeDL> {
|
|
||||||
// create path
|
|
||||||
let path = Path::new(dl_path);
|
|
||||||
|
|
||||||
// check if it already exists
|
impl YtDlpStream {
|
||||||
if !path.exists() {
|
/// Returns a mutable reference to the standard output to read bytes.
|
||||||
// if not create
|
/// You can use this with `tokio::io::AsyncReadExt` methods like `read_buf`.
|
||||||
create_dir_all(&path)?;
|
pub fn stdout(&mut self) -> &mut tokio::process::ChildStdout {
|
||||||
}
|
&mut self.stdout
|
||||||
|
|
||||||
// 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<Arg>, link: &str) -> Result<YoutubeDL> {
|
/// Waits for the process to complete and checks its status.
|
||||||
YoutubeDL::new_multiple_links(dl_path, args, vec![link.to_string()])
|
pub async fn wait(mut self) -> Result<()> {
|
||||||
|
let status = self.child.wait().await?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(YtDlpError::Failure {
|
||||||
|
code: status.code(),
|
||||||
|
stderr: "Process failed".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main builder for configuring and running yt-dlp.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct YtDlp {
|
||||||
|
links: Vec<String>,
|
||||||
|
args: Vec<(String, Option<String>)>,
|
||||||
|
output_dir: Option<PathBuf>,
|
||||||
|
executable_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YtDlp {
|
||||||
|
/// Create a new yt-dlp task for a single link.
|
||||||
|
pub fn new(link: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
links: vec![link.into()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the download and returns when finished the result as [`YoutubeDLResult`].
|
/// Create a new yt-dlp task for multiple links.
|
||||||
pub fn download(&self) -> Result<YoutubeDLResult> {
|
pub fn new_multiple(links: Vec<String>) -> Self {
|
||||||
let output = self.spawn_youtube_dl()?;
|
Self {
|
||||||
let mut result = YoutubeDLResult::new(&self.path);
|
links,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a custom path to the yt-dlp executable.
|
||||||
|
pub fn yt_dlp_path(mut self, path: impl Into<String>) -> 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);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a raw argument to the yt-dlp command.
|
||||||
|
pub fn arg(mut self, flag: impl Into<String>) -> 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<String>, value: impl Into<String>) -> 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<String>) -> Self {
|
||||||
|
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<String>) -> Self {
|
||||||
|
self.best_audio().extract_audio(true).audio_format(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience method for --output template.
|
||||||
|
pub fn output_template(self, template: impl Into<String>) -> Self {
|
||||||
|
self.arg_with("--output", template)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the video/audio format (e.g., "best", "mp4").
|
||||||
|
pub fn format(self, format: impl Into<String>) -> 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>) -> Self {
|
||||||
|
self.arg_with("--cookies", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load cookies from a browser (e.g., "firefox", "chrome").
|
||||||
|
pub fn cookies_from_browser(self, browser: impl Into<String>) -> Self {
|
||||||
|
self.arg_with("--cookies-from-browser", browser)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide a username for authentication.
|
||||||
|
pub fn username(self, username: impl Into<String>) -> Self {
|
||||||
|
self.arg_with("--username", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide a password for authentication.
|
||||||
|
pub fn password(self, password: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> Self {
|
||||||
|
self.arg_with("--proxy", proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limit download rate (e.g., "1M", "50K").
|
||||||
|
pub fn limit_rate(self, rate: impl Into<String>) -> 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<YtDlpResult> {
|
||||||
|
info!("Starting download for links: {:?}", self.links);
|
||||||
|
let output = self.spawn_yt_dlp(false).await?.wait_with_output().await?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(YoutubeDLError::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 {
|
||||||
|
code: output.status.code(),
|
||||||
|
stderr: err_msg,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
result.output = String::from_utf8(output.stdout)?;
|
|
||||||
|
|
||||||
Ok(result)
|
info!("yt-dlp download finished successfully");
|
||||||
|
Ok(YtDlpResult::new(String::from_utf8(output.stdout)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_youtube_dl(&self) -> Result<Output> {
|
/// Executes yt-dlp as a continuous process, allowing line-by-line output streaming.
|
||||||
let mut cmd = Command::new(YOUTUBE_DL_COMMAND);
|
/// Automatically adds `--newline` so progress updates are written on new lines.
|
||||||
cmd.current_dir(&self.path)
|
pub async fn download_process(&self) -> Result<YtDlpChild> {
|
||||||
.env("LC_ALL", "en_US.UTF-8")
|
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?;
|
||||||
|
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.map(|stdout| BufReader::new(stdout).lines());
|
||||||
|
|
||||||
|
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<YtDlpStream> {
|
||||||
|
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 {
|
||||||
|
code: None,
|
||||||
|
stderr: "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<Vec<VideoInfo>> {
|
||||||
|
info!("Fetching video info for links: {:?}", self.links);
|
||||||
|
let output = self.spawn_yt_dlp(true).await?.wait_with_output().await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let err_msg = String::from_utf8(output.stderr)?;
|
||||||
|
error!("yt-dlp get_info failed: {}", err_msg);
|
||||||
|
return Err(YtDlpError::Failure {
|
||||||
|
code: output.status.code(),
|
||||||
|
stderr: 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<tokio::process::Child> {
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.env("LC_ALL", "en_US.UTF-8")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
for arg in self.args.iter() {
|
if dump_json {
|
||||||
match &arg.input {
|
cmd.arg("--dump-json");
|
||||||
Some(input) => cmd.arg(&arg.arg).arg(input),
|
|
||||||
None => cmd.arg(&arg.arg),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for link in self.links.iter() {
|
for (arg, val) in &self.args {
|
||||||
cmd.arg(&link);
|
cmd.arg(arg);
|
||||||
|
if let Some(v) = val {
|
||||||
|
cmd.arg(v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let pr = cmd.spawn()?;
|
for link in &self.links {
|
||||||
Ok(pr.wait_with_output()?)
|
cmd.arg(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Executing command: {:?}", cmd);
|
||||||
|
Ok(cmd.spawn()?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{Arg, YoutubeDL};
|
use super::*;
|
||||||
use regex::Regex;
|
|
||||||
use std::{env, error::Error};
|
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn version() -> Result<(), Box<dyn Error>> {
|
async fn test_version() -> Result<()> {
|
||||||
let current_dir = env::current_dir()?;
|
let ytd = YtDlp::new("").arg("--version");
|
||||||
let ytd = YoutubeDL::new(
|
let result = ytd.download().await?;
|
||||||
¤t_dir,
|
assert!(
|
||||||
// get youtube-dl version
|
regex::Regex::new(r"\d{4}\.\d{2}\.\d{2}")
|
||||||
vec![Arg::new("--version")],
|
.unwrap()
|
||||||
// we don't need a link to print version
|
.is_match(result.output())
|
||||||
"",
|
);
|
||||||
)?;
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
let regex = Regex::new(r"\d{4}\.\d{2}\.\d{2}")?;
|
#[tokio::test]
|
||||||
let output = ytd.download()?;
|
async fn test_get_info() -> Result<()> {
|
||||||
|
// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
// check output
|
#[tokio::test]
|
||||||
// fails if youtube-dl is not installed
|
async fn test_download_process() -> Result<()> {
|
||||||
assert!(regex.is_match(output.output()));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user