Skip to main content

omni_dev/transcript/sources/youtube/
timedtext.rs

1//! Parser for YouTube's `json3` (a.k.a. `srv3`) timedtext format.
2//!
3//! The endpoint at `https://www.youtube.com/api/timedtext?...&fmt=json3`
4//! returns a document with an `events` array. Each event has a start time,
5//! duration, and a list of `seg` entries whose `utf8` payloads are
6//! concatenated to form the cue text. Events without `segs` are styling /
7//! window markers and are skipped here.
8
9use serde::Deserialize;
10
11use crate::transcript::cue::Cue;
12use crate::transcript::error::{Result, TranscriptError};
13
14/// Top-level json3 document.
15#[derive(Clone, Debug, Deserialize, Default)]
16struct Json3 {
17    #[serde(default)]
18    events: Vec<Event>,
19}
20
21/// A single event entry. Most fields are optional because YouTube emits
22/// styling-only events that have no timing or text.
23#[derive(Clone, Debug, Deserialize, Default)]
24#[serde(rename_all = "camelCase")]
25struct Event {
26    #[serde(default, rename = "tStartMs")]
27    t_start_ms: Option<u64>,
28    #[serde(default, rename = "dDurationMs")]
29    d_duration_ms: Option<u64>,
30    #[serde(default)]
31    segs: Option<Vec<Segment>>,
32}
33
34/// A single text segment within an event.
35#[derive(Clone, Debug, Deserialize, Default)]
36struct Segment {
37    #[serde(default)]
38    utf8: Option<String>,
39}
40
41/// GET a fully-prepared timedtext URL and return the response body.
42///
43/// `url` is consumed as-is. Callers normally obtain it from
44/// [`super::player_response::SelectedTrack::fetch_url`], which already
45/// carries the signed signature, `fmt=json3`, and any `tlang=` parameter.
46pub async fn fetch(http: &reqwest::Client, url: &str) -> Result<String> {
47    let started = std::time::Instant::now();
48    let result = http.get(url).send().await;
49    super::record_yt_http("GET", url, started, &result);
50    let response = result?.error_for_status()?;
51    Ok(response.text().await?)
52}
53
54/// Parse a json3 timedtext document into a list of cues, dropping events
55/// that carry no text (styling / window markers).
56pub fn parse(raw: &str) -> Result<Vec<Cue>> {
57    let doc: Json3 = serde_json::from_str(raw)
58        .map_err(|e| TranscriptError::ParseError(format!("timedtext json3: {e}")))?;
59
60    let mut cues = Vec::with_capacity(doc.events.len());
61    for event in doc.events {
62        let Some(segs) = event.segs else {
63            continue;
64        };
65        let text = segs.into_iter().filter_map(|s| s.utf8).collect::<String>();
66        if text.is_empty() {
67            continue;
68        }
69        let start_ms = event.t_start_ms.unwrap_or(0);
70        let end_ms = start_ms.saturating_add(event.d_duration_ms.unwrap_or(0));
71        cues.push(Cue::new(start_ms, end_ms, text));
72    }
73    Ok(cues)
74}
75
76#[cfg(test)]
77#[allow(clippy::unwrap_used, clippy::expect_used)]
78mod tests {
79    use super::*;
80
81    const FIXTURE_BASIC: &str = include_str!("fixtures/timedtext_basic.json");
82
83    #[test]
84    fn parse_basic_fixture() {
85        let cues = parse(FIXTURE_BASIC).unwrap();
86        assert_eq!(cues.len(), 3);
87        assert_eq!(cues[0], Cue::new(0, 1500, "Hello, world."));
88        assert_eq!(cues[1], Cue::new(2000, 3000, "This is a test."));
89        assert_eq!(cues[2], Cue::new(4000, 6000, "Final cue\nwith newline."));
90    }
91
92    #[test]
93    fn parse_empty_events_array() {
94        let cues = parse(r#"{"events": []}"#).unwrap();
95        assert!(cues.is_empty());
96    }
97
98    #[test]
99    fn parse_missing_events_key_is_empty() {
100        let cues = parse(r"{}").unwrap();
101        assert!(cues.is_empty());
102    }
103
104    #[test]
105    fn parse_skips_event_without_segs() {
106        let raw = r#"{
107            "events": [
108                { "tStartMs": 0, "dDurationMs": 1000 },
109                { "tStartMs": 1000, "dDurationMs": 1000, "segs": [{"utf8": "kept"}] }
110            ]
111        }"#;
112        let cues = parse(raw).unwrap();
113        assert_eq!(cues.len(), 1);
114        assert_eq!(cues[0].text, "kept");
115    }
116
117    #[test]
118    fn parse_skips_event_with_empty_text() {
119        let raw = r#"{
120            "events": [
121                { "tStartMs": 0, "dDurationMs": 1000, "segs": [{}] },
122                { "tStartMs": 1000, "dDurationMs": 1000, "segs": [{"utf8": ""}] },
123                { "tStartMs": 2000, "dDurationMs": 1000, "segs": [{"utf8": "kept"}] }
124            ]
125        }"#;
126        let cues = parse(raw).unwrap();
127        assert_eq!(cues.len(), 1);
128        assert_eq!(cues[0].text, "kept");
129    }
130
131    #[test]
132    fn parse_concatenates_multiple_segs() {
133        let raw = r#"{
134            "events": [
135                {
136                    "tStartMs": 0,
137                    "dDurationMs": 500,
138                    "segs": [
139                        {"utf8": "a "},
140                        {"utf8": "b "},
141                        {"utf8": "c"}
142                    ]
143                }
144            ]
145        }"#;
146        let cues = parse(raw).unwrap();
147        assert_eq!(cues, vec![Cue::new(0, 500, "a b c")]);
148    }
149
150    #[test]
151    fn parse_uses_zero_when_start_missing() {
152        let raw = r#"{
153            "events": [
154                { "dDurationMs": 1000, "segs": [{"utf8": "x"}] }
155            ]
156        }"#;
157        let cues = parse(raw).unwrap();
158        assert_eq!(cues, vec![Cue::new(0, 1000, "x")]);
159    }
160
161    #[test]
162    fn parse_uses_zero_when_duration_missing() {
163        let raw = r#"{
164            "events": [
165                { "tStartMs": 1500, "segs": [{"utf8": "instant"}] }
166            ]
167        }"#;
168        let cues = parse(raw).unwrap();
169        assert_eq!(cues, vec![Cue::new(1500, 1500, "instant")]);
170    }
171
172    #[test]
173    fn parse_invalid_json_errors() {
174        let err = parse("{ not json").unwrap_err();
175        assert!(matches!(err, TranscriptError::ParseError(_)));
176        assert!(err.to_string().contains("timedtext json3"));
177    }
178
179    #[test]
180    fn parse_ignores_unknown_event_fields() {
181        let raw = r#"{
182            "events": [
183                {
184                    "tStartMs": 0,
185                    "dDurationMs": 100,
186                    "wWinId": 1,
187                    "wpWinPosId": 2,
188                    "segs": [{"utf8": "x", "tOffsetMs": 0, "acAsrConf": 256}]
189                }
190            ]
191        }"#;
192        let cues = parse(raw).unwrap();
193        assert_eq!(cues, vec![Cue::new(0, 100, "x")]);
194    }
195
196    #[test]
197    fn parse_preserves_event_order() {
198        let raw = r#"{
199            "events": [
200                { "tStartMs": 0,    "dDurationMs": 100, "segs": [{"utf8": "first"}] },
201                { "tStartMs": 200,  "dDurationMs": 100, "segs": [{"utf8": "second"}] },
202                { "tStartMs": 1000, "dDurationMs": 100, "segs": [{"utf8": "third"}] }
203            ]
204        }"#;
205        let cues = parse(raw).unwrap();
206        let texts: Vec<_> = cues.iter().map(|c| c.text.as_str()).collect();
207        assert_eq!(texts, vec!["first", "second", "third"]);
208    }
209
210    #[test]
211    fn parse_handles_unicode_text() {
212        let raw = r#"{
213            "events": [
214                { "tStartMs": 0, "dDurationMs": 100, "segs": [{"utf8": "こんにちは "}, {"utf8": "🌍"}] }
215            ]
216        }"#;
217        let cues = parse(raw).unwrap();
218        assert_eq!(cues, vec![Cue::new(0, 100, "こんにちは 🌍")]);
219    }
220
221    #[tokio::test]
222    async fn fetch_returns_body_for_2xx() {
223        use wiremock::matchers::{method, path, query_param};
224        use wiremock::{Mock, MockServer, ResponseTemplate};
225
226        let server = MockServer::start().await;
227        Mock::given(method("GET"))
228            .and(path("/api/timedtext"))
229            .and(query_param("fmt", "json3"))
230            .respond_with(ResponseTemplate::new(200).set_body_string(FIXTURE_BASIC))
231            .expect(1)
232            .mount(&server)
233            .await;
234
235        let http = reqwest::Client::builder().build().unwrap();
236        let url = format!("{}/api/timedtext?lang=en&fmt=json3", server.uri());
237        let body = fetch(&http, &url).await.unwrap();
238        assert_eq!(body, FIXTURE_BASIC);
239    }
240
241    #[tokio::test]
242    async fn fetch_surfaces_non_2xx_as_http_error() {
243        use wiremock::matchers::{method, path};
244        use wiremock::{Mock, MockServer, ResponseTemplate};
245
246        let server = MockServer::start().await;
247        Mock::given(method("GET"))
248            .and(path("/api/timedtext"))
249            .respond_with(ResponseTemplate::new(404))
250            .mount(&server)
251            .await;
252
253        let http = reqwest::Client::builder().build().unwrap();
254        let url = format!("{}/api/timedtext?lang=en&fmt=json3", server.uri());
255        let err = fetch(&http, &url).await.unwrap_err();
256        assert!(matches!(err, TranscriptError::Http(_)));
257    }
258
259    #[test]
260    fn parse_saturates_when_duration_overflows() {
261        let raw = format!(
262            r#"{{ "events": [ {{ "tStartMs": {start}, "dDurationMs": {dur}, "segs": [{{"utf8":"x"}}] }} ] }}"#,
263            start = u64::MAX - 100,
264            dur = 1000,
265        );
266        let cues = parse(&raw).unwrap();
267        assert_eq!(cues.len(), 1);
268        assert_eq!(cues[0].end_ms, u64::MAX);
269    }
270}