diff --git a/Cargo.toml b/Cargo.toml index a5257ff..44b96ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ rstest = "0.21.0" tokio-test = "0.4.2" insta = { version = "1.17.1", features = ["ron", "redactions"] } path_macro = "1.0.0" +tracing-test = "0.2.5" # Included crates rustypipe = { path = ".", version = "0.2.0", default-features = false } @@ -115,3 +116,4 @@ rstest.workspace = true tokio-test.workspace = true insta.workspace = true path_macro.workspace = true +tracing-test.workspace = true diff --git a/src/client/player.rs b/src/client/player.rs index e7a0751..dbe68bf 100644 --- a/src/client/player.rs +++ b/src/client/player.rs @@ -20,7 +20,10 @@ use crate::{ }; use super::{ - response::{self, player}, + response::{ + self, + player::{self, Format}, + }, ClientType, MapRespCtx, MapResponse, MapResult, RustyPipeQuery, YTContext, }; @@ -150,7 +153,6 @@ impl MapResponse for response::Player { self, ctx: &MapRespCtx<'_>, ) -> Result, ExtractionError> { - let deobf = Deobfuscator::new(ctx.deobf.unwrap())?; let mut warnings = vec![]; // Check playability status @@ -220,7 +222,7 @@ impl MapResponse for response::Player { } }; - let mut streaming_data = + let streaming_data = self.streaming_data .ok_or(ExtractionError::InvalidData(Cow::Borrowed( "no streaming data", @@ -254,47 +256,16 @@ impl MapResponse for response::Player { is_live_content: video_details.is_live_content, }; - let mut formats = streaming_data.formats.c; - formats.append(&mut streaming_data.adaptive_formats.c); - - let mut video_streams: Vec = Vec::new(); - let mut video_only_streams: Vec = Vec::new(); - let mut audio_streams: Vec = Vec::new(); - - if !is_live { - let mut last_nsig: [String; 2] = [String::new(), String::new()]; - - warnings.append(&mut streaming_data.formats.warnings); - warnings.append(&mut streaming_data.adaptive_formats.warnings); - - for f in formats { - if f.format_type == player::FormatType::FormatStreamTypeOtf { - continue; - } - - match (f.is_video(), f.is_audio()) { - (true, true) => match map_video_stream(f, &deobf, &mut last_nsig) { - Ok(c) => video_streams.push(c), - Err(e) => warnings.push(e.to_string()), - }, - (true, false) => match map_video_stream(f, &deobf, &mut last_nsig) { - Ok(c) => video_only_streams.push(c), - Err(e) => warnings.push(e.to_string()), - }, - (false, true) => { - match map_audio_stream(f, &deobf, &mut last_nsig, &mut warnings) { - Ok(c) => audio_streams.push(c), - Err(e) => warnings.push(e.to_string()), - } - } - (false, false) => warnings.push(format!("invalid stream: itag {}", f.itag)), - } - } - } - - video_streams.sort_by(QualityOrd::quality_cmp); - video_only_streams.sort_by(QualityOrd::quality_cmp); - audio_streams.sort_by(QualityOrd::quality_cmp); + let streams = if !is_live { + let mut mapper = StreamsMapper::new(Deobfuscator::new(ctx.deobf.unwrap())?); + mapper.map_streams(streaming_data.formats); + mapper.map_streams(streaming_data.adaptive_formats); + let mut res = mapper.output()?; + warnings.append(&mut res.warnings); + res.c + } else { + Streams::default() + }; let subtitles = self.captions.map_or(Vec::new(), |captions| { captions @@ -363,9 +334,9 @@ impl MapResponse for response::Player { Ok(MapResult { c: VideoPlayer { details: video_info, - video_streams, - video_only_streams, - audio_streams, + video_streams: streams.video_streams, + video_only_streams: streams.video_only_streams, + audio_streams: streams.audio_streams, subtitles, expires_in_seconds: streaming_data.expires_in_seconds, hls_manifest_url: streaming_data.hls_manifest_url, @@ -382,51 +353,283 @@ impl MapResponse for response::Player { } } -fn cipher_to_url_params( - signature_cipher: &str, - deobf: &Deobfuscator, -) -> Result<(Url, BTreeMap), DeobfError> { - let params: HashMap, Cow> = - url::form_urlencoded::parse(signature_cipher.as_bytes()).collect(); - - // Parameters: - // `s`: Obfuscated signature - // `sp`: Signature parameter - // `url`: URL that is missing the signature parameter - - let sig = params.get("s").ok_or(DeobfError::Extraction("s param"))?; - let sp = params.get("sp").ok_or(DeobfError::Extraction("sp param"))?; - let raw_url = params - .get("url") - .ok_or(DeobfError::Extraction("no url param"))?; - let (url_base, mut url_params) = - util::url_to_params(raw_url).or(Err(DeobfError::Extraction("url params")))?; - - let deobf_sig = deobf.deobfuscate_sig(sig)?; - url_params.insert(sp.to_string(), deobf_sig); - - Ok((url_base, url_params)) +struct StreamsMapper { + deobf: Deobfuscator, + streams: Streams, + warnings: Vec, + /// First stream mapping error + first_err: Option, + /// Last obfuscated nsig parameter (cache) + last_nsig: String, + /// Last deobfuscated nsig parameter + last_nsig_deobf: String, } -fn deobf_nsig( - url_params: &mut BTreeMap, - deobf: &Deobfuscator, - last_nsig: &mut [String; 2], -) -> Result<(), DeobfError> { - let nsig: String; - if let Some(n) = url_params.get("n") { - nsig = if n == &last_nsig[0] { - last_nsig[1].clone() - } else { - let nsig = deobf.deobfuscate_nsig(n)?; - last_nsig[0] = n.to_string(); - last_nsig[1].clone_from(&nsig); - nsig +#[derive(Default)] +struct Streams { + video_streams: Vec, + video_only_streams: Vec, + audio_streams: Vec, +} + +impl StreamsMapper { + fn new(deobf: Deobfuscator) -> Self { + 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>) { + self.warnings.append(&mut streams.warnings); + + let map_e = |m: &mut Self, e: ExtractionError| { + m.warnings.push(e.to_string()); + if m.first_err.is_none() { + m.first_err = Some(e); + } }; - url_params.insert("n".to_owned(), nsig); - }; - Ok(()) + for f in streams.c { + if f.format_type == player::FormatType::FormatStreamTypeOtf { + continue; + } + + match (f.is_video(), f.is_audio()) { + (true, true) => match self.map_video_stream(f) { + Ok(c) => self.streams.video_streams.push(c), + Err(e) => map_e(self, e), + }, + (true, false) => match self.map_video_stream(f) { + Ok(c) => self.streams.video_only_streams.push(c), + Err(e) => map_e(self, e), + }, + (false, true) => match self.map_audio_stream(f) { + Ok(c) => self.streams.audio_streams.push(c), + Err(e) => map_e(self, e), + }, + (false, false) => self + .warnings + .push(format!("invalid stream: itag {}", f.itag)), + } + } + } + + fn output(mut self) -> Result, ExtractionError> { + // If we did not extract any streams and there were mapping errors, fail with the first error + if self.streams.video_streams.is_empty() + && (self.streams.video_only_streams.is_empty() || self.streams.audio_streams.is_empty()) + { + if let Some(e) = self.first_err { + return Err(e); + } + } + + self.streams.video_streams.sort_by(QualityOrd::quality_cmp); + self.streams + .video_only_streams + .sort_by(QualityOrd::quality_cmp); + self.streams.audio_streams.sort_by(QualityOrd::quality_cmp); + + Ok(MapResult { + c: self.streams, + warnings: self.warnings, + }) + } + + fn cipher_to_url_params( + &self, + signature_cipher: &str, + ) -> Result<(Url, BTreeMap), DeobfError> { + let params: HashMap, Cow> = + url::form_urlencoded::parse(signature_cipher.as_bytes()).collect(); + + // Parameters: + // `s`: Obfuscated signature + // `sp`: Signature parameter + // `url`: URL that is missing the signature parameter + + let sig = params.get("s").ok_or(DeobfError::Extraction("s param"))?; + let sp = params.get("sp").ok_or(DeobfError::Extraction("sp param"))?; + let raw_url = params + .get("url") + .ok_or(DeobfError::Extraction("no url param"))?; + 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)?; + url_params.insert(sp.to_string(), deobf_sig); + + Ok((url_base, url_params)) + } + + fn deobf_nsig(&mut self, url_params: &mut BTreeMap) -> Result<(), DeobfError> { + if let Some(n) = url_params.get("n") { + let nsig = if n == &self.last_nsig { + self.last_nsig_deobf.to_owned() + } else { + let nsig = self.deobf.deobfuscate_nsig(n)?; + self.last_nsig.clone_from(n); + self.last_nsig_deobf.clone_from(&nsig); + nsig + }; + + url_params.insert("n".to_owned(), nsig); + }; + Ok(()) + } + + fn map_url( + &mut self, + url: &Option, + signature_cipher: &Option, + ) -> Result { + let (url_base, mut url_params) = + match url { + Some(url) => util::url_to_params(url).map_err(|_| { + ExtractionError::InvalidData(format!("Could not parse url `{url}`").into()) + }), + None => match signature_cipher { + Some(signature_cipher) => { + self.cipher_to_url_params(signature_cipher).map_err(|e| { + ExtractionError::InvalidData( + format!("Could not deobfuscate signatureCipher `{signature_cipher}`: {e}") + .into(), + ) + }) + } + None => Err(ExtractionError::InvalidData( + "stream contained neither url or cipher".into(), + )), + }, + }?; + + self.deobf_nsig(&mut url_params)?; + let url = Url::parse_with_params(url_base.as_str(), url_params.iter()) + .map_err(|_| ExtractionError::InvalidData("could not combine URL".into()))?; + + Ok(UrlMapRes { + url: url.to_string(), + xtags: url_params.get("xtags").cloned(), + }) + } + + fn map_video_stream(&mut self, f: player::Format) -> Result { + let Some((mtype, codecs)) = parse_mime(&f.mime_type) else { + return Err(ExtractionError::InvalidData( + format!( + "Invalid mime type `{}` in video format {:?}", + &f.mime_type, &f + ) + .into(), + )); + }; + let Some(format) = get_video_format(mtype) else { + return Err(ExtractionError::InvalidData( + format!("invalid video format. itag: {}", f.itag).into(), + )); + }; + let map_res = self.map_url(&f.url, &f.signature_cipher)?; + + Ok(VideoStream { + url: map_res.url, + itag: f.itag, + bitrate: f.bitrate, + average_bitrate: f.average_bitrate.unwrap_or(f.bitrate), + size: f.content_length, + index_range: f.index_range, + init_range: f.init_range, + duration_ms: f.approx_duration_ms, + // Note that the format has already been verified using + // is_video(), so these unwraps are safe + width: f.width.unwrap(), + height: f.height.unwrap(), + fps: f.fps.unwrap(), + quality: f.quality_label.unwrap(), + hdr: f.color_info.unwrap_or_default().primaries + == player::Primaries::ColorPrimariesBt2020, + format, + codec: get_video_codec(codecs), + mime: f.mime_type, + }) + } + + fn map_audio_stream(&mut self, f: player::Format) -> Result { + let Some((mtype, codecs)) = parse_mime(&f.mime_type) else { + return Err(ExtractionError::InvalidData( + format!( + "Invalid mime type `{}` in video format {:?}", + &f.mime_type, &f + ) + .into(), + )); + }; + let format = get_audio_format(mtype).ok_or_else(|| { + ExtractionError::InvalidData(format!("invalid audio format. itag: {}", f.itag).into()) + })?; + let map_res = self.map_url(&f.url, &f.signature_cipher)?; + + Ok(AudioStream { + url: map_res.url, + itag: f.itag, + bitrate: f.bitrate, + average_bitrate: f.average_bitrate.unwrap_or(f.bitrate), + size: f.content_length.unwrap(), + index_range: f.index_range, + init_range: f.init_range, + duration_ms: f.approx_duration_ms, + format, + codec: get_audio_codec(codecs), + mime: f.mime_type, + channels: f.audio_channels, + loudness_db: f.loudness_db, + track: f + .audio_track + .map(|t| self.map_audio_track(t, map_res.xtags)), + }) + } + + fn map_audio_track( + &mut self, + track: response::player::AudioTrack, + xtags: Option, + ) -> AudioTrack { + let mut lang = None; + let mut track_type = None; + + if let Some(xtags) = xtags { + xtags + .split(':') + .filter_map(|param| param.split_once('=')) + .for_each(|(k, v)| match k { + "lang" => { + lang = Some(v.to_owned()); + } + "acont" => match serde_plain::from_str(v) { + Ok(v) => { + track_type = Some(v); + } + Err(_) => { + self.warnings + .push(format!("could not parse audio track type `{v}`")); + } + }, + _ => {} + }); + } + + AudioTrack { + id: track.id, + lang, + lang_name: track.display_name, + is_default: track.audio_is_default, + track_type, + } + } } struct UrlMapRes { @@ -434,122 +637,6 @@ struct UrlMapRes { xtags: Option, } -fn map_url( - url: &Option, - signature_cipher: &Option, - deobf: &Deobfuscator, - last_nsig: &mut [String; 2], -) -> Result { - let (url_base, mut url_params) = match url { - Some(url) => util::url_to_params(url).map_err(|_| { - ExtractionError::InvalidData(format!("Could not parse url `{url}`").into()) - }), - None => match signature_cipher { - Some(signature_cipher) => cipher_to_url_params(signature_cipher, deobf).map_err(|e| { - ExtractionError::InvalidData( - format!("Could not deobfuscate signatureCipher `{signature_cipher}`: {e}") - .into(), - ) - }), - None => Err(ExtractionError::InvalidData( - "stream contained neither url or cipher".into(), - )), - }, - }?; - - deobf_nsig(&mut url_params, deobf, last_nsig)?; - let url = Url::parse_with_params(url_base.as_str(), url_params.iter()) - .map_err(|_| ExtractionError::InvalidData("could not combine URL".into()))?; - - Ok(UrlMapRes { - url: url.to_string(), - xtags: url_params.get("xtags").cloned(), - }) -} - -fn map_video_stream( - f: player::Format, - deobf: &Deobfuscator, - last_nsig: &mut [String; 2], -) -> Result { - let Some((mtype, codecs)) = parse_mime(&f.mime_type) else { - return Err(ExtractionError::InvalidData( - format!( - "Invalid mime type `{}` in video format {:?}", - &f.mime_type, &f - ) - .into(), - )); - }; - let Some(format) = get_video_format(mtype) else { - return Err(ExtractionError::InvalidData( - format!("invalid video format. itag: {}", f.itag).into(), - )); - }; - let map_res = map_url(&f.url, &f.signature_cipher, deobf, last_nsig)?; - - Ok(VideoStream { - url: map_res.url, - itag: f.itag, - bitrate: f.bitrate, - average_bitrate: f.average_bitrate.unwrap_or(f.bitrate), - size: f.content_length, - index_range: f.index_range, - init_range: f.init_range, - duration_ms: f.approx_duration_ms, - // Note that the format has already been verified using - // is_video(), so these unwraps are safe - width: f.width.unwrap(), - height: f.height.unwrap(), - fps: f.fps.unwrap(), - quality: f.quality_label.unwrap(), - hdr: f.color_info.unwrap_or_default().primaries == player::Primaries::ColorPrimariesBt2020, - format, - codec: get_video_codec(codecs), - mime: f.mime_type, - }) -} - -fn map_audio_stream( - f: player::Format, - deobf: &Deobfuscator, - last_nsig: &mut [String; 2], - warnings: &mut Vec, -) -> Result { - let Some((mtype, codecs)) = parse_mime(&f.mime_type) else { - return Err(ExtractionError::InvalidData( - format!( - "Invalid mime type `{}` in video format {:?}", - &f.mime_type, &f - ) - .into(), - )); - }; - let format = get_audio_format(mtype).ok_or_else(|| { - ExtractionError::InvalidData(format!("invalid audio format. itag: {}", f.itag).into()) - })?; - let map_res = map_url(&f.url, &f.signature_cipher, deobf, last_nsig)?; - - Ok(AudioStream { - url: map_res.url, - itag: f.itag, - bitrate: f.bitrate, - average_bitrate: f.average_bitrate.unwrap_or(f.bitrate), - size: f.content_length.unwrap(), - index_range: f.index_range, - init_range: f.init_range, - duration_ms: f.approx_duration_ms, - format, - codec: get_audio_codec(codecs), - mime: f.mime_type, - channels: f.audio_channels, - loudness_db: f.loudness_db, - track: f - .audio_track - .map(|t| map_audio_track(t, map_res.xtags, warnings)), - }) -} - fn parse_mime(mime: &str) -> Option<(&str, Vec<&str>)> { static PATTERN: Lazy = Lazy::new(|| Regex::new(r#"(\w+/\w+);\scodecs="([a-zA-Z-0-9.,\s]*)""#).unwrap()); @@ -609,43 +696,6 @@ fn get_audio_codec(codecs: Vec<&str>) -> AudioCodec { AudioCodec::Unknown } -fn map_audio_track( - track: response::player::AudioTrack, - xtags: Option, - warnings: &mut Vec, -) -> AudioTrack { - let mut lang = None; - let mut track_type = None; - - if let Some(xtags) = xtags { - xtags - .split(':') - .filter_map(|param| param.split_once('=')) - .for_each(|(k, v)| match k { - "lang" => { - lang = Some(v.to_owned()); - } - "acont" => match serde_plain::from_str(v) { - Ok(v) => { - track_type = Some(v); - } - Err(_) => { - warnings.push(format!("could not parse audio track type `{v}`")); - } - }, - _ => {} - }); - } - - AudioTrack { - id: track.id, - lang, - lang_name: track.display_name, - is_default: track.audio_is_default, - track_type, - } -} - #[cfg(test)] mod tests { use std::{fs::File, io::BufReader}; @@ -711,16 +761,11 @@ 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 last_nsig: [String; 2] = [String::new(), String::new()]; - let deobf = Deobfuscator::new(&DEOBF_DATA).unwrap(); - let url = map_url( - &None, - &Some(signature_cipher.to_owned()), - &deobf, - &mut last_nsig, - ) - .unwrap() - .url; + let mut mapper = StreamsMapper::new(Deobfuscator::new(&DEOBF_DATA).unwrap()); + let url = mapper + .map_url(&None, &Some(signature_cipher.to_owned())) + .unwrap() + .url; assert_eq!(url, "https://rr5---sn-h0jelnez.googlevideo.com/videoplayback?c=WEB&clen=3781277&dur=229.301&ei=vb7nYvH5BMK8gAfBj7ToBQ&expire=1659376413&fexp=24001373%2C24007246&fvip=5&gir=yes&id=o-AB_BABwrXZJN428ZwDxq5ScPn2AbcGODnRlTVhCQ3mj2&initcwndbps=1588750&ip=2003%3Ade%3Aaf06%3A6300%3Ac750%3A1b77%3Ac74a%3A80e3&itag=251&keepalive=yes&lmt=1655510291473933&lsig=AG3C_xAwRQIgCKCGJ1iu4wlaGXy3jcJyU3inh9dr1FIfqYOZEG_MdmACIQCbungkQYFk7EhD6K2YvLaHFMjKOFWjw001_tLb0lPDtg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&mh=hH&mime=audio%2Fwebm&mm=31%2C26&mn=sn-h0jelnez%2Csn-4g5ednsl&ms=au%2Conr&mt=1659354538&mv=m&mvi=5&n=XzXGSfGusw6OCQ&ns=b_Mq_qlTFcSGlG9RpwpM9xQH&pl=37&rbqsm=fr&requiressl=yes&sig=AOq0QJ8wRQIhAPIsKd7-xi4xVHEC9gb__dU4hzfzsHEj9ytd3nt0gEceAiACJWBcw-wFEq9qir35bwKHJZxtQ9mOL7SKiVkLQNDa6A%3D%3D&source=youtube&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&spc=lT-Khi831z8dTejFIRCvCEwx_6romtM&txp=4532434&vprv=1"); } diff --git a/src/deobfuscate.rs b/src/deobfuscate.rs index 8efada3..58e2924 100644 --- a/src/deobfuscate.rs +++ b/src/deobfuscate.rs @@ -84,28 +84,19 @@ impl Deobfuscator { /// Deobfuscate the `s` parameter from the `signature_cipher` field pub fn deobfuscate_sig(&self, sig: &str) -> Result { - let res = self.ctx.call_function(DEOBF_SIG_FUNC_NAME, vec![sig])?; + let res = self.ctx.call_function(DEOBF_SIG_FUNC_NAME, [sig])?; - res.as_str().map_or( - Err(DeobfError::Other("sig deobfuscation func returned null")), - |res| { - tracing::debug!("deobfuscated sig"); - Ok(res.to_owned()) - }, - ) + res.into_string() + .ok_or(DeobfError::Other("sig deobfuscation fn returned no string")) } /// Deobfuscate the `n` stream URL parameter to circumvent throttling pub fn deobfuscate_nsig(&self, nsig: &str) -> Result { - let res = self.ctx.call_function(DEOBF_NSIG_FUNC_NAME, vec![nsig])?; + let res = self.ctx.call_function(DEOBF_NSIG_FUNC_NAME, [nsig])?; - res.as_str().map_or( - Err(DeobfError::Other("nsig deobfuscation func returned null")), - |res| { - tracing::debug!("deobfuscated nsig"); - Ok(res.to_owned()) - }, - ) + res.into_string().ok_or(DeobfError::Other( + "nsig deobfuscation fn returned no string", + )) } } @@ -144,12 +135,9 @@ fn get_sig_fn(player_js: &str) -> Result { let deobfuscate_function = format!( "var {};", - function_pattern + &function_pattern .captures(player_js) - .ok_or(DeobfError::Extraction("deobf function"))? - .get(1) - .unwrap() - .as_str() + .ok_or(DeobfError::Extraction("deobf function"))?[1] ); static HELPER_OBJECT_NAME_REGEX: Lazy = @@ -168,57 +156,37 @@ fn get_sig_fn(player_js: &str) -> Result { let helper_pattern = Regex::new(&helper_pattern_str) .map_err(|_| DeobfError::Other("could not parse helper pattern regex"))?; let player_js_nonl = player_js.replace('\n', ""); - let helper_object = helper_pattern + let helper_object = &helper_pattern .captures(&player_js_nonl) - .ok_or(DeobfError::Extraction("helper object"))? - .get(1) - .unwrap() - .as_str(); + .ok_or(DeobfError::Extraction("helper object"))?[1]; - Ok(helper_object.to_owned() + let js_fn = helper_object.to_owned() + &deobfuscate_function - + &caller_function(DEOBF_SIG_FUNC_NAME, &dfunc_name)) + + &caller_function(DEOBF_SIG_FUNC_NAME, &dfunc_name); + verify_fn(&js_fn, DEOBF_SIG_FUNC_NAME)?; + + Ok(js_fn) } -fn get_nsig_fn_name(player_js: &str) -> Result { +fn get_nsig_fn_names(player_js: &str) -> impl Iterator + '_ { static FUNCTION_NAME_REGEX: Lazy = Lazy::new(|| { // x.get( .. y=functionName[array_num](z) .. x.set( Regex::new(r#"\w\.get\(.+\w=(\w{2,})\[(\d+)\]\(\w\).+\w\.set\("#).unwrap() }); - let fname_match = FUNCTION_NAME_REGEX - .captures(player_js) - .ok_or(DeobfError::Extraction("n_deobf function"))?; + FUNCTION_NAME_REGEX + .captures_iter(player_js) + .filter_map(|fname_match| { + let function_name = &fname_match[1]; - let function_name = fname_match.get(1).unwrap().as_str(); + let array_num = fname_match[2].parse::().ok()?; + let array_pattern_str = + format!(r#"var {}\s*=\s*\[(.+?)]"#, regex::escape(function_name)); + let array_pattern = Regex::new(&array_pattern_str).ok()?; - if fname_match.len() == 1 { - return Ok(function_name.to_owned()); - } - - let array_num = fname_match - .get(2) - .unwrap() - .as_str() - .parse::() - .or(Err(DeobfError::Other("could not parse array_num")))?; - let array_pattern_str = format!(r#"var {}\s*=\s*\[(.+?)]"#, regex::escape(function_name)); - let array_pattern = Regex::new(&array_pattern_str).or(Err(DeobfError::Other( - "could not parse helper pattern regex", - )))?; - - let array_str = array_pattern - .captures(player_js) - .ok_or(DeobfError::Extraction("n_deobf array_str"))? - .get(1) - .unwrap() - .as_str(); - - let mut names = array_str.split(','); - let name = names - .nth(array_num) - .ok_or(DeobfError::Extraction("n_deobf function name"))?; - Ok(name.to_owned()) + let array_str = &array_pattern.captures(player_js)?[1]; + array_str.split(',').nth(array_num).map(str::to_owned) + }) } fn extract_js_fn(js: &str, name: &str) -> Result { @@ -273,13 +241,44 @@ fn extract_js_fn(js: &str, name: &str) -> Result { Ok(js[start..end].to_owned()) } -fn get_nsig_fn(player_js: &str) -> Result { - let function_name = get_nsig_fn_name(player_js)?; - let function_base = function_name.clone() + "=function"; - let offset = player_js.find(&function_base).unwrap_or_default(); +/// Verify if the deobfuscation function successfully processes a random input string +fn verify_fn(js_fn: &str, fn_name: &str) -> Result<(), DeobfError> { + let ctx = quick_js::Context::new().or(Err(DeobfError::Other("could not create QuickJS rt")))?; + ctx.eval(js_fn)?; + let res = ctx + .call_function(fn_name, [util::generate_content_playback_nonce()])? + .into_string() + .ok_or(DeobfError::Other("deobfuscation fn returned no string"))?; + if res.is_empty() { + return Err(DeobfError::Other("deobfuscation fn returned empty string")); + } + Ok(()) +} - extract_js_fn(&player_js[offset..], &function_name) - .map(|s| s + ";" + &caller_function(DEOBF_NSIG_FUNC_NAME, &function_name)) +fn get_nsig_fn(player_js: &str) -> Result { + let extract_fn = |name: &str| -> Result { + let function_base = format!("{name}=function"); + let offset = player_js + .find(&function_base) + .ok_or(DeobfError::Extraction("could not find function base"))?; + + let js_fn = extract_js_fn(&player_js[offset..], name) + .map(|s| s + ";" + &caller_function(DEOBF_NSIG_FUNC_NAME, name))?; + verify_fn(&js_fn, DEOBF_NSIG_FUNC_NAME)?; + tracing::info!("Successfully extracted nsig fn `{name}`"); + Ok(js_fn) + }; + + util::find_map_or_last_err( + get_nsig_fn_names(player_js), + DeobfError::Extraction("no nsig fn name found"), + |name| { + extract_fn(&name).map_err(|e| { + tracing::warn!("Failed to extract nsig fn `{name}`: {e}"); + e + }) + }, + ) } async fn get_player_js_url(http: &Client) -> Result { @@ -293,12 +292,9 @@ async fn get_player_js_url(http: &Client) -> Result { static PLAYER_HASH_PATTERN: Lazy = Lazy::new(|| { Regex::new(r"https:\\/\\/www\.youtube\.com\\/s\\/player\\/([a-z0-9]{8})\\/").unwrap() }); - let player_hash = PLAYER_HASH_PATTERN + let player_hash = &PLAYER_HASH_PATTERN .captures(&text) - .ok_or(DeobfError::Extraction("player hash"))? - .get(1) - .unwrap() - .as_str(); + .ok_or(DeobfError::Extraction("player hash"))?[1]; Ok(format!( "https://www.youtube.com/s/player/{player_hash}/player_ias.vflset/en_US/base.js" @@ -316,10 +312,7 @@ fn get_sts(player_js: &str) -> Result { Ok(STS_PATTERN .captures(player_js) - .ok_or(DeobfError::Extraction("sts"))? - .get(1) - .unwrap() - .as_str() + .ok_or(DeobfError::Extraction("sts"))?[1] .to_owned()) } @@ -329,6 +322,7 @@ mod tests { use crate::util::tests::TESTFILES; use path_macro::path; use rstest::{fixture, rstest}; + use tracing_test::traced_test; static TEST_JS: Lazy = Lazy::new(|| { let js_path = path!(*TESTFILES / "deobf" / "dummy_player.js"); @@ -380,9 +374,9 @@ 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 } #[test] - fn t_get_nsig_fn_name() { - let name = get_nsig_fn_name(&TEST_JS).unwrap(); - assert_eq!(name, "Vo"); + fn t_get_nsig_fn_names() { + let names = get_nsig_fn_names(&TEST_JS).collect::>(); + assert_eq!(names, ["Vo"]); } #[test] @@ -433,14 +427,15 @@ 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 } #[tokio::test] + #[traced_test] async fn t_update() { let client = Client::new(); let deobf_data = DeobfData::extract(client, None).await.unwrap(); let deobf = Deobfuscator::new(&deobf_data).unwrap(); let deobf_sig = deobf.deobfuscate_sig("GOqGOqGOq0QJ8wRAIgaryQHfplJ9xJSKFywyaSMHuuwZYsoMTAvRvfm51qIGECIA5061zWeyfMPX9hEl_U6f9J0tr7GTJMKyPf5XNrJb5fb5i").unwrap(); - println!("{deobf_sig}"); + assert!(deobf_sig.len() >= 100); let deobf_nsig = deobf.deobfuscate_nsig("WHbZ-Nj2TSJxder").unwrap(); - println!("{deobf_nsig}"); + assert!(deobf_nsig.len() >= 10); } } diff --git a/src/util/mod.rs b/src/util/mod.rs index 75dcb00..013ccdd 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -551,6 +551,24 @@ impl<'a> Iterator for SplitTokens<'a> { } } +/// Applies function to the elements of iterator and returns the first successful result +/// or the last error if the function fails on all elements. If the iterator is empty, e_empty +/// is returned. +pub fn find_map_or_last_err(mut iter: I, e_empty: E, mut f: P) -> Result +where + I: Iterator, + P: FnMut(T) -> Result, +{ + let res = iter.try_fold(e_empty, |_, itm| match f(itm) { + Ok(o) => Err(o), + Err(e) => Ok(e), + }); + match res { + Ok(e) => Err(e), + Err(o) => Ok(o), + } +} + #[cfg(test)] pub(crate) mod tests { use std::{fs::File, io::BufReader, path::PathBuf}; @@ -730,4 +748,27 @@ pub(crate) mod tests { let res = country_from_name(name); assert_eq!(res, expect); } + + #[test] + fn t_find_map_or_last_err() { + // Success + let res = find_map_or_last_err([1, 2, 3].into_iter(), 0, |x: i32| { + if x > 2 { + Ok(true) + } else { + Err(x) + } + }); + assert_eq!(res, Ok(true)); + + // Error + let res = find_map_or_last_err([1, 2, 3].into_iter(), 0, |x: i32| Err::<(), _>(x)); + assert_eq!(res, Err(3)); + + // Empty iterator + assert_eq!( + find_map_or_last_err(std::iter::empty(), 0, |_: i32| Ok(true)), + Err(0) + ); + } }