omni_dev/transcript/sources/youtube/
timedtext.rs1use serde::Deserialize;
10
11use crate::transcript::cue::Cue;
12use crate::transcript::error::{Result, TranscriptError};
13
14#[derive(Clone, Debug, Deserialize, Default)]
16struct Json3 {
17 #[serde(default)]
18 events: Vec<Event>,
19}
20
21#[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#[derive(Clone, Debug, Deserialize, Default)]
36struct Segment {
37 #[serde(default)]
38 utf8: Option<String>,
39}
40
41pub 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
54pub 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}