tail-fin-youtube 0.7.8

YouTube adapter for tail-fin: search, video, channel, comments, transcript via InnerTube API
Documentation
//! `Site` implementation for YouTube.
//!
//! YouTube commands in tail-fin target the InnerTube API at
//! `www.youtube.com/youtubei/v1/*`. Search / video / channel /
//! comments / transcript all work anonymously; subscriptions /
//! saved / history need an authenticated session (SID/HSID/SSID
//! cookies on `.youtube.com` and `.google.com`).
//!
//! Because most commands are anonymous, validation here is deliberately
//! permissive — we only confirm `youtube.com` is reachable. Callers
//! invoking authenticated commands surface auth failures through
//! `detect_auth_failure`, not `validate`.

use std::time::Duration;

use async_trait::async_trait;
use tail_fin_core::{
    AuthFailureKind, BrowserSession, FailureIndicators, SessionStatus, Site, SiteError,
};

pub struct YoutubeSite;

#[async_trait]
impl Site for YoutubeSite {
    fn id(&self) -> &'static str {
        "youtube"
    }

    fn display_name(&self) -> &'static str {
        "YouTube"
    }

    fn cookie_domain_patterns(&self) -> &'static [&'static str] {
        // YT auth cookies span both `.youtube.com` (SAPISID) and
        // `.google.com` (SID / HSID). Listed together so refresh
        // picks both up.
        &["*.youtube.com", "*.google.com"]
    }

    fn refresh_url(&self) -> &'static str {
        "https://www.youtube.com/"
    }

    fn refresh_interval_min(&self) -> Duration {
        Duration::from_secs(180)
    }

    async fn validate(&self, session: &BrowserSession) -> Result<SessionStatus, SiteError> {
        let status = session
            .http_ping("https://www.youtube.com/")
            .await
            .map_err(|e| SiteError::ValidationFailed {
                site: self.id(),
                reason: format!("homepage ping: {e}"),
            })?;

        Ok(match status {
            200 => SessionStatus::Valid,
            401 | 403 => SessionStatus::Expired,
            429 => SessionStatus::Blocked {
                reason: "YouTube rate limit".into(),
                retry_after: Some(Duration::from_secs(300)),
            },
            0 => SessionStatus::Unknown,
            other => SessionStatus::Degrading {
                estimated_expiry: None,
                hint: format!("unexpected HTTP {other}"),
            },
        })
    }

    fn detect_auth_failure(&self, indicators: &FailureIndicators) -> Option<AuthFailureKind> {
        // InnerTube returns 401 for stale SAPISID and 403 for
        // consent-wall / age-restriction failures. Without body
        // inspection, 403 could mean either — default to CookieExpired
        // and let callers that care distinguish via body.
        match indicators.status {
            Some(401) | Some(403) => Some(AuthFailureKind::CookieExpired),
            Some(429) => Some(AuthFailureKind::RateLimited {
                retry_after: Duration::from_secs(300),
            }),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn indicators(status: u16) -> FailureIndicators {
        FailureIndicators {
            status: Some(status),
            ..Default::default()
        }
    }

    #[test]
    fn identity_fields() {
        let s = YoutubeSite;
        assert_eq!(s.id(), "youtube");
        assert_eq!(s.display_name(), "YouTube");
        assert_eq!(
            s.cookie_domain_patterns(),
            &["*.youtube.com", "*.google.com"]
        );
        assert_eq!(s.refresh_url(), "https://www.youtube.com/");
    }

    #[test]
    fn detect_cookie_expired_on_401_and_403() {
        for status in [401, 403] {
            assert!(matches!(
                YoutubeSite.detect_auth_failure(&indicators(status)),
                Some(AuthFailureKind::CookieExpired)
            ));
        }
    }

    #[test]
    fn detect_rate_limited_on_429() {
        match YoutubeSite.detect_auth_failure(&indicators(429)) {
            Some(AuthFailureKind::RateLimited { retry_after }) => {
                assert_eq!(retry_after, Duration::from_secs(300));
            }
            other => panic!("expected RateLimited, got {other:?}"),
        }
    }

    #[test]
    fn detect_returns_none_for_ok_and_unknown() {
        assert!(YoutubeSite.detect_auth_failure(&indicators(200)).is_none());
        assert!(YoutubeSite.detect_auth_failure(&indicators(500)).is_none());
    }
}