use serde_json::{json, Value};
use crate::transcript::error::Result;
pub(crate) const PLAYER_PATH: &str = "/youtubei/v1/player";
pub(crate) const BROWSE_PATH: &str = "/youtubei/v1/browse";
pub(crate) const INNERTUBE_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
pub(crate) const WEB_INNERTUBE_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
pub(crate) const WEB_CLIENT_NAME: &str = "WEB";
pub(crate) const WEB_CLIENT_VERSION: &str = "2.20240101.00.00";
pub(crate) const CLIENT_NAME: &str = "ANDROID_VR";
pub(crate) const CLIENT_VERSION: &str = "1.62.27";
pub(crate) const ANDROID_SDK_VERSION: u32 = 32;
pub(crate) const DEVICE_MAKE: &str = "Oculus";
pub(crate) const DEVICE_MODEL: &str = "Quest 3";
pub(crate) const OS_NAME: &str = "Android";
pub(crate) const OS_VERSION: &str = "12L";
const API_KEY_HEADER: &str = "X-Goog-Api-Key";
pub(crate) fn client_context(visitor_data: &str) -> Value {
json!({
"client": {
"clientName": CLIENT_NAME,
"clientVersion": CLIENT_VERSION,
"androidSdkVersion": ANDROID_SDK_VERSION,
"deviceMake": DEVICE_MAKE,
"deviceModel": DEVICE_MODEL,
"osName": OS_NAME,
"osVersion": OS_VERSION,
"hl": "en",
"gl": "US",
"visitorData": visitor_data,
},
})
}
pub(crate) fn web_client_context() -> Value {
json!({
"client": {
"clientName": WEB_CLIENT_NAME,
"clientVersion": WEB_CLIENT_VERSION,
"hl": "en",
"gl": "US",
},
})
}
pub async fn fetch_browse(http: &reqwest::Client, base_url: &str, body: &Value) -> Result<String> {
let url = format!(
"{base}{path}",
base = base_url.trim_end_matches('/'),
path = BROWSE_PATH,
);
let response = http
.post(&url)
.header(API_KEY_HEADER, WEB_INNERTUBE_API_KEY)
.json(body)
.send()
.await?
.error_for_status()?;
Ok(response.text().await?)
}
pub async fn fetch_player_response(
http: &reqwest::Client,
base_url: &str,
video_id: &str,
visitor_data: &str,
) -> Result<String> {
let url = format!(
"{base}{path}",
base = base_url.trim_end_matches('/'),
path = PLAYER_PATH,
);
let body = json!({
"context": client_context(visitor_data),
"videoId": video_id,
"contentCheckOk": true,
"racyCheckOk": true,
});
let response = http
.post(&url)
.header(API_KEY_HEADER, INNERTUBE_API_KEY)
.json(&body)
.send()
.await?
.error_for_status()?;
Ok(response.text().await?)
}
pub async fn fetch_player_response_web(
http: &reqwest::Client,
base_url: &str,
video_id: &str,
) -> Result<String> {
let url = format!(
"{base}{path}",
base = base_url.trim_end_matches('/'),
path = PLAYER_PATH,
);
let body = json!({
"context": web_client_context(),
"videoId": video_id,
"contentCheckOk": true,
"racyCheckOk": true,
});
let response = http
.post(&url)
.header(API_KEY_HEADER, WEB_INNERTUBE_API_KEY)
.json(&body)
.send()
.await?
.error_for_status()?;
Ok(response.text().await?)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::Value;
use wiremock::matchers::{body_partial_json, header, method, path};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
const VIDEO_ID: &str = "dQw4w9WgXcQ";
const VISITOR_DATA: &str = "test-visitor-data";
const FIXTURE_BASIC: &str = include_str!("fixtures/player_response_basic.json");
fn http() -> reqwest::Client {
reqwest::Client::builder().build().unwrap()
}
#[tokio::test]
async fn posts_to_player_endpoint_with_android_vr_context_and_video_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.and(header(API_KEY_HEADER, INNERTUBE_API_KEY))
.and(body_partial_json(json!({
"videoId": VIDEO_ID,
"context": { "client": { "clientName": CLIENT_NAME } },
})))
.respond_with(ResponseTemplate::new(200).set_body_string(FIXTURE_BASIC))
.expect(1)
.mount(&server)
.await;
let body = fetch_player_response(&http(), &server.uri(), VIDEO_ID, VISITOR_DATA)
.await
.unwrap();
assert_eq!(body, FIXTURE_BASIC);
}
#[tokio::test]
async fn body_pins_full_quest_device_fingerprint() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.and(body_partial_json(json!({
"context": {
"client": {
"clientName": CLIENT_NAME,
"clientVersion": CLIENT_VERSION,
"androidSdkVersion": ANDROID_SDK_VERSION,
"deviceMake": DEVICE_MAKE,
"deviceModel": DEVICE_MODEL,
"osName": OS_NAME,
"osVersion": OS_VERSION,
"hl": "en",
"gl": "US",
}
}
})))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let _ = fetch_player_response(&http(), &server.uri(), VIDEO_ID, VISITOR_DATA)
.await
.unwrap();
}
#[tokio::test]
async fn body_includes_visitor_data_under_client() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.and(body_partial_json(json!({
"context": { "client": { "visitorData": VISITOR_DATA } },
})))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let _ = fetch_player_response(&http(), &server.uri(), VIDEO_ID, VISITOR_DATA)
.await
.unwrap();
}
#[tokio::test]
async fn url_no_longer_carries_legacy_key_query() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.respond_with(|req: &Request| {
assert!(
req.url.query().is_none(),
"request URL must not carry a query string; got {:?}",
req.url.query()
);
ResponseTemplate::new(200).set_body_string("{}")
})
.expect(1)
.mount(&server)
.await;
let _ = fetch_player_response(&http(), &server.uri(), VIDEO_ID, VISITOR_DATA)
.await
.unwrap();
}
#[tokio::test]
async fn surfaces_non_2xx_as_http_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let err = fetch_player_response(&http(), &server.uri(), VIDEO_ID, VISITOR_DATA)
.await
.unwrap_err();
assert!(matches!(err, crate::transcript::TranscriptError::Http(_)));
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn body_includes_check_flags() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.respond_with(|req: &Request| {
let parsed: Value = serde_json::from_slice(&req.body).unwrap();
assert_eq!(parsed["contentCheckOk"], Value::Bool(true));
assert_eq!(parsed["racyCheckOk"], Value::Bool(true));
ResponseTemplate::new(200).set_body_string("{}")
})
.expect(1)
.mount(&server)
.await;
let _ = fetch_player_response(&http(), &server.uri(), VIDEO_ID, VISITOR_DATA)
.await
.unwrap();
}
#[tokio::test]
async fn browse_posts_to_browse_endpoint_with_web_key_and_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BROWSE_PATH))
.and(header(API_KEY_HEADER, WEB_INNERTUBE_API_KEY))
.and(body_partial_json(json!({
"browseId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"context": { "client": { "clientName": WEB_CLIENT_NAME } },
})))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"ok":true}"#))
.expect(1)
.mount(&server)
.await;
let body = json!({
"context": web_client_context(),
"browseId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
});
let out = fetch_browse(&http(), &server.uri(), &body).await.unwrap();
assert_eq!(out, r#"{"ok":true}"#);
}
#[tokio::test]
async fn browse_surfaces_non_2xx_as_http_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BROWSE_PATH))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let body = json!({ "context": client_context(VISITOR_DATA), "browseId": "UCabc" });
let err = fetch_browse(&http(), &server.uri(), &body)
.await
.unwrap_err();
assert!(matches!(err, crate::transcript::TranscriptError::Http(_)));
}
#[test]
fn client_context_pins_full_quest_fingerprint() {
let ctx = client_context("vd-token");
let client = &ctx["client"];
assert_eq!(client["clientName"], CLIENT_NAME);
assert_eq!(client["clientVersion"], CLIENT_VERSION);
assert_eq!(client["osVersion"], OS_VERSION);
assert_eq!(client["visitorData"], "vd-token");
}
#[tokio::test]
async fn trailing_slash_in_base_url_is_normalised() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let with_slash = format!("{}/", server.uri());
let _ = fetch_player_response(&http(), &with_slash, VIDEO_ID, VISITOR_DATA)
.await
.unwrap();
}
#[tokio::test]
async fn web_player_posts_with_web_key_and_web_client_and_no_visitor_data() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.and(header(API_KEY_HEADER, WEB_INNERTUBE_API_KEY))
.respond_with(|req: &Request| {
let parsed: Value = serde_json::from_slice(&req.body).unwrap();
assert_eq!(parsed["videoId"], VIDEO_ID);
assert_eq!(parsed["context"]["client"]["clientName"], WEB_CLIENT_NAME);
assert!(
parsed["context"]["client"]["visitorData"].is_null(),
"WEB metadata call must not carry visitorData"
);
ResponseTemplate::new(200).set_body_string("{}")
})
.expect(1)
.mount(&server)
.await;
let _ = fetch_player_response_web(&http(), &server.uri(), VIDEO_ID)
.await
.unwrap();
}
#[tokio::test]
async fn web_player_surfaces_non_2xx_as_http_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(PLAYER_PATH))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let err = fetch_player_response_web(&http(), &server.uri(), VIDEO_ID)
.await
.unwrap_err();
assert!(matches!(err, crate::transcript::TranscriptError::Http(_)));
}
}