add player response model
This commit is contained in:
parent
b85b9893a8
commit
030fd9934e
25 changed files with 11765 additions and 121 deletions
|
|
@ -1,15 +1,19 @@
|
|||
use std::time::Instant;
|
||||
mod player;
|
||||
mod response;
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use fancy_regex::Regex;
|
||||
use log::{debug, error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
use reqwest::{header, Client, ClientBuilder, Request};
|
||||
use serde::Serialize;
|
||||
use reqwest::{header, Client, ClientBuilder, Method, Request, RequestBuilder, Response};
|
||||
use serde::{Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::util;
|
||||
use crate::{deobfuscate::Deobfuscator, util};
|
||||
|
||||
pub enum ClientType {
|
||||
Desktop,
|
||||
|
|
@ -19,14 +23,14 @@ pub enum ClientType {
|
|||
Ios,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
struct BaseRequest {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BaseRequest {
|
||||
context: ContextYT,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ContextYT {
|
||||
client: ClientInfo,
|
||||
/// only used on desktop
|
||||
|
|
@ -38,8 +42,8 @@ struct ContextYT {
|
|||
third_party: Option<ThirdParty>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClientInfo {
|
||||
client_name: String,
|
||||
client_version: String,
|
||||
|
|
@ -54,8 +58,8 @@ struct ClientInfo {
|
|||
gl: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestYT {
|
||||
internal_experiment_flags: Vec<String>,
|
||||
use_ssl: bool,
|
||||
|
|
@ -70,20 +74,98 @@ impl Default for RequestYT {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Default)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[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(Serialize, Debug)]
|
||||
#[serde(rename = "camelCase")]
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ThirdParty {
|
||||
embed_url: String,
|
||||
}
|
||||
|
||||
const DEFAULT_UA: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; rv:107.0) Gecko/20100101 Firefox/107.0";
|
||||
|
||||
const CONSENT_COOKIE: &str = "CONSENT";
|
||||
const CONSENT_COOKIE_YES: &str = "YES+yt.462272069.de+FX+";
|
||||
const CONSENT_COOKIE_NO: &str = "PENDING+";
|
||||
|
||||
const DESKTOP_CLIENT_VERSION: &str = "2.20220721.05.00_1";
|
||||
const DESKTOP_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
||||
|
||||
const YOUTUBEI_V1_URL: &str = "https://www.youtube.com/youtubei/v1/";
|
||||
const YOUTUBEI_V1_GAPIS_URL: &str = "https://youtubei.googleapis.com/youtubei/v1/";
|
||||
|
||||
const DISABLE_PRETTY_PRINT_PARAMETER: &str = "&prettyPrint=false";
|
||||
|
||||
pub struct RustyTube {
|
||||
pub locale: Arc<Locale>,
|
||||
pub desktop_client: DesktopClient,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Locale {
|
||||
lang: String,
|
||||
country: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_ua("en", "US")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_ua(lang: &str, country: &str) -> Self {
|
||||
let locale = Arc::new(Locale {
|
||||
lang: lang.to_owned(),
|
||||
country: country.to_owned(),
|
||||
});
|
||||
|
||||
Self {
|
||||
locale: locale.clone(),
|
||||
desktop_client: DesktopClient::new(locale),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn get_ytclient(&self, client_type: ClientType) -> impl YTClient {
|
||||
match client_type {
|
||||
ClientType::Desktop => self.desktop_client,
|
||||
ClientType::DesktopMusic => todo!(),
|
||||
ClientType::TvHtml5Embed => todo!(),
|
||||
ClientType::Android => todo!(),
|
||||
ClientType::Ios => todo!(),
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait YTClient {
|
||||
fn new(locale: Arc<Locale>) -> Self;
|
||||
|
||||
async fn get_base_request_body(&self, localized: bool) -> BaseRequest;
|
||||
async fn request_builder(&self, method: Method, url: &str) -> RequestBuilder;
|
||||
async fn exec_request(&self, request: Request) -> Result<Response>;
|
||||
async fn exec_request_text(&self, request: Request) -> Result<String>;
|
||||
}
|
||||
|
||||
pub struct DesktopClient {
|
||||
locale: Arc<Locale>,
|
||||
http: Client,
|
||||
data: Mutex<DesktopClientData>,
|
||||
consent_cookie_yes: String,
|
||||
consent_cookie_no: String,
|
||||
deobf: Deobfuscator,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DesktopClientData {
|
||||
last_update: Option<Instant>,
|
||||
client_version: String,
|
||||
|
|
@ -108,50 +190,24 @@ impl DesktopClientData {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_UA: &str =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; rv:107.0) Gecko/20100101 Firefox/107.0";
|
||||
#[async_trait]
|
||||
impl YTClient for DesktopClient {
|
||||
fn new(locale: Arc<Locale>) -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
const CONSENT_COOKIE: &str = "CONSENT";
|
||||
const CONSENT_COOKIE_YES: &str = "YES+yt.462272069.de+FX+";
|
||||
const CONSENT_COOKIE_NO: &str = "PENDING+";
|
||||
|
||||
const DESKTOP_CLIENT_VERSION: &str = "2.20220721.05.00_1";
|
||||
const DESKTOP_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
|
||||
|
||||
const CONTENT_PLAYBACK_NONCE_ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
pub struct RustyTube {
|
||||
http: Client,
|
||||
desktop_client_data: Mutex<DesktopClientData>,
|
||||
|
||||
lang: String,
|
||||
country: String,
|
||||
|
||||
consent_cookie_yes: String,
|
||||
consent_cookie_no: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_ua("en", "US", DEFAULT_UA)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_ua(lang: &str, country: &str, user_agent: &str) -> Self {
|
||||
let http = ClientBuilder::new()
|
||||
.user_agent(user_agent)
|
||||
.user_agent(DEFAULT_UA)
|
||||
.gzip(true)
|
||||
.brotli(true)
|
||||
.build()
|
||||
.expect("unable to build the HTTP client");
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let deobf = Deobfuscator::new(http.clone());
|
||||
|
||||
Self {
|
||||
locale,
|
||||
http,
|
||||
desktop_client_data: Mutex::new(DesktopClientData::default()),
|
||||
lang: lang.to_owned(),
|
||||
country: country.to_owned(),
|
||||
data: Mutex::new(DesktopClientData::default()),
|
||||
consent_cookie_yes: format!(
|
||||
"{}={}{}",
|
||||
CONSENT_COOKIE,
|
||||
|
|
@ -164,11 +220,92 @@ impl RustyTube {
|
|||
CONSENT_COOKIE_NO,
|
||||
rng.gen_range(100..1000)
|
||||
),
|
||||
deobf,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_base_request_body(&self, localized: bool) -> BaseRequest {
|
||||
BaseRequest {
|
||||
context: ContextYT {
|
||||
client: ClientInfo {
|
||||
client_name: "WEB".to_owned(),
|
||||
client_version: self.get_client_version().await,
|
||||
client_screen: None,
|
||||
platform: "DESKTOP".to_owned(),
|
||||
original_url: Some("https://www.youtube.com".to_owned()),
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
},
|
||||
},
|
||||
request: Some(RequestYT::default()),
|
||||
user: User::default(),
|
||||
third_party: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_builder(&self, method: Method, endpoint: &str) -> RequestBuilder {
|
||||
self.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.consent_cookie_no.to_owned())
|
||||
.header("X-YouTube-Client-Name", "1")
|
||||
.header("X-YouTube-Client-Version", self.get_client_version().await)
|
||||
}
|
||||
|
||||
async fn exec_request(&self, request: Request) -> Result<Response> {
|
||||
Ok(self.http.execute(request).await?.error_for_status()?)
|
||||
}
|
||||
|
||||
async fn exec_request_text(&self, request: Request) -> Result<String> {
|
||||
Ok(self.exec_request(request).await?.text().await?)
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopClient {
|
||||
async fn extract_client_version_from_swjs(&self) -> Result<Option<String>> {
|
||||
let swjs = self
|
||||
.exec_request_text(
|
||||
self.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.consent_cookie_yes.to_owned())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to download sw.js")?;
|
||||
|
||||
static CLIENT_VERSION_PATTERNS: Lazy<[Regex; 3]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("innertube_context_client_version\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("client.version=([0-9\\.]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
Ok(util::get_cg_from_regexes(
|
||||
CLIENT_VERSION_PATTERNS.iter(),
|
||||
&swjs,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_client_version(&self) -> String {
|
||||
let mut client_data = self.desktop_client_data.lock().await;
|
||||
let mut client_data = self.data.lock().await;
|
||||
|
||||
if client_data.is_old() {
|
||||
let client_version = self.extract_client_version_from_swjs().await;
|
||||
|
|
@ -194,58 +331,7 @@ impl RustyTube {
|
|||
last_update: Some(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
client_data.client_version.to_string()
|
||||
}
|
||||
|
||||
async fn extract_client_version_from_swjs(&self) -> Result<Option<String>> {
|
||||
let swjs = self
|
||||
.exec_request(
|
||||
self.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.consent_cookie_yes.to_owned())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.context("Failed to download sw.js")?;
|
||||
|
||||
static CLIENT_VERSION_PATTERNS: Lazy<[Regex; 3]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("innertube_context_client_version\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("client.version=([0-9\\.]+)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
/*
|
||||
static API_KEY_PATTERNS: Lazy<[Regex; 2]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_API_KEY\":\"([0-9a-zA-Z_-]+?)\"").unwrap(),
|
||||
Regex::new("innertubeApiKey\":\"([0-9a-zA-Z_-]+?)\"").unwrap(),
|
||||
]
|
||||
});*/
|
||||
|
||||
Ok(util::get_cg_from_regexes(
|
||||
CLIENT_VERSION_PATTERNS.iter(),
|
||||
&swjs,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
fn generate_content_playback_nonce() -> String {
|
||||
util::random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 16)
|
||||
}
|
||||
|
||||
fn generate_t_parameter() -> String {
|
||||
util::random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 12)
|
||||
}
|
||||
|
||||
async fn exec_request(&self, request: Request) -> Result<String> {
|
||||
let resp = self.http.execute(request).await?.error_for_status()?;
|
||||
Ok(resp.text().await?)
|
||||
client_data.client_version.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +340,7 @@ mod tests {
|
|||
use super::*;
|
||||
use test_log::test;
|
||||
|
||||
/*
|
||||
#[test(tokio::test)]
|
||||
async fn t_extract_client_version_from_swjs() {
|
||||
let rt = RustyTube::new();
|
||||
|
|
@ -300,4 +387,5 @@ mod tests {
|
|||
let request_str = serde_json::to_string_pretty(&request).unwrap();
|
||||
println!("{}", request_str);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
|
|||
85
src/client/player.rs
Normal file
85
src/client/player.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use reqwest::Method;
|
||||
use serde::{Serialize};
|
||||
|
||||
use super::{response, BaseRequest, RustyTube, YTClient};
|
||||
use crate::util;
|
||||
|
||||
// REQUEST
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QPlayer {
|
||||
#[serde(flatten)]
|
||||
base: BaseRequest,
|
||||
/// Website playback context
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
playback_context: Option<QPlaybackContext>,
|
||||
/// Content playback nonce (16 random chars)
|
||||
cpn: String,
|
||||
/// YouTube video ID
|
||||
video_id: String,
|
||||
/// Set to true to allow extraction of streams with sensitive content
|
||||
content_check_ok: bool,
|
||||
/// Probably refers to allowing sensitive content, too
|
||||
racy_check_ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QPlaybackContext {
|
||||
content_playback_context: QContentPlaybackContext,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QContentPlaybackContext {
|
||||
/// Signature timestamp extracted from player.js
|
||||
signature_timestamp: String,
|
||||
/// Referer URL from website
|
||||
referer: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
pub async fn fetch_player(&self, video_id: &str) -> Result<response::Player> {
|
||||
let sts = self.desktop_client.deobf.get_sts().await?;
|
||||
|
||||
let request_body = QPlayer {
|
||||
base: self.desktop_client.get_base_request_body(false).await,
|
||||
playback_context: Some(QPlaybackContext {
|
||||
content_playback_context: QContentPlaybackContext {
|
||||
signature_timestamp: sts,
|
||||
referer: format!("https://www.youtube.com/watch?v={}", video_id),
|
||||
},
|
||||
}),
|
||||
cpn: util::generate_content_playback_nonce(),
|
||||
video_id: video_id.to_owned(),
|
||||
content_check_ok: true,
|
||||
racy_check_ok: true,
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.desktop_client
|
||||
.request_builder(Method::POST, "player")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(resp.json::<response::Player>().await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use test_log::test;
|
||||
|
||||
#[test(tokio::test)]
|
||||
async fn t_fetch_stream() {
|
||||
let rt = RustyTube::new();
|
||||
let stream = rt.fetch_player("ZeerrnuLi5E").await.unwrap();
|
||||
|
||||
dbg!(stream);
|
||||
}
|
||||
}
|
||||
3
src/client/response/mod.rs
Normal file
3
src/client/response/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod player;
|
||||
|
||||
pub use player::Player;
|
||||
246
src/client/response/player.rs
Normal file
246
src/client/response/player.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
use std::ops::Range;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
||||
|
||||
use crate::serializer::mime_type::MimeType;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Player {
|
||||
pub playability_status: PlayabilityStatus,
|
||||
pub streaming_data: Option<StreamingData>,
|
||||
pub captions: Option<Captions>,
|
||||
pub video_details: Option<VideoDetails>,
|
||||
pub microformat: Option<Microformat>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum PlayabilityStatus {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Ok { live_streamability: Option<Empty> },
|
||||
/// Video cant be played because of DRM / Geoblock
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Unplayable {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>,
|
||||
},
|
||||
/// Age limit / Private video
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LoginRequired {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LiveStreamOffline {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
/// Video was censored / deleted
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Error {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Empty {}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamingData {
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub expires_in_seconds: u64,
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub formats: Option<Vec<Format>>,
|
||||
#[serde_as(as = "Option<VecSkipError<_>>")]
|
||||
pub adaptive_formats: Option<Vec<Format>>,
|
||||
/// Only on livestreams
|
||||
pub dash_manifest_url: Option<String>,
|
||||
/// Only on livestreams
|
||||
pub hls_manifest_url: Option<String>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Format {
|
||||
pub itag: u32,
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(default, rename = "type")]
|
||||
pub format_type: FormatType,
|
||||
|
||||
#[serde(with = "crate::serializer::mime_type")]
|
||||
pub mime_type: MimeType,
|
||||
|
||||
pub bitrate: Option<u64>,
|
||||
|
||||
pub width: Option<u64>,
|
||||
pub height: Option<u64>,
|
||||
|
||||
#[serde_as(as = "Option<crate::serializer::range::Range>")]
|
||||
pub index_range: Option<Range<u32>>,
|
||||
#[serde_as(as = "Option<crate::serializer::range::Range>")]
|
||||
pub init_range: Option<Range<u32>>,
|
||||
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub content_length: Option<u32>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub quality: Option<Quality>,
|
||||
pub fps: Option<u8>,
|
||||
pub quality_label: Option<String>,
|
||||
pub average_bitrate: Option<u32>,
|
||||
pub color_info: Option<ColorInfo>,
|
||||
|
||||
// Audio only
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "DefaultOnError")]
|
||||
pub audio_quality: Option<AudioQuality>,
|
||||
|
||||
// #[serde_as(as = "Option<JsonString>")]
|
||||
// pub approx_duration_ms: Option<u32>,
|
||||
|
||||
// Audio only
|
||||
#[serde_as(as = "Option<JsonString>")]
|
||||
pub audio_sample_rate: Option<u32>,
|
||||
pub audio_channels: Option<u8>,
|
||||
pub loudness_db: Option<f64>,
|
||||
|
||||
pub signature_cipher: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Quality {
|
||||
Tiny,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Highres,
|
||||
Hd720,
|
||||
Hd1080,
|
||||
Hd1440,
|
||||
Hd2160,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AudioQuality {
|
||||
#[serde(rename = "AUDIO_QUALITY_LOW", alias = "low")]
|
||||
Low,
|
||||
#[serde(rename = "AUDIO_QUALITY_MEDIUM", alias = "medium")]
|
||||
Medium,
|
||||
#[serde(rename = "AUDIO_QUALITY_HIGH", alias = "high")]
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum FormatType {
|
||||
#[default]
|
||||
Default,
|
||||
/// This stream only works via DASH and not via progressive HTTP.
|
||||
FormatStreamTypeOtf,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ColorInfo {
|
||||
pub primaries: Primaries,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Primaries {
|
||||
#[default]
|
||||
ColorPrimariesBt709,
|
||||
ColorPrimariesBt2020,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Captions {
|
||||
pub player_captions_tracklist_renderer: PlayerCaptionsTracklistRenderer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerCaptionsTracklistRenderer {
|
||||
pub caption_tracks: Vec<CaptionTrack>,
|
||||
pub translation_languages: Vec<TranslationLanguage>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptionTrack {
|
||||
pub base_url: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub name: String,
|
||||
pub language_code: String,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranslationLanguage {
|
||||
pub language_code: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub language_name: String
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoDetails {
|
||||
pub video_id: String,
|
||||
pub title: String,
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub length_seconds: u32,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
pub channel_id: String,
|
||||
#[serde(default)]
|
||||
pub short_description: String,
|
||||
pub thumbnail: Option<Thumbnails>,
|
||||
#[serde_as(as = "JsonString")]
|
||||
pub view_count: u64,
|
||||
pub author: String,
|
||||
pub is_live_content: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Thumbnails {
|
||||
pub thumbnails: Vec<Thumbnail>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Thumbnail {
|
||||
pub url: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Microformat {
|
||||
pub player_microformat_renderer: PlayerMicroformatRenderer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerMicroformatRenderer {
|
||||
pub is_family_safe: bool,
|
||||
pub category: String,
|
||||
pub publish_date: NaiveDate,
|
||||
pub upload_date: NaiveDate,
|
||||
}
|
||||
|
|
@ -14,11 +14,13 @@ pub struct Deobfuscator {
|
|||
cache: RwLock<JSCache>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JSCache {
|
||||
js_url: Option<String>,
|
||||
last_update: Instant,
|
||||
last_update: Option<Instant>,
|
||||
js_url: String,
|
||||
sig_fn: String,
|
||||
nsig_fn: String,
|
||||
sts: String,
|
||||
}
|
||||
|
||||
impl Deobfuscator {
|
||||
|
|
@ -26,12 +28,7 @@ impl Deobfuscator {
|
|||
pub fn new(http: Client) -> Self {
|
||||
Self {
|
||||
http,
|
||||
cache: RwLock::new(JSCache {
|
||||
js_url: None,
|
||||
last_update: Instant::now(),
|
||||
sig_fn: "".to_owned(),
|
||||
nsig_fn: "".to_owned(),
|
||||
}),
|
||||
cache: RwLock::new(JSCache::default()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,12 +48,14 @@ impl Deobfuscator {
|
|||
|
||||
let sig_fn = get_sig_fn(&player_js)?;
|
||||
let nsig_fn = get_nsig_fn(&player_js)?;
|
||||
let sts = get_sts(&player_js)?;
|
||||
|
||||
*cache = JSCache {
|
||||
js_url: Some(url.to_owned()),
|
||||
last_update: Instant::now(),
|
||||
last_update: Some(Instant::now()),
|
||||
js_url: url.to_owned(),
|
||||
sig_fn,
|
||||
nsig_fn,
|
||||
sts,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -73,11 +72,20 @@ impl Deobfuscator {
|
|||
let cache = self.cache.read().await;
|
||||
deobfuscate_nsig(nsig, &cache.nsig_fn)
|
||||
}
|
||||
|
||||
pub async fn get_sts(&self) -> Result<String> {
|
||||
self.update().await?;
|
||||
let cache = self.cache.read().await;
|
||||
Ok(cache.sts.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl JSCache {
|
||||
fn is_stale(&self) -> bool {
|
||||
self.js_url.is_none() || Instant::now().duration_since(self.last_update).as_secs() > 3600
|
||||
match self.last_update {
|
||||
Some(last_update) => Instant::now().duration_since(last_update).as_secs() > 3600,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +309,19 @@ async fn get_response(http: &Client, url: &str) -> Result<String> {
|
|||
Ok(resp.text().await?)
|
||||
}
|
||||
|
||||
fn get_sts(player_js: &str) -> Result<String> {
|
||||
let sts_pattern = Regex::new("signatureTimestamp[=:](\\d+)").unwrap();
|
||||
|
||||
Ok(some_or_bail!(
|
||||
sts_pattern.captures(&player_js)?,
|
||||
Err(anyhow!("could not find sts"))
|
||||
)
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -363,6 +384,12 @@ c[36](c[8],c[32]),c[20](c[25],c[10]),c[2](c[22],c[8]),c[32](c[20],c[16]),c[32](c
|
|||
assert_eq!(res, N_DEOBF_FUNC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_get_sts() {
|
||||
let res = get_sts(TEST_JS).unwrap();
|
||||
assert_eq!(res, "19187")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_deobfuscate_nsig() {
|
||||
let res = deobfuscate_nsig("BI_n4PxQ22is-KKajKUW", N_DEOBF_FUNC).unwrap();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
mod macros;
|
||||
|
||||
mod util;
|
||||
mod serializer;
|
||||
mod deobfuscate;
|
||||
|
||||
pub mod client;
|
||||
|
|
|
|||
113
src/serializer/mime_type.rs
Normal file
113
src/serializer/mime_type.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use once_cell::sync::Lazy;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
use serde::de::{Deserialize, Deserializer, Error, Unexpected};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MimeType {
|
||||
pub mime: String,
|
||||
pub codecs: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<MimeType, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
static PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"(\w+/\w+);\scodecs="([a-zA-Z-0-9.,\s]*)""#).unwrap());
|
||||
|
||||
// deserializing into a &str gives back an error
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
let captures = PATTERN.captures(&s).ok().flatten().ok_or_else(|| {
|
||||
D::Error::invalid_value(
|
||||
Unexpected::Str(&s),
|
||||
&"a valid mime type with the format <TYPE>/<SUBTYPE>",
|
||||
)
|
||||
})?;
|
||||
let mime = captures.get(1).unwrap().as_str().to_owned();
|
||||
let codecs = captures
|
||||
.get(2)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.split(", ")
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
Ok(MimeType {
|
||||
mime,
|
||||
codecs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serialize<S>(mime_type: &MimeType, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut s = format!(r#"{}; codecs=""#, mime_type.mime,);
|
||||
|
||||
for codec in mime_type.codecs.iter() {
|
||||
s.push_str(codec);
|
||||
s.push(',');
|
||||
s.push(' ');
|
||||
}
|
||||
|
||||
s.pop();
|
||||
s.pop();
|
||||
s.push('"');
|
||||
s.serialize(serializer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct S {
|
||||
#[serde(with = "crate::serializer::mime_type")]
|
||||
mime: MimeType,
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
r#"{"mime": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\""}"#,
|
||||
MimeType {
|
||||
mime: "video/mp4".to_owned(),
|
||||
codecs: vec!["avc1.42001E".to_owned(), "mp4a.40.2".to_owned()],
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"mime": "video/webm; codecs=\"vp9\""}"#,
|
||||
MimeType {
|
||||
mime: "video/webm".to_owned(),
|
||||
codecs: vec!["vp9".to_owned()],
|
||||
}
|
||||
)]
|
||||
#[case(
|
||||
r#"{"mime": "audio/webm; codecs=\"opus\""}"#,
|
||||
MimeType {
|
||||
mime: "audio/webm".to_owned(),
|
||||
codecs: vec!["opus".to_owned()],
|
||||
}
|
||||
)]
|
||||
fn t_deserialize(#[case] test_json: &str, #[case] exp: MimeType) {
|
||||
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
||||
assert_eq!(res.mime, exp)
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn t_serialize() {
|
||||
let s = S {
|
||||
mime: MimeType {
|
||||
mime: "video/webm".to_owned(),
|
||||
codecs: vec!["av01.0.08M.08".to_owned()],
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(json, r#"{"mime":"video/webm; codecs=\"av01.0.08M.08\""}"#)
|
||||
}
|
||||
}
|
||||
3
src/serializer/mod.rs
Normal file
3
src/serializer/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod range;
|
||||
pub mod mime_type;
|
||||
pub mod text;
|
||||
27
src/serializer/range.rs
Normal file
27
src/serializer/range.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use serde_with::{DeserializeAs, json::JsonString, serde_as, SerializeAs};
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct Range {
|
||||
#[serde_as(as = "JsonString")]
|
||||
start: u32,
|
||||
#[serde_as(as = "JsonString")]
|
||||
end: u32,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, std::ops::Range<u32>> for Range {
|
||||
fn deserialize_as<D>(deserializer: D) -> Result<std::ops::Range<u32>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de> {
|
||||
let range = Range::deserialize(deserializer)?;
|
||||
Ok(std::ops::Range { start: range.start, end: range.end })
|
||||
}
|
||||
}
|
||||
|
||||
impl SerializeAs<std::ops::Range<u32>> for Range {
|
||||
fn serialize_as<S>(&std::ops::Range { start, end }: &std::ops::Range<u32>, serializer: S) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error> where
|
||||
S: Serializer {
|
||||
Range { start, end }.serialize(serializer)
|
||||
}
|
||||
}
|
||||
109
src/serializer/text.rs
Normal file
109
src/serializer/text.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use serde::{Deserialize, Deserializer};
|
||||
use serde_with::{serde_as, DeserializeAs};
|
||||
|
||||
/// The YouTube API has multiple ways of outputting text. This deserializer
|
||||
/// is an attempt to unify them.
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "text": "Hello World"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "simpleText": "Hello World"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Multiple "runs" of text should be joined with spaces
|
||||
/// ```json
|
||||
/// {
|
||||
/// "runs": [
|
||||
/// {"text": "Hello"},
|
||||
/// {"text": "World"},
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Text {
|
||||
Simple {
|
||||
#[serde(alias = "simpleText")]
|
||||
text: String,
|
||||
},
|
||||
Multiple {
|
||||
#[serde_as(as = "Vec<crate::serializer::text::Text>")]
|
||||
runs: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> DeserializeAs<'de, String> for Text {
|
||||
fn deserialize_as<D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let text = Text::deserialize(deserializer)?;
|
||||
match text {
|
||||
Text::Simple { text } => Ok(text),
|
||||
Text::Multiple { runs } => Ok(runs.join("")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rstest::rstest;
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[rstest]
|
||||
#[case(
|
||||
r#"{
|
||||
"txt": {
|
||||
"text": "Hello World"
|
||||
}
|
||||
}"#,
|
||||
"Hello World"
|
||||
)]
|
||||
#[case(
|
||||
r#"{
|
||||
"txt": {
|
||||
"simpleText": "Hello World"
|
||||
}
|
||||
}"#,
|
||||
"Hello World"
|
||||
)]
|
||||
#[case(
|
||||
r#"{
|
||||
"txt": {
|
||||
"runs": [
|
||||
{
|
||||
"text": "Abo für "
|
||||
},
|
||||
{
|
||||
"text": "MBCkpop"
|
||||
},
|
||||
{
|
||||
"text": " beenden?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
"Abo für MBCkpop beenden?"
|
||||
)]
|
||||
fn t_deserialize(#[case] test_json: &str, #[case] exp: &str) {
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
struct S {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
txt: String,
|
||||
}
|
||||
|
||||
let res = serde_json::from_str::<S>(&test_json).unwrap();
|
||||
assert_eq!(res.txt, exp)
|
||||
}
|
||||
}
|
||||
11
src/util.rs
11
src/util.rs
|
|
@ -1,6 +1,9 @@
|
|||
use fancy_regex::Regex;
|
||||
use rand::Rng;
|
||||
|
||||
const CONTENT_PLAYBACK_NONCE_ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
/// Return the given capture group that matches first in a list of regexes
|
||||
pub fn get_cg_from_regexes<'a, I>(mut regexes: I, text: &str, cg: usize) -> Option<String>
|
||||
where
|
||||
|
|
@ -26,3 +29,11 @@ pub fn random_string(charset: &[u8], length: usize) -> String {
|
|||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn generate_content_playback_nonce() -> String {
|
||||
random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 16)
|
||||
}
|
||||
|
||||
pub fn generate_t_parameter() -> String {
|
||||
random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 12)
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue