use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use sha1::{Digest, Sha1};
use super::clients::{ORIGIN_YOUTUBE_MUSIC, YouTubeClient};
pub(super) fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}
pub fn sapisid_hash(cookies: &str, origin: &str) -> Option<String> {
let sapisid =
cookie_value(cookies, "SAPISID").or_else(|| cookie_value(cookies, "__Secure-3PAPISID"))?;
let ts = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let mut hasher = Sha1::new();
hasher.update(format!("{ts} {sapisid} {origin}").as_bytes());
Some(format!(
"SAPISIDHASH {ts}_{}",
hex::encode(hasher.finalize())
))
}
fn cookie_value(header: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
for part in header.split(';') {
let p = part.trim();
if let Some(v) = p.strip_prefix(&prefix) {
return Some(v.to_string());
}
}
None
}
fn build_context(client: YouTubeClient) -> Value {
let mut obj = serde_json::Map::new();
obj.insert(
"clientName".into(),
Value::String(client.client_name.into()),
);
obj.insert(
"clientVersion".into(),
Value::String(client.client_version.into()),
);
obj.insert("hl".into(), Value::String("en".into()));
obj.insert("gl".into(), Value::String("US".into()));
if !client.os_name.is_empty() {
obj.insert("osName".into(), Value::String(client.os_name.into()));
}
if !client.os_version.is_empty() {
obj.insert("osVersion".into(), Value::String(client.os_version.into()));
}
if !client.device_make.is_empty() {
obj.insert(
"deviceMake".into(),
Value::String(client.device_make.into()),
);
}
if !client.device_model.is_empty() {
obj.insert(
"deviceModel".into(),
Value::String(client.device_model.into()),
);
}
if let Some(sdk) = client.android_sdk_version {
obj.insert("androidSdkVersion".into(), Value::Number(sdk.into()));
}
Value::Object(obj)
}
#[derive(Default, Clone, Copy)]
pub struct PlayerExtras<'a> {
pub content_pot: Option<&'a str>,
pub visitor_data: Option<&'a str>,
pub signature_timestamp: Option<u64>,
}
#[tracing::instrument(name = "yt.player_http", skip(cookies, extras), fields(client = client.client_name, video_id = %video_id))]
pub async fn player(
client: YouTubeClient,
video_id: &str,
cookies: Option<&str>,
extras: PlayerExtras<'_>,
) -> Result<Value, String> {
let mut context_client = build_context(client);
if let Some(vd) = extras.visitor_data
&& let Value::Object(ref mut m) = context_client
{
m.insert("visitorData".into(), Value::String(vd.to_string()));
}
let mut body = json!({
"context": {
"client": context_client,
"user": { "lockedSafetyMode": false }
},
"videoId": video_id,
"contentCheckOk": true,
"racyCheckOk": true,
});
if let Some(pot) = extras.content_pot {
body["serviceIntegrityDimensions"] = json!({ "poToken": pot });
}
if let Some(sts) = extras.signature_timestamp {
body["playbackContext"] = json!({
"contentPlaybackContext": { "signatureTimestamp": sts }
});
}
if client.is_embedded {
body["context"]["thirdParty"] = json!({
"embedUrl": format!("https://www.youtube.com/watch?v={video_id}")
});
}
let host = if client.client_name == "WEB_REMIX" {
ORIGIN_YOUTUBE_MUSIC
} else {
"https://www.youtube.com"
};
let url = format!("{host}/youtubei/v1/player?prettyPrint=false");
let mut req = http_client()
.post(&url)
.header("User-Agent", client.user_agent)
.header("Content-Type", "application/json")
.header("X-Goog-Api-Format-Version", "1")
.header("X-YouTube-Client-Name", client.client_id)
.header("X-YouTube-Client-Version", client.client_version);
if client.client_name.starts_with("WEB") {
req = req
.header("X-Origin", ORIGIN_YOUTUBE_MUSIC)
.header("Referer", format!("{ORIGIN_YOUTUBE_MUSIC}/"));
}
if client.login_supported
&& let Some(c) = cookies
{
let auth =
sapisid_hash(c, ORIGIN_YOUTUBE_MUSIC).ok_or_else(|| "SAPISID missing".to_string())?;
req = req.header("Cookie", c).header("Authorization", auth);
}
let resp = req
.json(&body)
.send()
.await
.map_err(|e| format!("player HTTP: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let snippet: String = text.chars().take(300).collect();
return Err(format!("player HTTP {status}: {snippet}"));
}
resp.json::<Value>()
.await
.map_err(|e| format!("player JSON parse: {e}"))
}
pub async fn browse(browse_id: &str, cookies: &str) -> Result<Value, String> {
browse_maybe_auth(browse_id, Some(cookies)).await
}
#[tracing::instrument(name = "yt.browse", skip(cookies), fields(browse_id = %browse_id, anon = cookies.is_none()))]
pub async fn browse_maybe_auth(browse_id: &str, cookies: Option<&str>) -> Result<Value, String> {
let client = super::clients::WEB_REMIX;
let context = build_context(client);
let body = json!({
"context": { "client": context, "user": { "lockedSafetyMode": false } },
"browseId": browse_id,
});
let mut req = http_client()
.post(format!(
"{ORIGIN_YOUTUBE_MUSIC}/youtubei/v1/browse?prettyPrint=false"
))
.header("User-Agent", client.user_agent)
.header("Content-Type", "application/json")
.header("X-Goog-Api-Format-Version", "1")
.header("X-YouTube-Client-Name", client.client_id)
.header("X-YouTube-Client-Version", client.client_version)
.header("X-Origin", ORIGIN_YOUTUBE_MUSIC)
.header("Referer", format!("{ORIGIN_YOUTUBE_MUSIC}/"));
if let Some(c) = cookies {
let auth =
sapisid_hash(c, ORIGIN_YOUTUBE_MUSIC).ok_or_else(|| "SAPISID missing".to_string())?;
req = req.header("Cookie", c).header("Authorization", auth);
}
let resp = req
.json(&body)
.send()
.await
.map_err(|e| format!("browse HTTP: {e}"))?;
if !resp.status().is_success() {
return Err(format!("browse HTTP {}", resp.status()));
}
resp.json::<Value>()
.await
.map_err(|e| format!("browse JSON parse: {e}"))
}
pub fn extract_visitor_data(resp: &Value) -> Option<String> {
resp.pointer("/responseContext/visitorData")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
pub async fn browse_continuation(continuation: &str, cookies: &str) -> Result<Value, String> {
browse_continuation_maybe_auth(continuation, Some(cookies)).await
}
#[tracing::instrument(name = "yt.browse_continuation", skip(continuation, cookies), fields(anon = cookies.is_none()))]
pub async fn browse_continuation_maybe_auth(
continuation: &str,
cookies: Option<&str>,
) -> Result<Value, String> {
let client = super::clients::WEB_REMIX;
let context = build_context(client);
let body = json!({
"context": { "client": context, "user": { "lockedSafetyMode": false } },
});
let mut req = http_client()
.post(format!(
"{ORIGIN_YOUTUBE_MUSIC}/youtubei/v1/browse?ctoken={continuation}&continuation={continuation}&prettyPrint=false"
))
.header("User-Agent", client.user_agent)
.header("Content-Type", "application/json")
.header("X-Goog-Api-Format-Version", "1")
.header("X-YouTube-Client-Name", client.client_id)
.header("X-YouTube-Client-Version", client.client_version)
.header("X-Origin", ORIGIN_YOUTUBE_MUSIC)
.header("Referer", format!("{ORIGIN_YOUTUBE_MUSIC}/"));
if let Some(c) = cookies {
let auth =
sapisid_hash(c, ORIGIN_YOUTUBE_MUSIC).ok_or_else(|| "SAPISID missing".to_string())?;
req = req.header("Cookie", c).header("Authorization", auth);
}
let resp = req
.json(&body)
.send()
.await
.map_err(|e| format!("browse continuation HTTP: {e}"))?;
if !resp.status().is_success() {
return Err(format!("browse continuation HTTP {}", resp.status()));
}
resp.json::<Value>()
.await
.map_err(|e| format!("browse continuation JSON parse: {e}"))
}
pub async fn visitor_id(cookies: Option<&str>) -> Result<String, String> {
let client = super::clients::WEB_REMIX;
let context = build_context(client);
let body = json!({ "context": { "client": context } });
let mut req = http_client()
.post(format!(
"{ORIGIN_YOUTUBE_MUSIC}/youtubei/v1/visitor_id?prettyPrint=false"
))
.header("User-Agent", client.user_agent)
.header("Content-Type", "application/json")
.header("X-YouTube-Client-Name", client.client_id)
.header("X-YouTube-Client-Version", client.client_version)
.header("X-Origin", ORIGIN_YOUTUBE_MUSIC)
.header("Referer", format!("{ORIGIN_YOUTUBE_MUSIC}/"));
if let Some(c) = cookies {
let auth =
sapisid_hash(c, ORIGIN_YOUTUBE_MUSIC).ok_or_else(|| "SAPISID missing".to_string())?;
req = req.header("Cookie", c).header("Authorization", auth);
}
let resp = req
.json(&body)
.send()
.await
.map_err(|e| format!("visitor_id HTTP: {e}"))?;
if !resp.status().is_success() {
return Err(format!("visitor_id HTTP {}", resp.status()));
}
let json: Value = resp
.json()
.await
.map_err(|e| format!("visitor_id JSON: {e}"))?;
extract_visitor_data(&json).ok_or_else(|| "no visitorData in response".to_string())
}