Phase 5 — PoTokenProvider trait + stream_extractor wiring
Mirrors NPE PoTokenProvider.java + PoTokenResult.java; defines the
host-injection surface for BotGuard attestation. The Rust crate stays
out of the BotGuard business — embedders (Straw on Android, future
Sulkta CLI via Browserless, etc.) supply their own impl.
src/youtube/potoken/mod.rs
* PoTokenResult { player_request_po_token, streaming_data_po_token,
visitor_data } + ::new + ::single constructors
* PoTokenError (Unavailable, MintFailed) — FIX vs NPE: split 'declined'
(Ok(None)) from 'errored' (Err) so callers can react differently
* trait PoTokenProvider with 4 client-scoped methods; default impl
returns Ok(None) so embedders can override just what they support
* set_po_token_provider / clear_po_token_provider / po_token_provider
static registration via RwLock<Option<Arc<dyn PoTokenProvider>>>
src/youtube/potoken/noop.rs
* NoopPoTokenProvider — safe default
src/youtube/stream_extractor.rs
* resolve_po_token via options-first-then-provider helper
(options_or_provider)
* Android branch: pulls player_request_po_token + visitor_data into
/player body, streams streaming_data_po_token through to URL &pot=
* iOS branch: same shape, gated on fetch_ios_client AND non-empty
provider result
Kotlin side (PoTokenWebView lift into Straw via UniFFI's foreign-trait
bridge) is separate work — strawcore just owns the contract.
Tests: 77 lib unit pass (+4 since Phase 4) + 7 Phase 2 offline + 7
Phase 4 offline = 91 green.
This commit is contained in:
parent
a47e142ab7
commit
b4286b8236
4 changed files with 271 additions and 8 deletions
|
|
@ -8,6 +8,7 @@ pub mod constants;
|
||||||
pub mod itag;
|
pub mod itag;
|
||||||
pub mod js;
|
pub mod js;
|
||||||
pub mod parsing;
|
pub mod parsing;
|
||||||
|
pub mod potoken;
|
||||||
pub mod stream_extractor;
|
pub mod stream_extractor;
|
||||||
pub mod stream_helper;
|
pub mod stream_helper;
|
||||||
|
|
||||||
|
|
|
||||||
195
src/youtube/potoken/mod.rs
Normal file
195
src/youtube/potoken/mod.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
streaming_data_po_token: impl Into<String>,
|
||||||
|
visitor_data: impl Into<String>,
|
||||||
|
) -> 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<String>, visitor_data: impl Into<String>) -> 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<Option<PoTokenResult>, PoTokenError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// po_token for the WEB_EMBEDDED_PLAYER client.
|
||||||
|
fn get_web_embedded_client_po_token(
|
||||||
|
&self,
|
||||||
|
_video_id: &str,
|
||||||
|
) -> Result<Option<PoTokenResult>, 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<Option<PoTokenResult>, 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<Option<PoTokenResult>, PoTokenError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static REGISTERED_PROVIDER: Lazy<RwLock<Option<Arc<dyn PoTokenProvider>>>> =
|
||||||
|
Lazy::new(|| RwLock::new(None));
|
||||||
|
|
||||||
|
pub fn set_po_token_provider(provider: Arc<dyn PoTokenProvider>) {
|
||||||
|
*REGISTERED_PROVIDER.write() = Some(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_po_token_provider() {
|
||||||
|
*REGISTERED_PROVIDER.write() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn po_token_provider() -> Option<Arc<dyn PoTokenProvider>> {
|
||||||
|
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<Option<PoTokenResult>, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/youtube/potoken/noop.rs
Normal file
11
src/youtube/potoken/noop.rs
Normal file
|
|
@ -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 {}
|
||||||
|
|
@ -32,6 +32,7 @@ use crate::stream::{
|
||||||
};
|
};
|
||||||
use crate::youtube::itag::{lookup as itag_lookup, ItagType, MediaFormat};
|
use crate::youtube::itag::{lookup as itag_lookup, ItagType, MediaFormat};
|
||||||
use crate::youtube::js::PlayerManager;
|
use crate::youtube::js::PlayerManager;
|
||||||
|
use crate::youtube::potoken::{po_token_provider, PoTokenResult};
|
||||||
use crate::youtube::stream_helper::{self, generate_content_playback_nonce};
|
use crate::youtube::stream_helper::{self, generate_content_playback_nonce};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
|
@ -64,14 +65,32 @@ pub fn stream_info_with(
|
||||||
let localization = NewPipe::preferred_localization();
|
let localization = NewPipe::preferred_localization();
|
||||||
let content_country = NewPipe::preferred_content_country();
|
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<PoTokenResult> = 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 android_cpn = generate_content_playback_nonce();
|
||||||
let player_response = fetch_android(
|
let player_response = fetch_android(
|
||||||
video_id,
|
video_id,
|
||||||
&localization,
|
&localization,
|
||||||
&content_country,
|
&content_country,
|
||||||
&android_cpn,
|
&android_cpn,
|
||||||
options.android_player_request_pot.as_deref(),
|
android_token
|
||||||
options.android_visitor_data.as_deref(),
|
.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)?;
|
check_playability_status(&player_response)?;
|
||||||
|
|
@ -87,6 +106,21 @@ pub fn stream_info_with(
|
||||||
.unwrap_or(Value::Null);
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
// Optional iOS — best-effort.
|
// Optional iOS — best-effort.
|
||||||
|
let ios_token: Option<PoTokenResult> = 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_streaming_data, ios_cpn) = if options.fetch_ios_client {
|
||||||
let ios_cpn = generate_content_playback_nonce();
|
let ios_cpn = generate_content_playback_nonce();
|
||||||
match stream_helper::get_ios_player_response(
|
match stream_helper::get_ios_player_response(
|
||||||
|
|
@ -94,8 +128,8 @@ pub fn stream_info_with(
|
||||||
&localization,
|
&localization,
|
||||||
&content_country,
|
&content_country,
|
||||||
&ios_cpn,
|
&ios_cpn,
|
||||||
options.ios_player_request_pot.as_deref(),
|
ios_token.as_ref().map(|t| t.player_request_po_token.as_str()),
|
||||||
options.ios_visitor_data.as_deref(),
|
ios_token.as_ref().map(|t| t.visitor_data.as_str()),
|
||||||
) {
|
) {
|
||||||
Ok(r) if !is_player_response_not_valid(&r, video_id) => (
|
Ok(r) if !is_player_response_not_valid(&r, video_id) => (
|
||||||
r.get("streamingData").cloned().unwrap_or(Value::Null),
|
r.get("streamingData").cloned().unwrap_or(Value::Null),
|
||||||
|
|
@ -107,6 +141,15 @@ pub fn stream_info_with(
|
||||||
(Value::Null, None)
|
(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()
|
let signature_timestamp = PlayerManager::instance()
|
||||||
.signature_timestamp(video_id)
|
.signature_timestamp(video_id)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
@ -129,21 +172,34 @@ pub fn stream_info_with(
|
||||||
video_id,
|
video_id,
|
||||||
&android_cpn,
|
&android_cpn,
|
||||||
ios_cpn.as_deref(),
|
ios_cpn.as_deref(),
|
||||||
options.android_streaming_pot.as_deref(),
|
android_streaming_pot.as_deref(),
|
||||||
options.ios_streaming_pot.as_deref(),
|
ios_streaming_pot.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
populate_manifests(
|
populate_manifests(
|
||||||
&mut info,
|
&mut info,
|
||||||
&android_streaming_data,
|
&android_streaming_data,
|
||||||
&ios_streaming_data,
|
&ios_streaming_data,
|
||||||
options.android_streaming_pot.as_deref(),
|
android_streaming_pot.as_deref(),
|
||||||
options.ios_streaming_pot.as_deref(),
|
ios_streaming_pot.as_deref(),
|
||||||
);
|
);
|
||||||
populate_captions(&mut info, &player_response);
|
populate_captions(&mut info, &player_response);
|
||||||
|
|
||||||
Ok(info)
|
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<PoTokenResult>,
|
||||||
|
) -> Option<PoTokenResult> {
|
||||||
|
// 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(
|
fn fetch_android(
|
||||||
video_id: &str,
|
video_id: &str,
|
||||||
localization: &Localization,
|
localization: &Localization,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue