diff --git a/src/youtube/mod.rs b/src/youtube/mod.rs index b1deb15..0e7b064 100644 --- a/src/youtube/mod.rs +++ b/src/youtube/mod.rs @@ -8,6 +8,7 @@ pub mod constants; pub mod itag; pub mod js; pub mod parsing; +pub mod potoken; pub mod stream_extractor; pub mod stream_helper; diff --git a/src/youtube/potoken/mod.rs b/src/youtube/potoken/mod.rs new file mode 100644 index 0000000..f453901 --- /dev/null +++ b/src/youtube/potoken/mod.rs @@ -0,0 +1,195 @@ +// PoTokenProvider — the BotGuard / attestation surface NPE delegates to +// the embedder. Mirrors NPE PoTokenProvider.java + PoTokenResult.java. +// +// Architecturally: BotGuard runs in a real browser-y environment (WebView +// on Android). NPE doesn't ship a BotGuard impl — it asks the host app to +// produce po_tokens via this trait. Straw's Kotlin side will register an +// adapter that calls into PoTokenWebView (lifted verbatim from NewPipe +// app under GPL-3.0 compat). +// +// Two distinct token strings per call (audit Track E): +// * player_request_po_token — goes into JSON body +// serviceIntegrityDimensions.poToken +// * streaming_data_po_token — goes into URL &pot=<...> +// They CAN differ. +// +// visitor_data: the visitor token the po_token was minted against. The +// player request MUST send the same visitorData in context.client, or +// YT 403's the streaming URLs. +// +// FIX (audit Track E §2.2): NPE's Java API returns null both for +// "provider declined" and "provider errored." We split: Ok(None) = +// declined (no provider available for this client / video), Err = the +// provider tried and failed (the caller should still attempt extraction +// without po_token). + +pub mod noop; + +use std::sync::Arc; + +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use thiserror::Error; + +pub use noop::NoopPoTokenProvider; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoTokenResult { + pub player_request_po_token: String, + pub streaming_data_po_token: String, + pub visitor_data: String, +} + +impl PoTokenResult { + pub fn new( + player_request_po_token: impl Into, + streaming_data_po_token: impl Into, + visitor_data: impl Into, + ) -> Self { + Self { + player_request_po_token: player_request_po_token.into(), + streaming_data_po_token: streaming_data_po_token.into(), + visitor_data: visitor_data.into(), + } + } + + /// Convenience for clients that mint a single token used for both + /// the JSON body and the URL `&pot=`. + pub fn single(token: impl Into, visitor_data: impl Into) -> Self { + let token = token.into(); + Self { + streaming_data_po_token: token.clone(), + player_request_po_token: token, + visitor_data: visitor_data.into(), + } + } +} + +#[derive(Debug, Error)] +pub enum PoTokenError { + #[error("po_token provider unavailable: {0}")] + Unavailable(String), + #[error("po_token mint failed: {0}")] + MintFailed(String), +} + +pub trait PoTokenProvider: Send + Sync { + /// po_token for the WEB client. Returns Ok(None) when the provider + /// doesn't support this client (e.g. no browser available). + fn get_web_client_po_token( + &self, + _video_id: &str, + ) -> Result, PoTokenError> { + Ok(None) + } + + /// po_token for the WEB_EMBEDDED_PLAYER client. + fn get_web_embedded_client_po_token( + &self, + _video_id: &str, + ) -> Result, PoTokenError> { + Ok(None) + } + + /// po_token for the ANDROID client. NPE's primary use case — paired + /// with `getAndroidPlayerResponse()` to satisfy YT's anti-bot check. + fn get_android_client_po_token( + &self, + _video_id: &str, + ) -> Result, PoTokenError> { + Ok(None) + } + + /// po_token for the IOS client. Optional — iOS can extract without + /// a po_token but URL count + quality is reduced. + fn get_ios_client_po_token( + &self, + _video_id: &str, + ) -> Result, PoTokenError> { + Ok(None) + } +} + +static REGISTERED_PROVIDER: Lazy>>> = + Lazy::new(|| RwLock::new(None)); + +pub fn set_po_token_provider(provider: Arc) { + *REGISTERED_PROVIDER.write() = Some(provider); +} + +pub fn clear_po_token_provider() { + *REGISTERED_PROVIDER.write() = None; +} + +pub fn po_token_provider() -> Option> { + REGISTERED_PROVIDER.read().clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + struct CountingProvider { + android_calls: std::sync::atomic::AtomicU32, + } + + impl PoTokenProvider for CountingProvider { + fn get_android_client_po_token( + &self, + _video_id: &str, + ) -> Result, PoTokenError> { + self.android_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Some(PoTokenResult::single("mock-pot", "mock-visitor"))) + } + } + + #[test] + fn provider_registers_and_resolves() { + let p = Arc::new(CountingProvider { + android_calls: 0.into(), + }); + set_po_token_provider(p.clone()); + let got = po_token_provider().expect("registered"); + let r = got.get_android_client_po_token("vid").unwrap().unwrap(); + assert_eq!(r.streaming_data_po_token, "mock-pot"); + assert_eq!(r.player_request_po_token, "mock-pot"); + assert_eq!(r.visitor_data, "mock-visitor"); + assert_eq!( + p.android_calls.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + clear_po_token_provider(); + } + + #[test] + fn provider_default_decline() { + struct DeclineEverything; + impl PoTokenProvider for DeclineEverything {} + set_po_token_provider(Arc::new(DeclineEverything)); + let p = po_token_provider().unwrap(); + assert!(p.get_android_client_po_token("vid").unwrap().is_none()); + assert!(p.get_ios_client_po_token("vid").unwrap().is_none()); + assert!(p.get_web_client_po_token("vid").unwrap().is_none()); + assert!(p.get_web_embedded_client_po_token("vid").unwrap().is_none()); + clear_po_token_provider(); + } + + #[test] + fn po_token_result_constructors() { + let r = PoTokenResult::new("a", "b", "c"); + assert_eq!(r.player_request_po_token, "a"); + assert_eq!(r.streaming_data_po_token, "b"); + + let r = PoTokenResult::single("xx", "yy"); + assert_eq!(r.player_request_po_token, "xx"); + assert_eq!(r.streaming_data_po_token, "xx"); + assert_eq!(r.visitor_data, "yy"); + } + + #[test] + fn unset_provider_returns_none() { + clear_po_token_provider(); + assert!(po_token_provider().is_none()); + } +} diff --git a/src/youtube/potoken/noop.rs b/src/youtube/potoken/noop.rs new file mode 100644 index 0000000..bff1c05 --- /dev/null +++ b/src/youtube/potoken/noop.rs @@ -0,0 +1,11 @@ +// Default PoTokenProvider — always declines. The Rust crate isn't going +// to mint po_tokens by itself (BotGuard needs a WebView); embedders +// (Straw on Android, a future Sulkta CLI calling out to Browserless, +// etc.) provide their own impl. This noop is a safe default for tests +// and for code paths that don't require a po_token. + +use crate::youtube::potoken::PoTokenProvider; + +pub struct NoopPoTokenProvider; + +impl PoTokenProvider for NoopPoTokenProvider {} diff --git a/src/youtube/stream_extractor.rs b/src/youtube/stream_extractor.rs index 0540163..ccd6cad 100644 --- a/src/youtube/stream_extractor.rs +++ b/src/youtube/stream_extractor.rs @@ -32,6 +32,7 @@ use crate::stream::{ }; use crate::youtube::itag::{lookup as itag_lookup, ItagType, MediaFormat}; use crate::youtube::js::PlayerManager; +use crate::youtube::potoken::{po_token_provider, PoTokenResult}; use crate::youtube::stream_helper::{self, generate_content_playback_nonce}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -64,14 +65,32 @@ pub fn stream_info_with( let localization = NewPipe::preferred_localization(); let content_country = NewPipe::preferred_content_country(); + // Resolve po_token via the registered provider if present, falling + // back to caller-supplied options. The trait split (Ok(None) vs + // Err) lets us treat "provider declined" as "go anonymous" and + // "provider errored" as "still try anonymous but log the failure." + let provider = po_token_provider(); + let android_token: Option = options_or_provider( + options.android_player_request_pot.as_deref(), + options.android_streaming_pot.as_deref(), + options.android_visitor_data.as_deref(), + || { + provider + .as_ref() + .and_then(|p| p.get_android_client_po_token(video_id).ok().flatten()) + }, + ); + let android_cpn = generate_content_playback_nonce(); let player_response = fetch_android( video_id, &localization, &content_country, &android_cpn, - options.android_player_request_pot.as_deref(), - options.android_visitor_data.as_deref(), + android_token + .as_ref() + .map(|t| t.player_request_po_token.as_str()), + android_token.as_ref().map(|t| t.visitor_data.as_str()), )?; check_playability_status(&player_response)?; @@ -87,6 +106,21 @@ pub fn stream_info_with( .unwrap_or(Value::Null); // Optional iOS — best-effort. + let ios_token: Option = if options.fetch_ios_client { + options_or_provider( + options.ios_player_request_pot.as_deref(), + options.ios_streaming_pot.as_deref(), + options.ios_visitor_data.as_deref(), + || { + provider + .as_ref() + .and_then(|p| p.get_ios_client_po_token(video_id).ok().flatten()) + }, + ) + } else { + None + }; + let (ios_streaming_data, ios_cpn) = if options.fetch_ios_client { let ios_cpn = generate_content_playback_nonce(); match stream_helper::get_ios_player_response( @@ -94,8 +128,8 @@ pub fn stream_info_with( &localization, &content_country, &ios_cpn, - options.ios_player_request_pot.as_deref(), - options.ios_visitor_data.as_deref(), + ios_token.as_ref().map(|t| t.player_request_po_token.as_str()), + ios_token.as_ref().map(|t| t.visitor_data.as_str()), ) { Ok(r) if !is_player_response_not_valid(&r, video_id) => ( r.get("streamingData").cloned().unwrap_or(Value::Null), @@ -107,6 +141,15 @@ pub fn stream_info_with( (Value::Null, None) }; + let android_streaming_pot = android_token + .as_ref() + .map(|t| t.streaming_data_po_token.clone()) + .or_else(|| options.android_streaming_pot.clone()); + let ios_streaming_pot = ios_token + .as_ref() + .map(|t| t.streaming_data_po_token.clone()) + .or_else(|| options.ios_streaming_pot.clone()); + let signature_timestamp = PlayerManager::instance() .signature_timestamp(video_id) .unwrap_or(0); @@ -129,21 +172,34 @@ pub fn stream_info_with( video_id, &android_cpn, ios_cpn.as_deref(), - options.android_streaming_pot.as_deref(), - options.ios_streaming_pot.as_deref(), + android_streaming_pot.as_deref(), + ios_streaming_pot.as_deref(), )?; populate_manifests( &mut info, &android_streaming_data, &ios_streaming_data, - options.android_streaming_pot.as_deref(), - options.ios_streaming_pot.as_deref(), + android_streaming_pot.as_deref(), + ios_streaming_pot.as_deref(), ); populate_captions(&mut info, &player_response); Ok(info) } +fn options_or_provider( + opt_player_token: Option<&str>, + opt_streaming_token: Option<&str>, + opt_visitor: Option<&str>, + provider_fn: impl FnOnce() -> Option, +) -> Option { + // Caller-supplied wins when ALL three are present. + if let (Some(p), Some(s), Some(v)) = (opt_player_token, opt_streaming_token, opt_visitor) { + return Some(PoTokenResult::new(p, s, v)); + } + provider_fn() +} + fn fetch_android( video_id: &str, localization: &Localization,