Skip to main content

omni_dev/transcript/sources/
youtube.rs

1//! YouTube [`TranscriptSource`].
2//!
3//! Wires the offline parsers ([`url`], [`player_response`], [`timedtext`])
4//! into a concrete [`TranscriptSource`] backed by an HTTP client. The
5//! request shape is pinned to the `ANDROID_VR` InnerTube client (see
6//! [`innertube`]); a `visitorData` token is scraped from the watch page
7//! on first use ([`watch_page`]) and cached for the lifetime of the
8//! [`Youtube`] instance.
9
10use std::time::Duration;
11
12use async_trait::async_trait;
13use chrono::{SubsecRound, Utc};
14
15use crate::transcript::error::Result;
16use crate::transcript::source::{FetchOpts, LanguageInfo, MediaInfo, Transcript, TranscriptSource};
17
18pub mod channel;
19pub mod innertube;
20pub mod metadata;
21pub mod player_response;
22pub mod timedtext;
23pub mod url;
24pub mod watch_page;
25
26pub use channel::VideoEntry;
27pub use metadata::VideoMetadata;
28
29pub use player_response::{
30    check_playability, extract_media_info, list_languages, parse as parse_player_response,
31    select_track, CaptionTrack, PlayerResponse, SelectedTrack,
32};
33pub use timedtext::parse as parse_timedtext;
34pub use url::extract_video_id;
35
36/// Default origin for InnerTube and timedtext requests. Tests substitute
37/// a `wiremock::MockServer::uri()` instead.
38const DEFAULT_BASE_URL: &str = "https://www.youtube.com";
39
40/// HTTP request timeout. Picked to match
41/// [`crate::atlassian::client::AtlassianClient`]'s 30 s timeout.
42const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
43
44/// User-Agent advertised to YouTube on InnerTube `/player` calls. Must
45/// match the `clientName` / `clientVersion` constants in [`innertube`] —
46/// YouTube cross-checks UA against `clientName` as part of bot detection
47/// and a mismatch is one of the flagged signals. The trailing `gzip`
48/// token isn't decorative; that's what the real Quest YouTube app emits.
49///
50/// The watch-page bootstrap in [`watch_page`] uses a separate
51/// browser-shaped UA — it scrapes a public HTML page, not InnerTube.
52const USER_AGENT: &str = "com.google.android.apps.youtube.vr.oculus/1.62.27 \
53     (Linux; U; Android 12; Quest 3) gzip";
54
55/// Whether `input` is recognised as a YouTube locator (URL or bare ID).
56///
57/// Used by the future `omni-dev transcript fetch <url>` auto-detection
58/// path and by [`TranscriptSource::matches`].
59pub fn matches_url(input: &str) -> bool {
60    extract_video_id(input).is_ok()
61}
62
63/// Appends a best-effort `service = "transcript"` HTTP record for one YouTube
64/// request attempt. Recorded from the raw [`reqwest::Result`] *before*
65/// `error_for_status`, so a 4xx/5xx response is logged with its real status and
66/// a transport failure with its error message. Mirrors the datadog hook
67/// ([`crate::datadog`]); URL query/fragment secrets are redacted centrally by
68/// [`crate::request_log::record_http`].
69pub(crate) fn record_yt_http(
70    method: &str,
71    url: &str,
72    started: std::time::Instant,
73    result: &reqwest::Result<reqwest::Response>,
74) {
75    match result {
76        Ok(r) => crate::request_log::record_http(
77            "transcript",
78            method,
79            url,
80            started,
81            Some(r.status().as_u16()),
82            None,
83        ),
84        Err(e) => crate::request_log::record_http(
85            "transcript",
86            method,
87            url,
88            started,
89            None,
90            Some(&e.to_string()),
91        ),
92    }
93}
94
95/// YouTube [`TranscriptSource`].
96///
97/// Holds a single [`reqwest::Client`] reused across the watch-page,
98/// InnerTube, and timedtext calls. Cheap to construct; in steady state
99/// it is fine to keep one instance per process.
100///
101/// On first use, a `visitorData` token is scraped from the watch page
102/// and cached in [`tokio::sync::OnceCell`]. Concurrent first-callers
103/// serialise on a single fetch rather than double-fetching, and every
104/// subsequent InnerTube `/player` POST forwards the cached token.
105#[derive(Debug, Clone)]
106pub struct Youtube {
107    http: reqwest::Client,
108    base_url: String,
109    visitor_data: tokio::sync::OnceCell<String>,
110}
111
112impl Youtube {
113    /// Construct a YouTube source with default HTTP settings (30 s timeout,
114    /// ANDROID_VR User-Agent) targeting the public YouTube origin.
115    pub fn new() -> Result<Self> {
116        let http = reqwest::Client::builder()
117            .timeout(REQUEST_TIMEOUT)
118            .user_agent(USER_AGENT)
119            .build()?;
120        Ok(Self {
121            http,
122            base_url: DEFAULT_BASE_URL.to_string(),
123            visitor_data: tokio::sync::OnceCell::new(),
124        })
125    }
126
127    /// Construct a YouTube source pointed at an alternate origin. Used by
128    /// tests to inject a `wiremock::MockServer::uri()`. The HTTP client
129    /// retains the production timeout and User-Agent so request shape
130    /// matches the real client.
131    pub fn with_base_url(base_url: impl Into<String>) -> Result<Self> {
132        let http = reqwest::Client::builder()
133            .timeout(REQUEST_TIMEOUT)
134            .user_agent(USER_AGENT)
135            .build()?;
136        Ok(Self {
137            http,
138            base_url: base_url.into(),
139            visitor_data: tokio::sync::OnceCell::new(),
140        })
141    }
142
143    /// Cached `visitorData` token. First call scrapes the watch page;
144    /// concurrent first-callers serialise on a single in-flight scrape
145    /// (`OnceCell::get_or_try_init`) rather than double-fetching.
146    async fn visitor_data(&self) -> Result<&str> {
147        self.visitor_data
148            .get_or_try_init(|| watch_page::fetch_visitor_data(&self.http, &self.base_url))
149            .await
150            .map(String::as_str)
151    }
152
153    /// Common preamble: locator → video ID → watch-page bootstrap →
154    /// InnerTube POST → `playerResponse` parse → playability check.
155    ///
156    /// `extract_video_id` runs first so an invalid locator short-circuits
157    /// before any HTTP — lazy `visitor_data` fetch only happens on a
158    /// validated locator.
159    async fn load_player_response(&self, locator: &str) -> Result<PlayerResponse> {
160        let video_id = extract_video_id(locator)?;
161        let visitor_data = self.visitor_data().await?;
162        let raw =
163            innertube::fetch_player_response(&self.http, &self.base_url, &video_id, visitor_data)
164                .await?;
165        let response = parse_player_response(&raw)?;
166        check_playability(&response)?;
167        Ok(response)
168    }
169
170    /// Resolve a channel locator (`@handle`, `/c/Name`, channel URL, or a raw
171    /// `UC…` ID) to its canonical `UC…` channel ID. See
172    /// [`channel::resolve_channel_id`].
173    pub async fn resolve_channel_id(&self, input: &str) -> Result<String> {
174        channel::resolve_channel_id(&self.http, &self.base_url, input).await
175    }
176
177    /// Enumerate a channel's recent uploads via its RSS feed (newest-first,
178    /// ~15 most recent). See [`channel::fetch_recent_videos`].
179    pub async fn recent_channel_videos(&self, channel_id: &str) -> Result<Vec<VideoEntry>> {
180        channel::fetch_recent_videos(&self.http, &self.base_url, channel_id).await
181    }
182
183    /// Enumerate a channel's full upload history via the InnerTube `/browse`
184    /// endpoint (newest-first). Uses the WEB client; no `visitorData` bootstrap
185    /// is needed (browse is a public endpoint). See
186    /// [`channel::fetch_all_video_ids`].
187    pub async fn all_channel_video_ids(&self, channel_id: &str) -> Result<Vec<String>> {
188        channel::fetch_all_video_ids(&self.http, &self.base_url, channel_id).await
189    }
190
191    /// Fetch per-video metadata (title, channel, publish date, view/like
192    /// counts, …) via a single WEB-client `/player` call.
193    ///
194    /// Independent of the transcript path: it uses the un-gated WEB client
195    /// (see [`innertube::fetch_player_response_web`]) and needs **no
196    /// `visitorData` bootstrap**, so already-synced videos can be backfilled
197    /// or refreshed without touching the bot-gated `ANDROID_VR` path.
198    /// `fetched_at` is stamped at fetch time (UTC, second precision).
199    pub async fn fetch_video_metadata(&self, video_id: &str) -> Result<VideoMetadata> {
200        let raw =
201            innertube::fetch_player_response_web(&self.http, &self.base_url, video_id).await?;
202        let fetched_at = Utc::now().trunc_subsecs(0);
203        metadata::parse(&raw, fetched_at)
204    }
205}
206
207#[async_trait]
208impl TranscriptSource for Youtube {
209    fn name(&self) -> &'static str {
210        "youtube"
211    }
212
213    fn matches(url: &str) -> bool {
214        matches_url(url)
215    }
216
217    async fn fetch(&self, locator: &str, opts: &FetchOpts) -> Result<Transcript> {
218        let response = self.load_player_response(locator).await?;
219        let selected = select_track(&response, opts)?;
220        let body = timedtext::fetch(&self.http, &selected.fetch_url).await?;
221        let cues = timedtext::parse(&body)?;
222        let locator_id = response
223            .video_details
224            .as_ref()
225            .map(|d| d.video_id.clone())
226            .unwrap_or_default();
227        Ok(Transcript {
228            source: self.name().to_string(),
229            locator_id,
230            language: selected.language.clone(),
231            kind: selected.kind,
232            cues,
233        })
234    }
235
236    async fn list_languages(&self, locator: &str) -> Result<Vec<LanguageInfo>> {
237        let response = self.load_player_response(locator).await?;
238        Ok(list_languages(&response))
239    }
240
241    async fn info(&self, locator: &str) -> Result<MediaInfo> {
242        let response = self.load_player_response(locator).await?;
243        Ok(extract_media_info(&response))
244    }
245}
246
247#[cfg(test)]
248#[allow(clippy::unwrap_used, clippy::expect_used)]
249mod tests {
250    //! Two layers:
251    //!
252    //! 1. Offline acceptance gate — parse a checked-in `playerResponse`,
253    //!    select the requested track, parse a checked-in json3 transcript,
254    //!    render via [`format::srt`], and compare to a golden `.srt`.
255    //!    Carried over from step 2.
256    //! 2. HTTP-driven `TranscriptSource` impl tested against a
257    //!    `wiremock::MockServer` serving both the InnerTube `/player`
258    //!    endpoint and the timedtext URL the player response points at.
259    //!
260    //! [`format::srt`]: crate::transcript::format::srt
261
262    use super::*;
263    use crate::transcript::error::TranscriptError;
264    use crate::transcript::format::srt;
265    use crate::transcript::source::{FetchOpts, TrackKind};
266    use serde_json::Value;
267    use wiremock::matchers::{method, path};
268    use wiremock::{Mock, MockServer, ResponseTemplate};
269
270    const PLAYER_RESPONSE: &str = include_str!("youtube/fixtures/player_response_basic.json");
271    const PLAYER_RESPONSE_AGE_GATED: &str =
272        include_str!("youtube/fixtures/player_response_age_gated.json");
273    const TIMEDTEXT: &str = include_str!("youtube/fixtures/timedtext_basic.json");
274    const EXPECTED_SRT: &str = include_str!("youtube/fixtures/expected_basic.srt");
275
276    const VIDEO_ID: &str = "dQw4w9WgXcQ";
277
278    // ── Offline acceptance gate (carried from step 2) ──
279
280    #[test]
281    fn matches_url_accepts_canonical_forms() {
282        assert!(matches_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
283        assert!(matches_url("https://youtu.be/dQw4w9WgXcQ"));
284    }
285
286    #[test]
287    fn matches_url_rejects_other_hosts() {
288        assert!(!matches_url("https://vimeo.com/123456"));
289        assert!(!matches_url("not a url"));
290    }
291
292    #[test]
293    fn matches_url_accepts_bare_video_id() {
294        assert!(matches_url(VIDEO_ID));
295    }
296
297    #[tokio::test]
298    async fn record_yt_http_logs_transport_error_without_panicking() {
299        // The Ok arm is exercised by every wiremock fetch test; this covers the
300        // Err arm — a transport-level failure (connection refused on a closed
301        // port), recorded best-effort with no status and the error text.
302        let client = reqwest::Client::builder()
303            .timeout(std::time::Duration::from_millis(200))
304            .build()
305            .unwrap();
306        let url = "http://127.0.0.1:1/youtubei/v1/player";
307        let result = client.get(url).send().await;
308        assert!(
309            result.is_err(),
310            "a closed port must yield a transport error"
311        );
312        // Best-effort logging must not panic on an Err result.
313        record_yt_http("GET", url, std::time::Instant::now(), &result);
314    }
315
316    #[test]
317    fn end_to_end_player_response_to_srt() {
318        let response = parse_player_response(PLAYER_RESPONSE).unwrap();
319        check_playability(&response).unwrap();
320
321        let opts = FetchOpts::new("en-US");
322        let selected = select_track(&response, &opts).unwrap();
323        assert_eq!(selected.kind, TrackKind::Manual);
324        assert_eq!(selected.language, "en-US");
325
326        let cues = parse_timedtext(TIMEDTEXT).unwrap();
327        assert_eq!(cues.len(), 3);
328
329        let video_id = response
330            .video_details
331            .as_ref()
332            .map(|d| d.video_id.clone())
333            .unwrap_or_default();
334        let transcript = Transcript {
335            source: "youtube".to_string(),
336            locator_id: video_id,
337            language: selected.language.clone(),
338            kind: selected.kind,
339            cues,
340        };
341        let rendered = srt::render(&transcript.cues);
342        assert_eq!(rendered, EXPECTED_SRT);
343    }
344
345    #[test]
346    fn end_to_end_translation_path_picks_target_language() {
347        let response = parse_player_response(PLAYER_RESPONSE).unwrap();
348        let mut opts = FetchOpts::new("ja");
349        opts.translate_to = Some("fr".into());
350        let selected = select_track(&response, &opts).unwrap();
351        assert_eq!(selected.kind, TrackKind::Translated);
352        assert_eq!(selected.language, "fr");
353        assert!(selected.fetch_url.contains("tlang=fr"));
354    }
355
356    // ── HTTP-driven TranscriptSource impl ──
357
358    /// Take the checked-in `player_response_basic.json` fixture and rewrite
359    /// every caption track's `baseUrl` to point at the mock server, so
360    /// `select_track` produces a URL the same mock will answer for the
361    /// timedtext GET.
362    fn fixture_with_rewritten_caption_urls(mock_uri: &str) -> String {
363        let mut value: Value = serde_json::from_str(PLAYER_RESPONSE).unwrap();
364        let tracks = value["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"]
365            .as_array_mut()
366            .unwrap();
367        for track in tracks {
368            let lang = track["languageCode"].as_str().unwrap().to_string();
369            track["baseUrl"] = Value::String(format!("{mock_uri}/api/timedtext?lang={lang}"));
370        }
371        serde_json::to_string(&value).unwrap()
372    }
373
374    /// Watch-page fixture used to satisfy the `visitorData` bootstrap in
375    /// every wiremock-driven test below. The exact token value doesn't
376    /// matter for these tests — only that the bootstrap returns *some*
377    /// token so [`load_player_response`] can proceed.
378    const WATCH_PAGE: &str = include_str!("youtube/fixtures/watch_page_with_visitor_data.html");
379
380    /// Mount the watch-page bootstrap mock onto `server`. Every
381    /// [`Youtube::fetch`] / `list_languages` / `info` call in the tests
382    /// below triggers this on first use (cached thereafter via
383    /// `OnceCell`).
384    async fn mount_watch_page(server: &MockServer) {
385        Mock::given(method("GET"))
386            .and(path("/watch"))
387            .respond_with(ResponseTemplate::new(200).set_body_string(WATCH_PAGE))
388            .mount(server)
389            .await;
390    }
391
392    async fn mock_server_with_basic_video() -> MockServer {
393        let server = MockServer::start().await;
394        let player_response = fixture_with_rewritten_caption_urls(&server.uri());
395
396        mount_watch_page(&server).await;
397
398        Mock::given(method("POST"))
399            .and(path(innertube::PLAYER_PATH))
400            .respond_with(ResponseTemplate::new(200).set_body_string(player_response))
401            .mount(&server)
402            .await;
403
404        Mock::given(method("GET"))
405            .and(path("/api/timedtext"))
406            .respond_with(ResponseTemplate::new(200).set_body_string(TIMEDTEXT))
407            .mount(&server)
408            .await;
409
410        server
411    }
412
413    #[tokio::test]
414    async fn fetch_returns_transcript_assembled_from_both_endpoints() {
415        let server = mock_server_with_basic_video().await;
416        let yt = Youtube::with_base_url(server.uri()).unwrap();
417        let opts = FetchOpts::new("en-US");
418
419        let transcript = yt
420            .fetch(
421                &format!("https://www.youtube.com/watch?v={VIDEO_ID}"),
422                &opts,
423            )
424            .await
425            .unwrap();
426
427        assert_eq!(transcript.source, "youtube");
428        assert_eq!(transcript.locator_id, VIDEO_ID);
429        assert_eq!(transcript.language, "en-US");
430        assert_eq!(transcript.kind, TrackKind::Manual);
431        assert_eq!(transcript.cues.len(), 3);
432        // Render and compare to the golden SRT to catch any divergence
433        // between the HTTP and offline pipelines.
434        assert_eq!(srt::render(&transcript.cues), EXPECTED_SRT);
435    }
436
437    #[tokio::test]
438    async fn fetch_accepts_bare_video_id_as_locator() {
439        let server = mock_server_with_basic_video().await;
440        let yt = Youtube::with_base_url(server.uri()).unwrap();
441        let opts = FetchOpts::new("en-US");
442
443        let transcript = yt.fetch(VIDEO_ID, &opts).await.unwrap();
444        assert_eq!(transcript.locator_id, VIDEO_ID);
445    }
446
447    #[tokio::test]
448    async fn fetch_propagates_language_not_found() {
449        let server = mock_server_with_basic_video().await;
450        let yt = Youtube::with_base_url(server.uri()).unwrap();
451        let opts = FetchOpts::new("zz");
452
453        let err = yt.fetch(VIDEO_ID, &opts).await.unwrap_err();
454        assert!(matches!(err, TranscriptError::LanguageNotFound { .. }));
455    }
456
457    #[tokio::test]
458    async fn fetch_surfaces_age_gated_as_playability_refused() {
459        let server = MockServer::start().await;
460        mount_watch_page(&server).await;
461        Mock::given(method("POST"))
462            .and(path(innertube::PLAYER_PATH))
463            .respond_with(ResponseTemplate::new(200).set_body_string(PLAYER_RESPONSE_AGE_GATED))
464            .mount(&server)
465            .await;
466
467        let yt = Youtube::with_base_url(server.uri()).unwrap();
468        let err = yt.fetch(VIDEO_ID, &FetchOpts::new("en")).await.unwrap_err();
469        match err {
470            TranscriptError::PlayabilityRefused { status, .. } => {
471                assert_eq!(status, "LOGIN_REQUIRED");
472            }
473            other => panic!("wrong variant: {other:?}"),
474        }
475    }
476
477    #[tokio::test]
478    async fn fetch_invalid_locator_short_circuits_before_http() {
479        // No mock server needed — the call should fail at URL parsing.
480        let yt = Youtube::with_base_url("http://127.0.0.1:1").unwrap();
481        let err = yt
482            .fetch("not-a-url", &FetchOpts::new("en"))
483            .await
484            .unwrap_err();
485        assert!(matches!(err, TranscriptError::InvalidLocator(_)));
486    }
487
488    #[tokio::test]
489    async fn fetch_surfaces_innertube_500_as_http_error() {
490        let server = MockServer::start().await;
491        mount_watch_page(&server).await;
492        Mock::given(method("POST"))
493            .and(path(innertube::PLAYER_PATH))
494            .respond_with(ResponseTemplate::new(500))
495            .mount(&server)
496            .await;
497
498        let yt = Youtube::with_base_url(server.uri()).unwrap();
499        let err = yt.fetch(VIDEO_ID, &FetchOpts::new("en")).await.unwrap_err();
500        assert!(matches!(err, TranscriptError::Http(_)));
501    }
502
503    #[tokio::test]
504    async fn list_languages_projects_caption_tracks() {
505        let server = mock_server_with_basic_video().await;
506        let yt = Youtube::with_base_url(server.uri()).unwrap();
507
508        let langs = yt.list_languages(VIDEO_ID).await.unwrap();
509        let codes: Vec<_> = langs.iter().map(|l| l.code.as_str()).collect();
510        assert!(codes.contains(&"en-US"));
511        assert!(codes.contains(&"es"));
512        assert!(codes.contains(&"en"));
513    }
514
515    #[tokio::test]
516    async fn info_returns_video_metadata() {
517        let server = mock_server_with_basic_video().await;
518        let yt = Youtube::with_base_url(server.uri()).unwrap();
519
520        let info = yt.info(VIDEO_ID).await.unwrap();
521        assert_eq!(info.source, "youtube");
522        assert_eq!(info.locator_id, VIDEO_ID);
523        assert_eq!(info.title, "Sample Video");
524        assert_eq!(info.duration_ms, Some(212_000));
525        assert_eq!(info.languages.len(), 3);
526    }
527
528    #[tokio::test]
529    async fn matches_static_dispatch_through_trait() {
530        // Object-safety / static-method routing sanity check.
531        assert!(<Youtube as TranscriptSource>::matches(
532            "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
533        ));
534        assert!(!<Youtube as TranscriptSource>::matches(
535            "https://vimeo.com/1"
536        ));
537    }
538
539    #[tokio::test]
540    async fn name_is_lowercase_youtube() {
541        let server = mock_server_with_basic_video().await;
542        let yt = Youtube::with_base_url(server.uri()).unwrap();
543        assert_eq!(yt.name(), "youtube");
544    }
545
546    #[test]
547    fn new_constructs_default_client() {
548        // Smoke test for the production constructor — exercises the
549        // reqwest::Client::builder() path with the pinned timeout / UA.
550        let yt = Youtube::new().unwrap();
551        assert_eq!(yt.base_url, DEFAULT_BASE_URL);
552    }
553
554    #[tokio::test]
555    async fn fetch_threads_visitor_data_into_innertube_body() {
556        // Pin the bootstrap → InnerTube wiring: the token scraped from the
557        // watch page must appear under `context.client.visitorData` on the
558        // outbound /player POST. Captures the inbound JSON and asserts on
559        // the value the fixture publishes.
560        const EXPECTED_TOKEN: &str = "CgtkUTQyOFR3aV9NSSjFoYvBBjIKCgJVUxIEGgAgPg%3D%3D";
561
562        let server = MockServer::start().await;
563        mount_watch_page(&server).await;
564        let player_response = fixture_with_rewritten_caption_urls(&server.uri());
565
566        Mock::given(method("POST"))
567            .and(path(innertube::PLAYER_PATH))
568            .respond_with(move |req: &wiremock::Request| {
569                let parsed: Value = serde_json::from_slice(&req.body).unwrap();
570                assert_eq!(
571                    parsed["context"]["client"]["visitorData"],
572                    Value::String(EXPECTED_TOKEN.to_string()),
573                );
574                ResponseTemplate::new(200).set_body_string(player_response.clone())
575            })
576            .expect(1)
577            .mount(&server)
578            .await;
579
580        Mock::given(method("GET"))
581            .and(path("/api/timedtext"))
582            .respond_with(ResponseTemplate::new(200).set_body_string(TIMEDTEXT))
583            .mount(&server)
584            .await;
585
586        let yt = Youtube::with_base_url(server.uri()).unwrap();
587        let _ = yt.fetch(VIDEO_ID, &FetchOpts::new("en-US")).await.unwrap();
588    }
589
590    #[tokio::test]
591    async fn visitor_data_fetched_only_once_for_repeated_calls() {
592        // Sequential calls via a single `Youtube` instance must hit the
593        // watch page exactly once — `OnceCell` caches across calls.
594        let server = MockServer::start().await;
595        let player_response = fixture_with_rewritten_caption_urls(&server.uri());
596
597        Mock::given(method("GET"))
598            .and(path("/watch"))
599            .respond_with(ResponseTemplate::new(200).set_body_string(WATCH_PAGE))
600            .expect(1)
601            .mount(&server)
602            .await;
603
604        Mock::given(method("POST"))
605            .and(path(innertube::PLAYER_PATH))
606            .respond_with(ResponseTemplate::new(200).set_body_string(player_response))
607            .mount(&server)
608            .await;
609
610        Mock::given(method("GET"))
611            .and(path("/api/timedtext"))
612            .respond_with(ResponseTemplate::new(200).set_body_string(TIMEDTEXT))
613            .mount(&server)
614            .await;
615
616        let yt = Youtube::with_base_url(server.uri()).unwrap();
617        let _ = yt.fetch(VIDEO_ID, &FetchOpts::new("en-US")).await.unwrap();
618        let _ = yt.fetch(VIDEO_ID, &FetchOpts::new("en-US")).await.unwrap();
619        // wiremock asserts expect(1) on server drop.
620    }
621
622    #[tokio::test]
623    async fn visitor_data_fetched_only_once_under_concurrency() {
624        // Concurrent first-callers must serialise on a single in-flight
625        // scrape rather than each issuing their own watch-page GET.
626        // `tokio::sync::OnceCell::get_or_try_init` is documented to
627        // provide this guarantee; this test pins the contract.
628        let server = MockServer::start().await;
629        let player_response = fixture_with_rewritten_caption_urls(&server.uri());
630
631        Mock::given(method("GET"))
632            .and(path("/watch"))
633            .respond_with(ResponseTemplate::new(200).set_body_string(WATCH_PAGE))
634            .expect(1)
635            .mount(&server)
636            .await;
637
638        Mock::given(method("POST"))
639            .and(path(innertube::PLAYER_PATH))
640            .respond_with(ResponseTemplate::new(200).set_body_string(player_response))
641            .mount(&server)
642            .await;
643
644        Mock::given(method("GET"))
645            .and(path("/api/timedtext"))
646            .respond_with(ResponseTemplate::new(200).set_body_string(TIMEDTEXT))
647            .mount(&server)
648            .await;
649
650        let yt = Youtube::with_base_url(server.uri()).unwrap();
651        let opts = FetchOpts::new("en-US");
652        let (a, b, c) = tokio::join!(
653            yt.fetch(VIDEO_ID, &opts),
654            yt.fetch(VIDEO_ID, &opts),
655            yt.fetch(VIDEO_ID, &opts),
656        );
657        a.unwrap();
658        b.unwrap();
659        c.unwrap();
660        // wiremock asserts expect(1) on server drop.
661    }
662
663    #[tokio::test]
664    async fn fetch_surfaces_missing_visitor_data_as_typed_error() {
665        // Watch-page format has drifted (no visitorData token): the fetch
666        // must propagate `MissingVisitorData` rather than fall through to
667        // an unauthenticated /player call.
668        let server = MockServer::start().await;
669        Mock::given(method("GET"))
670            .and(path("/watch"))
671            .respond_with(
672                ResponseTemplate::new(200).set_body_string("<html><body>no token</body></html>"),
673            )
674            .mount(&server)
675            .await;
676
677        let yt = Youtube::with_base_url(server.uri()).unwrap();
678        let err = yt.fetch(VIDEO_ID, &FetchOpts::new("en")).await.unwrap_err();
679        assert!(matches!(err, TranscriptError::MissingVisitorData { .. }));
680    }
681
682    #[tokio::test]
683    async fn fetch_surfaces_malformed_innertube_json_as_parse_error() {
684        let server = MockServer::start().await;
685        mount_watch_page(&server).await;
686        Mock::given(method("POST"))
687            .and(path(innertube::PLAYER_PATH))
688            .respond_with(ResponseTemplate::new(200).set_body_string("{ not json"))
689            .mount(&server)
690            .await;
691
692        let yt = Youtube::with_base_url(server.uri()).unwrap();
693        let err = yt.fetch(VIDEO_ID, &FetchOpts::new("en")).await.unwrap_err();
694        assert!(matches!(err, TranscriptError::ParseError(_)));
695    }
696
697    #[tokio::test]
698    async fn fetch_video_metadata_projects_web_player_without_watch_bootstrap() {
699        // The metadata path is independent of the gated transcript path: a
700        // single WEB /player call, no watch-page (visitorData) mock mounted.
701        const WEB_METADATA: &str =
702            include_str!("youtube/fixtures/player_response_web_metadata.json");
703        let server = MockServer::start().await;
704        Mock::given(method("POST"))
705            .and(path(innertube::PLAYER_PATH))
706            .respond_with(ResponseTemplate::new(200).set_body_string(WEB_METADATA))
707            .expect(1)
708            .mount(&server)
709            .await;
710
711        let yt = Youtube::with_base_url(server.uri()).unwrap();
712        let meta = yt.fetch_video_metadata("dQw4w9WgXcQ").await.unwrap();
713        assert_eq!(meta.video_id, "dQw4w9WgXcQ");
714        assert_eq!(meta.category.as_deref(), Some("Music"));
715        assert_eq!(meta.like_count, Some(19_148_727));
716        // fetched_at is stamped at fetch time.
717        assert!((Utc::now() - meta.fetched_at).num_seconds().abs() < 60);
718    }
719
720    #[tokio::test]
721    async fn fetch_video_metadata_surfaces_http_error() {
722        let server = MockServer::start().await;
723        Mock::given(method("POST"))
724            .and(path(innertube::PLAYER_PATH))
725            .respond_with(ResponseTemplate::new(500))
726            .mount(&server)
727            .await;
728        let yt = Youtube::with_base_url(server.uri()).unwrap();
729        let err = yt.fetch_video_metadata("dQw4w9WgXcQ").await.unwrap_err();
730        assert!(matches!(err, TranscriptError::Http(_)));
731    }
732
733    // ── Online integration test ──
734    //
735    // Hits real YouTube — gated behind the `online_tests` custom cfg
736    // (declared in `Cargo.toml`'s `[lints.rust]`), *not* a cargo feature,
737    // so `cargo test --all-features` does not compile or run it. CI never
738    // sets the cfg; run manually with
739    // `RUSTFLAGS='--cfg online_tests' cargo test online_fetch_against_public_video`.
740    // Note that YouTube blocks well-known cloud / CI IPs with
741    // `LOGIN_REQUIRED`, so this test passes only from a residential
742    // network — it is intentionally manual-only.
743    #[cfg(online_tests)]
744    #[tokio::test]
745    async fn online_fetch_against_public_video() {
746        // "Me at the zoo" — the first YouTube video, captioned, stable.
747        const STABLE_VIDEO_ID: &str = "jNQXAC9IVRw";
748        let yt = Youtube::new().unwrap();
749        let opts = FetchOpts::new("en");
750        let transcript = yt.fetch(STABLE_VIDEO_ID, &opts).await.unwrap();
751        assert_eq!(transcript.source, "youtube");
752        assert_eq!(transcript.locator_id, STABLE_VIDEO_ID);
753        assert!(!transcript.cues.is_empty());
754    }
755}