refactor: restructure VideoUnavailable error

remove internal error types from public interface
This commit is contained in:
ThetaDev 2023-05-08 03:36:54 +02:00
parent 6ab7b2415a
commit 289b1cdbf4
6 changed files with 151 additions and 106 deletions

View file

@ -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())

View file

@ -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<VideoPlayer> 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,
});
}
};

View file

@ -37,6 +37,8 @@ pub(crate) enum PlayabilityStatus {
LoginRequired {
#[serde(default)]
reason: String,
#[serde(default)]
messages: Vec<String>,
},
#[serde(rename_all = "camelCase")]
LiveStreamOffline {

View file

@ -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 <https://www.youtube.com/watch?v=aQvGIIdgFDM>,
@ -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<serde_json::Error> for ExtractionError {
fn from(value: serde_json::Error) -> Self {
Self::InvalidData(value.to_string().into())
}
}
impl From<reqwest::Error> 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(_)
)
}
}

View file

@ -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(())
}
}

View file

@ -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