pub mod parsing;
pub mod site;
pub mod types;
pub mod util;
use tail_fin_common::page::ensure_on_domain;
use tail_fin_common::BrowserSession;
use tail_fin_common::TailFinError;
use tokio::sync::OnceCell;
pub use site::YoutubeSite;
pub use types::{Channel, Comment, InnerTubeContext, TranscriptSegment, Video};
pub use util::{extract_channel_id, extract_video_id};
pub struct YouTubeClient {
session: BrowserSession,
inner_tube: OnceCell<InnerTubeContext>,
}
impl YouTubeClient {
pub fn new(session: BrowserSession) -> Self {
Self {
session,
inner_tube: OnceCell::new(),
}
}
async fn ensure_innertube(&self) -> Result<&InnerTubeContext, TailFinError> {
self.inner_tube
.get_or_try_init(|| async {
ensure_on_domain(&self.session, &["www.youtube.com"]).await?;
let result = self
.session
.eval(EXTRACT_INNERTUBE_JS)
.await
.map_err(TailFinError::Browser)?;
let parsed = if let Some(s) = result.as_str() {
serde_json::from_str::<serde_json::Value>(s)?
} else if result.is_object() {
result
} else {
return Err(TailFinError::Parse(
"Failed to extract InnerTube config from page".into(),
));
};
let api_key = parsed
.get("apiKey")
.and_then(|v| v.as_str())
.ok_or_else(|| TailFinError::Parse("Missing INNERTUBE_API_KEY".into()))?
.to_string();
let context = parsed
.get("context")
.cloned()
.ok_or_else(|| TailFinError::Parse("Missing INNERTUBE_CONTEXT".into()))?;
Ok(InnerTubeContext { api_key, context })
})
.await
}
async fn innertube_request(
&self,
endpoint: &str,
extra_body: serde_json::Value,
) -> Result<serde_json::Value, TailFinError> {
let ctx = self.ensure_innertube().await?;
let api_key = ctx.api_key.clone();
let context = ctx.context.clone();
let mut body = extra_body;
body.as_object_mut()
.ok_or_else(|| TailFinError::Parse("body must be an object".into()))?
.insert("context".to_string(), context);
let url = format!(
"https://www.youtube.com/youtubei/v1/{}?key={}&prettyPrint=false",
endpoint, api_key
);
let body_json = serde_json::to_string(&body).unwrap_or_default();
let url_json = serde_json::to_string(&url).unwrap_or_default();
let js = format!(
r#"(async () => {{
const resp = await fetch({url}, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
credentials: 'include',
body: {body}
}});
if (!resp.ok) return {{ __error: true, status: resp.status, statusText: resp.statusText }};
return await resp.json();
}})()"#,
url = url_json,
body = serde_json::to_string(&body_json).unwrap_or_default(),
);
let result = self
.session
.eval(&js)
.await
.map_err(TailFinError::Browser)?;
if result
.get("__error")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let status = result.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
let text = result
.get("statusText")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
return Err(TailFinError::Api(format!("HTTP {} {}", status, text)));
}
Ok(result)
}
pub async fn search(&self, query: &str, count: usize) -> Result<Vec<Video>, TailFinError> {
let body = serde_json::json!({ "query": query });
let data = self.innertube_request("search", body).await?;
Ok(parsing::parse_search_results(&data, count))
}
pub async fn video(&self, video_id: &str) -> Result<Option<Video>, TailFinError> {
let body = serde_json::json!({ "videoId": video_id });
let data = self.innertube_request("next", body).await?;
Ok(parsing::parse_video_detail(&data))
}
pub async fn channel(&self, channel_input: &str) -> Result<Option<Channel>, TailFinError> {
let browse_id = if channel_input.starts_with('@') {
let body = serde_json::json!({
"url": format!("https://www.youtube.com/{}", channel_input),
});
let data = self
.innertube_request("navigation/resolve_url", body)
.await?;
data.pointer("/endpoint/browseEndpoint/browseId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
TailFinError::Parse(format!(
"Could not resolve handle '{}' to channel ID",
channel_input
))
})?
.to_string()
} else {
channel_input.to_string()
};
let body = serde_json::json!({ "browseId": browse_id });
let data = self.innertube_request("browse", body).await?;
Ok(parsing::parse_channel(&data))
}
pub async fn comments(
&self,
video_id: &str,
count: usize,
) -> Result<Vec<Comment>, TailFinError> {
let ctx = self.ensure_innertube().await?;
let api_key = ctx.api_key.clone();
let context_json = serde_json::to_string(&ctx.context).unwrap_or_default();
let js = format!(
r#"(async () => {{
const apiKey = {api_key};
const context = {context};
// Step 1: Get continuation token
const nextResp = await fetch(
`https://www.youtube.com/youtubei/v1/next?key=${{apiKey}}&prettyPrint=false`,
{{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
credentials: 'include',
body: JSON.stringify({{ context, videoId: {video_id} }})
}}
);
const nextData = await nextResp.json();
// Find comment continuation token (targetId === 'comments-section')
const contents = nextData?.contents?.twoColumnWatchNextResults?.results?.results?.contents;
let token = null;
if (contents) {{
for (const item of contents) {{
if (item?.itemSectionRenderer?.targetId === 'comments-section') {{
token = item.itemSectionRenderer.contents?.[0]
?.continuationItemRenderer?.continuationEndpoint
?.continuationCommand?.token;
break;
}}
}}
}}
if (!token) return {{ error: 'no_token' }};
// Step 2: Fetch comments
const commResp = await fetch(
`https://www.youtube.com/youtubei/v1/next?key=${{apiKey}}&prettyPrint=false`,
{{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
credentials: 'include',
body: JSON.stringify({{ context, continuation: token }})
}}
);
return await commResp.json();
}})()"#,
api_key = serde_json::to_string(&api_key).unwrap_or_default(),
context = context_json,
video_id = serde_json::to_string(video_id).unwrap_or_default(),
);
let data = self
.session
.eval(&js)
.await
.map_err(TailFinError::Browser)?;
if data.get("error").is_some() {
return Ok(vec![]);
}
let mut comments = parsing::parse_comments_from_mutations(&data, count);
if comments.is_empty() {
comments = parsing::parse_comments(&data, count);
}
Ok(comments)
}
pub async fn trending(&self, count: usize) -> Result<Vec<Video>, TailFinError> {
let body = serde_json::json!({
"browseId": "UC4R8DWoMoI7CAwX8_LjQHig",
"params": "EgdsaXZldGFikgEDCKEK",
});
let data = self.innertube_request("browse", body).await?;
Ok(parsing::parse_trending(&data, count))
}
pub async fn transcript(&self, video_id: &str) -> Result<Vec<TranscriptSegment>, TailFinError> {
let ctx = self.ensure_innertube().await?;
let api_key = ctx.api_key.clone();
let body = serde_json::json!({
"context": {
"client": {
"clientName": "ANDROID",
"clientVersion": "20.10.38",
}
},
"videoId": video_id,
});
let url = format!(
"https://www.youtube.com/youtubei/v1/player?key={}&prettyPrint=false",
api_key
);
let body_json = serde_json::to_string(&body).unwrap_or_default();
let url_json = serde_json::to_string(&url).unwrap_or_default();
let js = format!(
r#"(async () => {{
const resp = await fetch({url}, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
credentials: 'include',
body: {body}
}});
if (!resp.ok) return {{ __error: true, status: resp.status }};
return await resp.json();
}})()"#,
url = url_json,
body = serde_json::to_string(&body_json).unwrap_or_default(),
);
let data = self
.session
.eval(&js)
.await
.map_err(TailFinError::Browser)?;
if data.get("__error").is_some() {
return Err(TailFinError::Api("Failed to fetch player data".into()));
}
let caption_url = data
.pointer("/captions/playerCaptionsTracklistRenderer/captionTracks")
.and_then(|v| v.as_array())
.and_then(|tracks| {
tracks.iter().find_map(|t| {
t.get("baseUrl")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
})
.ok_or_else(|| TailFinError::Api("No captions available for this video".into()))?;
let xml_js = format!(
r#"(async () => {{
const resp = await fetch({}, {{ credentials: "include" }});
return await resp.text();
}})()"#,
serde_json::to_string(&caption_url).unwrap_or_default()
);
let xml_result = self
.session
.eval(&xml_js)
.await
.map_err(TailFinError::Browser)?;
let xml = xml_result.as_str().unwrap_or("");
Ok(parsing::parse_caption_xml(xml))
}
pub async fn subscriptions(&self, count: usize) -> Result<Vec<Channel>, TailFinError> {
let body = serde_json::json!({ "browseId": "FEchannels" });
let data = self.innertube_request("browse", body).await?;
Ok(parsing::parse_subscriptions(&data, count))
}
}
const EXTRACT_INNERTUBE_JS: &str = r#"(() => {
// Try ytcfg global first
const cfg = window.ytcfg;
if (cfg) {
const apiKey = cfg.get('INNERTUBE_API_KEY');
const context = cfg.get('INNERTUBE_CONTEXT');
if (apiKey) return { apiKey, context };
}
// Fallback: extract from inline script tags
const scripts = Array.from(document.querySelectorAll('script'));
let apiKey = null;
for (const s of scripts) {
const text = s.textContent || '';
const m = text.match(/"INNERTUBE_API_KEY"\s*:\s*"([^"]+)"/);
if (m) { apiKey = m[1]; break; }
}
if (!apiKey) return null;
let ctx = null;
for (const s of scripts) {
const text = s.textContent || '';
const m = text.match(/"INNERTUBE_CONTEXT"\s*:\s*(\{[\s\S]*?\})\s*,\s*"INNERTUBE/);
if (m) { try { ctx = JSON.parse(m[1]); } catch(e) {} break; }
}
return { apiKey, context: ctx };
})()"#;