// YoutubeStreamHelper — 5 per-client /player request helpers. // Mirrors NPE services/youtube/YoutubeStreamHelper.java. // // Each helper builds the InnerTube envelope + the per-endpoint payload // (videoId, cpn, contentCheckOk, racyCheckOk, playbackContext, optional // serviceIntegrityDimensions for poToken), POSTs to the right URL with // the right headers, returns the parsed JSON. use serde_json::{json, Map, Value}; use crate::downloader::request::Request; use crate::exceptions::{ExtractionError, NetworkError, ParsingError}; use crate::localization::{ContentCountry, Localization}; use crate::newpipe::NewPipe; use crate::youtube::client_request::{build_envelope, InnertubeClientRequestInfo}; use crate::youtube::constants::*; use crate::youtube::parsing::{ android_user_agent, ios_user_agent, mobile_post_headers, youtube_post_headers, }; /// Builds a 12-char alphanumeric `cpn` (content playback nonce). NPE uses /// a custom alphabet; we mirror it. NOT cryptographically random — just /// shaped to look like YT's own format. Per-client cpn, so we keep it as /// a free helper. pub fn generate_content_playback_nonce() -> String { use std::time::{SystemTime, UNIX_EPOCH}; const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let mut seed = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); let mut out = String::with_capacity(16); for _ in 0..16 { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); out.push(ALPHABET[(seed.rotate_right(7) as usize) % ALPHABET.len()] as char); } out } /// Common body fields every /player call needs. fn add_player_body_fields(body: &mut Map, video_id: &str, cpn: &str) { body.insert("videoId".into(), Value::String(video_id.into())); body.insert("cpn".into(), Value::String(cpn.into())); body.insert("contentCheckOk".into(), Value::Bool(true)); body.insert("racyCheckOk".into(), Value::Bool(true)); } fn add_playback_context(body: &mut Map, signature_timestamp: i32, referer: &str) { body.insert( "playbackContext".into(), json!({ "contentPlaybackContext": { "signatureTimestamp": signature_timestamp, "referer": referer, } }), ); } fn add_service_integrity_dimensions(body: &mut Map, po_token: &str) { body.insert( "serviceIntegrityDimensions".into(), json!({ "poToken": po_token }), ); } fn envelope_to_body(envelope: Value) -> Map { match envelope { Value::Object(map) => map, _ => Map::new(), } } /// WEB-client metadata-only /player call — used for microformat + /// thumbnails only; never used as a stream URL source. pub fn get_web_metadata_player_response( video_id: &str, localization: &Localization, content_country: &ContentCountry, signature_timestamp: i32, ) -> Result { let info = InnertubeClientRequestInfo::of_web_client(); let env = build_envelope(&info, localization, content_country, None); let mut body = envelope_to_body(env); add_player_body_fields(&mut body, video_id, &generate_content_playback_nonce()); add_playback_context(&mut body, signature_timestamp, "https://www.youtube.com"); let url = format!( "{YOUTUBEI_V1_URL}player{DISABLE_PRETTY_PRINT_PARAM}&$fields=microformat,videoDetails.thumbnail.thumbnails,videoDetails.videoId" ); post_youtube(&url, &Value::Object(body), youtube_post_headers()) } /// ANDROID full /player call. Hits the gapis endpoint with the mobile /// header set. Caller must supply (cpn, po_token) — they are paired with /// the URLs the response will return; mixing them with iOS values returns /// 403. pub fn get_android_player_response( video_id: &str, localization: &Localization, content_country: &ContentCountry, cpn: &str, po_token: Option<&str>, visitor_data: Option<&str>, ) -> Result { let mut info = InnertubeClientRequestInfo::of_android_client(); if let Some(v) = visitor_data { info.client_info.visitor_data = Some(v.into()); } let env = build_envelope(&info, localization, content_country, None); let mut body = envelope_to_body(env); add_player_body_fields(&mut body, video_id, cpn); if let Some(token) = po_token { add_service_integrity_dimensions(&mut body, token); } let url = format!( "{YOUTUBEI_V1_GAPIS_URL}player{DISABLE_PRETTY_PRINT_PARAM}&t={t}&id={video_id}", t = generate_content_playback_nonce() ); let ua = android_user_agent(content_country); post_youtube(&url, &Value::Object(body), mobile_post_headers(&ua)) } /// ANDROID `/reel/reel_item_watch` fallback — used when no poToken is /// available. Returns a `playerResponse`-shaped JSON wrapped inside the /// reel response. pub fn get_android_reel_player_response( video_id: &str, localization: &Localization, content_country: &ContentCountry, cpn: &str, ) -> Result { let info = InnertubeClientRequestInfo::of_android_client(); let env = build_envelope(&info, localization, content_country, None); let mut body = envelope_to_body(env); body.insert( "playerRequest".into(), json!({ "videoId": video_id, "cpn": cpn, }), ); add_player_body_fields(&mut body, video_id, cpn); let url = format!( "{YOUTUBEI_V1_GAPIS_URL}reel/reel_item_watch{DISABLE_PRETTY_PRINT_PARAM}&t={t}&id={video_id}&$fields=playerResponse", t = generate_content_playback_nonce() ); let ua = android_user_agent(content_country); post_youtube(&url, &Value::Object(body), mobile_post_headers(&ua)) } /// IOS /player call. The iOS-progressive URLs returned here are subject /// to YT's ~917 KiB server-side cap — DO NOT route playback through /// these as the primary path. They're useful for HLS manifests on live /// streams. (See workspace memory/2026-05-24-night2-straw-vc18-rollback.md /// for the cap diagnostic.) pub fn get_ios_player_response( video_id: &str, localization: &Localization, content_country: &ContentCountry, cpn: &str, po_token: Option<&str>, visitor_data: Option<&str>, ) -> Result { let mut info = InnertubeClientRequestInfo::of_ios_client(); if let Some(v) = visitor_data { info.client_info.visitor_data = Some(v.into()); } let env = build_envelope(&info, localization, content_country, None); let mut body = envelope_to_body(env); add_player_body_fields(&mut body, video_id, cpn); if let Some(token) = po_token { add_service_integrity_dimensions(&mut body, token); } let url = format!( "{YOUTUBEI_V1_GAPIS_URL}player{DISABLE_PRETTY_PRINT_PARAM}&t={t}&id={video_id}", t = generate_content_playback_nonce() ); let ua = ios_user_agent(content_country); post_youtube(&url, &Value::Object(body), mobile_post_headers(&ua)) } fn post_youtube( url: &str, body: &Value, headers: Vec<(String, String)>, ) -> Result { let downloader = NewPipe::downloader().ok_or(ExtractionError::DownloaderMissing)?; let serialized = serde_json::to_vec(body).map_err(|e| { ExtractionError::Parsing(ParsingError::Invalid(format!("serialize body: {e}"))) })?; let mut builder = Request::post(url, serialized); for (k, v) in headers { builder = builder.add_header(&k, &v); } let resp = downloader.execute(builder.build())?; if resp.response_code() != 200 { return Err(ExtractionError::Network(NetworkError::Transport(format!( "HTTP {} from {url}", resp.response_code() )))); } let parsed: Value = serde_json::from_str(resp.response_body()) .map_err(|e| ExtractionError::Parsing(ParsingError::JsonShape(e.to_string())))?; Ok(parsed) } #[cfg(test)] mod tests { use super::*; #[test] fn cpn_is_16_chars_alphanumeric() { let cpn = generate_content_playback_nonce(); assert_eq!(cpn.len(), 16); assert!(cpn .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); } #[test] fn two_consecutive_cpns_differ() { // The nanos-seeded LCG advances monotonically — two back-to-back // calls should produce different cpns. let a = generate_content_playback_nonce(); std::thread::sleep(std::time::Duration::from_millis(2)); let b = generate_content_playback_nonce(); assert_ne!(a, b); } }