tail-fin-youtube 0.7.8

YouTube adapter for tail-fin: search, video, channel, comments, transcript via InnerTube API
Documentation
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};

/// Browser-based YouTube client.
///
/// Calls InnerTube API endpoints via browser eval fetch.
/// InnerTube context is lazily extracted on first API call.
pub struct YouTubeClient {
    session: BrowserSession,
    inner_tube: OnceCell<InnerTubeContext>,
}

impl YouTubeClient {
    /// Wrap a browser session for YouTube API access.
    pub fn new(session: BrowserSession) -> Self {
        Self {
            session,
            inner_tube: OnceCell::new(),
        }
    }

    /// Bootstrap InnerTube context from the page if not already cached.
    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
    }

    /// Make an InnerTube API call via browser eval fetch.
    ///
    /// Uses session.eval() directly instead of page_fetch_with_body to avoid
    /// timeout issues on sequential calls.
    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)
    }

    /// Search for videos.
    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))
    }

    /// Get video details.
    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))
    }

    /// Get channel info. If input starts with `@`, resolves via resolve_url first.
    pub async fn channel(&self, channel_input: &str) -> Result<Option<Channel>, TailFinError> {
        let browse_id = if channel_input.starts_with('@') {
            // Resolve @handle to browseId via resolve_url endpoint
            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))
    }

    /// Get video comments.
    ///
    /// Follows OpenCLI's approach: two-step fetch in a single browser eval
    /// to avoid page_fetch timeout issues on sequential calls.
    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();

        // Do both API calls in a single eval to avoid sequential page_fetch timeouts
        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)
    }

    /// Get trending videos (via YouTube's trending channel).
    pub async fn trending(&self, count: usize) -> Result<Vec<Video>, TailFinError> {
        // YouTube removed FEtrending; use the trending channel browseId instead
        let body = serde_json::json!({
            "browseId": "UC4R8DWoMoI7CAwX8_LjQHig",
            "params": "EgdsaXZldGFikgEDCKEK",
        });
        let data = self.innertube_request("browse", body).await?;
        Ok(parsing::parse_trending(&data, count))
    }

    /// Get video transcript.
    ///
    /// Follows OpenCLI's approach: call /youtubei/v1/player with Android client
    /// context to get caption track URLs (bypasses PoToken), then fetch the
    /// caption XML and parse timestamps + text.
    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();

        // Use Android client context to bypass PoToken requirement
        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()));
        }

        // Extract caption track URL from player response
        let caption_url = data
            .pointer("/captions/playerCaptionsTracklistRenderer/captionTracks")
            .and_then(|v| v.as_array())
            .and_then(|tracks| {
                // Prefer manual captions, fallback to auto-generated
                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()))?;

        // Fetch the caption XML via browser
        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))
    }

    /// Get subscriptions (requires login).
    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))
    }
}

/// JavaScript to extract InnerTube API key and context from YouTube page.
///
/// Tries `window.ytcfg` first, then falls back to parsing inline `<script>` tags.
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 };
})()"#;