add player response model

This commit is contained in:
ThetaDev 2022-07-28 21:04:38 +02:00
parent b85b9893a8
commit 030fd9934e
25 changed files with 11765 additions and 121 deletions

View file

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

View file

@ -0,0 +1,3 @@
pub mod player;
pub use player::Player;

View 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,
}

View file

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

View file

@ -4,6 +4,7 @@
mod macros;
mod util;
mod serializer;
mod deobfuscate;
pub mod client;

113
src/serializer/mime_type.rs Normal file
View 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
View file

@ -0,0 +1,3 @@
pub mod range;
pub mod mime_type;
pub mod text;

27
src/serializer/range.rs Normal file
View 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
View 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)
}
}

View file

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