strawcore/src/youtube/stream_helper.rs
Cobb Hayes c8dfc8a34a Public-flip audit: scrub audit-ticket prefixes + LAN refs + tighten README
URLs → git.sulkta.com. Audit-ticket prefixes (SPEC §N, audit Track X, vc=N
audit-fix, FIX (audit ...), PORT DEVIATION) stripped from comments — technical
reasoning retained. Crafting-table LAN refs softened to 'Sulkta build host'.
README sheds marketing scaffolding + stale status tables.
2026-05-27 13:29:52 -07:00

230 lines
8.6 KiB
Rust

// 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<String, Value>, 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<String, Value>, signature_timestamp: i32, referer: &str) {
body.insert(
"playbackContext".into(),
json!({
"contentPlaybackContext": {
"signatureTimestamp": signature_timestamp,
"referer": referer,
}
}),
);
}
fn add_service_integrity_dimensions(body: &mut Map<String, Value>, po_token: &str) {
body.insert(
"serviceIntegrityDimensions".into(),
json!({ "poToken": po_token }),
);
}
fn envelope_to_body(envelope: Value) -> Map<String, Value> {
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<Value, ExtractionError> {
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<Value, ExtractionError> {
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<Value, ExtractionError> {
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<Value, ExtractionError> {
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<Value, ExtractionError> {
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);
}
}