omni_dev/transcript/sources/
youtube.rs1use 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
36const DEFAULT_BASE_URL: &str = "https://www.youtube.com";
39
40const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
43
44const USER_AGENT: &str = "com.google.android.apps.youtube.vr.oculus/1.62.27 \
53 (Linux; U; Android 12; Quest 3) gzip";
54
55pub fn matches_url(input: &str) -> bool {
60 extract_video_id(input).is_ok()
61}
62
63pub(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#[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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 const WATCH_PAGE: &str = include_str!("youtube/fixtures/watch_page_with_visitor_data.html");
379
380 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 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 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 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 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 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 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 }
621
622 #[tokio::test]
623 async fn visitor_data_fetched_only_once_under_concurrency() {
624 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 }
662
663 #[tokio::test]
664 async fn fetch_surfaces_missing_visitor_data_as_typed_error() {
665 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 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 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 #[cfg(online_tests)]
744 #[tokio::test]
745 async fn online_fetch_against_public_video() {
746 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}