From 1b60c97a183b9d74b92df14b5b113c61aba1be7f Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Wed, 18 Dec 2024 19:44:42 +0100 Subject: [PATCH] fix: update client versions, enable Opus audio with iOS client --- src/client/mod.rs | 81 +++++++++++++++++++++++++------------------- src/client/player.rs | 48 +++++++++++++------------- 2 files changed, 72 insertions(+), 57 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index a4dfeec..cef5916 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -83,14 +83,8 @@ pub enum ClientType { } impl ClientType { - fn is_web(self) -> bool { - match self { - ClientType::Desktop - | ClientType::DesktopMusic - | ClientType::Mobile - | ClientType::Tv => true, - ClientType::Android | ClientType::Ios => false, - } + fn needs_deobf(self) -> bool { + !matches!(self, ClientType::Ios) } } @@ -113,13 +107,19 @@ struct YTContext<'a> { struct ClientInfo<'a> { client_name: &'a str, client_version: Cow<'a, str>, + #[serde(skip_serializing_if = "str::is_empty")] + client_screen: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + device_model: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + os_name: &'a str, + #[serde(skip_serializing_if = "str::is_empty")] + os_version: &'a str, #[serde(skip_serializing_if = "Option::is_none")] - client_screen: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - device_model: Option<&'a str>, + android_sdk_version: Option, platform: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - original_url: Option<&'a str>, + #[serde(skip_serializing_if = "str::is_empty")] + original_url: &'a str, visitor_data: &'a str, hl: Language, gl: Country, @@ -132,10 +132,13 @@ impl Default for ClientInfo<'_> { Self { client_name: "", client_version: Cow::default(), - client_screen: None, - device_model: None, + client_screen: "", + device_model: "", + os_name: "", + os_version: "", + android_sdk_version: None, platform: "", - original_url: None, + original_url: "", visitor_data: "", hl: Language::En, gl: Country::Us, @@ -298,14 +301,14 @@ const YOUTUBE_TV_URL: &str = "https://www.youtube.com/tv"; const DISABLE_PRETTY_PRINT_PARAMETER: &str = "prettyPrint=false"; // Web client -const DESKTOP_CLIENT_VERSION: &str = "2.20241010.09.00"; -const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20241007.00.00"; -const MOBILE_CLIENT_VERSION: &str = "2.20241011.01.00"; -const TV_CLIENT_VERSION: &str = "7.20241008.14.02"; +const DESKTOP_CLIENT_VERSION: &str = "2.20241216.05.00"; +const DESKTOP_MUSIC_CLIENT_VERSION: &str = "1.20241216.01.00"; +const MOBILE_CLIENT_VERSION: &str = "2.20241217.07.00"; +const TV_CLIENT_VERSION: &str = "7.20241211.14.00"; // Mobile app client -const APP_CLIENT_VERSION: &str = "18.03.33"; -const IOS_DEVICE_MODEL: &str = "iPhone14,5"; +const APP_CLIENT_VERSION: &str = "19.44.38"; +const IOS_DEVICE_MODEL: &str = "iPhone16,2"; const OAUTH_CLIENT_ID: &str = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com"; @@ -321,7 +324,8 @@ static VISITOR_DATA_REGEX: Lazy = /// /// The order may change in the future in case YouTube applies changes to their /// platform that disable a client or make it less reliable. -pub const DEFAULT_PLAYER_CLIENT_ORDER: &[ClientType] = &[ClientType::Tv, ClientType::Ios]; +pub const DEFAULT_PLAYER_CLIENT_ORDER: &[ClientType] = + &[ClientType::Ios, ClientType::Tv, ClientType::Android]; /// The RustyPipe client used to access YouTube's API /// @@ -1505,13 +1509,13 @@ impl RustyPipeQuery { ClientType::Mobile => MOBILE_UA.into(), ClientType::Tv => TV_UA.into(), ClientType::Android => format!( - "com.google.android.youtube/{} (Linux; U; Android 12; {}) gzip", - APP_CLIENT_VERSION, self.opts.country + "com.google.android.youtube/{} (Linux; U; Android 11) gzip", + APP_CLIENT_VERSION ) .into(), ClientType::Ios => format!( - "com.google.ios.youtube/{} ({}; U; CPU iOS 15_4 like Mac OS X; {})", - APP_CLIENT_VERSION, IOS_DEVICE_MODEL, self.opts.country + "com.google.ios.youtube/{} ({}; U; CPU iOS 18_1_0 like Mac OS X)", + APP_CLIENT_VERSION, IOS_DEVICE_MODEL ) .into(), } @@ -1550,7 +1554,7 @@ impl RustyPipeQuery { client_name: "WEB", client_version: self.client.get_client_version(ctype).await, platform: "DESKTOP", - original_url: Some(YOUTUBE_HOME_URL), + original_url: YOUTUBE_HOME_URL, visitor_data, hl, gl, @@ -1565,7 +1569,7 @@ impl RustyPipeQuery { client_name: "WEB_REMIX", client_version: self.client.get_client_version(ctype).await, platform: "DESKTOP", - original_url: Some(YOUTUBE_MUSIC_HOME_URL), + original_url: YOUTUBE_MUSIC_HOME_URL, visitor_data, hl, gl, @@ -1580,7 +1584,7 @@ impl RustyPipeQuery { client_name: "MWEB", client_version: self.client.get_client_version(ctype).await, platform: "MOBILE", - original_url: Some(YOUTUBE_MOBILE_HOME_URL), + original_url: YOUTUBE_MOBILE_HOME_URL, visitor_data, hl, gl, @@ -1594,9 +1598,9 @@ impl RustyPipeQuery { client: ClientInfo { client_name: "TVHTML5", client_version: self.client.get_client_version(ctype).await, - client_screen: Some("WATCH"), + client_screen: "WATCH", platform: "TV", - device_model: Some("SmartTV"), + device_model: "SmartTV", visitor_data, hl, gl, @@ -1612,6 +1616,9 @@ impl RustyPipeQuery { client: ClientInfo { client_name: "ANDROID", client_version: APP_CLIENT_VERSION.into(), + os_name: "Android", + os_version: "11", + android_sdk_version: Some(30), platform: "MOBILE", visitor_data, hl, @@ -1626,7 +1633,9 @@ impl RustyPipeQuery { client: ClientInfo { client_name: "IOS", client_version: APP_CLIENT_VERSION.into(), - device_model: Some(IOS_DEVICE_MODEL), + device_model: IOS_DEVICE_MODEL, + os_name: "iPhone", + os_version: "18.1.0.22B83", platform: "MOBILE", visitor_data, hl, @@ -1721,6 +1730,7 @@ impl RustyPipeQuery { .post(format!( "{YOUTUBEI_V1_GAPIS_URL}{endpoint}?{DISABLE_PRETTY_PRINT_PARAMETER}" )) + .header("X-YouTube-Client-Name", "3") .header("X-Goog-Api-Format-Version", "2"), ClientType::Ios => self .client @@ -1729,9 +1739,12 @@ impl RustyPipeQuery { .post(format!( "{YOUTUBEI_V1_GAPIS_URL}{endpoint}?{DISABLE_PRETTY_PRINT_PARAMETER}" )) + .header("X-YouTube-Client-Name", "5") .header("X-Goog-Api-Format-Version", "2"), }; - r = r.header(header::USER_AGENT, self.user_agent(ctype).as_ref()); + r = r + .header(header::CONTENT_TYPE, "application/json") + .header(header::USER_AGENT, self.user_agent(ctype).as_ref()); if let Some(vdata) = self.opts.visitor_data.as_deref().or(visitor_data) { r = r.header("X-Goog-EOM-Visitor-Id", vdata); } diff --git a/src/client/player.rs b/src/client/player.rs index 8c0362b..47fb7a0 100644 --- a/src/client/player.rs +++ b/src/client/player.rs @@ -10,7 +10,7 @@ use serde::Serialize; use url::Url; use crate::{ - deobfuscate::Deobfuscator, + deobfuscate::{DeobfData, Deobfuscator}, error::{internal::DeobfError, Error, ExtractionError, UnavailabilityReason}, model::{ traits::QualityOrd, AudioCodec, AudioFormat, AudioStream, AudioTrack, Frameset, Subtitle, @@ -34,17 +34,12 @@ struct QPlayer<'a> { /// Website playback context #[serde(skip_serializing_if = "Option::is_none")] playback_context: Option>, - /// Content playback nonce (mobile only, 16 random chars) - #[serde(skip_serializing_if = "Option::is_none")] - cpn: Option, /// YouTube video ID video_id: &'a str, /// 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, - #[serde(skip_serializing_if = "Option::is_none")] - params: Option<&'a str>, } #[derive(Debug, Serialize)] @@ -125,31 +120,27 @@ impl RustyPipeQuery { client_type: ClientType, ) -> Result { let video_id = video_id.as_ref(); - let deobf = self.client.get_deobf_data().await?; + let mut deobf = None; - let request_body = if client_type.is_web() { + let request_body = if client_type.needs_deobf() { + deobf = Some(self.client.get_deobf_data().await?); QPlayer { playback_context: Some(QPlaybackContext { content_playback_context: QContentPlaybackContext { - signature_timestamp: &deobf.sts, + signature_timestamp: &deobf.as_ref().unwrap().sts, referer: format!("https://www.youtube.com/watch?v={video_id}"), }, }), - cpn: None, video_id, content_check_ok: true, racy_check_ok: true, - params: None, } } else { QPlayer { playback_context: None, - cpn: Some(util::generate_content_playback_nonce()), video_id, content_check_ok: true, racy_check_ok: true, - // Source: https://github.com/TeamNewPipe/NewPipeExtractor/pull/1168 - params: Some("CgIIAQ%3D%3D").filter(|_| client_type == ClientType::Android), } }; @@ -160,7 +151,7 @@ impl RustyPipeQuery { "player", &request_body, MapRespOptions { - deobf: Some(&deobf), + deobf: deobf.as_ref(), unlocalized: true, ..Default::default() }, @@ -277,7 +268,7 @@ impl MapResponse for response::Player { }; let streams = if !is_live { - let mut mapper = StreamsMapper::new(Deobfuscator::new(ctx.deobf.unwrap())?); + let mut mapper = StreamsMapper::new(ctx.deobf)?; mapper.map_streams(streaming_data.formats); mapper.map_streams(streaming_data.adaptive_formats); let mut res = mapper.output()?; @@ -374,7 +365,7 @@ impl MapResponse for response::Player { } struct StreamsMapper { - deobf: Deobfuscator, + deobf: Option, streams: Streams, warnings: Vec, /// First stream mapping error @@ -393,15 +384,20 @@ struct Streams { } impl StreamsMapper { - fn new(deobf: Deobfuscator) -> Self { - Self { + fn new(deobf_data: Option<&DeobfData>) -> Result { + let deobf = match deobf_data { + Some(deobf_data) => Some(Deobfuscator::new(deobf_data)?), + None => None, + }; + + Ok(Self { deobf, streams: Streams::default(), warnings: Vec::new(), first_err: None, last_nsig: String::new(), last_nsig_deobf: String::new(), - } + }) } fn map_streams(&mut self, mut streams: MapResult>) { @@ -461,6 +457,12 @@ impl StreamsMapper { }) } + fn deobf(&self) -> Result<&Deobfuscator, DeobfError> { + self.deobf + .as_ref() + .ok_or(DeobfError::Other("no deobfuscator")) + } + fn cipher_to_url_params( &self, signature_cipher: &str, @@ -481,7 +483,7 @@ impl StreamsMapper { let (url_base, mut url_params) = util::url_to_params(raw_url).or(Err(DeobfError::Extraction("url params")))?; - let deobf_sig = self.deobf.deobfuscate_sig(sig)?; + let deobf_sig = self.deobf()?.deobfuscate_sig(sig)?; url_params.insert(sp.to_string(), deobf_sig); Ok((url_base, url_params)) @@ -492,7 +494,7 @@ impl StreamsMapper { let nsig = if n == &self.last_nsig { self.last_nsig_deobf.to_owned() } else { - let nsig = self.deobf.deobfuscate_nsig(n)?; + let nsig = self.deobf()?.deobfuscate_nsig(n)?; self.last_nsig.clone_from(n); self.last_nsig_deobf.clone_from(&nsig); nsig @@ -782,7 +784,7 @@ mod tests { #[test] fn cipher_to_url() { let signature_cipher = "s=w%3DAe%3DA6aDNQLkViKS7LOm9QtxZJHKwb53riq9qEFw-ecBWJCAiA%3DcEg0tn3dty9jEHszfzh4Ud__bg9CEHVx4ix-7dKsIPAhIQRw8JQ0qOA&sp=sig&url=https://rr5---sn-h0jelnez.googlevideo.com/videoplayback%3Fexpire%3D1659376413%26ei%3Dvb7nYvH5BMK8gAfBj7ToBQ%26ip%3D2003%253Ade%253Aaf06%253A6300%253Ac750%253A1b77%253Ac74a%253A80e3%26id%3Do-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2%26itag%3D251%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DhH%26mm%3D31%252C26%26mn%3Dsn-h0jelnez%252Csn-4g5ednsl%26ms%3Dau%252Conr%26mv%3Dm%26mvi%3D5%26pl%3D37%26initcwndbps%3D1588750%26spc%3DlT-Khi831z8dTejFIRCvCEwx_6romtM%26vprv%3D1%26mime%3Daudio%252Fwebm%26ns%3Db_Mq_qlTFcSGlG9RpwpM9xQH%26gir%3Dyes%26clen%3D3781277%26dur%3D229.301%26lmt%3D1655510291473933%26mt%3D1659354538%26fvip%3D5%26keepalive%3Dyes%26fexp%3D24001373%252C24007246%26c%3DWEB%26rbqsm%3Dfr%26txp%3D4532434%26n%3Dd2g6G2hVqWIXxedQ%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cspc%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%253D%253D"; - let mut mapper = StreamsMapper::new(Deobfuscator::new(&DEOBF_DATA).unwrap()); + let mut mapper = StreamsMapper::new(Some(&DEOBF_DATA)).unwrap(); let url = mapper .map_url(&None, &Some(signature_cipher.to_owned())) .unwrap()