Compare commits
No commits in common. "289b1cdbf4cba534767445c721edfccfb94ed796" and "d7caba81d0a12918ff5141fb37d8a81c54312af3" have entirely different histories.
289b1cdbf4
...
d7caba81d0
7 changed files with 147 additions and 204 deletions
|
@ -1141,7 +1141,7 @@ impl RustyPipeQuery {
|
||||||
Ok(mapres.c)
|
Ok(mapres.c)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if e.should_report() || self.opts.report {
|
if e.should_report() {
|
||||||
create_report(Level::ERR, Some(e.to_string()), Vec::new());
|
create_report(Level::ERR, Some(e.to_string()), Vec::new());
|
||||||
}
|
}
|
||||||
Err(e.into())
|
Err(e.into())
|
||||||
|
|
|
@ -10,7 +10,7 @@ use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
deobfuscate::Deobfuscator,
|
deobfuscate::Deobfuscator,
|
||||||
error::{internal::DeobfError, Error, ExtractionError, UnavailabilityReason},
|
error::{DeobfError, Error, ExtractionError},
|
||||||
model::{
|
model::{
|
||||||
traits::QualityOrd, AudioCodec, AudioFormat, AudioStream, AudioTrack, ChannelId, Subtitle,
|
traits::QualityOrd, AudioCodec, AudioFormat, AudioStream, AudioTrack, ChannelId, Subtitle,
|
||||||
VideoCodec, VideoFormat, VideoPlayer, VideoPlayerDetails, VideoStream,
|
VideoCodec, VideoFormat, VideoPlayer, VideoPlayerDetails, VideoStream,
|
||||||
|
@ -73,10 +73,9 @@ impl RustyPipeQuery {
|
||||||
|
|
||||||
match tv_res {
|
match tv_res {
|
||||||
// Output desktop client error if the tv client is unsupported
|
// Output desktop client error if the tv client is unsupported
|
||||||
Err(Error::Extraction(ExtractionError::VideoUnavailable {
|
Err(Error::Extraction(ExtractionError::VideoClientUnsupported(_))) => {
|
||||||
reason: UnavailabilityReason::UnsupportedClient,
|
Err(Error::Extraction(e))
|
||||||
..
|
}
|
||||||
})) => Err(Error::Extraction(e)),
|
|
||||||
_ => tv_res,
|
_ => tv_res,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -144,7 +143,8 @@ impl MapResponse<VideoPlayer> for response::Player {
|
||||||
_lang: Language,
|
_lang: Language,
|
||||||
deobf: Option<&crate::deobfuscate::DeobfData>,
|
deobf: Option<&crate::deobfuscate::DeobfData>,
|
||||||
) -> Result<super::MapResult<VideoPlayer>, ExtractionError> {
|
) -> Result<super::MapResult<VideoPlayer>, 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![];
|
let mut warnings = vec![];
|
||||||
|
|
||||||
// Check playability status
|
// Check playability status
|
||||||
|
@ -162,54 +162,47 @@ impl MapResponse<VideoPlayer> for response::Player {
|
||||||
msg.push_str(&error_screen.player_error_message_renderer.subreason);
|
msg.push_str(&error_screen.player_error_message_renderer.subreason);
|
||||||
}
|
}
|
||||||
|
|
||||||
let reason = msg
|
for word in msg.split_whitespace() {
|
||||||
.split_whitespace()
|
match word {
|
||||||
.find_map(|word| match word {
|
// reason: "This video requires payment to watch."
|
||||||
"payment" => Some(UnavailabilityReason::Paid),
|
"payment" => return Err(ExtractionError::VideoUnavailable("DRM", msg)),
|
||||||
"Premium" => Some(UnavailabilityReason::Premium),
|
// reason: "The uploader has not made this video available in your country."
|
||||||
"members-only" => Some(UnavailabilityReason::MembersOnly),
|
"country" => return Err(ExtractionError::VideoGeoblocked),
|
||||||
"country" => Some(UnavailabilityReason::Geoblocked),
|
// reason (Android): "This video can only be played on newer versions of Android or other supported devices."
|
||||||
"Android" | "websites" => Some(UnavailabilityReason::UnsupportedClient),
|
// reason (TV client): "Playback on other websites has been disabled by the video owner."
|
||||||
_ => None,
|
"Android" | "websites" => {
|
||||||
})
|
return Err(ExtractionError::VideoClientUnsupported(msg))
|
||||||
.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(' ');
|
|
||||||
}
|
}
|
||||||
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"
|
// reason (age restriction): "Sign in to confirm your age"
|
||||||
// or: "This video may be inappropriate for some users."
|
// or: "This video may be inappropriate for some users."
|
||||||
// reason (private): "This video is private"
|
// reason (private): "This video is private"
|
||||||
let reason = msg
|
if reason
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.find_map(|word| match word {
|
.any(|word| word == "age" || word == "inappropriate")
|
||||||
"age" | "inappropriate" => Some(UnavailabilityReason::AgeRestricted),
|
{
|
||||||
"private" => Some(UnavailabilityReason::Private),
|
return Err(ExtractionError::VideoAgeRestricted);
|
||||||
_ => None,
|
}
|
||||||
})
|
return Err(ExtractionError::VideoUnavailable("being private", reason));
|
||||||
.unwrap_or_default();
|
|
||||||
return Err(ExtractionError::VideoUnavailable { reason, msg });
|
|
||||||
}
|
}
|
||||||
response::player::PlayabilityStatus::LiveStreamOffline { reason } => {
|
response::player::PlayabilityStatus::LiveStreamOffline { reason } => {
|
||||||
return Err(ExtractionError::VideoUnavailable {
|
return Err(ExtractionError::VideoUnavailable(
|
||||||
reason: UnavailabilityReason::OfflineLivestream,
|
"offline livestream",
|
||||||
msg: reason,
|
reason,
|
||||||
});
|
))
|
||||||
}
|
}
|
||||||
response::player::PlayabilityStatus::Error { 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 (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"
|
// reason: "This video is unavailable"
|
||||||
return Err(ExtractionError::VideoUnavailable {
|
return Err(ExtractionError::VideoUnavailable(
|
||||||
reason: UnavailabilityReason::Deleted,
|
"deletion/censorship",
|
||||||
msg: reason,
|
reason,
|
||||||
});
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -37,8 +37,6 @@ pub(crate) enum PlayabilityStatus {
|
||||||
LoginRequired {
|
LoginRequired {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
reason: String,
|
reason: String,
|
||||||
#[serde(default)]
|
|
||||||
messages: Vec<String>,
|
|
||||||
},
|
},
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
LiveStreamOffline {
|
LiveStreamOffline {
|
||||||
|
|
|
@ -4,10 +4,9 @@ use regex::Regex;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{error::DeobfError, util};
|
||||||
error::{internal::DeobfError, Error},
|
|
||||||
util,
|
type Result<T> = core::result::Result<T, DeobfError>;
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Deobfuscator {
|
pub struct Deobfuscator {
|
||||||
ctx: quick_js::Context,
|
ctx: quick_js::Context,
|
||||||
|
@ -22,7 +21,7 @@ pub struct DeobfData {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeobfData {
|
impl DeobfData {
|
||||||
pub async fn download(http: Client) -> Result<Self, Error> {
|
pub async fn download(http: Client) -> Result<Self> {
|
||||||
let js_url = get_player_js_url(&http).await?;
|
let js_url = get_player_js_url(&http).await?;
|
||||||
let player_js = get_response(&http, &js_url).await?;
|
let player_js = get_response(&http, &js_url).await?;
|
||||||
|
|
||||||
|
@ -42,7 +41,7 @@ impl DeobfData {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deobfuscator {
|
impl Deobfuscator {
|
||||||
pub fn new(data: &DeobfData) -> Result<Self, DeobfError> {
|
pub fn new(data: &DeobfData) -> Result<Self> {
|
||||||
let ctx =
|
let ctx =
|
||||||
quick_js::Context::new().or(Err(DeobfError::Other("could not create QuickJS rt")))?;
|
quick_js::Context::new().or(Err(DeobfError::Other("could not create QuickJS rt")))?;
|
||||||
ctx.eval(&data.sig_fn)?;
|
ctx.eval(&data.sig_fn)?;
|
||||||
|
@ -51,7 +50,7 @@ impl Deobfuscator {
|
||||||
Ok(Self { ctx })
|
Ok(Self { ctx })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deobfuscate_sig(&self, sig: &str) -> Result<String, DeobfError> {
|
pub fn deobfuscate_sig(&self, sig: &str) -> Result<String> {
|
||||||
let res = self.ctx.call_function(DEOBF_SIG_FUNC_NAME, vec![sig])?;
|
let res = self.ctx.call_function(DEOBF_SIG_FUNC_NAME, vec![sig])?;
|
||||||
|
|
||||||
res.as_str().map_or(
|
res.as_str().map_or(
|
||||||
|
@ -63,7 +62,7 @@ impl Deobfuscator {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deobfuscate_nsig(&self, nsig: &str) -> Result<String, DeobfError> {
|
pub fn deobfuscate_nsig(&self, nsig: &str) -> Result<String> {
|
||||||
let res = self.ctx.call_function(DEOBF_NSIG_FUNC_NAME, vec![nsig])?;
|
let res = self.ctx.call_function(DEOBF_NSIG_FUNC_NAME, vec![nsig])?;
|
||||||
|
|
||||||
res.as_str().map_or(
|
res.as_str().map_or(
|
||||||
|
@ -79,7 +78,7 @@ impl Deobfuscator {
|
||||||
const DEOBF_SIG_FUNC_NAME: &str = "deobf_sig";
|
const DEOBF_SIG_FUNC_NAME: &str = "deobf_sig";
|
||||||
const DEOBF_NSIG_FUNC_NAME: &str = "deobf_nsig";
|
const DEOBF_NSIG_FUNC_NAME: &str = "deobf_nsig";
|
||||||
|
|
||||||
fn get_sig_fn_name(player_js: &str) -> Result<String, DeobfError> {
|
fn get_sig_fn_name(player_js: &str) -> Result<String> {
|
||||||
static FUNCTION_REGEXES: Lazy<[FancyRegex; 6]> = Lazy::new(|| {
|
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(),
|
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};")
|
format!("var {mapped_name}={fn_name};")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_sig_fn(player_js: &str) -> Result<String, DeobfError> {
|
fn get_sig_fn(player_js: &str) -> Result<String> {
|
||||||
let dfunc_name = get_sig_fn_name(player_js)?;
|
let dfunc_name = get_sig_fn_name(player_js)?;
|
||||||
|
|
||||||
let function_pattern_str =
|
let function_pattern_str =
|
||||||
|
@ -142,7 +141,7 @@ fn get_sig_fn(player_js: &str) -> Result<String, DeobfError> {
|
||||||
+ &caller_function(DEOBF_SIG_FUNC_NAME, &dfunc_name))
|
+ &caller_function(DEOBF_SIG_FUNC_NAME, &dfunc_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_nsig_fn_name(player_js: &str) -> Result<String, DeobfError> {
|
fn get_nsig_fn_name(player_js: &str) -> Result<String> {
|
||||||
static FUNCTION_NAME_REGEX: Lazy<Regex> = Lazy::new(|| {
|
static FUNCTION_NAME_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||||
Regex::new("\\.get\\(\"n\"\\)\\)&&\\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\\[(\\d+)])?\\([a-zA-Z0-9$_]\\)")
|
Regex::new("\\.get\\(\"n\"\\)\\)&&\\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\\[(\\d+)])?\\([a-zA-Z0-9$_]\\)")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -184,7 +183,7 @@ fn get_nsig_fn_name(player_js: &str) -> Result<String, DeobfError> {
|
||||||
Ok(name.to_owned())
|
Ok(name.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_js_fn(js: &str, name: &str) -> Result<String, DeobfError> {
|
fn extract_js_fn(js: &str, name: &str) -> Result<String> {
|
||||||
let scan = ress::Scanner::new(js);
|
let scan = ress::Scanner::new(js);
|
||||||
let mut state = 0;
|
let mut state = 0;
|
||||||
let mut level = 0;
|
let mut level = 0;
|
||||||
|
@ -236,7 +235,7 @@ fn extract_js_fn(js: &str, name: &str) -> Result<String, DeobfError> {
|
||||||
Ok(js[start..end].to_owned())
|
Ok(js[start..end].to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_nsig_fn(player_js: &str) -> Result<String, DeobfError> {
|
fn get_nsig_fn(player_js: &str) -> Result<String> {
|
||||||
let function_name = get_nsig_fn_name(player_js)?;
|
let function_name = get_nsig_fn_name(player_js)?;
|
||||||
let function_base = function_name.to_owned() + "=function";
|
let function_base = function_name.to_owned() + "=function";
|
||||||
let offset = player_js.find(&function_base).unwrap_or_default();
|
let offset = player_js.find(&function_base).unwrap_or_default();
|
||||||
|
@ -245,7 +244,7 @@ fn get_nsig_fn(player_js: &str) -> Result<String, DeobfError> {
|
||||||
.map(|s| s + ";" + &caller_function(DEOBF_NSIG_FUNC_NAME, &function_name))
|
.map(|s| s + ";" + &caller_function(DEOBF_NSIG_FUNC_NAME, &function_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_player_js_url(http: &Client) -> Result<String, Error> {
|
async fn get_player_js_url(http: &Client) -> Result<String> {
|
||||||
let resp = http
|
let resp = http
|
||||||
.get("https://www.youtube.com/iframe_api")
|
.get("https://www.youtube.com/iframe_api")
|
||||||
.send()
|
.send()
|
||||||
|
@ -268,12 +267,12 @@ async fn get_player_js_url(http: &Client) -> Result<String, Error> {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_response(http: &Client, url: &str) -> Result<String, Error> {
|
async fn get_response(http: &Client, url: &str) -> Result<String> {
|
||||||
let resp = http.get(url).send().await?.error_for_status()?;
|
let resp = http.get(url).send().await?.error_for_status()?;
|
||||||
Ok(resp.text().await?)
|
Ok(resp.text().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_sts(player_js: &str) -> Result<String, DeobfError> {
|
fn get_sts(player_js: &str) -> Result<String> {
|
||||||
static STS_PATTERN: Lazy<Regex> =
|
static STS_PATTERN: Lazy<Regex> =
|
||||||
Lazy::new(|| Regex::new("signatureTimestamp[=:](\\d+)").unwrap());
|
Lazy::new(|| Regex::new("signatureTimestamp[=:](\\d+)").unwrap());
|
||||||
|
|
||||||
|
|
172
src/error.rs
172
src/error.rs
|
@ -1,17 +1,23 @@
|
||||||
//! RustyPipe error types
|
//! 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)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
/// Error extracting content from YouTube
|
/// Error extracting content from YouTube
|
||||||
#[error("extraction error: {0}")]
|
#[error("extraction error: {0}")]
|
||||||
Extraction(#[from] ExtractionError),
|
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 from the HTTP client
|
||||||
#[error("http error: {0}")]
|
#[error("http error: {0}")]
|
||||||
Http(Cow<'static, str>),
|
Http(#[from] reqwest::Error),
|
||||||
/// Erroneous HTTP status code received
|
/// Erroneous HTTP status code received
|
||||||
#[error("http status code: {0} message: {1}")]
|
#[error("http status code: {0} message: {1}")]
|
||||||
HttpStatus(u16, Cow<'static, str>),
|
HttpStatus(u16, Cow<'static, str>),
|
||||||
|
@ -20,6 +26,28 @@ pub enum Error {
|
||||||
Other(Cow<'static, str>),
|
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
|
/// Error extracting content from YouTube
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
@ -30,25 +58,31 @@ pub enum ExtractionError {
|
||||||
/// - Deletion/Censorship
|
/// - Deletion/Censorship
|
||||||
/// - Private video that requires a Google account
|
/// - Private video that requires a Google account
|
||||||
/// - DRM (Movies and TV shows)
|
/// - DRM (Movies and TV shows)
|
||||||
#[error("Video cant be played because it is {reason}. Reason (from YT): {msg}")]
|
#[error("Video cant be played because of {0}. Reason (from YT): {1}")]
|
||||||
VideoUnavailable {
|
VideoUnavailable(&'static str, String),
|
||||||
/// Reason why the video could not be extracted
|
/// Video cannot be extracted because it is age restricted.
|
||||||
reason: UnavailabilityReason,
|
///
|
||||||
/// The error message as returned from YouTube
|
/// Age restriction may be circumvented with the [`crate::client::ClientType::TvHtml5Embed`] client.
|
||||||
msg: String,
|
#[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
|
/// Content is not available / does not exist
|
||||||
#[error("Content is not available. Reason: {0}")]
|
#[error("Content is not available. Reason: {0}")]
|
||||||
ContentUnavailable(Cow<'static, str>),
|
ContentUnavailable(Cow<'static, str>),
|
||||||
/// Bad request (Error 400 from YouTube), probably invalid input parameters
|
/// Bad request (Error 400 from YouTube), probably invalid input parameters
|
||||||
#[error("Bad request. Reason: {0}")]
|
#[error("Bad request. Reason: {0}")]
|
||||||
BadRequest(Cow<'static, str>),
|
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}")]
|
#[error("got invalid data from YT: {0}")]
|
||||||
InvalidData(Cow<'static, str>),
|
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
|
/// YouTube returned data that does not match the queried ID
|
||||||
///
|
///
|
||||||
/// Specifically YouTube may return this video <https://www.youtube.com/watch?v=aQvGIIdgFDM>,
|
/// Specifically YouTube may return this video <https://www.youtube.com/watch?v=aQvGIIdgFDM>,
|
||||||
|
@ -68,120 +102,22 @@ pub enum ExtractionError {
|
||||||
DeserializationWarnings,
|
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<DeobfError> for Error {
|
|
||||||
fn from(value: DeobfError) -> Self {
|
|
||||||
Self::Extraction(value.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DeobfError> for ExtractionError {
|
|
||||||
fn from(value: DeobfError) -> Self {
|
|
||||||
Self::Deobfuscation(value.to_string().into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
impl ExtractionError {
|
||||||
pub(crate) fn should_report(&self) -> bool {
|
pub(crate) fn should_report(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
ExtractionError::InvalidData(_) | ExtractionError::WrongResult(_)
|
ExtractionError::Deserialization(_)
|
||||||
|
| ExtractionError::InvalidData(_)
|
||||||
|
| ExtractionError::WrongResult(_)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn switch_client(&self) -> bool {
|
pub(crate) fn switch_client(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
ExtractionError::VideoUnavailable {
|
ExtractionError::VideoClientUnsupported(_)
|
||||||
reason: UnavailabilityReason::AgeRestricted
|
| ExtractionError::VideoAgeRestricted
|
||||||
| UnavailabilityReason::UnsupportedClient,
|
| ExtractionError::WrongResult(_)
|
||||||
..
|
|
||||||
} | ExtractionError::WrongResult(_)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,14 +19,15 @@
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
fs::File,
|
fs::File,
|
||||||
io::Error,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
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};
|
use crate::{deobfuscate::DeobfData, util};
|
||||||
|
|
||||||
const FILENAME_FORMAT: &[time::format_description::FormatItem] =
|
const FILENAME_FORMAT: &[time::format_description::FormatItem] =
|
||||||
|
@ -126,10 +127,10 @@ impl FileReporter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _report(&self, report: &Report) -> Result<(), String> {
|
fn _report(&self, report: &Report) -> Result<(), Error> {
|
||||||
let report_path = get_report_path(&self.path, report, "json").map_err(|e| e.to_string())?;
|
let report_path = get_report_path(&self.path, report, "json")?;
|
||||||
let file = File::create(report_path).map_err(|e| e.to_string())?;
|
serde_json::to_writer_pretty(&File::create(report_path)?, &report)
|
||||||
serde_json::to_writer_pretty(&file, &report).map_err(|e| e.to_string())?;
|
.map_err(|e| Error::Other(format!("could not serialize report. err: {e}").into()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ use time::macros::date;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery};
|
use rustypipe::client::{ClientType, RustyPipe, RustyPipeQuery};
|
||||||
use rustypipe::error::{Error, ExtractionError, UnavailabilityReason};
|
use rustypipe::error::{Error, ExtractionError};
|
||||||
use rustypipe::model::{
|
use rustypipe::model::{
|
||||||
paginator::Paginator,
|
paginator::Paginator,
|
||||||
richtext::ToPlaintext,
|
richtext::ToPlaintext,
|
||||||
|
@ -280,25 +280,41 @@ fn get_player(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[case::not_found("86abcdefghi", UnavailabilityReason::Deleted)]
|
#[case::not_found(
|
||||||
#[case::deleted("64DYi_8ESh0", UnavailabilityReason::Deleted)]
|
"86abcdefghi",
|
||||||
#[case::censored("6SJNVb0GnPI", UnavailabilityReason::Deleted)]
|
"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.
|
// 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::geoblock(
|
||||||
#[case::drm("1bfOsni7EgI", UnavailabilityReason::Paid)]
|
"sJL6WA-aGkQ",
|
||||||
#[case::private("s7_qI6_mIXc", UnavailabilityReason::Private)]
|
"extraction error: Video is not available in your country"
|
||||||
#[case::age_restricted("CUO8secmc0g", UnavailabilityReason::AgeRestricted)]
|
)]
|
||||||
#[case::premium_only("3LvozjEOUxU", UnavailabilityReason::Premium)]
|
#[case::drm(
|
||||||
#[case::members_only("vYmAhoZYg64", UnavailabilityReason::MembersOnly)]
|
"1bfOsni7EgI",
|
||||||
fn get_player_error(#[case] id: &str, #[case] expect: UnavailabilityReason, rp: RustyPipe) {
|
"extraction error: Video cant be played because of DRM. Reason (from YT): "
|
||||||
let err = tokio_test::block_on(rp.query().player(id)).unwrap_err();
|
)]
|
||||||
|
#[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 {
|
assert!(
|
||||||
Error::Extraction(ExtractionError::VideoUnavailable { reason, .. }) => {
|
err.starts_with(msg),
|
||||||
assert_eq!(reason, expect, "got {err}")
|
"got error msg: `{err}`, expected: `{msg}`"
|
||||||
}
|
);
|
||||||
_ => panic!("got {err}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//#PLAYLIST
|
//#PLAYLIST
|
||||||
|
|
Loading…
Reference in a new issue