refactored client, added reports
This commit is contained in:
parent
bb015561c1
commit
6cc927031a
22 changed files with 9091 additions and 1 deletions
382
src/client2/mod.rs
Normal file
382
src/client2/mod.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
pub mod playlist;
|
||||
|
||||
mod response;
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use fancy_regex::Regex;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
use reqwest::{header, Client, ClientBuilder, Method, RequestBuilder};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
cache::Cache,
|
||||
model::{Country, Language},
|
||||
report::{YamlFileReporter, Level, Report, Reporter},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClientType {
|
||||
Desktop,
|
||||
DesktopMusic,
|
||||
TvHtml5Embed,
|
||||
Android,
|
||||
Ios,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContextYT {
|
||||
client: ClientInfo,
|
||||
/// only used on desktop
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
request: Option<RequestYT>,
|
||||
user: User,
|
||||
/// only used for the embedded player
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
third_party: Option<ThirdParty>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClientInfo {
|
||||
client_name: String,
|
||||
client_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
client_screen: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
device_model: Option<String>,
|
||||
platform: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
original_url: Option<String>,
|
||||
hl: Language,
|
||||
gl: Country,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestYT {
|
||||
internal_experiment_flags: Vec<String>,
|
||||
use_ssl: bool,
|
||||
}
|
||||
|
||||
impl Default for RequestYT {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
internal_experiment_flags: vec![],
|
||||
use_ssl: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct User {
|
||||
// TO DO: provide a way to enable restricted mode with:
|
||||
// "enableSafetyMode": true
|
||||
locked_safety_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ThirdParty {
|
||||
embed_url: String,
|
||||
}
|
||||
|
||||
const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0";
|
||||
|
||||
const CONSENT_COOKIE: &str = "CONSENT";
|
||||
const CONSENT_COOKIE_YES: &str = "YES+yt.462272069.de+FX+";
|
||||
|
||||
const YOUTUBEI_V1_URL: &str = "https://www.youtube.com/youtubei/v1/";
|
||||
const YOUTUBEI_V1_GAPIS_URL: &str = "https://youtubei.googleapis.com/youtubei/v1/";
|
||||
const YOUTUBE_MUSIC_V1_URL: &str = "https://music.youtube.com/youtubei/v1/";
|
||||
|
||||
const DISABLE_PRETTY_PRINT_PARAMETER: &str = "&prettyPrint=false";
|
||||
|
||||
const DESKTOP_CLIENT_VERSION: &str = "2.20220909.00.00";
|
||||
const DESKTOP_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
||||
const TVHTML5_CLIENT_VERSION: &str = "2.0";
|
||||
const DESKTOP_MUSIC_API_KEY: &str = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30";
|
||||
const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20220831.01.02";
|
||||
|
||||
const MOBILE_CLIENT_VERSION: &str = "17.29.35";
|
||||
const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
|
||||
const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
|
||||
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
|
||||
|
||||
static CLIENT_VERSION_REGEXES: Lazy<[Regex; 1]> =
|
||||
Lazy::new(|| [Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap()]);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RustyPipe {
|
||||
inner: Arc<RustyPipeRef>,
|
||||
opts: RustyPipeOpts,
|
||||
}
|
||||
|
||||
struct RustyPipeRef {
|
||||
http: Client,
|
||||
cache: Cache,
|
||||
reporter: Option<Box<dyn Reporter>>,
|
||||
user_agent: String,
|
||||
consent_cookie: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RustyPipeOpts {
|
||||
lang: Language,
|
||||
country: Country,
|
||||
report: bool,
|
||||
}
|
||||
|
||||
impl Default for RustyPipe {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
Some(Cache::from_json_file("RustyPipeCache.json")),
|
||||
Some(Box::new(YamlFileReporter::default())),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RustyPipeOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lang: Language::En,
|
||||
country: Country::Us,
|
||||
report: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RustyPipe {
|
||||
pub fn new(
|
||||
cache: Option<Cache>,
|
||||
reporter: Option<Box<dyn Reporter>>,
|
||||
user_agent: Option<String>,
|
||||
) -> Self {
|
||||
let cache = cache.unwrap_or_else(|| Cache::default());
|
||||
let user_agent = user_agent.unwrap_or(DEFAULT_UA.to_owned());
|
||||
|
||||
let http = ClientBuilder::new()
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.build()
|
||||
.expect("unable to build the HTTP client");
|
||||
|
||||
RustyPipe {
|
||||
inner: Arc::new(RustyPipeRef {
|
||||
http,
|
||||
cache,
|
||||
reporter,
|
||||
user_agent,
|
||||
consent_cookie: format!(
|
||||
"{}={}{}",
|
||||
CONSENT_COOKIE,
|
||||
CONSENT_COOKIE_YES,
|
||||
rand::thread_rng().gen_range(100..1000)
|
||||
),
|
||||
}),
|
||||
opts: RustyPipeOpts::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lang(mut self, lang: Language) -> Self {
|
||||
self.opts.lang = lang;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn country(mut self, country: Country) -> Self {
|
||||
self.opts.country = country;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn report(mut self, report: bool) -> Self {
|
||||
self.opts.report = report;
|
||||
self
|
||||
}
|
||||
|
||||
async fn get_context(&self, ctype: ClientType, localized: bool) -> ContextYT {
|
||||
match ctype {
|
||||
ClientType::Desktop => ContextYT {
|
||||
client: ClientInfo {
|
||||
client_name: "WEB".to_owned(),
|
||||
client_version: DESKTOP_CLIENT_VERSION.to_owned(),
|
||||
client_screen: None,
|
||||
device_model: None,
|
||||
platform: "DESKTOP".to_owned(),
|
||||
original_url: Some("https://www.youtube.com/".to_owned()),
|
||||
hl: match localized {
|
||||
true => self.opts.lang,
|
||||
false => Language::En,
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.opts.country,
|
||||
false => Country::Us,
|
||||
},
|
||||
},
|
||||
request: Some(RequestYT::default()),
|
||||
user: User::default(),
|
||||
third_party: None,
|
||||
},
|
||||
ClientType::DesktopMusic => todo!(),
|
||||
ClientType::TvHtml5Embed => todo!(),
|
||||
ClientType::Android => todo!(),
|
||||
ClientType::Ios => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_builder(
|
||||
&self,
|
||||
ctype: ClientType,
|
||||
method: Method,
|
||||
endpoint: &str,
|
||||
) -> RequestBuilder {
|
||||
match ctype {
|
||||
ClientType::Desktop => self
|
||||
.inner
|
||||
.http
|
||||
.request(
|
||||
method,
|
||||
format!(
|
||||
"{}{}?key={}{}",
|
||||
YOUTUBEI_V1_URL, endpoint, DESKTOP_API_KEY, DISABLE_PRETTY_PRINT_PARAMETER
|
||||
),
|
||||
)
|
||||
.header(header::ORIGIN, "https://www.youtube.com")
|
||||
.header(header::REFERER, "https://www.youtube.com")
|
||||
.header(header::COOKIE, self.inner.consent_cookie.to_owned())
|
||||
.header("X-YouTube-Client-Name", "1")
|
||||
.header("X-YouTube-Client-Version", DESKTOP_CLIENT_VERSION),
|
||||
ClientType::DesktopMusic => todo!(),
|
||||
ClientType::TvHtml5Embed => todo!(),
|
||||
ClientType::Android => todo!(),
|
||||
ClientType::Ios => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_request<
|
||||
R: DeserializeOwned + MapResponse<M> + Debug,
|
||||
M,
|
||||
B: Serialize + ?Sized,
|
||||
>(
|
||||
&self,
|
||||
ctype: ClientType,
|
||||
operation: &str,
|
||||
method: Method,
|
||||
endpoint: &str,
|
||||
id: &str,
|
||||
body: &B,
|
||||
) -> Result<M> {
|
||||
let request = self
|
||||
.request_builder(ctype, method.clone(), endpoint)
|
||||
.await
|
||||
.json(body)
|
||||
.build()?;
|
||||
|
||||
let request_url = request.url().to_string();
|
||||
let request_headers = request.headers().to_owned();
|
||||
|
||||
let response = self.inner.http.execute(request).await?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_str = response.text().await?;
|
||||
|
||||
let create_report =
|
||||
|level: Level, error: Option<String>, msgs: Vec<String>, deserialized: Option<&R>| {
|
||||
if let Some(reporter) = &self.inner.reporter {
|
||||
let report = Report {
|
||||
package: "rustypipe".to_owned(),
|
||||
version: "0.1.0".to_owned(),
|
||||
date: chrono::Local::now(),
|
||||
level,
|
||||
operation: operation.to_owned(),
|
||||
error,
|
||||
msgs,
|
||||
http_request: crate::report::HTTPRequest {
|
||||
url: request_url,
|
||||
method: method.to_string(),
|
||||
req_header: request_headers
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(k.to_string(), v.to_str().unwrap_or_default().to_owned())
|
||||
})
|
||||
.collect(),
|
||||
req_body: serde_json::to_string(body).unwrap_or_default(),
|
||||
status: status.into(),
|
||||
resp_body: resp_str.to_owned(),
|
||||
},
|
||||
deserialized: deserialized.map(|d| format!("{:?}", d)),
|
||||
};
|
||||
|
||||
reporter.report(&report);
|
||||
}
|
||||
};
|
||||
|
||||
if status.is_client_error() || status.is_server_error() {
|
||||
let e = anyhow!("Server responded with error code {}", status);
|
||||
create_report(Level::ERR, Some(e.to_string()), vec![], None);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
match serde_json::from_str::<R>(&resp_str) {
|
||||
Ok(deserialized) => match deserialized.map_response(self.opts.lang, id) {
|
||||
Ok(mapres) => {
|
||||
if !mapres.warnings.is_empty() {
|
||||
create_report(
|
||||
Level::WRN,
|
||||
Some("Warnings during deserialization/mapping".to_owned()),
|
||||
mapres.warnings,
|
||||
Some(&deserialized),
|
||||
);
|
||||
} else if self.opts.report {
|
||||
create_report(Level::DBG, None, vec![], Some(&deserialized));
|
||||
}
|
||||
Ok(mapres.c)
|
||||
}
|
||||
Err(e) => {
|
||||
let emsg = "Could not map reponse";
|
||||
create_report(
|
||||
Level::ERR,
|
||||
Some(emsg.to_owned()),
|
||||
vec![e.to_string()],
|
||||
Some(&deserialized),
|
||||
);
|
||||
Err(e).context(emsg)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let emsg = "Could not deserialize response";
|
||||
create_report(Level::ERR, Some(emsg.to_owned()), vec![e.to_string()], None);
|
||||
Err(e).context(emsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait MapResponse<T> {
|
||||
fn map_response(&self, lang: Language, id: &str) -> Result<MapResult<T>>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MapResult<T> {
|
||||
pub c: T,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl<T> Debug for MapResult<T> where T: Debug {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.c.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
}
|
||||
*/
|
||||
Reference in a new issue