diff --git a/Cargo.toml b/Cargo.toml index 9c05861..7da8952 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords.workspace = true categories.workspace = true description = "Client for the public YouTube / YouTube Music API (Innertube), inspired by NewPipe" -include = ["/src", "README.md", "LICENSE", "!snapshots"] +include = ["/src", "README.md", "CHANGELOG.md", "LICENSE", "!snapshots"] [workspace] members = [".", "codegen", "downloader", "cli"] diff --git a/Justfile b/Justfile index 5fd6b75..e4d0194 100644 --- a/Justfile +++ b/Justfile @@ -1,6 +1,6 @@ test: # cargo test --features=rss - cargo nextest run --features=rss --no-fail-fast --failure-output final --retries 1 + cargo nextest run --workspace --features=rss --no-fail-fast --failure-output final --retries 1 unittest: cargo nextest run --features=rss --no-fail-fast --failure-output final --lib diff --git a/downloader/Cargo.toml b/downloader/Cargo.toml index b04d087..7582dfa 100644 --- a/downloader/Cargo.toml +++ b/downloader/Cargo.toml @@ -48,3 +48,9 @@ time.workspace = true lofty = { version = "0.21.0", optional = true } image = { version = "0.25.0", optional = true } smartcrop2 = { version = "0.3.0", optional = true } + +[dev-dependencies] +path_macro.workspace = true +rstest.workspace = true +serde_json.workspace = true +temp_testdir = "0.2.3" diff --git a/downloader/src/error.rs b/downloader/src/error.rs new file mode 100644 index 0000000..8970dd4 --- /dev/null +++ b/downloader/src/error.rs @@ -0,0 +1,54 @@ +use std::{borrow::Cow, path::PathBuf}; + +use rustypipe::client::ClientType; + +/// Error from the video downloader +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum DownloadError { + /// RustyPipe error + #[error("{0}")] + RustyPipe(#[from] rustypipe::error::Error), + /// Error from the HTTP client + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + /// 403 error trying to download video + #[error("YouTube returned 403 error")] + Forbidden(ClientType), + /// File IO error + #[error(transparent)] + Io(#[from] std::io::Error), + /// FFmpeg returned an error + #[error("FFmpeg error: {0}")] + Ffmpeg(Cow<'static, str>), + /// Error parsing ranges for progressive download + #[error("Progressive download error: {0}")] + Progressive(Cow<'static, str>), + /// Video could not be downloaded because of invalid player data + #[error("input error: {0}")] + Input(Cow<'static, str>), + /// Download target already exists + #[error("file {0} already exists")] + Exists(PathBuf), + #[cfg(feature = "audiotag")] + /// Audio tagging error + #[error("Audio tag error: {0}")] + AudioTag(Cow<'static, str>), + /// Other error + #[error("error: {0}")] + Other(Cow<'static, str>), +} + +#[cfg(feature = "audiotag")] +impl From for DownloadError { + fn from(value: lofty::error::LoftyError) -> Self { + Self::AudioTag(value.to_string().into()) + } +} + +#[cfg(feature = "audiotag")] +impl From for DownloadError { + fn from(value: image::ImageError) -> Self { + Self::AudioTag(value.to_string().into()) + } +} diff --git a/downloader/src/lib.rs b/downloader/src/lib.rs index 5c65fb0..a1483d4 100644 --- a/downloader/src/lib.rs +++ b/downloader/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] #![warn(missing_docs, clippy::todo, clippy::dbg_macro)] +mod error; mod util; use std::{ @@ -42,7 +43,7 @@ use rustypipe::model::{richtext::ToPlaintext, VideoDetails, VideoPlayerDetails}; #[cfg(feature = "audiotag")] use time::{Date, OffsetDateTime}; -pub use util::DownloadError; +pub use error::DownloadError; type Result = core::result::Result; diff --git a/downloader/src/util.rs b/downloader/src/util.rs index 5069c96..5f87339 100644 --- a/downloader/src/util.rs +++ b/downloader/src/util.rs @@ -1,58 +1,8 @@ -use std::{borrow::Cow, collections::BTreeMap, path::PathBuf}; +use std::collections::BTreeMap; use reqwest::Url; -use rustypipe::client::ClientType; -/// Error from the video downloader -#[derive(thiserror::Error, Debug)] -#[non_exhaustive] -pub enum DownloadError { - /// RustyPipe error - #[error("{0}")] - RustyPipe(#[from] rustypipe::error::Error), - /// Error from the HTTP client - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - /// 403 error trying to download video - #[error("YouTube returned 403 error")] - Forbidden(ClientType), - /// File IO error - #[error(transparent)] - Io(#[from] std::io::Error), - /// FFmpeg returned an error - #[error("FFmpeg error: {0}")] - Ffmpeg(Cow<'static, str>), - /// Error parsing ranges for progressive download - #[error("Progressive download error: {0}")] - Progressive(Cow<'static, str>), - /// Video could not be downloaded because of invalid player data - #[error("input error: {0}")] - Input(Cow<'static, str>), - /// Download target already exists - #[error("file {0} already exists")] - Exists(PathBuf), - #[cfg(feature = "audiotag")] - /// Audio tagging error - #[error("Audio tag error: {0}")] - AudioTag(Cow<'static, str>), - /// Other error - #[error("error: {0}")] - Other(Cow<'static, str>), -} - -#[cfg(feature = "audiotag")] -impl From for DownloadError { - fn from(value: lofty::error::LoftyError) -> Self { - Self::AudioTag(value.to_string().into()) - } -} - -#[cfg(feature = "audiotag")] -impl From for DownloadError { - fn from(value: image::ImageError) -> Self { - Self::AudioTag(value.to_string().into()) - } -} +use crate::DownloadError; /// Split an URL into its base string and parameter map /// diff --git a/downloader/tests/tests.rs b/downloader/tests/tests.rs new file mode 100644 index 0000000..3fc035d --- /dev/null +++ b/downloader/tests/tests.rs @@ -0,0 +1,113 @@ +use std::{fs, os::unix::fs::MetadataExt, path::Path, process::Command}; + +use path_macro::path; +use rstest::{fixture, rstest}; +use rustypipe::{client::RustyPipe, model::AudioCodec, param::StreamFilter}; +use rustypipe_downloader::Downloader; +use temp_testdir::TempDir; + +/// Get a new RusttyPipe instance +#[fixture] +fn rp() -> RustyPipe { + let vdata = std::env::var("YT_VDATA").ok(); + RustyPipe::builder() + .strict() + .storage_dir(path!(env!("CARGO_MANIFEST_DIR") / "..")) + .visitor_data_opt(vdata) + .build() + .unwrap() +} + +#[rstest] +#[tokio::test] +async fn download_video(rp: RustyPipe) { + let td = TempDir::default(); + let td_path = td.to_path_buf(); + + let dl = Downloader::builder().rustypipe(&rp).build(); + + let res = dl + .id("UXqq0ZvbOnk") + .to_dir(&td_path) + .stream_filter(StreamFilter::new().video_max_res(480)) + .download() + .await + .unwrap(); + + assert_eq!( + res.dest, + path!(td_path / "CHARGE - Blender Open Movie [UXqq0ZvbOnk].mp4") + ); + assert_eq!(res.player_data.details.id, "UXqq0ZvbOnk"); +} + +#[rstest] +#[tokio::test] +async fn download_music(rp: RustyPipe) { + let td = TempDir::default(); + let td_path = td.to_path_buf(); + + let dl = Downloader::builder() + .audio_tag() + .crop_cover() + .rustypipe(&rp) + .build(); + + let res = dl + .id("bVtv3st8bgc") + .to_dir(&td_path) + .stream_filter( + StreamFilter::new() + .no_video() + .audio_codecs([AudioCodec::Opus]), + ) + .download() + .await + .unwrap(); + + assert_eq!( + res.dest, + path!(td_path / "Lord of the Riffs [bVtv3st8bgc].opus") + ); + assert_eq!(res.player_data.details.id, "bVtv3st8bgc"); + let fm = fs::metadata(&res.dest).unwrap(); + assert_gte(fm.size(), 6_000_000, "file size"); + assert_audio_meta( + &res.dest, + "Lord of the Riffs", + "Alexander Nakarada - CreatorChords", + "Lord of the Riffs", + "2022-02-05", + ); +} + +/// Assert that number A is greater than or equal to number B +#[track_caller] +fn assert_gte(a: T, b: T, msg: &str) { + assert!(a >= b, "expected >= {b} {msg}, got {a}"); +} + +#[track_caller] +fn assert_audio_meta(p: &Path, title: &str, artist: &str, album: &str, date: &str) { + let res = Command::new("ffprobe") + .args([ + "-loglevel", + "error", + "-show_entries", + "stream_tags", + "-of", + "json", + ]) + .arg(p) + .output() + .unwrap(); + if !res.status.success() { + panic!("ffprobe error\n{}", String::from_utf8_lossy(&res.stderr)) + } + let res_json = serde_json::from_slice::(&res.stdout).unwrap(); + let tags = &res_json["streams"][0]["tags"]; + assert_eq!(tags["TITLE"].as_str(), Some(title)); + assert_eq!(tags["ARTIST"].as_str(), Some(artist)); + assert_eq!(tags["ALBUM"].as_str(), Some(album)); + assert_eq!(tags["DATE"].as_str(), Some(date)); +} diff --git a/tests/youtube.rs b/tests/youtube.rs index ca5f079..4e5f656 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -2697,6 +2697,7 @@ fn rp(lang: Language) -> RustyPipe { let vdata = std::env::var("YT_VDATA").ok(); RustyPipe::builder() .strict() + .storage_dir(env!("CARGO_MANIFEST_DIR")) .lang(lang) .visitor_data_opt(vdata) .build()