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
63#[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 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 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 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 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 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 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 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 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 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 #[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 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 const WATCH_PAGE: &str = include_str!("youtube/fixtures/watch_page_with_visitor_data.html");
328
329 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 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 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 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 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 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 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 }
570
571 #[tokio::test]
572 async fn visitor_data_fetched_only_once_under_concurrency() {
573 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 }
611
612 #[tokio::test]
613 async fn fetch_surfaces_missing_visitor_data_as_typed_error() {
614 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 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 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 #[cfg(online_tests)]
693 #[tokio::test]
694 async fn online_fetch_against_public_video() {
695 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}