use futures::StreamExt;
use serde::Deserialize;
use crate::error::{Error, Result, UnavailableReason};
pub(crate) const MAX_RESPONSE_BYTES: u64 = 32 * 1024 * 1024;
pub(crate) async fn read_bounded(resp: reqwest::Response, stage: &'static str) -> Result<Vec<u8>> {
if let Some(len) = resp.content_length() {
if len > MAX_RESPONSE_BYTES {
return Err(Error::Extraction {
stage,
message: format!("response body too large: {len} bytes"),
});
}
}
let mut buf = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|source| Error::Network { stage, source })?;
if buf.len() as u64 + chunk.len() as u64 > MAX_RESPONSE_BYTES {
return Err(Error::Extraction {
stage,
message: "response body exceeded size limit".into(),
});
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
async fn read_bounded_json<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
stage: &'static str,
) -> Result<T> {
let bytes = read_bounded(resp, stage).await?;
serde_json::from_slice(&bytes).map_err(|e| {
if let Some(message) = error_envelope_message(&bytes) {
Error::Extraction {
stage,
message: format!("InnerTube error: {message}"),
}
} else {
Error::Extraction {
stage,
message: format!("invalid JSON response: {e}"),
}
}
})
}
fn error_envelope_message(bytes: &[u8]) -> Option<String> {
let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
value
.get("error")?
.get("message")?
.as_str()
.map(str::to_string)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum ClientKind {
Web,
Android,
Ios,
Tv,
}
const INNERTUBE_API_KEY: &str = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
#[derive(Debug, Clone)]
pub(crate) struct ClientParams {
pub client_name: &'static str,
pub client_name_id: u32,
pub client_version: &'static str,
pub user_agent: &'static str,
pub extras: serde_json::Value,
}
impl ClientKind {
fn params(&self) -> ClientParams {
match self {
ClientKind::Web => ClientParams {
client_name: "WEB",
client_name_id: 1,
client_version: "2.20240620.05.00",
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
extras: serde_json::json!({}),
},
ClientKind::Android => ClientParams {
client_name: "ANDROID",
client_name_id: 3,
client_version: "20.10.38",
user_agent: "com.google.android.youtube/20.10.38 (Linux; U; Android 14) gzip",
extras: serde_json::json!({
"androidSdkVersion": 34,
"osName": "Android",
"osVersion": "14",
}),
},
ClientKind::Ios => ClientParams {
client_name: "IOS",
client_name_id: 5,
client_version: "20.10.4",
user_agent:
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X)",
extras: serde_json::json!({
"deviceMake": "Apple",
"deviceModel": "iPhone16,2",
"osName": "iPhone",
"osVersion": "18.3.2.22D82",
}),
},
ClientKind::Tv => ClientParams {
client_name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
client_name_id: 85,
client_version: "2.0",
user_agent: "Mozilla/5.0 (PlayStation; PlayStation 4/8.03) AppleWebKit/605.1.15 \
(KHTML, like Gecko)",
extras: serde_json::json!({}),
},
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct BrowseRequest {
pub browse_id: Option<String>,
pub continuation: Option<String>,
pub params: Option<String>,
}
pub(crate) struct InnerTube {
http: reqwest::Client,
base: String,
}
impl InnerTube {
pub fn with_base_url(http: reqwest::Client, base: String) -> Self {
Self {
http,
base: base.trim_end_matches('/').to_string(),
}
}
fn context(client: ClientKind) -> serde_json::Value {
let params = client.params();
let mut clientv = serde_json::json!({
"clientName": params.client_name,
"clientVersion": params.client_version,
"hl": "en",
"gl": "US",
});
if let (Some(obj), Some(extra)) = (clientv.as_object_mut(), params.extras.as_object()) {
for (k, v) in extra {
obj.insert(k.clone(), v.clone());
}
}
serde_json::json!({ "client": clientv })
}
async fn post(
&self,
path: &str,
client: ClientKind,
body: serde_json::Value,
) -> Result<reqwest::Response> {
let params = client.params();
let url = format!(
"{}/youtubei/v1/{}?key={}",
self.base, path, INNERTUBE_API_KEY
);
self.http
.post(url)
.header("User-Agent", params.user_agent)
.header("Content-Type", "application/json")
.header("X-Goog-Api-Key", INNERTUBE_API_KEY)
.header("X-YouTube-Client-Name", params.client_name_id.to_string())
.header("X-YouTube-Client-Version", params.client_version)
.header("Origin", "https://www.youtube.com")
.header("Referer", "https://www.youtube.com/")
.json(&body)
.send()
.await
.map_err(|source| Error::Network {
stage: "innertube",
source,
})
}
pub async fn player(
&self,
video_id: &str,
client: ClientKind,
sts: Option<u64>,
) -> Result<PlayerResponse> {
let mut body = serde_json::json!({
"videoId": video_id,
"context": Self::context(client),
"contentCheckOk": true,
"racyCheckOk": true,
});
if let (Some(obj), Some(sts)) = (body.as_object_mut(), sts) {
obj.insert(
"playbackContext".into(),
serde_json::json!({
"contentPlaybackContext": { "signatureTimestamp": sts }
}),
);
}
let resp = self.post("player", client, body).await?;
let parsed: PlayerResponse = read_bounded_json(resp, "innertube/player").await?;
parsed.playability_status.ensure_ok()?;
Ok(parsed)
}
pub async fn browse(&self, req: BrowseRequest) -> Result<serde_json::Value> {
let client = ClientKind::Web;
let mut body = serde_json::json!({ "context": Self::context(client) });
if let Some(obj) = body.as_object_mut() {
if let Some(id) = req.browse_id {
obj.insert("browseId".into(), serde_json::Value::String(id));
}
if let Some(cont) = req.continuation {
obj.insert("continuation".into(), serde_json::Value::String(cont));
}
if let Some(params) = req.params {
obj.insert("params".into(), serde_json::Value::String(params));
}
}
let resp = self.post("browse", client, body).await?;
read_bounded_json(resp, "innertube/browse").await
}
pub async fn resolve_url(&self, url: &str) -> Result<serde_json::Value> {
let client = ClientKind::Web;
let body = serde_json::json!({
"context": Self::context(client),
"url": url,
});
let resp = self.post("navigation/resolve_url", client, body).await?;
read_bounded_json(resp, "innertube/resolve_url").await
}
pub async fn search(
&self,
query: &str,
continuation: Option<&str>,
) -> Result<serde_json::Value> {
let client = ClientKind::Web;
let body = match continuation {
Some(cont) => serde_json::json!({
"context": Self::context(client),
"continuation": cont,
}),
None => serde_json::json!({
"context": Self::context(client),
"query": query,
"params": "EgIQAQ%3D%3D",
}),
};
let resp = self.post("search", client, body).await?;
read_bounded_json(resp, "innertube/search").await
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlayerResponse {
pub playability_status: PlayabilityStatus,
pub video_details: VideoDetails,
pub streaming_data: Option<StreamingData>,
pub microformat: Option<Microformat>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlayabilityStatus {
pub status: String,
#[serde(default)]
pub reason: Option<String>,
}
impl PlayabilityStatus {
fn ensure_ok(&self) -> Result<()> {
if self.status == "OK" {
return Ok(());
}
let message = self.reason.clone().unwrap_or_default();
let reason = match self.status.as_str() {
"LOGIN_REQUIRED" => UnavailableReason::AgeRestricted,
"ERROR" => UnavailableReason::Gone,
"LIVE_STREAM_OFFLINE" => UnavailableReason::Live,
"UNPLAYABLE" => {
let lower = message.to_lowercase();
if lower.contains("not available in your country")
|| lower.contains("geo")
|| lower.contains("country")
{
UnavailableReason::GeoBlocked
} else {
UnavailableReason::Other
}
}
_ => UnavailableReason::Other,
};
Err(Error::Unavailable { reason, message })
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct VideoDetails {
pub video_id: String,
pub title: String,
#[serde(default)]
pub length_seconds: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub channel_id: Option<String>,
#[serde(default)]
pub view_count: Option<String>,
#[serde(default)]
pub short_description: Option<String>,
#[serde(default)]
pub thumbnail: Option<ThumbnailSet>,
#[serde(default)]
pub is_live: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ThumbnailSet {
#[serde(default)]
pub thumbnails: Vec<RawThumbnail>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct RawThumbnail {
pub url: String,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StreamingData {
#[serde(default)]
pub formats: Vec<RawFormat>,
#[serde(default)]
pub adaptive_formats: Vec<RawFormat>,
#[allow(dead_code)]
#[serde(default)]
pub hls_manifest_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RawFormat {
pub itag: u32,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub signature_cipher: Option<String>,
pub mime_type: String,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
#[serde(default)]
pub fps: Option<f64>,
#[serde(default)]
pub bitrate: Option<u64>,
#[serde(default)]
pub content_length: Option<String>,
#[serde(default)]
pub audio_sample_rate: Option<String>,
#[serde(default)]
pub audio_channels: Option<u8>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Microformat {
#[serde(default)]
pub player_microformat_renderer: Option<PlayerMicroformatRenderer>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlayerMicroformatRenderer {
#[serde(default)]
pub upload_date: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
fn fixture(name: &str) -> String {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/innertube")
.join(name);
std::fs::read_to_string(p).expect("fixture present")
}
#[tokio::test]
async fn player_request_sends_client_context_and_parses() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/player"))
.and(|req: &Request| {
let body: serde_json::Value = match serde_json::from_slice(&req.body) {
Ok(v) => v,
Err(_) => return false,
};
body["context"]["client"]["clientName"] == "ANDROID"
})
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixture("player_android.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
let resp = it
.player("dQw4w9WgXcQ", ClientKind::Android, None)
.await
.unwrap();
assert_eq!(resp.video_details.video_id, "dQw4w9WgXcQ");
assert_eq!(resp.streaming_data.unwrap().formats.len(), 1);
}
#[tokio::test]
async fn player_request_sends_innertube_headers_and_sts() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/player"))
.and(|req: &Request| {
let header = |name: &str| {
req.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let key_ok = header("x-goog-api-key").as_deref() == Some(INNERTUBE_API_KEY);
let cname_ok = header("x-youtube-client-name").as_deref() == Some("3");
let cver_ok = header("x-youtube-client-version").as_deref() == Some("20.10.38");
let origin_ok = header("origin").as_deref() == Some("https://www.youtube.com");
let ua_ok = header("user-agent").as_deref()
== Some("com.google.android.youtube/20.10.38 (Linux; U; Android 14) gzip");
let body: serde_json::Value = match serde_json::from_slice(&req.body) {
Ok(v) => v,
Err(_) => return false,
};
let sts_ok = body["playbackContext"]["contentPlaybackContext"]
["signatureTimestamp"]
== 19834;
let client = &body["context"]["client"];
let extras_ok = client["androidSdkVersion"] == 34
&& client["osName"] == "Android"
&& client["osVersion"] == "14"
&& client["clientVersion"] == "20.10.38";
key_ok && cname_ok && cver_ok && origin_ok && ua_ok && sts_ok && extras_ok
})
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixture("player_android.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
it.player("dQw4w9WgXcQ", ClientKind::Android, Some(19834))
.await
.unwrap();
}
#[tokio::test]
async fn ios_player_request_sends_ios_user_agent_and_device_model() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/player"))
.and(|req: &Request| {
let header = |name: &str| {
req.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let ua_ok = header("user-agent").as_deref()
== Some(
"com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X)",
);
let cname_ok = header("x-youtube-client-name").as_deref() == Some("5");
let body: serde_json::Value = match serde_json::from_slice(&req.body) {
Ok(v) => v,
Err(_) => return false,
};
let client = &body["context"]["client"];
let extras_ok = client["deviceMake"] == "Apple"
&& client["deviceModel"] == "iPhone16,2"
&& client["clientVersion"] == "20.10.4";
ua_ok && cname_ok && extras_ok
})
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixture("player_android.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
it.player("dQw4w9WgXcQ", ClientKind::Ios, None)
.await
.unwrap();
}
#[tokio::test]
async fn search_continuation_omits_query_and_params() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/search"))
.and(|req: &Request| {
let body: serde_json::Value =
serde_json::from_slice(&req.body).unwrap_or(serde_json::Value::Null);
body.get("continuation").is_none()
&& body["query"] == "rust"
&& body["params"] == "EgIQAQ%3D%3D"
})
.respond_with(
ResponseTemplate::new(200).set_body_raw(fixture("search.json"), "application/json"),
)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/search"))
.and(|req: &Request| {
let body: serde_json::Value =
serde_json::from_slice(&req.body).unwrap_or(serde_json::Value::Null);
body.get("continuation").and_then(serde_json::Value::as_str) == Some("TOK")
&& body.get("query").is_none()
&& body.get("params").is_none()
})
.respond_with(
ResponseTemplate::new(200).set_body_raw(fixture("search.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
it.search("rust", None).await.unwrap();
it.search("rust", Some("TOK")).await.unwrap();
}
#[tokio::test]
async fn player_parses_adaptive_and_microformat() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/player"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixture("player_android.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
let resp = it
.player("dQw4w9WgXcQ", ClientKind::Android, None)
.await
.unwrap();
let sd = resp.streaming_data.unwrap();
assert_eq!(sd.adaptive_formats.len(), 1);
let af = &sd.adaptive_formats[0];
assert_eq!(af.itag, 251);
assert!(af.signature_cipher.is_some());
assert!(af.url.is_none());
let mf = resp
.microformat
.unwrap()
.player_microformat_renderer
.unwrap();
assert_eq!(mf.upload_date.as_deref(), Some("2009-10-25"));
}
#[tokio::test]
async fn browse_parses_entries_and_continuation() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/browse"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixture("browse_playlist.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
let value = it
.browse(BrowseRequest {
browse_id: Some("VLPLx".into()),
..Default::default()
})
.await
.unwrap();
let list = &value["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][0]["tabRenderer"]
["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]
["playlistVideoListRenderer"]["contents"];
assert_eq!(list[0]["playlistVideoRenderer"]["videoId"], "aaaaaaaaaaa");
assert_eq!(
list[2]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]
["token"],
"CONT_TOKEN_1"
);
}
#[tokio::test]
async fn search_sends_query_and_returns_raw_value() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/youtubei/v1/search"))
.and(|req: &Request| {
let body: serde_json::Value = match serde_json::from_slice(&req.body) {
Ok(v) => v,
Err(_) => return false,
};
body["query"] == "rust"
})
.respond_with(
ResponseTemplate::new(200).set_body_raw(fixture("search.json"), "application/json"),
)
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
let value = it.search("rust", None).await.unwrap();
assert!(value["contents"]["twoColumnSearchResultsRenderer"].is_object());
}
async fn player_status_error(status: &str, reason: Option<&str>) -> Error {
let server = MockServer::start().await;
let mut body = serde_json::json!({
"playabilityStatus": { "status": status },
"videoDetails": { "videoId": "x", "title": "t" }
});
if let (Some(obj), Some(r)) = (body["playabilityStatus"].as_object_mut(), reason) {
obj.insert("reason".into(), serde_json::Value::String(r.to_string()));
}
Mock::given(method("POST"))
.and(path("/youtubei/v1/player"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
let it = InnerTube::with_base_url(reqwest::Client::new(), server.uri());
it.player("x", ClientKind::Web, None).await.unwrap_err()
}
#[tokio::test]
async fn unavailable_status_maps_to_typed_error() {
assert!(matches!(
player_status_error("LOGIN_REQUIRED", None).await,
Error::Unavailable {
reason: UnavailableReason::AgeRestricted,
..
}
));
assert!(matches!(
player_status_error("ERROR", None).await,
Error::Unavailable {
reason: UnavailableReason::Gone,
..
}
));
assert!(matches!(
player_status_error(
"UNPLAYABLE",
Some("This video is not available in your country")
)
.await,
Error::Unavailable {
reason: UnavailableReason::GeoBlocked,
..
}
));
assert!(matches!(
player_status_error("LIVE_STREAM_OFFLINE", None).await,
Error::Unavailable {
reason: UnavailableReason::Live,
..
}
));
}
}