Skip to main content

omni_dev/transcript/sources/youtube/
player_response.rs

1//! Serde-deserialisable view of YouTube's `playerResponse` envelope plus the
2//! caption-track selector.
3//!
4//! Only the fields this crate actually consumes are modelled — everything
5//! else is dropped on the floor. Most fields are wrapped in [`Option`] so
6//! malformed or partial responses surface through
7//! [`TranscriptError::ParseError`] at the call site, not as deserialisation
8//! errors deep inside `serde_json`.
9
10use serde::Deserialize;
11
12use crate::transcript::error::{Result, TranscriptError};
13use crate::transcript::source::{FetchOpts, LanguageInfo, MediaInfo, TrackKind};
14
15/// Top-level `playerResponse` envelope.
16#[derive(Clone, Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct PlayerResponse {
19    /// Whether YouTube will serve the video and captions.
20    pub playability_status: PlayabilityStatus,
21    /// Per-video metadata. Absent for refused playback.
22    #[serde(default)]
23    pub video_details: Option<VideoDetails>,
24    /// Caption-track listing. Absent for videos with no captions at all.
25    #[serde(default)]
26    pub captions: Option<Captions>,
27}
28
29/// Why YouTube will or will not play the video.
30#[derive(Clone, Debug, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct PlayabilityStatus {
33    /// `OK` for playable videos. Other values include `LOGIN_REQUIRED`,
34    /// `AGE_VERIFICATION_REQUIRED`, `UNPLAYABLE`, `LIVE_STREAM_OFFLINE`.
35    pub status: String,
36    /// Optional human-readable reason, e.g. "Sign in to confirm your age".
37    #[serde(default)]
38    pub reason: Option<String>,
39}
40
41/// Metadata about the video itself.
42#[derive(Clone, Debug, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct VideoDetails {
45    /// 11-character video ID.
46    pub video_id: String,
47    /// Display title.
48    pub title: String,
49    /// Duration in seconds, encoded as a numeric string.
50    #[serde(default)]
51    pub length_seconds: Option<String>,
52    /// Channel / uploader name.
53    #[serde(default)]
54    pub author: Option<String>,
55}
56
57/// Wraps the actual tracklist renderer. YouTube nests one level deep here.
58#[derive(Clone, Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Captions {
61    /// The renderer carrying the caption tracks and translation languages.
62    pub player_captions_tracklist_renderer: TracklistRenderer,
63}
64
65/// The set of caption tracks plus the languages YouTube can translate into.
66#[derive(Clone, Debug, Deserialize, Default)]
67#[serde(rename_all = "camelCase")]
68pub struct TracklistRenderer {
69    /// All caption tracks available on the video.
70    #[serde(default)]
71    pub caption_tracks: Vec<CaptionTrack>,
72    /// Languages YouTube can machine-translate any translatable track into.
73    #[serde(default)]
74    pub translation_languages: Vec<TranslationLanguage>,
75}
76
77/// A single caption track.
78#[derive(Clone, Debug, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct CaptionTrack {
81    /// Pre-signed URL for the timedtext endpoint. Append `&fmt=json3` when
82    /// fetching to get the structured event format this crate parses.
83    pub base_url: String,
84    /// Display name of the track, e.g. `English (United States)`.
85    #[serde(default)]
86    pub name: Option<LocalizedText>,
87    /// IETF-style language tag, e.g. `en`, `en-US`, `pt-BR`.
88    pub language_code: String,
89    /// Present and equal to `"asr"` for auto-generated tracks; absent
90    /// for human-authored ones.
91    #[serde(default)]
92    pub kind: Option<String>,
93    /// Whether YouTube allows machine-translating this track.
94    #[serde(default)]
95    pub is_translatable: Option<bool>,
96}
97
98/// A localised text payload, in either of YouTube's two text shapes.
99///
100/// `{ "simpleText": "..." }` for short labels, or
101/// `{ "runs": [{ "text": "..." }, ...] }` for fields that mix runs of
102/// styled text. The shape varies by client and by field; modern
103/// `ANDROID_VR` `/player` responses typically use `runs` for caption
104/// track names. Either is accepted; [`Self::text`] returns the
105/// concatenation.
106#[derive(Clone, Debug, Deserialize)]
107#[serde(untagged)]
108pub enum LocalizedText {
109    /// `{ "simpleText": "<label>" }`.
110    Simple {
111        /// The flat text payload.
112        #[serde(rename = "simpleText")]
113        simple_text: String,
114    },
115    /// `{ "runs": [{ "text": "..." }, ...] }`.
116    Runs {
117        /// The styled text fragments.
118        runs: Vec<TextRun>,
119    },
120}
121
122/// A single styled-text fragment within a [`LocalizedText::Runs`] payload.
123#[derive(Clone, Debug, Deserialize)]
124pub struct TextRun {
125    /// The fragment's text. Only the text is consumed; YouTube also
126    /// includes styling fields (`bold`, `italics`, `navigationEndpoint`,
127    /// etc.) that are dropped by this crate.
128    pub text: String,
129}
130
131impl LocalizedText {
132    /// Resolve to a flat string, joining `runs[].text` if present.
133    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/// A target language for machine translation.
142#[derive(Clone, Debug, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct TranslationLanguage {
145    /// IETF language tag of the translation target.
146    pub language_code: String,
147    /// Display name of the translation target.
148    #[serde(default)]
149    pub language_name: Option<LocalizedText>,
150}
151
152impl CaptionTrack {
153    /// Whether this track is the auto-generated (ASR) variant.
154    pub fn is_asr(&self) -> bool {
155        self.kind.as_deref() == Some("asr")
156    }
157}
158
159/// The outcome of [`select_track`].
160///
161/// Carries the chosen track, the URL to fetch (with `&tlang=` appended
162/// for translations), and the metadata that should appear on the
163/// resulting [`Transcript`](crate::transcript::Transcript).
164#[derive(Clone, Debug)]
165pub struct SelectedTrack<'a> {
166    /// Reference to the chosen track in the source response.
167    pub track: &'a CaptionTrack,
168    /// URL to fetch (base URL with `fmt=json3` appended; for translation
169    /// flows, also `&tlang=<target>`).
170    pub fetch_url: String,
171    /// Effective language code for the returned transcript. For translation
172    /// this is the target language; otherwise it is the track's own code.
173    pub language: String,
174    /// Whether the result is manual, asr-generated, or machine-translated.
175    pub kind: TrackKind,
176}
177
178/// Parse a raw `playerResponse` JSON document. Map serde failures to
179/// [`TranscriptError::ParseError`] for caller convenience.
180pub fn parse(raw: &str) -> Result<PlayerResponse> {
181    serde_json::from_str(raw)
182        .map_err(|e| TranscriptError::ParseError(format!("playerResponse: {e}")))
183}
184
185/// Surface a non-`OK` playability status as a typed error.
186pub 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
197/// Select the caption track that best matches `opts`.
198///
199/// Selection priority:
200///
201/// 1. Manual track whose `language_code` exactly equals `opts.language`.
202/// 2. Manual track whose `language_code` *starts with* `opts.language`
203///    (so `en` matches `en-US`).
204/// 3. If `opts.allow_auto`: the same two passes against ASR tracks.
205/// 4. If `opts.translate_to` is set and the target language is in the
206///    response's `translationLanguages`, append `&tlang=<target>` to the
207///    first translatable track.
208/// 5. Otherwise [`TranscriptError::LanguageNotFound`], or
209///    [`TranscriptError::AutoCaptionsRequireOptIn`] if the only matches
210///    were ASR and the caller did not pass `allow_auto`.
211pub 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, /* asr = */ false) {
220        return Ok(materialise_native(track));
221    }
222    if opts.allow_auto {
223        if let Some(track) = pick(tracks, &opts.language, /* asr = */ 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, /* asr = */ 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
311/// Build the timedtext fetch URL from a caption track's `baseUrl`.
312///
313/// YouTube embeds a default `fmt=` (typically `srv3`, the legacy XML
314/// format) in the `baseUrl` it ships in `playerResponse`. A naive
315/// `&fmt=json3` append produces a URL with two `fmt=` params; YouTube
316/// honours the *first* one and returns SRV3, which the json3 parser
317/// rejects with a 1:1 "expected value" error. Replacing rather than
318/// appending is required.
319///
320/// Same treatment for `tlang=` on the translation path.
321fn timedtext_url(base_url: &str, tlang: Option<&str>) -> String {
322    let Ok(mut url) = url::Url::parse(base_url) else {
323        // Defensive: if YouTube ever returns a non-URL baseUrl, fall
324        // back to a naive append so the caller still gets *some*
325        // reachable URL instead of a hard error here.
326        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
354/// Project a [`PlayerResponse`] to the [`LanguageInfo`] list expected by
355/// `omni-dev transcript youtube list-langs`.
356pub 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
381/// Project a [`PlayerResponse`] to the [`MediaInfo`] expected by
382/// `omni-dev transcript youtube info`.
383pub 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        // "en" is not present as a manual track; "en-US" is.
485        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        // Build a response with only an ASR track for `de`.
493        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        // The remaining `en` track is asr.
501        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        // Both an `en-US` manual (prefix-match) and `en` asr (exact) exist.
527        // Manual must win even when the asr match is "better" by exactness.
528        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"); // no native ja track
554        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()); // not in translationLanguages
567        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        // Native `es` was available, so the translation flag is ignored.
578        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        // No manual track exists at all; only an ASR `en` track. Translation
585        // must still synthesise a target track from the ASR base.
586        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        // Mark the only manual track non-translatable; selector must skip it
604        // and either pick an ASR base or yield no candidate.
605        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        // The ASR `en` track is translatable by default, so selector falls
620        // through to it.
621        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        // Synthetic — real YouTube URLs always have a query string, but
630        // the helper must still produce a valid URL if not.
631        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        // Real ANDROID_VR /player responses ship caption URLs with an
645        // embedded `fmt=srv3` (legacy XML). A naive append would leave
646        // both `fmt` params; YouTube honours the first and serves SRV3,
647        // which the json3 parser rejects. Pin the replacement contract.
648        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        // If YouTube ever returns a baseUrl that isn't parseable as a
683        // URL, the helper falls back to a naive append rather than
684        // panicking — the caller still gets a reachable string.
685        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        // Same fallback, exercised through the translation path so the
699        // `&tlang=` branch of the non-URL fallback is covered.
700        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        // `?` vs `&` separator branch in the non-URL fallback. The
715        // string contains `?` so the next param is joined with `&`.
716        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        // Track without a `name` field: `LanguageInfo.name` falls back
730        // to the language code rather than panicking. Exercises the
731        // `None` branch of `t.name.as_ref().map_or_else(...)`.
732        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        // Real-world ANDROID_VR /player responses use this shape for
771        // caption-track names; the previous struct-only deserialiser
772        // failed with `missing field simpleText` on these payloads.
773        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        // End-to-end: a player_response carrying `name: { runs: [...] }`
796        // must deserialise and project to LanguageInfo.name correctly.
797        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}