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