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.
230 lines
8.6 KiB
Rust
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);
|
|
}
|
|
}
|