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