add builder to RustyPipe

This commit is contained in:
ThetaDev 2022-09-17 00:41:23 +02:00
parent d6cfc7e914
commit 17b6844eb0
10 changed files with 526 additions and 316 deletions

View file

@ -7,7 +7,7 @@ edition = "2021"
members = [".", "codegen", "cli"] members = [".", "codegen", "cli"]
[features] [features]
default = ["default-tls", "yaml"] default = ["default-tls"]
# Reqwest TLS # Reqwest TLS
default-tls = ["reqwest/default-tls"] default-tls = ["reqwest/default-tls"]
@ -15,7 +15,7 @@ rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
# Error reports in yaml format # Error reports in yaml format
yaml = ["serde_yaml"] report-yaml = ["serde_yaml"]
[dependencies] [dependencies]
# quick-js = "0.4.1" # quick-js = "0.4.1"
@ -27,7 +27,7 @@ thiserror = "1.0.31"
url = "2.2.2" url = "2.2.2"
log = "0.4.17" log = "0.4.17"
reqwest = {version = "0.11.11", default-features = false, features = ["json", "gzip", "brotli", "stream"]} reqwest = {version = "0.11.11", default-features = false, features = ["json", "gzip", "brotli", "stream"]}
tokio = {version = "1.20.0", features = ["macros", "fs", "process"]} tokio = {version = "1.20.0", features = ["macros", "time", "fs", "process"]}
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.82" serde_json = "1.0.82"
serde_yaml = {version = "0.9.11", optional = true} serde_yaml = {version = "0.9.11", optional = true}

View file

@ -85,7 +85,7 @@ pub async fn collect_dates(project_root: &Path, concurrency: usize) {
(DateCase::Dec, "PL1J-6JOckZtHo91uApeb10Qlf2XhkfM-9"), (DateCase::Dec, "PL1J-6JOckZtHo91uApeb10Qlf2XhkfM-9"),
]; ];
let rp = RustyPipe::default(); let rp = RustyPipe::new();
let collected_dates = stream::iter(LANGUAGES) let collected_dates = stream::iter(LANGUAGES)
.map(|lang| { .map(|lang| {
let rp = rp.clone(); let rp = rp.clone();

View file

@ -4,7 +4,6 @@ use std::{
}; };
use rustypipe::{ use rustypipe::{
cache::FileStorage,
client::{ClientType, RustyPipe}, client::{ClientType, RustyPipe},
report::{Report, Reporter}, report::{Report, Reporter},
}; };
@ -43,11 +42,11 @@ impl Reporter for TestFileReporter {
fn rp_testfile(json_path: &Path) -> RustyPipe { fn rp_testfile(json_path: &Path) -> RustyPipe {
let reporter = TestFileReporter::new(json_path); let reporter = TestFileReporter::new(json_path);
RustyPipe::new( RustyPipe::builder()
Some(Box::new(FileStorage::default())), .reporter(Box::new(reporter))
Some(Box::new(reporter)), .report()
None, .strict()
) .build()
} }
pub async fn download_testfiles(project_root: &Path) { pub async fn download_testfiles(project_root: &Path) {
@ -74,17 +73,12 @@ async fn player(testfiles: &Path) {
} }
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query() rp.query().get_player(video_id, client_type).await.unwrap();
.report(true)
.strict(true)
.get_player(video_id, client_type)
.await
.unwrap();
} }
} }
async fn player_model(testfiles: &Path) { async fn player_model(testfiles: &Path) {
let rp = RustyPipe::default(); let rp = RustyPipe::builder().strict().build();
for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] { for (name, id) in [("multilanguage", "tVWWp1PqDus"), ("hdr", "LXb3EKWsInQ")] {
let mut json_path = testfiles.to_path_buf(); let mut json_path = testfiles.to_path_buf();
@ -97,7 +91,6 @@ async fn player_model(testfiles: &Path) {
let player_data = rp let player_data = rp
.query() .query()
.strict(true)
.get_player(id, ClientType::Desktop) .get_player(id, ClientType::Desktop)
.await .await
.unwrap(); .unwrap();
@ -122,11 +115,6 @@ async fn playlist(testfiles: &Path) {
} }
let rp = rp_testfile(&json_path); let rp = rp_testfile(&json_path);
rp.query() rp.query().get_playlist(id).await.unwrap();
.report(true)
.strict(true)
.get_playlist(id)
.await
.unwrap();
} }
} }

View file

@ -12,7 +12,9 @@ use fancy_regex::Regex;
use log::{error, warn}; use log::{error, warn};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::Rng; use rand::Rng;
use reqwest::{header, Client, ClientBuilder, Method, Request, RequestBuilder, Response}; use reqwest::{
header, Client, ClientBuilder, Method, Request, RequestBuilder, Response, StatusCode,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -20,7 +22,7 @@ use crate::{
cache::{CacheStorage, FileStorage}, cache::{CacheStorage, FileStorage},
deobfuscate::{DeobfData, Deobfuscator}, deobfuscate::{DeobfData, Deobfuscator},
model::{Country, Language}, model::{Country, Language},
report::{JsonFileReporter, Level, Report, Reporter}, report::{FileReporter, Level, Report, Reporter},
serializer::MapResult, serializer::MapResult,
util, util,
}; };
@ -63,9 +65,16 @@ impl ClientType {
} }
} }
#[derive(Clone, Debug, Serialize)]
struct YTQuery<T> {
context: YTContext,
#[serde(flatten)]
data: T,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ContextYT { struct YTContext {
client: ClientInfo, client: ClientInfo,
/// only used on desktop /// only used on desktop
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -161,9 +170,11 @@ struct RustyPipeRef {
http: Client, http: Client,
storage: Option<Box<dyn CacheStorage + Sync + Send>>, storage: Option<Box<dyn CacheStorage + Sync + Send>>,
reporter: Option<Box<dyn Reporter + Sync + Send>>, reporter: Option<Box<dyn Reporter + Sync + Send>>,
n_retries: u32,
user_agent: String, user_agent: String,
consent_cookie: String, consent_cookie: String,
cache: Mutex<CacheData>, cache: Mutex<CacheData>,
default_opts: RustyPipeOpts,
} }
#[derive(Clone)] #[derive(Clone)]
@ -174,22 +185,20 @@ struct RustyPipeOpts {
strict: bool, strict: bool,
} }
pub struct RustyPipeBuilder {
storage: Option<Box<dyn CacheStorage + Sync + Send>>,
reporter: Option<Box<dyn Reporter + Sync + Send>>,
n_retries: u32,
user_agent: String,
default_opts: RustyPipeOpts,
}
#[derive(Clone)] #[derive(Clone)]
pub struct RustyPipeQuery { pub struct RustyPipeQuery {
client: RustyPipe, client: RustyPipe,
opts: RustyPipeOpts, opts: RustyPipeOpts,
} }
impl Default for RustyPipe {
fn default() -> Self {
Self::new(
Some(Box::new(FileStorage::default())),
Some(Box::new(JsonFileReporter::default())),
None,
)
}
}
impl Default for RustyPipeOpts { impl Default for RustyPipeOpts {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -247,23 +256,36 @@ impl<T> From<T> for CacheEntry<T> {
} }
} }
impl RustyPipe { impl Default for RustyPipeBuilder {
/// Create a new RustyPipe instance fn default() -> Self {
pub fn new( Self::new()
storage: Option<Box<dyn CacheStorage + Sync + Send>>, }
reporter: Option<Box<dyn Reporter + Sync + Send>>, }
user_agent: Option<String>,
) -> Self {
let user_agent = user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned());
impl RustyPipeBuilder {
/// Constructs a new `RustyPipeBuilder`.
///
/// This is the same as `RustyPipe::builder()`
pub fn new() -> Self {
RustyPipeBuilder {
default_opts: RustyPipeOpts::default(),
storage: Some(Box::new(FileStorage::default())),
reporter: Some(Box::new(FileReporter::default())),
n_retries: 3,
user_agent: DEFAULT_UA.to_owned(),
}
}
/// Returns a new, configured RustyPipe instance.
pub fn build(self) -> RustyPipe {
let http = ClientBuilder::new() let http = ClientBuilder::new()
.user_agent(user_agent.to_owned()) .user_agent(self.user_agent.to_owned())
.gzip(true) .gzip(true)
.brotli(true) .brotli(true)
.build() .build()
.expect("unable to build the HTTP client"); .unwrap();
let cache = if let Some(storage) = &storage { let cache = if let Some(storage) = &self.storage {
if let Some(data) = storage.read() { if let Some(data) = storage.read() {
match serde_json::from_str::<CacheData>(&data) { match serde_json::from_str::<CacheData>(&data) {
Ok(data) => data, Ok(data) => data,
@ -282,9 +304,10 @@ impl RustyPipe {
RustyPipe { RustyPipe {
inner: Arc::new(RustyPipeRef { inner: Arc::new(RustyPipeRef {
http, http,
storage, storage: self.storage,
reporter, reporter: self.reporter,
user_agent, n_retries: self.n_retries,
user_agent: self.user_agent,
consent_cookie: format!( consent_cookie: format!(
"{}={}{}", "{}={}{}",
CONSENT_COOKIE, CONSENT_COOKIE,
@ -292,45 +315,341 @@ impl RustyPipe {
rand::thread_rng().gen_range(100..1000) rand::thread_rng().gen_range(100..1000)
), ),
cache: Mutex::new(cache), cache: Mutex::new(cache),
default_opts: RustyPipeOpts::default(),
}), }),
} }
} }
/// Create a new RustyPipe instance configured for testing /// Add a `CacheStorage` backend for persisting cached information
#[cfg(test)] /// (YouTube client versions, deobfuscation code) between
#[cfg(feature = "yaml")] /// program executions.
pub fn new_test() -> Self { ///
Self::new( /// **Default value**: `FileStorage` in `rustypipe_cache.json`
Some(Box::new(FileStorage::default())), pub fn storage(mut self, storage: Box<dyn CacheStorage + Sync + Send>) -> Self {
Some(Box::new(crate::report::YamlFileReporter::default())), self.storage = Some(storage);
None, self
)
} }
/// Disable cache storage
pub fn no_storage(mut self) -> Self {
self.storage = None;
self
}
/// Add a `Reporter` to collect error details
///
/// **Default value**: `FileReporter` creating reports in `./rustypipe_reports`
pub fn reporter(mut self, reporter: Box<dyn Reporter + Sync + Send>) -> Self {
self.reporter = Some(reporter);
self
}
/// Disable the creation of report files in case of errors and warnings.
pub fn no_reporter(mut self) -> Self {
self.reporter = None;
self
}
/// Set the number of retries for HTTP requests.
///
/// If a HTTP requests fails and retries are enabled,
/// RustyPipe waits 1 second before the next attempt.
/// The waiting time is doubled for subsequent attempts (including a bit of
/// random jitter to be less predictable).
///
/// **Default value**: 3
pub fn n_retries(mut self, n_retries: u32) -> Self {
self.n_retries = n_retries;
self
}
/// Set the user agent used for making requests to the web API.
///
/// **Default value**: `Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0`
/// (Firefox ESR on Debian)
pub fn user_agent(mut self, user_agent: &str) -> Self {
self.user_agent = user_agent.to_owned();
self
}
/// Set the language parameter used when accessing the YouTube API.
/// This will change multilanguage video titles, descriptions and textual dates
///
/// **Default value**: `Language::En` (English)
///
/// **Info**: you can set this option for individual queries, too
pub fn lang(mut self, lang: Language) -> Self {
self.default_opts.lang = lang;
self
}
/// Set the country parameter used when accessing the YouTube API.
/// This will change trends and recommended content.
///
/// **Default value**: `Country::Us` (USA)
///
/// **Info**: you can set this option for individual queries, too
pub fn country(mut self, country: Country) -> Self {
self.default_opts.country = country;
self
}
/// Generate a report on every operation.
/// This should only be used for debugging.
///
/// **Info**: you can set this option for individual queries, too
pub fn report(mut self) -> Self {
self.default_opts.report = true;
self
}
/// Enable strict mode, causing operations to fail if there
/// are warnings during deserialization (e.g. invalid items).
/// This should only be used for testing.
///
/// **Info**: you can set this option for individual queries, too
pub fn strict(mut self) -> Self {
self.default_opts.strict = true;
self
}
}
impl Default for RustyPipe {
fn default() -> Self {
Self::new()
}
}
impl RustyPipe {
/// Create a new RustyPipe instance with default settings.
///
/// To create an instance with custom options, use `RustyPipeBuilder` instead.
pub fn new() -> Self {
RustyPipeBuilder::new().build()
}
/// Constructs a new `RustyPipeBuilder`.
///
/// This is the same as `RustyPipeBuilder::new()`
pub fn builder() -> RustyPipeBuilder {
RustyPipeBuilder::new()
}
/// Constructs a new `RustyPipeQuery`.
pub fn query(&self) -> RustyPipeQuery { pub fn query(&self) -> RustyPipeQuery {
RustyPipeQuery { RustyPipeQuery {
client: self.clone(), client: self.clone(),
opts: RustyPipeOpts { opts: self.inner.default_opts.clone(),
lang: Language::En, }
country: Country::Us, }
report: false,
strict: false, /// Execute the given http request.
async fn http_request(&self, request: Request) -> Result<Response, reqwest::Error> {
let mut last_res: Option<Result<Response, reqwest::Error>> = None;
for n in 0..self.inner.n_retries {
let res = self.inner.http.execute(request.try_clone().unwrap()).await;
let emsg = match &res {
Ok(response) => {
let status = response.status();
// Immediately return in case of success or unrecoverable status code
if status.is_success() || !status.is_server_error() {
return res;
}
status.to_string()
}
Err(e) => {
// Immediately return in case of unrecoverable error
if !e.is_timeout() && !e.is_connect() {
return res;
}
e.to_string()
}
};
let ms = util::retry_delay(n, 1000, 60000, 3);
warn!("Retry attempt #{}. Error: {}. Waiting {} ms", n, emsg, ms);
tokio::time::sleep(std::time::Duration::from_millis(ms.into())).await;
last_res = Some(res);
}
last_res.unwrap()
}
/// Execute the given http request, returning an error in case of a
/// non-successful status code.
async fn http_request_estatus(&self, request: Request) -> Result<Response> {
Ok(self.http_request(request).await?.error_for_status()?)
}
/// Execute the given http request, returning the response body as a string.
async fn http_request_txt(&self, request: Request) -> Result<String> {
Ok(self.http_request_estatus(request).await?.text().await?)
}
/// Extract the current version of the YouTube desktop client from the website.
async fn extract_desktop_client_version(&self) -> Result<String> {
let from_swjs = async {
let swjs = self
.http_request_txt(
self.inner
.http
.get("https://www.youtube.com/sw.js")
.header(header::ORIGIN, "https://www.youtube.com")
.header(header::REFERER, "https://www.youtube.com")
.header(header::COOKIE, self.inner.consent_cookie.to_owned())
.build()
.unwrap(),
)
.await
.context("Failed to download sw.js")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &swjs, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version in sw.js"))
};
let from_html = async {
let html = self
.http_request_txt(
self.inner
.http
.get("https://www.youtube.com/results?search_query=")
.build()
.unwrap(),
)
.await
.context("Failed to get YT Desktop page")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &html, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version on html page"))
};
match from_swjs.await {
Ok(client_version) => Ok(client_version),
Err(_) => from_html.await,
}
}
/// Extract the current version of the YouTube Music desktop client from the website.
async fn extract_music_client_version(&self) -> Result<String> {
let from_swjs = async {
let swjs = self
.http_request_txt(
self.inner
.http
.get("https://music.youtube.com/sw.js")
.header(header::ORIGIN, "https://music.youtube.com")
.header(header::REFERER, "https://music.youtube.com")
.header(header::COOKIE, self.inner.consent_cookie.to_owned())
.build()
.unwrap(),
)
.await
.context("Failed to download sw.js")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &swjs, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version in sw.js"))
};
let from_html = async {
let html = self
.http_request_txt(
self.inner
.http
.get("https://music.youtube.com")
.build()
.unwrap(),
)
.await
.context("Failed to get YT Desktop page")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &html, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version on html page"))
};
match from_swjs.await {
Ok(client_version) => Ok(client_version),
Err(_) => from_html.await,
}
}
/// Get the current version of the YouTube web client from the following sources
///
/// 1. from cache
/// 2. from YouTube's service worker script (`sw.js`)
/// 3. from the YouTube website
/// 4. fall back to the hardcoded version
async fn get_desktop_client_version(&self) -> String {
let mut cache = self.inner.cache.lock().await;
match cache.desktop_client.get() {
Some(cdata) => cdata.version.to_owned(),
None => match self.extract_desktop_client_version().await {
Ok(version) => {
cache.desktop_client = CacheEntry::from(ClientData {
version: version.to_owned(),
});
self.store_cache(&cache);
version
}
Err(e) => {
warn!("{}, falling back to hardcoded version", e);
DESKTOP_CLIENT_VERSION.to_owned()
}
}, },
} }
} }
#[cfg(test)] /// Get the current version of the YouTube Music web client from the following sources
pub fn test_query(&self) -> RustyPipeQuery { ///
RustyPipeQuery { /// 1. from cache
client: self.clone(), /// 2. from YouTube Music's service worker script (`sw.js`)
opts: RustyPipeOpts { /// 3. from the YouTube Music website
lang: Language::En, /// 4. fall back to the hardcoded version
country: Country::Us, async fn get_music_client_version(&self) -> String {
report: false, let mut cache = self.inner.cache.lock().await;
strict: true,
match cache.music_client.get() {
Some(cdata) => cdata.version.to_owned(),
None => match self.extract_music_client_version().await {
Ok(version) => {
cache.music_client = CacheEntry::from(ClientData {
version: version.to_owned(),
});
self.store_cache(&cache);
version
}
Err(e) => {
warn!("{}, falling back to hardcoded version", e);
DESKTOP_MUSIC_CLIENT_VERSION.to_owned()
}
}, },
} }
} }
/// Instantiate a new deobfuscator from either cached or extracted YouTube JavaScript code.
async fn get_deobf(&self) -> Result<Deobfuscator> {
let mut cache = self.inner.cache.lock().await;
match cache.deobf.get() {
Some(deobf) => Ok(Deobfuscator::from(deobf.to_owned())),
None => {
let deobf = Deobfuscator::new(self.inner.http.clone()).await?;
cache.deobf = CacheEntry::from(deobf.get_data());
self.store_cache(&cache);
Ok(deobf)
}
}
}
/// Write the given cache data to the storage backend.
fn store_cache(&self, cache: &CacheData) {
if let Some(storage) = &self.inner.storage {
match serde_json::to_string(cache) {
Ok(data) => storage.write(&data),
Err(e) => error!("Could not serialize cache. Error: {}", e),
}
}
}
} }
impl RustyPipeQuery { impl RustyPipeQuery {
@ -350,20 +669,26 @@ impl RustyPipeQuery {
/// Generate a report on every operation. /// Generate a report on every operation.
/// This should only be used for debugging. /// This should only be used for debugging.
pub fn report(mut self, report: bool) -> Self { pub fn report(mut self) -> Self {
self.opts.report = report; self.opts.report = true;
self self
} }
/// Enable strict mode, causing operations to fail if there /// Enable strict mode, causing operations to fail if there
/// are warnings during deserialization (e.g. invalid items). /// are warnings during deserialization (e.g. invalid items).
/// This should only be used for testing. /// This should only be used for testing.
pub fn strict(mut self, strict: bool) -> Self { pub fn strict(mut self) -> Self {
self.opts.strict = strict; self.opts.strict = true;
self self
} }
async fn get_context(&self, ctype: ClientType, localized: bool) -> ContextYT { /// Create a new context object, which is included in every request to
/// the YouTube API and contains language, country and device parameters.
///
/// # Parameters
/// - `ctype`: Client type (`Desktop`, `DesktopMusic`, `Android`, ...)
/// - `localized`: Whether to include the configured language and country
async fn get_context(&self, ctype: ClientType, localized: bool) -> YTContext {
let hl = match localized { let hl = match localized {
true => self.opts.lang, true => self.opts.lang,
false => Language::En, false => Language::En,
@ -374,10 +699,10 @@ impl RustyPipeQuery {
}; };
match ctype { match ctype {
ClientType::Desktop => ContextYT { ClientType::Desktop => YTContext {
client: ClientInfo { client: ClientInfo {
client_name: "WEB".to_owned(), client_name: "WEB".to_owned(),
client_version: self.get_desktop_client_version().await, client_version: self.client.get_desktop_client_version().await,
client_screen: None, client_screen: None,
device_model: None, device_model: None,
platform: "DESKTOP".to_owned(), platform: "DESKTOP".to_owned(),
@ -389,10 +714,10 @@ impl RustyPipeQuery {
user: User::default(), user: User::default(),
third_party: None, third_party: None,
}, },
ClientType::DesktopMusic => ContextYT { ClientType::DesktopMusic => YTContext {
client: ClientInfo { client: ClientInfo {
client_name: "WEB_REMIX".to_owned(), client_name: "WEB_REMIX".to_owned(),
client_version: self.get_music_client_version().await, client_version: self.client.get_music_client_version().await,
client_screen: None, client_screen: None,
device_model: None, device_model: None,
platform: "DESKTOP".to_owned(), platform: "DESKTOP".to_owned(),
@ -404,7 +729,7 @@ impl RustyPipeQuery {
user: User::default(), user: User::default(),
third_party: None, third_party: None,
}, },
ClientType::TvHtml5Embed => ContextYT { ClientType::TvHtml5Embed => YTContext {
client: ClientInfo { client: ClientInfo {
client_name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER".to_owned(), client_name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER".to_owned(),
client_version: TVHTML5_CLIENT_VERSION.to_owned(), client_version: TVHTML5_CLIENT_VERSION.to_owned(),
@ -421,7 +746,7 @@ impl RustyPipeQuery {
embed_url: "https://www.youtube.com/".to_owned(), embed_url: "https://www.youtube.com/".to_owned(),
}), }),
}, },
ClientType::Android => ContextYT { ClientType::Android => YTContext {
client: ClientInfo { client: ClientInfo {
client_name: "ANDROID".to_owned(), client_name: "ANDROID".to_owned(),
client_version: MOBILE_CLIENT_VERSION.to_owned(), client_version: MOBILE_CLIENT_VERSION.to_owned(),
@ -436,7 +761,7 @@ impl RustyPipeQuery {
user: User::default(), user: User::default(),
third_party: None, third_party: None,
}, },
ClientType::Ios => ContextYT { ClientType::Ios => YTContext {
client: ClientInfo { client: ClientInfo {
client_name: "IOS".to_owned(), client_name: "IOS".to_owned(),
client_version: MOBILE_CLIENT_VERSION.to_owned(), client_version: MOBILE_CLIENT_VERSION.to_owned(),
@ -454,6 +779,13 @@ impl RustyPipeQuery {
} }
} }
/// Create a new Reqwest HTTP request builder with the URL and headers required
/// for accessing the YouTube API
///
/// # Parameters
/// - `ctype`: Client type (`Desktop`, `DesktopMusic`, `Android`, ...)
/// - `method`: HTTP method
/// - `endpoint`: YouTube API endpoint (`https://www.youtube.com/youtubei/v1/<XYZ>?key=...`)
async fn request_builder( async fn request_builder(
&self, &self,
ctype: ClientType, ctype: ClientType,
@ -478,7 +810,7 @@ impl RustyPipeQuery {
.header("X-YouTube-Client-Name", "1") .header("X-YouTube-Client-Name", "1")
.header( .header(
"X-YouTube-Client-Version", "X-YouTube-Client-Version",
self.get_desktop_client_version().await, self.client.get_desktop_client_version().await,
), ),
ClientType::DesktopMusic => self ClientType::DesktopMusic => self
.client .client
@ -500,7 +832,7 @@ impl RustyPipeQuery {
.header("X-YouTube-Client-Name", "67") .header("X-YouTube-Client-Name", "67")
.header( .header(
"X-YouTube-Client-Version", "X-YouTube-Client-Version",
self.get_music_client_version().await, self.client.get_music_client_version().await,
), ),
ClientType::TvHtml5Embed => self ClientType::TvHtml5Embed => self
.client .client
@ -564,6 +896,20 @@ impl RustyPipeQuery {
} }
} }
/// Execute a request to the YouTube API, then deobfuscate and map the response.
///
/// Creates a report in case of failure for easy debugging.
///
/// # Parameters
/// - `ctype`: Client type (`Desktop`, `DesktopMusic`, `Android`, ...)
/// - `operation`: Name of the RustyPipe operation (only for reporting, e.g. `get_player`)
/// - `id`: ID of the requested entity (Video ID, Channel ID, ...).
/// The ID is included in reports and is also passed to the mapper for validating the response.
/// Set it to an empty string if you are not requesting an entity with an ID.
/// - `method`: HTTP method
/// - `endpoint`: YouTube API endpoint (`https://www.youtube.com/youtubei/v1/<XYZ>?key=...`)
/// - `body`: Serializable request body to be sent in json format
/// - `deobf`: Deobfuscator (is passed to the mapper to deobfuscate stream URLs).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn execute_request_deobf< async fn execute_request_deobf<
R: DeserializeOwned + MapResponse<M> + Debug, R: DeserializeOwned + MapResponse<M> + Debug,
@ -573,9 +919,9 @@ impl RustyPipeQuery {
&self, &self,
ctype: ClientType, ctype: ClientType,
operation: &str, operation: &str,
id: &str,
method: Method, method: Method,
endpoint: &str, endpoint: &str,
id: &str,
body: &B, body: &B,
deobf: Option<&Deobfuscator>, deobf: Option<&Deobfuscator>,
) -> Result<M> { ) -> Result<M> {
@ -600,7 +946,7 @@ impl RustyPipeQuery {
version: "0.1.0".to_owned(), version: "0.1.0".to_owned(),
date: chrono::Local::now(), date: chrono::Local::now(),
level, level,
operation: operation.to_owned(), operation: format!("{}({})", operation, id),
error, error,
msgs, msgs,
deobf_data: deobf.map(Deobfuscator::get_data), deobf_data: deobf.map(Deobfuscator::get_data),
@ -661,6 +1007,19 @@ impl RustyPipeQuery {
} }
} }
/// Execute a request to the YouTube API, then map the response.
///
/// Creates a report in case of failure for easy debugging.
///
/// # Parameters
/// - `ctype`: Client type (`Desktop`, `DesktopMusic`, `Android`, ...)
/// - `operation`: Name of the RustyPipe operation (only for reporting, e.g. `get_player`)
/// - `id`: ID of the requested entity (Video ID, Channel ID, ...).
/// The ID is included in reports and is also passed to the mapper for validating the response.
/// Set it to an empty string if you are not requesting an entity with an ID.
/// - `method`: HTTP method
/// - `endpoint`: YouTube API endpoint (`https://www.youtube.com/youtubei/v1/<XYZ>?key=...`)
/// - `body`: Serializable request body to be sent in json format
async fn execute_request< async fn execute_request<
R: DeserializeOwned + MapResponse<M> + Debug, R: DeserializeOwned + MapResponse<M> + Debug,
M, M,
@ -669,174 +1028,31 @@ impl RustyPipeQuery {
&self, &self,
ctype: ClientType, ctype: ClientType,
operation: &str, operation: &str,
id: &str,
method: Method, method: Method,
endpoint: &str, endpoint: &str,
id: &str,
body: &B, body: &B,
) -> Result<M> { ) -> Result<M> {
self.execute_request_deobf::<R, M, B>(ctype, operation, method, endpoint, id, body, None) self.execute_request_deobf::<R, M, B>(ctype, operation, id, method, endpoint, body, None)
.await .await
} }
async fn get_desktop_client_version(&self) -> String {
let mut cache = self.client.inner.cache.lock().await;
match cache.desktop_client.get() {
Some(cdata) => cdata.version.to_owned(),
None => match extract_desktop_client_version(
self.client.inner.http.clone(),
self.client.inner.consent_cookie.to_owned(),
)
.await
{
Ok(version) => {
cache.desktop_client = CacheEntry::from(ClientData {
version: version.to_owned(),
});
self.write_cache(&cache);
version
}
Err(e) => {
warn!("{}, falling back to hardcoded version", e);
DESKTOP_CLIENT_VERSION.to_owned()
}
},
}
}
async fn get_music_client_version(&self) -> String {
let mut cache = self.client.inner.cache.lock().await;
match cache.music_client.get() {
Some(cdata) => cdata.version.to_owned(),
None => match extract_music_client_version(
self.client.inner.http.clone(),
self.client.inner.consent_cookie.to_owned(),
)
.await
{
Ok(version) => {
cache.music_client = CacheEntry::from(ClientData {
version: version.to_owned(),
});
self.write_cache(&cache);
version
}
Err(e) => {
warn!("{}, falling back to hardcoded version", e);
DESKTOP_MUSIC_CLIENT_VERSION.to_owned()
}
},
}
}
async fn get_deobf(&self) -> Result<Deobfuscator> {
let mut cache = self.client.inner.cache.lock().await;
match cache.deobf.get() {
Some(deobf) => Ok(Deobfuscator::from(deobf.to_owned())),
None => {
let deobf = Deobfuscator::new(self.client.inner.http.clone()).await?;
cache.deobf = CacheEntry::from(deobf.get_data());
self.write_cache(&cache);
Ok(deobf)
}
}
}
fn write_cache(&self, cache: &CacheData) {
if let Some(storage) = &self.client.inner.storage {
match serde_json::to_string(cache) {
Ok(data) => storage.write(&data),
Err(e) => error!("Could not serialize cache. Error: {}", e),
}
}
}
}
async fn extract_desktop_client_version(http: Client, consent_cookie: String) -> Result<String> {
let from_swjs = async {
let swjs = exec_request_text(
http.clone(),
http.get("https://www.youtube.com/sw.js")
.header(header::ORIGIN, "https://www.youtube.com")
.header(header::REFERER, "https://www.youtube.com")
.header(header::COOKIE, consent_cookie)
.build()
.unwrap(),
)
.await
.context("Failed to download sw.js")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &swjs, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version in sw.js"))
};
let from_html = async {
let html = exec_request_text(
http.clone(),
http.get("https://www.youtube.com/results?search_query=")
.build()
.unwrap(),
)
.await
.context("Failed to get YT Desktop page")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &html, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version on html page"))
};
match from_swjs.await {
Ok(client_version) => Ok(client_version),
Err(_) => from_html.await,
}
}
async fn extract_music_client_version(http: Client, consent_cookie: String) -> Result<String> {
let from_swjs = async {
let swjs = exec_request_text(
http.clone(),
http.get("https://music.youtube.com/sw.js")
.header(header::ORIGIN, "https://music.youtube.com")
.header(header::REFERER, "https://music.youtube.com")
.header(header::COOKIE, consent_cookie)
.build()
.unwrap(),
)
.await
.context("Failed to download sw.js")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &swjs, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version in sw.js"))
};
let from_html = async {
let html = exec_request_text(
http.clone(),
http.get("https://music.youtube.com").build().unwrap(),
)
.await
.context("Failed to get YT Desktop page")?;
util::get_cg_from_regexes(CLIENT_VERSION_REGEXES.iter(), &html, 1)
.ok_or_else(|| anyhow!("Could not find desktop client version on html page"))
};
match from_swjs.await {
Ok(client_version) => Ok(client_version),
Err(_) => from_html.await,
}
}
async fn exec_request(http: Client, request: Request) -> Result<Response> {
Ok(http.execute(request).await?.error_for_status()?)
}
async fn exec_request_text(http: Client, request: Request) -> Result<String> {
Ok(exec_request(http, request).await?.text().await?)
} }
/// Implement this for YouTube API response structs that need to be mapped to
/// RustyPipe models.
trait MapResponse<T> { trait MapResponse<T> {
/// Map the YouTube API response structs to a RustyPipe model.
///
/// Returns an error if crucial data required for the model could not be extracted.
///
/// Returns a `MapResult` with warnings if there were issues with the deserializing/mapping,
/// but the resulting data is still usable.
///
/// # Parameters
/// - `id`: The ID of the requested entity (Video ID, Channel ID, ...). If possible, assert
/// that the returned entity matches this ID and return an error instead.
/// - `lang`: Language of the request. Used for mapping localized information like dates.
/// - `deobf`: Deobfuscator (if passed to the `execute_request_deobf` method)
fn map_response( fn map_response(
self, self,
id: &str, id: &str,
@ -846,7 +1062,6 @@ trait MapResponse<T> {
} }
#[cfg(test)] #[cfg(test)]
#[cfg(feature = "yaml")]
mod tests { mod tests {
// use super::*; // use super::*;
} }

View file

@ -21,13 +21,13 @@ use crate::{
use super::{ use super::{
response::{self, player}, response::{self, player},
ClientType, ContextYT, MapResponse, MapResult, RustyPipeQuery, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext,
}; };
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QPlayer { struct QPlayer {
context: ContextYT, context: YTContext,
/// Website playback context /// Website playback context
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
playback_context: Option<QPlaybackContext>, playback_context: Option<QPlaybackContext>,
@ -61,7 +61,7 @@ impl RustyPipeQuery {
pub async fn get_player(self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> { pub async fn get_player(self, video_id: &str, client_type: ClientType) -> Result<VideoPlayer> {
let q1 = self.clone(); let q1 = self.clone();
let t_context = tokio::spawn(async move { q1.get_context(client_type, false).await }); let t_context = tokio::spawn(async move { q1.get_context(client_type, false).await });
let q2 = self.clone(); let q2 = self.client.clone();
let t_deobf = tokio::spawn(async move { q2.get_deobf().await }); let t_deobf = tokio::spawn(async move { q2.get_deobf().await });
let (context, deobf) = tokio::join!(t_context, t_deobf); let (context, deobf) = tokio::join!(t_context, t_deobf);
@ -96,9 +96,9 @@ impl RustyPipeQuery {
self.execute_request_deobf::<response::Player, _, _>( self.execute_request_deobf::<response::Player, _, _>(
client_type, client_type,
"get_player", "get_player",
video_id,
Method::POST, Method::POST,
"player", "player",
video_id,
&request_body, &request_body,
Some(&deobf), Some(&deobf),
) )
@ -559,7 +559,6 @@ fn get_audio_codec(codecs: Vec<&str>) -> AudioCodec {
} }
#[cfg(test)] #[cfg(test)]
#[cfg(feature = "yaml")]
mod tests { mod tests {
use std::{fs::File, io::BufReader, path::Path}; use std::{fs::File, io::BufReader, path::Path};
@ -632,9 +631,9 @@ mod tests {
#[case::ios(ClientType::Ios)] #[case::ios(ClientType::Ios)]
#[test_log::test(tokio::test)] #[test_log::test(tokio::test)]
async fn t_get_player(#[case] client_type: ClientType) { async fn t_get_player(#[case] client_type: ClientType) {
let rp = RustyPipe::new_test(); let rp = RustyPipe::builder().strict().build();
let player_data = rp let player_data = rp
.test_query() .query()
.get_player("n4tK7LYFxI0", client_type) .get_player("n4tK7LYFxI0", client_type)
.await .await
.unwrap(); .unwrap();

View file

@ -9,19 +9,19 @@ use crate::{
timeago, util, timeago, util,
}; };
use super::{response, ClientType, ContextYT, MapResponse, MapResult, RustyPipeQuery}; use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QPlaylist { struct QPlaylist {
context: ContextYT, context: YTContext,
browse_id: String, browse_id: String,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct QPlaylistCont { struct QPlaylistCont {
context: ContextYT, context: YTContext,
continuation: String, continuation: String,
} }
@ -36,9 +36,9 @@ impl RustyPipeQuery {
self.execute_request::<response::Playlist, _, _>( self.execute_request::<response::Playlist, _, _>(
ClientType::Desktop, ClientType::Desktop,
"get_playlist", "get_playlist",
playlist_id,
Method::POST, Method::POST,
"browse", "browse",
playlist_id,
&request_body, &request_body,
) )
.await .await
@ -57,9 +57,9 @@ impl RustyPipeQuery {
.execute_request::<response::PlaylistCont, _, _>( .execute_request::<response::PlaylistCont, _, _>(
ClientType::Desktop, ClientType::Desktop,
"get_playlist_cont", "get_playlist_cont",
&playlist.id,
Method::POST, Method::POST,
"browse", "browse",
&playlist.id,
&request_body, &request_body,
) )
.await?; .await?;
@ -339,8 +339,8 @@ mod tests {
#[case] description: Option<String>, #[case] description: Option<String>,
#[case] channel: Option<Channel>, #[case] channel: Option<Channel>,
) { ) {
let rp = RustyPipe::new_test(); let rp = RustyPipe::builder().strict().build();
let playlist = rp.test_query().get_playlist(id).await.unwrap(); let playlist = rp.query().get_playlist(id).await.unwrap();
assert_eq!(playlist.id, id); assert_eq!(playlist.id, id);
assert_eq!(playlist.name, name); assert_eq!(playlist.name, name);
@ -380,18 +380,15 @@ mod tests {
#[test_log::test(tokio::test)] #[test_log::test(tokio::test)]
async fn t_playlist_cont() { async fn t_playlist_cont() {
let rp = RustyPipe::new_test(); let rp = RustyPipe::builder().strict().build();
let mut playlist = rp let mut playlist = rp
.test_query() .query()
.get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi") .get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
.await .await
.unwrap(); .unwrap();
while playlist.ctoken.is_some() { while playlist.ctoken.is_some() {
rp.test_query() rp.query().get_playlist_cont(&mut playlist).await.unwrap();
.get_playlist_cont(&mut playlist)
.await
.unwrap();
} }
assert!(playlist.videos.len() > 100); assert!(playlist.videos.len() > 100);

View file

@ -65,11 +65,11 @@ pub trait Reporter {
fn report(&self, report: &Report); fn report(&self, report: &Report);
} }
pub struct JsonFileReporter { pub struct FileReporter {
path: PathBuf, path: PathBuf,
} }
impl JsonFileReporter { impl FileReporter {
pub fn new<P: AsRef<Path>>(path: P) -> Self { pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self { Self {
path: path.as_ref().to_path_buf(), path: path.as_ref().to_path_buf(),
@ -77,13 +77,21 @@ impl JsonFileReporter {
} }
fn _report(&self, report: &Report) -> Result<()> { fn _report(&self, report: &Report) -> Result<()> {
let report_path = get_report_path(&self.path, report)?; #[cfg(not(feature = "report-yaml"))]
serde_json::to_writer_pretty(&File::create(report_path)?, &report)?; {
let report_path = get_report_path(&self.path, report, "json")?;
serde_json::to_writer_pretty(&File::create(report_path)?, &report)?;
}
#[cfg(feature = "report-yaml")]
{
let report_path = get_report_path(&self.path, report, "yaml")?;
serde_yaml::to_writer(&File::create(report_path)?, &report)?;
}
Ok(()) Ok(())
} }
} }
impl Default for JsonFileReporter { impl Default for FileReporter {
fn default() -> Self { fn default() -> Self {
Self { Self {
path: Path::new("rustypipe_reports").to_path_buf(), path: Path::new("rustypipe_reports").to_path_buf(),
@ -91,51 +99,14 @@ impl Default for JsonFileReporter {
} }
} }
impl Reporter for JsonFileReporter { impl Reporter for FileReporter {
fn report(&self, report: &Report) { fn report(&self, report: &Report) {
self._report(report) self._report(report)
.unwrap_or_else(|e| error!("Could not store report file. Err: {}", e)); .unwrap_or_else(|e| error!("Could not store report file. Err: {}", e));
} }
} }
#[cfg(feature = "yaml")] fn get_report_path(root: &Path, report: &Report, ext: &str) -> Result<PathBuf> {
pub struct YamlFileReporter {
path: PathBuf,
}
#[cfg(feature = "yaml")]
impl YamlFileReporter {
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
path: path.as_ref().to_path_buf(),
}
}
fn _report(&self, report: &Report) -> Result<()> {
let report_path = get_report_path(&self.path, report)?;
serde_yaml::to_writer(&File::create(report_path)?, &report)?;
Ok(())
}
}
#[cfg(feature = "yaml")]
impl Default for YamlFileReporter {
fn default() -> Self {
Self {
path: Path::new("rustypipe_reports").to_path_buf(),
}
}
}
#[cfg(feature = "yaml")]
impl Reporter for YamlFileReporter {
fn report(&self, report: &Report) {
self._report(report)
.unwrap_or_else(|e| error!("Could not store report file. Err: {}", e));
}
}
fn get_report_path(root: &Path, report: &Report) -> Result<PathBuf> {
if !root.is_dir() { if !root.is_dir() {
std::fs::create_dir_all(root)?; std::fs::create_dir_all(root)?;
} }
@ -143,13 +114,13 @@ fn get_report_path(root: &Path, report: &Report) -> Result<PathBuf> {
let filename_prefix = format!("{}_{:?}", report.date.format("%F_%H-%M-%S"), report.level); let filename_prefix = format!("{}_{:?}", report.date.format("%F_%H-%M-%S"), report.level);
let mut report_path = root.to_path_buf(); let mut report_path = root.to_path_buf();
report_path.push(format!("{}.yaml", filename_prefix)); report_path.push(format!("{}.{}", filename_prefix, ext));
// ensure unique filename // ensure unique filename
for i in 1..u32::MAX { for i in 1..u32::MAX {
if report_path.exists() { if report_path.exists() {
report_path = root.to_path_buf(); report_path = root.to_path_buf();
report_path.push(format!("{}_{}.yaml", filename_prefix, i)); report_path.push(format!("{}_{}.{}", filename_prefix, i, ext));
} else { } else {
break; break;
} }

View file

@ -8,6 +8,10 @@ pub use vec_log_err::VecLogError;
use std::fmt::Debug; use std::fmt::Debug;
/// This represents a result from a deserializing/mapping operation.
/// It holds the desired content (`c`) and a list of warning messages,
/// if there occurred minor error during the deserializing or mapping
/// (e.g. certain list items could not be deserialized).
#[derive(Clone)] #[derive(Clone)]
pub struct MapResult<T> { pub struct MapResult<T> {
pub c: T, pub c: T,

View file

@ -8,6 +8,11 @@ use serde_with::{de::DeserializeAsWrap, DeserializeAs};
use super::MapResult; use super::MapResult;
/// Deserializes a list of arbitrary items into a `MapResult`,
/// creating warnings for items that could not be deserialized.
///
/// This is similar to `VecSkipError`, but it does not silently ignore
/// faulty items.
pub struct VecLogError<T>(PhantomData<T>); pub struct VecLogError<T>(PhantomData<T>);
impl<'de, T, U> DeserializeAs<'de, MapResult<Vec<T>>> for VecLogError<U> impl<'de, T, U> DeserializeAs<'de, MapResult<Vec<T>>> for VecLogError<U>

View file

@ -89,6 +89,21 @@ where
numbers numbers
} }
pub fn retry_delay(
n_past_retries: u32,
min_retry_interval: u32,
max_retry_interval: u32,
backoff_base: u32,
) -> u32 {
let unjittered_delay = backoff_base.checked_pow(n_past_retries).unwrap_or(u32::MAX);
let jitter_factor = rand::thread_rng().gen_range(800..1500);
let jittered_delay = unjittered_delay
.checked_mul(jitter_factor)
.unwrap_or(u32::MAX);
min_retry_interval.max(jittered_delay.min(max_retry_interval))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use rstest::rstest; use rstest::rstest;
@ -111,4 +126,20 @@ mod tests {
let n = parse_numeric_vec::<u32>(string); let n = parse_numeric_vec::<u32>(string);
assert_eq!(n, expect); assert_eq!(n, expect);
} }
#[rstest]
#[case(0, 800, 1500)]
#[case(1, 2400, 4500)]
#[case(2, 7200, 13500)]
#[case(100, 60000, 60000)]
fn t_retry_delay(#[case] n: u32, #[case] expect_min: u32, #[case] expect_max: u32) {
let res = retry_delay(n, 1000, 60000, 3);
assert!(
res >= expect_min && res <= expect_max,
"res: {} not within {} and {}",
res,
expect_min,
expect_max
);
}
} }