diff --git a/src/client/channel_rss.rs b/src/client/channel_rss.rs index b28a802..f3f7319 100644 --- a/src/client/channel_rss.rs +++ b/src/client/channel_rss.rs @@ -3,7 +3,7 @@ use std::fmt::Debug; use crate::{ error::{Error, ExtractionError}, model::ChannelRss, - report::{Report, RustyPipeInfo}, + report::Report, util, }; @@ -45,7 +45,7 @@ impl RustyPipeQuery { Err(e) => { if let Some(reporter) = &self.client.inner.reporter { let report = Report { - info: RustyPipeInfo::new(Some(self.opts.lang)), + info: self.rp_info(), level: crate::report::Level::ERR, operation: "channel_rss", error: Some(e.to_string()), diff --git a/src/client/mod.rs b/src/client/mod.rs index 3962be8..b3df2b2 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -362,6 +362,8 @@ const OAUTH_CLIENT_ID: &str = const OAUTH_CLIENT_SECRET: &str = "SboVhoG9s0rNafixCSGGKXAT"; const OAUTH_SCOPES: &str = "http://gdata.youtube.com https://www.googleapis.com/auth/youtube"; +const BOTGUARD_API_VERSION: &str = "1"; + static CLIENT_VERSION_REGEX: Lazy = Lazy::new(|| Regex::new(r#""INNERTUBE_CONTEXT_CLIENT_VERSION":"([\w\d\._-]+?)""#).unwrap()); @@ -409,11 +411,13 @@ pub struct RustyPipeBuilder { default_opts: RustyPipeOpts, storage_dir: Option, botguard_bin: DefaultOpt, + snapshot_file: Option, po_token_cache: bool, } struct BotguardCfg { program: OsString, + version: String, snapshot_file: PathBuf, po_token_cache: bool, } @@ -441,13 +445,6 @@ impl DefaultOpt { DefaultOpt::Default => Some(f()), } } - fn or_default_opt Option>(self, f: F) -> Option { - match self { - DefaultOpt::Some(x) => Some(x), - DefaultOpt::None => None, - DefaultOpt::Default => f(), - } - } } /// # RustyPipe query @@ -684,6 +681,7 @@ impl RustyPipeBuilder { user_agent: None, storage_dir: None, botguard_bin: DefaultOpt::Default, + snapshot_file: None, po_token_cache: false, } } @@ -749,27 +747,31 @@ impl RustyPipeBuilder { let visitor_data_cache = VisitorDataCache::new(http.clone(), 50, 20); - let botguard_bin = self.botguard_bin.or_default_opt(|| { - let n = OsString::from("rustypipe-botguard"); - let out = std::process::Command::new(&n) - .arg("--version") - .output() - .ok()?; - if !out.status.success() { - return None; + let botguard = match self.botguard_bin { + DefaultOpt::Some(botguard_bin) => Some(detect_botguard_bin(botguard_bin)?), + DefaultOpt::None => None, + DefaultOpt::Default => detect_botguard_bin("./rustypipe-botguard".into()) + .or_else(|_| detect_botguard_bin("rustypipe-botguard".into())) + .map_err(|e| tracing::debug!("could not detect rustypipe-botguard: {e}")) + .ok(), + } + .map(|(program, version)| { + tracing::debug!( + "rustypipe-botguard: using {} at {}", + version, + program.to_string_lossy() + ); + + BotguardCfg { + program: program.to_owned(), + version, + snapshot_file: self.snapshot_file.unwrap_or_else(|| { + let mut snapshot_file = storage_dir.clone(); + snapshot_file.push("bg_snapshot.bin"); + snapshot_file + }), + po_token_cache: self.po_token_cache, } - let output = String::from_utf8_lossy(&out.stdout); - let pat = "rustypipe-botguard-api "; - let pos = output.find(pat)? + pat.len(); - let pos_end = output[pos..] - .char_indices() - .find(|(_, c)| !c.is_ascii_digit()) - .map(|(p, _)| p + pos) - .unwrap_or(output.len()); - if &output[pos..pos_end] != "1" { - return None; - } - Some(n) }); Ok(RustyPipe { @@ -777,7 +779,7 @@ impl RustyPipeBuilder { http, storage, reporter: self.reporter.or_default(|| { - let mut report_dir = storage_dir.clone(); + let mut report_dir = storage_dir; report_dir.push(DEFAULT_REPORT_DIR); Box::new(FileReporter::new(report_dir)) }), @@ -791,15 +793,7 @@ impl RustyPipeBuilder { default_opts: self.default_opts, user_agent, visitor_data_cache, - botguard: botguard_bin.map(|program| { - let mut snapshot_file = storage_dir; - snapshot_file.push("bg_snapshot.bin"); - BotguardCfg { - program, - snapshot_file, - po_token_cache: self.po_token_cache, - } - }), + botguard, }), }) } @@ -1035,6 +1029,18 @@ impl RustyPipeBuilder { self } + /// Set the path where the rustypipe-botguard snapshot file is stored + /// + /// After solving a Botguard challenge, rustypipe-botguard stores its + /// JavaScript environment in a snapshot file, so it can quickly generate additional tokens. + /// + /// By default the snapshot is stored in the storage_dir (Filename: bg_snapshot.bin). + #[must_use] + pub fn botguard_snapshot_file>(mut self, snapshot_file: P) -> Self { + self.snapshot_file = Some(snapshot_file.into()); + self + } + /// Enable caching for session-bound PO tokens /// /// By default, RustyPipe calls Botguard for every player request to fetch both a @@ -1699,6 +1705,11 @@ impl RustyPipe { ); Ok(()) } + + /// Get the version string (e.g. `rustypipe-botguard 0.1.1`) of the used botguard binary + pub async fn version_botguard(&self) -> Option { + self.inner.botguard.as_ref().map(|bg| bg.version.to_owned()) + } } impl RustyPipeQuery { @@ -2177,7 +2188,7 @@ impl RustyPipeQuery { self.client.inner.visitor_data_cache.remove(visitor_data); } - /// Get PO tokens + /// Generate PO tokens async fn get_po_tokens(&self, idents: &[&str]) -> Result<(Vec, OffsetDateTime), Error> { let bg = self .client @@ -2250,6 +2261,7 @@ impl RustyPipeQuery { Ok((tokens, valid_until)) } + /// Get a session-bound PO token (either from cache or newly generated) async fn get_session_po_token(&self, visitor_data: &str) -> Result { if let Some(po_token) = self.client.inner.visitor_data_cache.get_pot(visitor_data) { return Ok(po_token); @@ -2263,7 +2275,7 @@ impl RustyPipeQuery { Ok(po_token) } - /// Get a Proof-of-origin token + /// Get a PO token (Proof-of-origin token) /// /// PO tokens are used by the web-based YouTube clients for requesting player data and video streams. /// @@ -2277,6 +2289,22 @@ impl RustyPipeQuery { }) } + /// Get a new RustyPipeInfo object for reports + fn rp_info(&self) -> RustyPipeInfo<'_> { + RustyPipeInfo::new( + Some(self.opts.lang), + self.client + .inner + .botguard + .as_ref() + .map(|bg| bg.version.as_str()), + ) + } + + /// Execute a request to the YouTube API, then deobfuscate and map the response. + /// + /// Runs a single attempt, returns Ok with a erroneous RequestResult in case of a + /// HTTP or mapping error so it can be retried/reported. async fn execute_request_attempt< R: DeserializeOwned + MapResponse + Debug, M, @@ -2368,6 +2396,10 @@ impl RustyPipeQuery { }) } + /// Execute a request to the YouTube API, then deobfuscate and map the response. + /// + /// Runs up to n_request_attempts, returns Ok with a erroneous RequestResult in case of a + /// HTTP or mapping error so it can be reported. async fn execute_request_inner< R: DeserializeOwned + MapResponse + Debug, M, @@ -2474,7 +2506,7 @@ impl RustyPipeQuery { if level > Level::DBG || self.opts.report { if let Some(reporter) = &self.client.inner.reporter { let report = Report { - info: RustyPipeInfo::new(Some(self.opts.lang)), + info: self.rp_info(), level, operation: &format!("{operation}({id})"), error, @@ -2674,6 +2706,46 @@ fn local_tz_offset() -> (String, i16) { } } +/// Check if a valid Botguard binary is available at the given location +fn detect_botguard_bin(program: OsString) -> Result<(OsString, String), Error> { + let out = std::process::Command::new(&program) + .arg("--version") + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Error::Other("rustypipe-botguard binary not found".into()) + } else { + Error::Other(format!("error calling rustypipe-botguard {e}").into()) + } + })?; + if !out.status.success() { + return Err(Error::Extraction(ExtractionError::Botguard( + format!("version check failed with status {}", out.status).into(), + ))); + } + let output = String::from_utf8_lossy(&out.stdout); + let pat = "rustypipe-botguard-api "; + let pos = output.find(pat).ok_or(Error::Other( + "no rustypipe-botguard-api version returned".into(), + ))? + pat.len(); + let pos_end = output[pos..] + .char_indices() + .find(|(_, c)| !c.is_ascii_digit()) + .map(|(p, _)| p + pos) + .unwrap_or(output.len()); + let api_version = &output[pos..pos_end]; + if api_version != BOTGUARD_API_VERSION { + return Err(Error::Other( + format!( + "incompatible rustypipe-botguard-api version {api_version}, expected {BOTGUARD_API_VERSION}" + ) + .into(), + )); + } + let version = output[..pos].lines().next().unwrap_or_default().to_owned(); + Ok((program, version)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/deobfuscate.rs b/src/deobfuscate.rs index d0adc12..75748b2 100644 --- a/src/deobfuscate.rs +++ b/src/deobfuscate.rs @@ -39,7 +39,7 @@ impl DeobfData { if let Err(e) = &res { if let Some(reporter) = reporter { let report = Report { - info: RustyPipeInfo::new(None), + info: RustyPipeInfo::new(None, None), level: Level::ERR, operation: "extract_deobf", error: Some(e.to_string()), diff --git a/src/report.rs b/src/report.rs index 85477fa..3ff5d4d 100644 --- a/src/report.rs +++ b/src/report.rs @@ -70,6 +70,8 @@ pub struct RustyPipeInfo<'a> { /// YouTube content language #[serde(skip_serializing_if = "Option::is_none")] pub language: Option, + /// RustyPipe Botguard version (`rustypipe-botguard 0.1.1`) + pub botguard_version: Option<&'a str>, } /// Reported HTTP request data @@ -104,13 +106,14 @@ pub enum Level { ERR, } -impl RustyPipeInfo<'_> { - pub(crate) fn new(language: Option) -> Self { +impl<'a> RustyPipeInfo<'a> { + pub(crate) fn new(language: Option, botguard_version: Option<&'a str>) -> Self { Self { package: env!("CARGO_PKG_NAME"), version: crate::VERSION, date: util::now_sec(), language, + botguard_version, } } }