mirror of
https://github.com/narrrl/ytd-rs.git
synced 2026-11-04 08:57:01 +01:00
feat: introduce async builder pattern and robust JSON metadata parsing
This commit is contained in:
1 parent
7ef2747b20
commit
1b5b684e26
1 file changed
+165
-184
+165
-184
@@ -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<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
|
||||
//! let download = ytd.download()?;
|
||||
//! #[tokio::main]
|
||||
//! 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("--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<T> = std::result::Result<T, YoutubeDLError>;
|
||||
|
||||
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<T> = std::result::Result<T, YtDlpError>;
|
||||
|
||||
/// 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<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.
|
||||
///
|
||||
/// 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<Arg>,
|
||||
links: Vec<String>,
|
||||
) -> Result<YoutubeDL> {
|
||||
// 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<String>,
|
||||
pub duration: Option<f64>,
|
||||
pub uploader: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[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<String>,
|
||||
args: Vec<(String, Option<String>)>,
|
||||
output_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 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> {
|
||||
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<String>) -> Self {
|
||||
Self {
|
||||
links,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the download and returns when finished the result as [`YoutubeDLResult`].
|
||||
pub fn download(&self) -> Result<YoutubeDLResult> {
|
||||
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<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)
|
||||
}
|
||||
|
||||
/// Convenience method for --output template.
|
||||
pub fn output_template(self, template: impl Into<String>) -> Self {
|
||||
self.arg_with("--output", template)
|
||||
}
|
||||
|
||||
/// Executes yt-dlp and returns the standard output.
|
||||
pub async fn download(&self) -> Result<YtDlpResult> {
|
||||
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<Output> {
|
||||
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<Vec<VideoInfo>> {
|
||||
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<std::process::Output> {
|
||||
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<dyn Error>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user