diff --git a/src/client/mod.rs b/src/client/mod.rs index 36d2d4a..aed9f04 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1141,7 +1141,7 @@ impl RustyPipeQuery { Ok(mapres.c) } Err(e) => { - if e.should_report() || self.opts.report { + if e.should_report() { create_report(Level::ERR, Some(e.to_string()), Vec::new()); } Err(e.into()) diff --git a/src/client/player.rs b/src/client/player.rs index 367821b..8ea30cd 100644 --- a/src/client/player.rs +++ b/src/client/player.rs @@ -10,7 +10,7 @@ use url::Url; use crate::{ deobfuscate::Deobfuscator, - error::{internal::DeobfError, Error, ExtractionError, UnavailabilityReason}, + error::{DeobfError, Error, ExtractionError}, model::{ traits::QualityOrd, AudioCodec, AudioFormat, AudioStream, AudioTrack, ChannelId, Subtitle, VideoCodec, VideoFormat, VideoPlayer, VideoPlayerDetails, VideoStream, @@ -73,10 +73,9 @@ impl RustyPipeQuery { match tv_res { // Output desktop client error if the tv client is unsupported - Err(Error::Extraction(ExtractionError::VideoUnavailable { - reason: UnavailabilityReason::UnsupportedClient, - .. - })) => Err(Error::Extraction(e)), + Err(Error::Extraction(ExtractionError::VideoClientUnsupported(_))) => { + Err(Error::Extraction(e)) + } _ => tv_res, } } else { @@ -144,7 +143,8 @@ impl MapResponse for response::Player { _lang: Language, deobf: Option<&crate::deobfuscate::DeobfData>, ) -> Result, ExtractionError> { - let deobf = Deobfuscator::new(deobf.unwrap())?; + let deobf = Deobfuscator::new(deobf.unwrap()) + .map_err(|e| ExtractionError::InvalidData(e.to_string().into()))?; let mut warnings = vec![]; // Check playability status @@ -162,54 +162,47 @@ impl MapResponse for response::Player { msg.push_str(&error_screen.player_error_message_renderer.subreason); } - let reason = msg - .split_whitespace() - .find_map(|word| match word { - "payment" => Some(UnavailabilityReason::Paid), - "Premium" => Some(UnavailabilityReason::Premium), - "members-only" => Some(UnavailabilityReason::MembersOnly), - "country" => Some(UnavailabilityReason::Geoblocked), - "Android" | "websites" => Some(UnavailabilityReason::UnsupportedClient), - _ => None, - }) - .unwrap_or_default(); - return Err(ExtractionError::VideoUnavailable { reason, msg }); - } - response::player::PlayabilityStatus::LoginRequired { reason, messages } => { - let mut msg = reason; - messages.iter().for_each(|m| { - if !msg.is_empty() { - msg.push(' '); + for word in msg.split_whitespace() { + match word { + // reason: "This video requires payment to watch." + "payment" => return Err(ExtractionError::VideoUnavailable("DRM", msg)), + // reason: "The uploader has not made this video available in your country." + "country" => return Err(ExtractionError::VideoGeoblocked), + // reason (Android): "This video can only be played on newer versions of Android or other supported devices." + // reason (TV client): "Playback on other websites has been disabled by the video owner." + "Android" | "websites" => { + return Err(ExtractionError::VideoClientUnsupported(msg)) + } + _ => {} } - msg.push_str(m); - }); - + } + return Err(ExtractionError::VideoUnavailable("being unplayable", msg)); + } + response::player::PlayabilityStatus::LoginRequired { reason } => { // reason (age restriction): "Sign in to confirm your age" // or: "This video may be inappropriate for some users." // reason (private): "This video is private" - let reason = msg + if reason .split_whitespace() - .find_map(|word| match word { - "age" | "inappropriate" => Some(UnavailabilityReason::AgeRestricted), - "private" => Some(UnavailabilityReason::Private), - _ => None, - }) - .unwrap_or_default(); - return Err(ExtractionError::VideoUnavailable { reason, msg }); + .any(|word| word == "age" || word == "inappropriate") + { + return Err(ExtractionError::VideoAgeRestricted); + } + return Err(ExtractionError::VideoUnavailable("being private", reason)); } response::player::PlayabilityStatus::LiveStreamOffline { reason } => { - return Err(ExtractionError::VideoUnavailable { - reason: UnavailabilityReason::OfflineLivestream, - msg: reason, - }); + return Err(ExtractionError::VideoUnavailable( + "offline livestream", + reason, + )) } response::player::PlayabilityStatus::Error { reason } => { // reason (censored): "This video has been removed for violating YouTube's policy on hate speech. Learn more about combating hate speech in your country." // reason: "This video is unavailable" - return Err(ExtractionError::VideoUnavailable { - reason: UnavailabilityReason::Deleted, - msg: reason, - }); + return Err(ExtractionError::VideoUnavailable( + "deletion/censorship", + reason, + )); } }; diff --git a/src/client/response/player.rs b/src/client/response/player.rs index a801a3a..7f6b240 100644 --- a/src/client/response/player.rs +++ b/src/client/response/player.rs @@ -37,8 +37,6 @@ pub(crate) enum PlayabilityStatus { LoginRequired { #[serde(default)] reason: String, - #[serde(default)] - messages: Vec, }, #[serde(rename_all = "camelCase")] LiveStreamOffline { diff --git a/src/deobfuscate.rs b/src/deobfuscate.rs index 4ce8bbd..e4b2705 100644 --- a/src/deobfuscate.rs +++ b/src/deobfuscate.rs @@ -4,10 +4,9 @@ use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::{ - error::{internal::DeobfError, Error}, - util, -}; +use crate::{error::DeobfError, util}; + +type Result = core::result::Result; pub struct Deobfuscator { ctx: quick_js::Context, @@ -22,7 +21,7 @@ pub struct DeobfData { } impl DeobfData { - pub async fn download(http: Client) -> Result { + pub async fn download(http: Client) -> Result { let js_url = get_player_js_url(&http).await?; let player_js = get_response(&http, &js_url).await?; @@ -42,7 +41,7 @@ impl DeobfData { } impl Deobfuscator { - pub fn new(data: &DeobfData) -> Result { + pub fn new(data: &DeobfData) -> Result { let ctx = quick_js::Context::new().or(Err(DeobfError::Other("could not create QuickJS rt")))?; ctx.eval(&data.sig_fn)?; @@ -51,7 +50,7 @@ impl Deobfuscator { Ok(Self { ctx }) } - pub fn deobfuscate_sig(&self, sig: &str) -> Result { + pub fn deobfuscate_sig(&self, sig: &str) -> Result { let res = self.ctx.call_function(DEOBF_SIG_FUNC_NAME, vec![sig])?; res.as_str().map_or( @@ -63,7 +62,7 @@ impl Deobfuscator { ) } - pub fn deobfuscate_nsig(&self, nsig: &str) -> Result { + pub fn deobfuscate_nsig(&self, nsig: &str) -> Result { let res = self.ctx.call_function(DEOBF_NSIG_FUNC_NAME, vec![nsig])?; res.as_str().map_or( @@ -79,7 +78,7 @@ impl Deobfuscator { const DEOBF_SIG_FUNC_NAME: &str = "deobf_sig"; const DEOBF_NSIG_FUNC_NAME: &str = "deobf_nsig"; -fn get_sig_fn_name(player_js: &str) -> Result { +fn get_sig_fn_name(player_js: &str) -> Result { static FUNCTION_REGEXES: Lazy<[FancyRegex; 6]> = Lazy::new(|| { [ FancyRegex::new("(?:\\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2,})\\s*=\\s*function\\(\\s*a\\s*\\)\\s*\\{\\s*a\\s*=\\s*a\\.split\\(\\s*\"\"\\s*\\)").unwrap(), @@ -99,7 +98,7 @@ fn caller_function(mapped_name: &str, fn_name: &str) -> String { format!("var {mapped_name}={fn_name};") } -fn get_sig_fn(player_js: &str) -> Result { +fn get_sig_fn(player_js: &str) -> Result { let dfunc_name = get_sig_fn_name(player_js)?; let function_pattern_str = @@ -142,7 +141,7 @@ fn get_sig_fn(player_js: &str) -> Result { + &caller_function(DEOBF_SIG_FUNC_NAME, &dfunc_name)) } -fn get_nsig_fn_name(player_js: &str) -> Result { +fn get_nsig_fn_name(player_js: &str) -> Result { static FUNCTION_NAME_REGEX: Lazy = Lazy::new(|| { Regex::new("\\.get\\(\"n\"\\)\\)&&\\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\\[(\\d+)])?\\([a-zA-Z0-9$_]\\)") .unwrap() @@ -184,7 +183,7 @@ fn get_nsig_fn_name(player_js: &str) -> Result { Ok(name.to_owned()) } -fn extract_js_fn(js: &str, name: &str) -> Result { +fn extract_js_fn(js: &str, name: &str) -> Result { let scan = ress::Scanner::new(js); let mut state = 0; let mut level = 0; @@ -236,7 +235,7 @@ fn extract_js_fn(js: &str, name: &str) -> Result { Ok(js[start..end].to_owned()) } -fn get_nsig_fn(player_js: &str) -> Result { +fn get_nsig_fn(player_js: &str) -> Result { let function_name = get_nsig_fn_name(player_js)?; let function_base = function_name.to_owned() + "=function"; let offset = player_js.find(&function_base).unwrap_or_default(); @@ -245,7 +244,7 @@ fn get_nsig_fn(player_js: &str) -> Result { .map(|s| s + ";" + &caller_function(DEOBF_NSIG_FUNC_NAME, &function_name)) } -async fn get_player_js_url(http: &Client) -> Result { +async fn get_player_js_url(http: &Client) -> Result { let resp = http .get("https://www.youtube.com/iframe_api") .send() @@ -268,12 +267,12 @@ async fn get_player_js_url(http: &Client) -> Result { )) } -async fn get_response(http: &Client, url: &str) -> Result { +async fn get_response(http: &Client, url: &str) -> Result { let resp = http.get(url).send().await?.error_for_status()?; Ok(resp.text().await?) } -fn get_sts(player_js: &str) -> Result { +fn get_sts(player_js: &str) -> Result { static STS_PATTERN: Lazy = Lazy::new(|| Regex::new("signatureTimestamp[=:](\\d+)").unwrap()); diff --git a/src/error.rs b/src/error.rs index 32461cc..101bbfc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,17 +1,23 @@ //! RustyPipe error types -use std::{borrow::Cow, fmt::Display}; +use std::borrow::Cow; -/// Error type for the RustyPipe library +/// Custom error type for the RustyPipe library #[derive(thiserror::Error, Debug)] #[non_exhaustive] pub enum Error { /// Error extracting content from YouTube #[error("extraction error: {0}")] Extraction(#[from] ExtractionError), + /// Error from the deobfuscater + #[error("deobfuscator error: {0}")] + Deobfuscation(#[from] DeobfError), + /// File IO error + #[error(transparent)] + Io(#[from] std::io::Error), /// Error from the HTTP client #[error("http error: {0}")] - Http(Cow<'static, str>), + Http(#[from] reqwest::Error), /// Erroneous HTTP status code received #[error("http status code: {0} message: {1}")] HttpStatus(u16, Cow<'static, str>), @@ -20,6 +26,28 @@ pub enum Error { Other(Cow<'static, str>), } +/// Error that occurred during the initialization +/// or use of the YouTube URL signature deobfuscator. +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum DeobfError { + /// Error from the HTTP client + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + /// Error during JavaScript execution + #[error("js execution error: {0}")] + JavaScript(#[from] quick_js::ExecutionError), + /// Error during JavaScript parsing + #[error("js parsing: {0}")] + JsParser(#[from] ress::error::Error), + /// Could not extract certain data + #[error("could not extract {0}")] + Extraction(&'static str), + /// Unspecified error + #[error("error: {0}")] + Other(&'static str), +} + /// Error extracting content from YouTube #[derive(thiserror::Error, Debug)] #[non_exhaustive] @@ -30,25 +58,31 @@ pub enum ExtractionError { /// - Deletion/Censorship /// - Private video that requires a Google account /// - DRM (Movies and TV shows) - #[error("Video cant be played because it is {reason}. Reason (from YT): {msg}")] - VideoUnavailable { - /// Reason why the video could not be extracted - reason: UnavailabilityReason, - /// The error message as returned from YouTube - msg: String, - }, + #[error("Video cant be played because of {0}. Reason (from YT): {1}")] + VideoUnavailable(&'static str, String), + /// Video cannot be extracted because it is age restricted. + /// + /// Age restriction may be circumvented with the [`crate::client::ClientType::TvHtml5Embed`] client. + #[error("Video is age restricted")] + VideoAgeRestricted, + /// Video cannot be extracted because it is not available in your country + #[error("Video is not available in your country")] + VideoGeoblocked, + /// Video cannot be extracted with the specified client + #[error("Video cant be played with this client. Reason (from YT): {0}")] + VideoClientUnsupported(String), /// Content is not available / does not exist #[error("Content is not available. Reason: {0}")] ContentUnavailable(Cow<'static, str>), /// Bad request (Error 400 from YouTube), probably invalid input parameters #[error("Bad request. Reason: {0}")] BadRequest(Cow<'static, str>), - /// YouTube returned data that could not be deserialized or parsed + /// Error deserializing YouTube's response JSON + #[error("deserialization error: {0}")] + Deserialization(#[from] serde_json::Error), + /// YouTube returned invalid data #[error("got invalid data from YT: {0}")] InvalidData(Cow<'static, str>), - /// Error deobfuscating YouTube's URL signatures - #[error("deobfuscation error: {0}")] - Deobfuscation(Cow<'static, str>), /// YouTube returned data that does not match the queried ID /// /// Specifically YouTube may return this video , @@ -68,120 +102,22 @@ pub enum ExtractionError { DeserializationWarnings, } -/// Reason why a video cannot be extracted -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum UnavailabilityReason { - /// Video is age restricted. - /// - /// Age restriction may be circumvented with the [`crate::client::ClientType::TvHtml5Embed`] client. - AgeRestricted, - /// Video was deleted or censored - Deleted, - /// Video is not available in your country - Geoblocked, - /// Video cannot be extracted with the specified client - UnsupportedClient, - /// Video is private - Private, - /// Video needs to be purchased and is protected by digital restrictions management - /// (e.g. movies and TV shows) - Paid, - /// Video is only available to YouTube Premium users - Premium, - /// Video is only available to channel members - MembersOnly, - /// Livestream has gone offline - OfflineLivestream, - /// Video cant be played for other reasons - #[default] - Unplayable, -} - -impl Display for UnavailabilityReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - UnavailabilityReason::AgeRestricted => f.write_str("age restriction"), - UnavailabilityReason::Deleted => f.write_str("deleted"), - UnavailabilityReason::Geoblocked => f.write_str("geoblocking"), - UnavailabilityReason::UnsupportedClient => f.write_str("unsupported by client"), - UnavailabilityReason::Private => f.write_str("private"), - UnavailabilityReason::Paid => f.write_str("paid"), - UnavailabilityReason::Premium => f.write_str("premium-only"), - UnavailabilityReason::MembersOnly => f.write_str("members-only"), - UnavailabilityReason::OfflineLivestream => f.write_str("an offline stream"), - UnavailabilityReason::Unplayable => f.write_str("unplayable"), - } - } -} - -pub(crate) mod internal { - use super::*; - - /// Error that occurred during the initialization - /// or use of the YouTube URL signature deobfuscator. - #[derive(thiserror::Error, Debug)] - pub enum DeobfError { - /// Error during JavaScript execution - #[error("js execution error: {0}")] - JavaScript(#[from] quick_js::ExecutionError), - /// Error during JavaScript parsing - #[error("js parsing: {0}")] - JsParser(#[from] ress::error::Error), - /// Could not extract certain data - #[error("could not extract {0}")] - Extraction(&'static str), - /// Unspecified error - #[error("error: {0}")] - Other(&'static str), - } - - impl From for Error { - fn from(value: DeobfError) -> Self { - Self::Extraction(value.into()) - } - } - - impl From for ExtractionError { - fn from(value: DeobfError) -> Self { - Self::Deobfuscation(value.to_string().into()) - } - } -} - -impl From for ExtractionError { - fn from(value: serde_json::Error) -> Self { - Self::InvalidData(value.to_string().into()) - } -} - -impl From for Error { - fn from(value: reqwest::Error) -> Self { - if value.is_status() { - if let Some(status) = value.status() { - return Self::HttpStatus(status.as_u16(), Default::default()); - } - } - Self::Http(value.to_string().into()) - } -} - impl ExtractionError { pub(crate) fn should_report(&self) -> bool { matches!( self, - ExtractionError::InvalidData(_) | ExtractionError::WrongResult(_) + ExtractionError::Deserialization(_) + | ExtractionError::InvalidData(_) + | ExtractionError::WrongResult(_) ) } pub(crate) fn switch_client(&self) -> bool { matches!( self, - ExtractionError::VideoUnavailable { - reason: UnavailabilityReason::AgeRestricted - | UnavailabilityReason::UnsupportedClient, - .. - } | ExtractionError::WrongResult(_) + ExtractionError::VideoClientUnsupported(_) + | ExtractionError::VideoAgeRestricted + | ExtractionError::WrongResult(_) ) } } diff --git a/src/report.rs b/src/report.rs index e2263af..f45f52c 100644 --- a/src/report.rs +++ b/src/report.rs @@ -19,14 +19,15 @@ use std::{ collections::BTreeMap, fs::File, - io::Error, path::{Path, PathBuf}, }; use log::error; use serde::{Deserialize, Serialize}; -use time::{macros::format_description, OffsetDateTime}; +use time::macros::format_description; +use time::OffsetDateTime; +use crate::error::Error; use crate::{deobfuscate::DeobfData, util}; const FILENAME_FORMAT: &[time::format_description::FormatItem] = @@ -126,10 +127,10 @@ impl FileReporter { } } - fn _report(&self, report: &Report) -> Result<(), String> { - let report_path = get_report_path(&self.path, report, "json").map_err(|e| e.to_string())?; - let file = File::create(report_path).map_err(|e| e.to_string())?; - serde_json::to_writer_pretty(&file, &report).map_err(|e| e.to_string())?; + fn _report(&self, report: &Report) -> Result<(), Error> { + let report_path = get_report_path(&self.path, report, "json")?; + serde_json::to_writer_pretty(&File::create(report_path)?, &report) + .map_err(|e| Error::Other(format!("could not serialize report. err: {e}").into()))?; Ok(()) } } diff --git a/tests/youtube.rs b/tests/youtube.rs index 0fb95f7..706c257 100644 --- a/tests/youtube.rs +++ b/tests/youtube.rs @@ -10,7 +10,7 @@ use time::macros::date; use time::OffsetDateTime; use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery}; -use rustypipe::error::{Error, ExtractionError, UnavailabilityReason}; +use rustypipe::error::{Error, ExtractionError}; use rustypipe::model::{ paginator::Paginator, richtext::ToPlaintext, @@ -280,25 +280,41 @@ fn get_player( } #[rstest] -#[case::not_found("86abcdefghi", UnavailabilityReason::Deleted)] -#[case::deleted("64DYi_8ESh0", UnavailabilityReason::Deleted)] -#[case::censored("6SJNVb0GnPI", UnavailabilityReason::Deleted)] +#[case::not_found( + "86abcdefghi", + "extraction error: Video cant be played because of deletion/censorship. Reason (from YT): " +)] +#[case::deleted( + "64DYi_8ESh0", + "extraction error: Video cant be played because of deletion/censorship. Reason (from YT): " +)] +#[case::censored( + "6SJNVb0GnPI", + "extraction error: Video cant be played because of deletion/censorship. Reason (from YT): " +)] // This video is geoblocked outside of Japan, so expect this test case to fail when using a Japanese IP address. -#[case::geoblock("sJL6WA-aGkQ", UnavailabilityReason::Geoblocked)] -#[case::drm("1bfOsni7EgI", UnavailabilityReason::Paid)] -#[case::private("s7_qI6_mIXc", UnavailabilityReason::Private)] -#[case::age_restricted("CUO8secmc0g", UnavailabilityReason::AgeRestricted)] -#[case::premium_only("3LvozjEOUxU", UnavailabilityReason::Premium)] -#[case::members_only("vYmAhoZYg64", UnavailabilityReason::MembersOnly)] -fn get_player_error(#[case] id: &str, #[case] expect: UnavailabilityReason, rp: RustyPipe) { - let err = tokio_test::block_on(rp.query().player(id)).unwrap_err(); +#[case::geoblock( + "sJL6WA-aGkQ", + "extraction error: Video is not available in your country" +)] +#[case::drm( + "1bfOsni7EgI", + "extraction error: Video cant be played because of DRM. Reason (from YT): " +)] +#[case::private( + "s7_qI6_mIXc", + "extraction error: Video cant be played because of being private. Reason (from YT): " +)] +#[case::age_restricted("CUO8secmc0g", "extraction error: Video is age restricted")] +fn get_player_error(#[case] id: &str, #[case] msg: &str, rp: RustyPipe) { + let err = tokio_test::block_on(rp.query().player(id)) + .unwrap_err() + .to_string(); - match err { - Error::Extraction(ExtractionError::VideoUnavailable { reason, .. }) => { - assert_eq!(reason, expect, "got {err}") - } - _ => panic!("got {err}"), - } + assert!( + err.starts_with(msg), + "got error msg: `{err}`, expected: `{msg}`" + ); } //#PLAYLIST