diff --git a/src/client/mod.rs b/src/client/mod.rs index aed9f04..36d2d4a 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() { + if e.should_report() || self.opts.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 c085b0a..367821b 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}, + error::{internal::DeobfError, Error, ExtractionError, UnavailabilityReason}, model::{ traits::QualityOrd, AudioCodec, AudioFormat, AudioStream, AudioTrack, ChannelId, Subtitle, VideoCodec, VideoFormat, VideoPlayer, VideoPlayerDetails, VideoStream, @@ -73,9 +73,10 @@ impl RustyPipeQuery { match tv_res { // Output desktop client error if the tv client is unsupported - Err(Error::Extraction(ExtractionError::VideoClientUnsupported(_))) => { - Err(Error::Extraction(e)) - } + Err(Error::Extraction(ExtractionError::VideoUnavailable { + reason: UnavailabilityReason::UnsupportedClient, + .. + })) => Err(Error::Extraction(e)), _ => tv_res, } } else { @@ -161,47 +162,54 @@ impl MapResponse for response::Player { msg.push_str(&error_screen.player_error_message_renderer.subreason); } - 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)) - } - _ => {} - } - } - return Err(ExtractionError::VideoUnavailable("being unplayable", msg)); + 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 } => { + response::player::PlayabilityStatus::LoginRequired { reason, messages } => { + let mut msg = reason; + messages.iter().for_each(|m| { + if !msg.is_empty() { + msg.push(' '); + } + msg.push_str(m); + }); + // reason (age restriction): "Sign in to confirm your age" // or: "This video may be inappropriate for some users." // reason (private): "This video is private" - if reason + let reason = msg .split_whitespace() - .any(|word| word == "age" || word == "inappropriate") - { - return Err(ExtractionError::VideoAgeRestricted); - } - return Err(ExtractionError::VideoUnavailable("being private", reason)); + .find_map(|word| match word { + "age" | "inappropriate" => Some(UnavailabilityReason::AgeRestricted), + "private" => Some(UnavailabilityReason::Private), + _ => None, + }) + .unwrap_or_default(); + return Err(ExtractionError::VideoUnavailable { reason, msg }); } response::player::PlayabilityStatus::LiveStreamOffline { reason } => { - return Err(ExtractionError::VideoUnavailable( - "offline livestream", - reason, - )) + return Err(ExtractionError::VideoUnavailable { + reason: UnavailabilityReason::OfflineLivestream, + msg: 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( - "deletion/censorship", - reason, - )); + return Err(ExtractionError::VideoUnavailable { + reason: UnavailabilityReason::Deleted, + msg: reason, + }); } }; diff --git a/src/client/response/player.rs b/src/client/response/player.rs index 7f6b240..a801a3a 100644 --- a/src/client/response/player.rs +++ b/src/client/response/player.rs @@ -37,6 +37,8 @@ pub(crate) enum PlayabilityStatus { LoginRequired { #[serde(default)] reason: String, + #[serde(default)] + messages: Vec, }, #[serde(rename_all = "camelCase")] LiveStreamOffline { diff --git a/src/error.rs b/src/error.rs index 9d5abea..32461cc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,6 @@ //! RustyPipe error types -use std::borrow::Cow; +use std::{borrow::Cow, fmt::Display}; /// Error type for the RustyPipe library #[derive(thiserror::Error, Debug)] @@ -9,12 +9,9 @@ pub enum Error { /// Error extracting content from YouTube #[error("extraction error: {0}")] Extraction(#[from] ExtractionError), - /// File IO error - #[error(transparent)] - Io(#[from] std::io::Error), /// Error from the HTTP client #[error("http error: {0}")] - Http(#[from] reqwest::Error), + Http(Cow<'static, str>), /// Erroneous HTTP status code received #[error("http status code: {0} message: {1}")] HttpStatus(u16, Cow<'static, str>), @@ -33,34 +30,25 @@ pub enum ExtractionError { /// - Deletion/Censorship /// - Private video that requires a Google account /// - DRM (Movies and TV shows) - #[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), + #[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, + }, /// 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>), - /// Error deserializing YouTube's response JSON - #[error("deserialization error: {0}")] - Deserialization(#[from] serde_json::Error), + /// YouTube returned data that could not be deserialized or parsed + #[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 invalid data - #[error("got invalid data from YT: {0}")] - InvalidData(Cow<'static, str>), /// YouTube returned data that does not match the queried ID /// /// Specifically YouTube may return this video , @@ -80,6 +68,53 @@ 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::*; @@ -114,22 +149,39 @@ pub(crate) mod internal { } } +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::Deserialization(_) - | ExtractionError::InvalidData(_) - | ExtractionError::WrongResult(_) + ExtractionError::InvalidData(_) | ExtractionError::WrongResult(_) ) } pub(crate) fn switch_client(&self) -> bool { matches!( self, - ExtractionError::VideoClientUnsupported(_) - | ExtractionError::VideoAgeRestricted - | ExtractionError::WrongResult(_) + ExtractionError::VideoUnavailable { + reason: UnavailabilityReason::AgeRestricted + | UnavailabilityReason::UnsupportedClient, + .. + } | ExtractionError::WrongResult(_) ) } } diff --git a/src/report.rs b/src/report.rs index f45f52c..e2263af 100644 --- a/src/report.rs +++ b/src/report.rs @@ -19,15 +19,14 @@ use std::{ collections::BTreeMap, fs::File, + io::Error, path::{Path, PathBuf}, }; use log::error; use serde::{Deserialize, Serialize}; -use time::macros::format_description; -use time::OffsetDateTime; +use time::{macros::format_description, OffsetDateTime}; -use crate::error::Error; use crate::{deobfuscate::DeobfData, util}; const FILENAME_FORMAT: &[time::format_description::FormatItem] = @@ -127,10 +126,10 @@ impl FileReporter { } } - 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()))?; + 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())?; Ok(()) } } diff --git a/tests/youtube.rs b/tests/youtube.rs index 706c257..0fb95f7 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}; +use rustypipe::error::{Error, ExtractionError, UnavailabilityReason}; use rustypipe::model::{ paginator::Paginator, richtext::ToPlaintext, @@ -280,41 +280,25 @@ fn get_player( } #[rstest] -#[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): " -)] +#[case::not_found("86abcdefghi", UnavailabilityReason::Deleted)] +#[case::deleted("64DYi_8ESh0", UnavailabilityReason::Deleted)] +#[case::censored("6SJNVb0GnPI", UnavailabilityReason::Deleted)] // This video is geoblocked outside of Japan, so expect this test case to fail when using a Japanese IP address. -#[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(); +#[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(); - assert!( - err.starts_with(msg), - "got error msg: `{err}`, expected: `{msg}`" - ); + match err { + Error::Extraction(ExtractionError::VideoUnavailable { reason, .. }) => { + assert_eq!(reason, expect, "got {err}") + } + _ => panic!("got {err}"), + } } //#PLAYLIST