#![deny(
missing_debug_implementations,
missing_copy_implementations,
trivial_casts,
trivial_numeric_casts,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications,
rust_2018_idioms
)]
#![warn(missing_docs)]
use serde::{Deserialize, Serialize};
use std::error::Error as StdError;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub mod model;
pub use crate::model::*;
#[derive(Clone, Serialize, Deserialize, Debug)]
pub enum YoutubeDlOutput {
Playlist(Box<Playlist>),
SingleVideo(Box<SingleVideo>),
}
impl YoutubeDlOutput {
#[cfg(test)]
fn to_single_video(self) -> SingleVideo {
match self {
YoutubeDlOutput::SingleVideo(video) => *video,
_ => panic!("this is a playlist, not a single video"),
}
}
#[cfg(test)]
fn to_playlist(self) -> Playlist {
match self {
YoutubeDlOutput::Playlist(playlist) => *playlist,
_ => panic!("this is a playlist, not a single video"),
}
}
}
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Json(serde_json::Error),
ExitCode {
code: i32,
stderr: String,
},
ProcessTimeout,
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Error::Io(err)
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Error::Json(err)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "io error: {}", err),
Self::Json(err) => write!(f, "json error: {}", err),
Self::ExitCode { code, stderr } => {
write!(f, "non-zero exit code: {}, stderr: {}", code, stderr)
}
Self::ProcessTimeout => write!(f, "process timed out"),
}
}
}
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Json(err) => Some(err),
Self::ExitCode { .. } => None,
Self::ProcessTimeout => None,
}
}
}
#[derive(Clone, Debug)]
pub enum SearchType {
Youtube,
Yahoo,
Google,
SoundCloud,
Custom(String),
}
impl fmt::Display for SearchType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SearchType::Yahoo => write!(f, "yvsearch"),
SearchType::Youtube => write!(f, "ytsearch"),
SearchType::Google => write!(f, "gvsearch"),
SearchType::SoundCloud => write!(f, "scsearch"),
SearchType::Custom(name) => write!(f, "{}", name),
}
}
}
#[derive(Clone, Debug)]
pub struct SearchOptions {
search_type: SearchType,
count: usize,
query: String,
}
impl SearchOptions {
pub fn youtube(query: impl Into<String>) -> Self {
Self {
query: query.into(),
search_type: SearchType::Youtube,
count: 1,
}
}
pub fn google(query: impl Into<String>) -> Self {
Self {
query: query.into(),
search_type: SearchType::Google,
count: 1,
}
}
pub fn yahoo(query: impl Into<String>) -> Self {
Self {
query: query.into(),
search_type: SearchType::Yahoo,
count: 1,
}
}
pub fn soundcloud(query: impl Into<String>) -> Self {
Self {
query: query.into(),
search_type: SearchType::SoundCloud,
count: 1,
}
}
pub fn custom(search_type: impl Into<String>, query: impl Into<String>) -> Self {
Self {
query: query.into(),
search_type: SearchType::Custom(search_type.into()),
count: 1,
}
}
pub fn with_count(self, count: usize) -> Self {
Self {
search_type: self.search_type,
query: self.query,
count,
}
}
}
impl fmt::Display for SearchOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}:{}", self.search_type, self.count, self.query)
}
}
#[derive(Clone, Debug)]
pub struct YoutubeDl {
youtube_dl_path: Option<PathBuf>,
format: Option<String>,
flat_playlist: bool,
socket_timeout: Option<String>,
all_formats: bool,
auth: Option<(String, String)>,
user_agent: Option<String>,
referer: Option<String>,
url: String,
process_timeout: Option<Duration>,
}
impl YoutubeDl {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
youtube_dl_path: None,
format: None,
flat_playlist: false,
socket_timeout: None,
all_formats: false,
auth: None,
user_agent: None,
referer: None,
process_timeout: None,
}
}
pub fn search_for(options: &SearchOptions) -> Self {
Self::new(options.to_string())
}
pub fn youtube_dl_path<P: AsRef<Path>>(&mut self, youtube_dl_path: P) -> &mut Self {
self.youtube_dl_path = Some(youtube_dl_path.as_ref().to_owned());
self
}
pub fn format<S: Into<String>>(&mut self, format: S) -> &mut Self {
self.format = Some(format.into());
self
}
pub fn flat_playlist(&mut self, flat_playlist: bool) -> &mut Self {
self.flat_playlist = flat_playlist;
self
}
pub fn socket_timeout<S: Into<String>>(&mut self, socket_timeout: S) -> &mut Self {
self.socket_timeout = Some(socket_timeout.into());
self
}
pub fn user_agent<S: Into<String>>(&mut self, user_agent: S) -> &mut Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn referer<S: Into<String>>(&mut self, referer: S) -> &mut Self {
self.referer = Some(referer.into());
self
}
pub fn all_formats(&mut self, all_formats: bool) -> &mut Self {
self.all_formats = all_formats;
self
}
pub fn auth<S: Into<String>>(&mut self, username: S, password: S) -> &mut Self {
self.auth = Some((username.into(), password.into()));
self
}
pub fn process_timeout(&mut self, timeout: Duration) -> &mut Self {
self.process_timeout = Some(timeout);
self
}
fn path(&self) -> &Path {
match &self.youtube_dl_path {
Some(path) => path,
None => Path::new("youtube-dl"),
}
}
fn process_args(&self) -> Vec<&str> {
let mut args = vec![];
if let Some(format) = &self.format {
args.push("-f");
args.push(format);
}
if self.flat_playlist {
args.push("--flat-playlist");
}
if let Some(timeout) = &self.socket_timeout {
args.push("--socket-timeout");
args.push(timeout);
}
if self.all_formats {
args.push("--all-formats");
}
if let Some((user, password)) = &self.auth {
args.push("-u");
args.push(user);
args.push("-p");
args.push(password);
}
if let Some(user_agent) = &self.user_agent {
args.push("--user-agent");
args.push(user_agent);
}
if let Some(referer) = &self.referer {
args.push("--referer");
args.push(referer);
}
args.push("-J");
args.push(&self.url);
log::debug!("youtube-dl arguments: {:?}", args);
args
}
pub fn run(&self) -> Result<YoutubeDlOutput, Error> {
use serde_json::{json, Value};
use std::io::Read;
use std::process::{Command, Stdio};
use wait_timeout::ChildExt;
let process_args = self.process_args();
let path = self.path();
let mut child = Command::new(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.args(process_args)
.spawn()?;
let mut stdout = Vec::new();
let child_stdout = child.stdout.take();
std::io::copy(&mut child_stdout.unwrap(), &mut stdout)?;
let exit_code = if let Some(timeout) = self.process_timeout {
match child.wait_timeout(timeout)? {
Some(status) => status,
None => {
child.kill()?;
return Err(Error::ProcessTimeout);
}
}
} else {
child.wait()?
};
if exit_code.success() {
let value: Value = serde_json::from_reader(stdout.as_slice())?;
let is_playlist = value["_type"] == json!("playlist");
if is_playlist {
let playlist: Playlist = serde_json::from_value(value)?;
Ok(YoutubeDlOutput::Playlist(Box::new(playlist)))
} else {
let video: SingleVideo = serde_json::from_value(value)?;
Ok(YoutubeDlOutput::SingleVideo(Box::new(video)))
}
} else {
let mut stderr = vec![];
if let Some(mut reader) = child.stderr {
reader.read_to_end(&mut stderr)?;
}
let stderr = String::from_utf8(stderr).unwrap_or_default();
Err(Error::ExitCode {
code: exit_code.code().unwrap_or(1),
stderr,
})
}
}
}
#[cfg(test)]
mod tests {
use crate::{SearchOptions, YoutubeDl};
use std::time::Duration;
#[test]
fn test_youtube_url() {
let output = YoutubeDl::new("https://www.youtube.com/watch?v=7XGyWcuYVrg")
.socket_timeout("15")
.run()
.unwrap()
.to_single_video();
assert_eq!(output.id, "7XGyWcuYVrg");
}
#[test]
fn test_with_timeout() {
let output = YoutubeDl::new("https://www.youtube.com/watch?v=7XGyWcuYVrg")
.socket_timeout("15")
.process_timeout(Duration::from_secs(15))
.run()
.unwrap()
.to_single_video();
assert_eq!(output.id, "7XGyWcuYVrg");
}
#[test]
fn test_unknown_url() {
YoutubeDl::new("https://www.rust-lang.org")
.socket_timeout("15")
.process_timeout(Duration::from_secs(15))
.run()
.unwrap_err();
}
#[test]
fn test_search() {
let output = YoutubeDl::search_for(&SearchOptions::youtube("Never Gonna Give You Up"))
.socket_timeout("15")
.process_timeout(Duration::from_secs(15))
.run()
.unwrap()
.to_playlist();
assert_eq!(output.entries.unwrap().first().unwrap().id, "dQw4w9WgXcQ");
}
}