1use serde::Deserialize;
11
12use crate::transcript::error::{Result, TranscriptError};
13use crate::transcript::source::{FetchOpts, LanguageInfo, MediaInfo, TrackKind};
14
15#[derive(Clone, Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct PlayerResponse {
19 pub playability_status: PlayabilityStatus,
21 #[serde(default)]
23 pub video_details: Option<VideoDetails>,
24 #[serde(default)]
26 pub captions: Option<Captions>,
27}
28
29#[derive(Clone, Debug, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct PlayabilityStatus {
33 pub status: String,
36 #[serde(default)]
38 pub reason: Option<String>,
39}
40
41#[derive(Clone, Debug, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct VideoDetails {
45 pub video_id: String,
47 pub title: String,
49 #[serde(default)]
51 pub length_seconds: Option<String>,
52 #[serde(default)]
54 pub author: Option<String>,
55}
56
57#[derive(Clone, Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Captions {
61 pub player_captions_tracklist_renderer: TracklistRenderer,
63}
64
65#[derive(Clone, Debug, Deserialize, Default)]
67#[serde(rename_all = "camelCase")]
68pub struct TracklistRenderer {
69 #[serde(default)]
71 pub caption_tracks: Vec<CaptionTrack>,
72 #[serde(default)]
74 pub translation_languages: Vec<TranslationLanguage>,
75}
76
77#[derive(Clone, Debug, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct CaptionTrack {
81 pub base_url: String,
84 #[serde(default)]
86 pub name: Option<LocalizedText>,
87 pub language_code: String,
89 #[serde(default)]
92 pub kind: Option<String>,
93 #[serde(default)]
95 pub is_translatable: Option<bool>,
96}
97
98#[derive(Clone, Debug, Deserialize)]
107#[serde(untagged)]
108pub enum LocalizedText {
109 Simple {
111 #[serde(rename = "simpleText")]
113 simple_text: String,
114 },
115 Runs {
117 runs: Vec<TextRun>,
119 },
120}
121
122#[derive(Clone, Debug, Deserialize)]
124pub struct TextRun {
125 pub text: String,
129}
130
131impl LocalizedText {
132 pub fn text(&self) -> String {
134 match self {
135 Self::Simple { simple_text } => simple_text.clone(),
136 Self::Runs { runs } => runs.iter().map(|r| r.text.as_str()).collect(),
137 }
138 }
139}
140
141#[derive(Clone, Debug, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct TranslationLanguage {
145 pub language_code: String,
147 #[serde(default)]
149 pub language_name: Option<LocalizedText>,
150}
151
152impl CaptionTrack {
153 pub fn is_asr(&self) -> bool {
155 self.kind.as_deref() == Some("asr")
156 }
157}
158
159#[derive(Clone, Debug)]
165pub struct SelectedTrack<'a> {
166 pub track: &'a CaptionTrack,
168 pub fetch_url: String,
171 pub language: String,
174 pub kind: TrackKind,
176}
177
178pub fn parse(raw: &str) -> Result<PlayerResponse> {
181 serde_json::from_str(raw)
182 .map_err(|e| TranscriptError::ParseError(format!("playerResponse: {e}")))
183}
184
185pub fn check_playability(response: &PlayerResponse) -> Result<()> {
187 if response.playability_status.status == "OK" {
188 Ok(())
189 } else {
190 Err(TranscriptError::PlayabilityRefused {
191 status: response.playability_status.status.clone(),
192 reason: response.playability_status.reason.clone(),
193 })
194 }
195}
196
197pub fn select_track<'a>(
212 response: &'a PlayerResponse,
213 opts: &FetchOpts,
214) -> Result<SelectedTrack<'a>> {
215 let tracks: &[CaptionTrack] = response.captions.as_ref().map_or(&[][..], |c| {
216 &c.player_captions_tracklist_renderer.caption_tracks
217 });
218
219 if let Some(track) = pick(tracks, &opts.language, false) {
220 return Ok(materialise_native(track));
221 }
222 if opts.allow_auto {
223 if let Some(track) = pick(tracks, &opts.language, true) {
224 return Ok(materialise_native(track));
225 }
226 }
227
228 if let Some(target) = opts.translate_to.as_deref() {
229 if let Some(track) = pick_translation_base(response, target) {
230 return Ok(materialise_translation(track, target));
231 }
232 }
233
234 let asr_only = !opts.allow_auto && pick(tracks, &opts.language, true).is_some();
235 if asr_only {
236 return Err(TranscriptError::AutoCaptionsRequireOptIn(
237 opts.language.clone(),
238 ));
239 }
240
241 let available = tracks
242 .iter()
243 .map(|t| t.language_code.clone())
244 .collect::<Vec<_>>();
245 Err(TranscriptError::LanguageNotFound {
246 requested: opts.language.clone(),
247 available,
248 })
249}
250
251fn pick<'a>(tracks: &'a [CaptionTrack], lang: &str, asr: bool) -> Option<&'a CaptionTrack> {
252 tracks
253 .iter()
254 .find(|t| t.is_asr() == asr && t.language_code == lang)
255 .or_else(|| {
256 tracks
257 .iter()
258 .find(|t| t.is_asr() == asr && t.language_code.starts_with(lang))
259 })
260}
261
262fn pick_translation_base<'a>(
263 response: &'a PlayerResponse,
264 target: &str,
265) -> Option<&'a CaptionTrack> {
266 let renderer = &response
267 .captions
268 .as_ref()?
269 .player_captions_tracklist_renderer;
270 let target_supported = renderer
271 .translation_languages
272 .iter()
273 .any(|l| l.language_code == target);
274 if !target_supported {
275 return None;
276 }
277 renderer
278 .caption_tracks
279 .iter()
280 .find(|t| !t.is_asr() && t.is_translatable.unwrap_or(true))
281 .or_else(|| {
282 renderer
283 .caption_tracks
284 .iter()
285 .find(|t| t.is_translatable.unwrap_or(true))
286 })
287}
288
289fn materialise_native(track: &CaptionTrack) -> SelectedTrack<'_> {
290 SelectedTrack {
291 track,
292 fetch_url: timedtext_url(&track.base_url, None),
293 language: track.language_code.clone(),
294 kind: if track.is_asr() {
295 TrackKind::Auto
296 } else {
297 TrackKind::Manual
298 },
299 }
300}
301
302fn materialise_translation<'a>(track: &'a CaptionTrack, target: &str) -> SelectedTrack<'a> {
303 SelectedTrack {
304 track,
305 fetch_url: timedtext_url(&track.base_url, Some(target)),
306 language: target.to_string(),
307 kind: TrackKind::Translated,
308 }
309}
310
311fn timedtext_url(base_url: &str, tlang: Option<&str>) -> String {
322 let Ok(mut url) = url::Url::parse(base_url) else {
323 let mut s = base_url.to_string();
327 s.push(if s.contains('?') { '&' } else { '?' });
328 s.push_str("fmt=json3");
329 if let Some(t) = tlang {
330 s.push_str("&tlang=");
331 s.push_str(t);
332 }
333 return s;
334 };
335 let preserved: Vec<(String, String)> = url
336 .query_pairs()
337 .filter(|(k, _)| k != "fmt" && k != "tlang")
338 .map(|(k, v)| (k.into_owned(), v.into_owned()))
339 .collect();
340 url.query_pairs_mut().clear();
341 {
342 let mut q = url.query_pairs_mut();
343 for (k, v) in &preserved {
344 q.append_pair(k, v);
345 }
346 q.append_pair("fmt", "json3");
347 if let Some(t) = tlang {
348 q.append_pair("tlang", t);
349 }
350 }
351 url.to_string()
352}
353
354pub fn list_languages(response: &PlayerResponse) -> Vec<LanguageInfo> {
357 response
358 .captions
359 .as_ref()
360 .map(|c| {
361 c.player_captions_tracklist_renderer
362 .caption_tracks
363 .iter()
364 .map(|t| LanguageInfo {
365 code: t.language_code.clone(),
366 name: t
367 .name
368 .as_ref()
369 .map_or_else(|| t.language_code.clone(), LocalizedText::text),
370 kind: if t.is_asr() {
371 TrackKind::Auto
372 } else {
373 TrackKind::Manual
374 },
375 })
376 .collect()
377 })
378 .unwrap_or_default()
379}
380
381pub fn extract_media_info(response: &PlayerResponse) -> MediaInfo {
384 let details = response.video_details.as_ref();
385 MediaInfo {
386 source: "youtube".to_string(),
387 locator_id: details.map(|d| d.video_id.clone()).unwrap_or_default(),
388 title: details.map(|d| d.title.clone()).unwrap_or_default(),
389 author: details.and_then(|d| d.author.clone()),
390 duration_ms: details
391 .and_then(|d| d.length_seconds.as_deref())
392 .and_then(|s| s.parse::<u64>().ok())
393 .map(|s| s * 1_000),
394 languages: list_languages(response),
395 }
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used, clippy::expect_used)]
400mod tests {
401 use super::*;
402
403 const FIXTURE_BASIC: &str = include_str!("fixtures/player_response_basic.json");
404 const FIXTURE_AGE_GATED: &str = include_str!("fixtures/player_response_age_gated.json");
405
406 fn opts(lang: &str) -> FetchOpts {
407 FetchOpts::new(lang)
408 }
409
410 #[test]
411 fn parse_basic_fixture() {
412 let r = parse(FIXTURE_BASIC).unwrap();
413 assert_eq!(r.playability_status.status, "OK");
414 let details = r.video_details.as_ref().unwrap();
415 assert_eq!(details.video_id, "dQw4w9WgXcQ");
416 assert_eq!(details.title, "Sample Video");
417 assert_eq!(details.length_seconds.as_deref(), Some("212"));
418 let renderer = &r
419 .captions
420 .as_ref()
421 .unwrap()
422 .player_captions_tracklist_renderer;
423 assert_eq!(renderer.caption_tracks.len(), 3);
424 assert_eq!(renderer.translation_languages.len(), 2);
425 }
426
427 #[test]
428 fn parse_invalid_json_errors() {
429 let err = parse("{ not valid json").unwrap_err();
430 assert!(matches!(err, TranscriptError::ParseError(_)));
431 }
432
433 #[test]
434 fn parse_missing_required_field_errors() {
435 let err = parse("{}").unwrap_err();
436 assert!(matches!(err, TranscriptError::ParseError(_)));
437 }
438
439 #[test]
440 fn check_playability_passes_for_ok() {
441 let r = parse(FIXTURE_BASIC).unwrap();
442 assert!(check_playability(&r).is_ok());
443 }
444
445 #[test]
446 fn check_playability_surfaces_login_required() {
447 let r = parse(FIXTURE_AGE_GATED).unwrap();
448 let err = check_playability(&r).unwrap_err();
449 match err {
450 TranscriptError::PlayabilityRefused { status, reason } => {
451 assert_eq!(status, "LOGIN_REQUIRED");
452 assert_eq!(reason.as_deref(), Some("Sign in to confirm your age"));
453 }
454 other => panic!("wrong variant: {other:?}"),
455 }
456 }
457
458 #[test]
459 fn caption_track_is_asr_detects_kind() {
460 let r = parse(FIXTURE_BASIC).unwrap();
461 let tracks = &r
462 .captions
463 .as_ref()
464 .unwrap()
465 .player_captions_tracklist_renderer
466 .caption_tracks;
467 let asr_count = tracks.iter().filter(|t| t.is_asr()).count();
468 assert_eq!(asr_count, 1);
469 }
470
471 #[test]
472 fn select_exact_manual_match() {
473 let r = parse(FIXTURE_BASIC).unwrap();
474 let s = select_track(&r, &opts("es")).unwrap();
475 assert_eq!(s.language, "es");
476 assert_eq!(s.kind, TrackKind::Manual);
477 assert!(s.fetch_url.contains("lang=es"));
478 assert!(s.fetch_url.contains("fmt=json3"));
479 }
480
481 #[test]
482 fn select_prefix_falls_back_to_longer_code() {
483 let r = parse(FIXTURE_BASIC).unwrap();
484 let s = select_track(&r, &opts("en")).unwrap();
486 assert_eq!(s.language, "en-US");
487 assert_eq!(s.kind, TrackKind::Manual);
488 }
489
490 #[test]
491 fn select_excludes_asr_by_default() {
492 let mut r = parse(FIXTURE_BASIC).unwrap();
494 let renderer = &mut r
495 .captions
496 .as_mut()
497 .unwrap()
498 .player_captions_tracklist_renderer;
499 renderer.caption_tracks.retain(|t| t.language_code == "en");
500 let err = select_track(&r, &opts("en")).unwrap_err();
502 assert!(matches!(err, TranscriptError::AutoCaptionsRequireOptIn(_)));
503 }
504
505 #[test]
506 fn select_includes_asr_when_allow_auto() {
507 let mut r = parse(FIXTURE_BASIC).unwrap();
508 r.captions
509 .as_mut()
510 .unwrap()
511 .player_captions_tracklist_renderer
512 .caption_tracks
513 .retain(|t| t.language_code == "en");
514 let mut o = opts("en");
515 o.allow_auto = true;
516 let s = select_track(&r, &o).unwrap();
517 assert_eq!(s.kind, TrackKind::Auto);
518 assert_eq!(s.language, "en");
519 }
520
521 #[test]
522 fn select_manual_takes_precedence_over_asr() {
523 let r = parse(FIXTURE_BASIC).unwrap();
524 let mut o = opts("en");
525 o.allow_auto = true;
526 let s = select_track(&r, &o).unwrap();
529 assert_eq!(s.kind, TrackKind::Manual);
530 assert_eq!(s.language, "en-US");
531 }
532
533 #[test]
534 fn select_unknown_language_errors_with_available_list() {
535 let r = parse(FIXTURE_BASIC).unwrap();
536 let err = select_track(&r, &opts("ja")).unwrap_err();
537 match err {
538 TranscriptError::LanguageNotFound {
539 requested,
540 available,
541 } => {
542 assert_eq!(requested, "ja");
543 assert!(available.iter().any(|c| c == "en-US"));
544 assert!(available.iter().any(|c| c == "es"));
545 }
546 other => panic!("wrong variant: {other:?}"),
547 }
548 }
549
550 #[test]
551 fn select_translation_synthesises_track() {
552 let r = parse(FIXTURE_BASIC).unwrap();
553 let mut o = opts("ja"); o.translate_to = Some("fr".into());
555 let s = select_track(&r, &o).unwrap();
556 assert_eq!(s.kind, TrackKind::Translated);
557 assert_eq!(s.language, "fr");
558 assert!(s.fetch_url.contains("tlang=fr"));
559 assert!(s.fetch_url.contains("fmt=json3"));
560 }
561
562 #[test]
563 fn select_translation_skipped_when_target_unsupported() {
564 let r = parse(FIXTURE_BASIC).unwrap();
565 let mut o = opts("ja");
566 o.translate_to = Some("zz".into()); let err = select_track(&r, &o).unwrap_err();
568 assert!(matches!(err, TranscriptError::LanguageNotFound { .. }));
569 }
570
571 #[test]
572 fn select_native_match_skips_translation_path() {
573 let r = parse(FIXTURE_BASIC).unwrap();
574 let mut o = opts("es");
575 o.translate_to = Some("fr".into());
576 let s = select_track(&r, &o).unwrap();
577 assert_eq!(s.kind, TrackKind::Manual);
579 assert_eq!(s.language, "es");
580 }
581
582 #[test]
583 fn select_translation_falls_back_to_asr_when_no_manual_translatable() {
584 let mut r = parse(FIXTURE_BASIC).unwrap();
587 let renderer = &mut r
588 .captions
589 .as_mut()
590 .unwrap()
591 .player_captions_tracklist_renderer;
592 renderer.caption_tracks.retain(CaptionTrack::is_asr);
593 let mut o = opts("ja");
594 o.translate_to = Some("fr".into());
595 let s = select_track(&r, &o).unwrap();
596 assert_eq!(s.kind, TrackKind::Translated);
597 assert_eq!(s.language, "fr");
598 assert!(s.fetch_url.contains("tlang=fr"));
599 }
600
601 #[test]
602 fn select_translation_skips_non_translatable_manual() {
603 let mut r = parse(FIXTURE_BASIC).unwrap();
606 let renderer = &mut r
607 .captions
608 .as_mut()
609 .unwrap()
610 .player_captions_tracklist_renderer;
611 renderer.caption_tracks.retain(|t| t.language_code != "es");
612 for t in &mut renderer.caption_tracks {
613 if !t.is_asr() {
614 t.is_translatable = Some(false);
615 }
616 }
617 let mut o = opts("ja");
618 o.translate_to = Some("fr".into());
619 let s = select_track(&r, &o).unwrap();
622 assert_eq!(s.kind, TrackKind::Translated);
623 assert_eq!(s.language, "fr");
624 assert!(s.track.is_asr());
625 }
626
627 #[test]
628 fn fetch_url_uses_question_mark_when_base_has_none() {
629 let track = CaptionTrack {
632 base_url: "https://example.com/captions".to_string(),
633 name: None,
634 language_code: "en".to_string(),
635 kind: None,
636 is_translatable: Some(true),
637 };
638 let s = materialise_native(&track);
639 assert_eq!(s.fetch_url, "https://example.com/captions?fmt=json3");
640 }
641
642 #[test]
643 fn fetch_url_replaces_existing_fmt_param() {
644 let track = CaptionTrack {
649 base_url: "https://example.com/timedtext?lang=en&fmt=srv3&signature=ABC".to_string(),
650 name: None,
651 language_code: "en".to_string(),
652 kind: None,
653 is_translatable: Some(true),
654 };
655 let s = materialise_native(&track);
656 assert!(s.fetch_url.contains("fmt=json3"));
657 assert!(!s.fetch_url.contains("fmt=srv3"));
658 assert!(s.fetch_url.contains("signature=ABC"));
659 assert!(s.fetch_url.contains("lang=en"));
660 }
661
662 #[test]
663 fn translation_url_replaces_existing_fmt_and_tlang() {
664 let track = CaptionTrack {
665 base_url: "https://example.com/timedtext?lang=en&fmt=srv3&tlang=de&signature=X"
666 .to_string(),
667 name: None,
668 language_code: "en".to_string(),
669 kind: None,
670 is_translatable: Some(true),
671 };
672 let s = materialise_translation(&track, "fr");
673 assert!(s.fetch_url.contains("fmt=json3"));
674 assert!(!s.fetch_url.contains("fmt=srv3"));
675 assert!(s.fetch_url.contains("tlang=fr"));
676 assert!(!s.fetch_url.contains("tlang=de"));
677 assert!(s.fetch_url.contains("signature=X"));
678 }
679
680 #[test]
681 fn fetch_url_handles_non_url_base_defensively() {
682 let track = CaptionTrack {
686 base_url: "not a url".to_string(),
687 name: None,
688 language_code: "en".to_string(),
689 kind: None,
690 is_translatable: Some(true),
691 };
692 let s = materialise_native(&track);
693 assert!(s.fetch_url.contains("fmt=json3"));
694 }
695
696 #[test]
697 fn translation_url_handles_non_url_base_defensively() {
698 let track = CaptionTrack {
701 base_url: "not a url".to_string(),
702 name: None,
703 language_code: "en".to_string(),
704 kind: None,
705 is_translatable: Some(true),
706 };
707 let s = materialise_translation(&track, "fr");
708 assert!(s.fetch_url.contains("fmt=json3"));
709 assert!(s.fetch_url.contains("&tlang=fr"));
710 }
711
712 #[test]
713 fn fallback_appends_with_ampersand_when_base_already_has_query() {
714 let track = CaptionTrack {
717 base_url: "broken url with ?existing=q".to_string(),
718 name: None,
719 language_code: "en".to_string(),
720 kind: None,
721 is_translatable: Some(true),
722 };
723 let s = materialise_native(&track);
724 assert!(s.fetch_url.ends_with("&fmt=json3"));
725 }
726
727 #[test]
728 fn list_languages_falls_back_to_language_code_when_name_absent() {
729 let mut r = parse(FIXTURE_BASIC).unwrap();
733 for t in &mut r
734 .captions
735 .as_mut()
736 .unwrap()
737 .player_captions_tracklist_renderer
738 .caption_tracks
739 {
740 t.name = None;
741 }
742 let langs = list_languages(&r);
743 assert!(langs.iter().any(|l| l.code == "en-US" && l.name == "en-US"));
744 assert!(langs.iter().any(|l| l.code == "es" && l.name == "es"));
745 }
746
747 #[test]
748 fn list_languages_projects_all_tracks() {
749 let r = parse(FIXTURE_BASIC).unwrap();
750 let langs = list_languages(&r);
751 assert_eq!(langs.len(), 3);
752 let by_code: std::collections::HashMap<_, _> =
753 langs.iter().map(|l| (l.code.clone(), l)).collect();
754 assert_eq!(by_code["en-US"].kind, TrackKind::Manual);
755 assert_eq!(by_code["en"].kind, TrackKind::Auto);
756 assert_eq!(by_code["es"].kind, TrackKind::Manual);
757 assert_eq!(by_code["en-US"].name, "English (United States)");
758 }
759
760 #[test]
761 fn localized_text_deserialises_simple_shape() {
762 let json = r#"{ "simpleText": "English (United States)" }"#;
763 let lt: LocalizedText = serde_json::from_str(json).unwrap();
764 assert!(matches!(lt, LocalizedText::Simple { .. }));
765 assert_eq!(lt.text(), "English (United States)");
766 }
767
768 #[test]
769 fn localized_text_deserialises_runs_shape() {
770 let json = r#"{ "runs": [{ "text": "English (auto-generated)" }] }"#;
774 let lt: LocalizedText = serde_json::from_str(json).unwrap();
775 assert!(matches!(lt, LocalizedText::Runs { .. }));
776 assert_eq!(lt.text(), "English (auto-generated)");
777 }
778
779 #[test]
780 fn localized_text_concatenates_multiple_runs() {
781 let json = r#"{ "runs": [{ "text": "English" }, { "text": " " }, { "text": "(auto)" }] }"#;
782 let lt: LocalizedText = serde_json::from_str(json).unwrap();
783 assert_eq!(lt.text(), "English (auto)");
784 }
785
786 #[test]
787 fn localized_text_handles_empty_runs() {
788 let json = r#"{ "runs": [] }"#;
789 let lt: LocalizedText = serde_json::from_str(json).unwrap();
790 assert_eq!(lt.text(), "");
791 }
792
793 #[test]
794 fn caption_track_accepts_runs_name_shape() {
795 let json = r#"{
798 "playabilityStatus": { "status": "OK" },
799 "captions": {
800 "playerCaptionsTracklistRenderer": {
801 "captionTracks": [{
802 "baseUrl": "https://www.youtube.com/api/timedtext?lang=en",
803 "name": { "runs": [{ "text": "English (auto-generated)" }] },
804 "languageCode": "en",
805 "kind": "asr"
806 }],
807 "translationLanguages": []
808 }
809 }
810 }"#;
811 let r = parse(json).unwrap();
812 let langs = list_languages(&r);
813 assert_eq!(langs.len(), 1);
814 assert_eq!(langs[0].code, "en");
815 assert_eq!(langs[0].name, "English (auto-generated)");
816 assert_eq!(langs[0].kind, TrackKind::Auto);
817 }
818
819 #[test]
820 fn list_languages_handles_no_captions() {
821 let r = parse(FIXTURE_AGE_GATED).unwrap();
822 assert_eq!(list_languages(&r), vec![]);
823 }
824
825 #[test]
826 fn extract_media_info_populates_fields() {
827 let r = parse(FIXTURE_BASIC).unwrap();
828 let info = extract_media_info(&r);
829 assert_eq!(info.source, "youtube");
830 assert_eq!(info.locator_id, "dQw4w9WgXcQ");
831 assert_eq!(info.title, "Sample Video");
832 assert_eq!(info.author.as_deref(), Some("Sample Channel"));
833 assert_eq!(info.duration_ms, Some(212_000));
834 assert_eq!(info.languages.len(), 3);
835 }
836
837 #[test]
838 fn extract_media_info_tolerates_missing_details() {
839 let r = parse(FIXTURE_AGE_GATED).unwrap();
840 let info = extract_media_info(&r);
841 assert_eq!(info.source, "youtube");
842 assert_eq!(info.locator_id, "");
843 assert_eq!(info.title, "");
844 assert!(info.languages.is_empty());
845 }
846}