use serde::Deserialize;
use crate::transcript::cue::Cue;
use crate::transcript::error::{Result, TranscriptError};
#[derive(Clone, Debug, Deserialize, Default)]
struct Json3 {
#[serde(default)]
events: Vec<Event>,
}
#[derive(Clone, Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct Event {
#[serde(default, rename = "tStartMs")]
t_start_ms: Option<u64>,
#[serde(default, rename = "dDurationMs")]
d_duration_ms: Option<u64>,
#[serde(default)]
segs: Option<Vec<Segment>>,
}
#[derive(Clone, Debug, Deserialize, Default)]
struct Segment {
#[serde(default)]
utf8: Option<String>,
}
pub async fn fetch(http: &reqwest::Client, url: &str) -> Result<String> {
let response = http.get(url).send().await?.error_for_status()?;
Ok(response.text().await?)
}
pub fn parse(raw: &str) -> Result<Vec<Cue>> {
let doc: Json3 = serde_json::from_str(raw)
.map_err(|e| TranscriptError::ParseError(format!("timedtext json3: {e}")))?;
let mut cues = Vec::with_capacity(doc.events.len());
for event in doc.events {
let Some(segs) = event.segs else {
continue;
};
let text = segs.into_iter().filter_map(|s| s.utf8).collect::<String>();
if text.is_empty() {
continue;
}
let start_ms = event.t_start_ms.unwrap_or(0);
let end_ms = start_ms.saturating_add(event.d_duration_ms.unwrap_or(0));
cues.push(Cue::new(start_ms, end_ms, text));
}
Ok(cues)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const FIXTURE_BASIC: &str = include_str!("fixtures/timedtext_basic.json");
#[test]
fn parse_basic_fixture() {
let cues = parse(FIXTURE_BASIC).unwrap();
assert_eq!(cues.len(), 3);
assert_eq!(cues[0], Cue::new(0, 1500, "Hello, world."));
assert_eq!(cues[1], Cue::new(2000, 3000, "This is a test."));
assert_eq!(cues[2], Cue::new(4000, 6000, "Final cue\nwith newline."));
}
#[test]
fn parse_empty_events_array() {
let cues = parse(r#"{"events": []}"#).unwrap();
assert!(cues.is_empty());
}
#[test]
fn parse_missing_events_key_is_empty() {
let cues = parse(r"{}").unwrap();
assert!(cues.is_empty());
}
#[test]
fn parse_skips_event_without_segs() {
let raw = r#"{
"events": [
{ "tStartMs": 0, "dDurationMs": 1000 },
{ "tStartMs": 1000, "dDurationMs": 1000, "segs": [{"utf8": "kept"}] }
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues.len(), 1);
assert_eq!(cues[0].text, "kept");
}
#[test]
fn parse_skips_event_with_empty_text() {
let raw = r#"{
"events": [
{ "tStartMs": 0, "dDurationMs": 1000, "segs": [{}] },
{ "tStartMs": 1000, "dDurationMs": 1000, "segs": [{"utf8": ""}] },
{ "tStartMs": 2000, "dDurationMs": 1000, "segs": [{"utf8": "kept"}] }
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues.len(), 1);
assert_eq!(cues[0].text, "kept");
}
#[test]
fn parse_concatenates_multiple_segs() {
let raw = r#"{
"events": [
{
"tStartMs": 0,
"dDurationMs": 500,
"segs": [
{"utf8": "a "},
{"utf8": "b "},
{"utf8": "c"}
]
}
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues, vec![Cue::new(0, 500, "a b c")]);
}
#[test]
fn parse_uses_zero_when_start_missing() {
let raw = r#"{
"events": [
{ "dDurationMs": 1000, "segs": [{"utf8": "x"}] }
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues, vec![Cue::new(0, 1000, "x")]);
}
#[test]
fn parse_uses_zero_when_duration_missing() {
let raw = r#"{
"events": [
{ "tStartMs": 1500, "segs": [{"utf8": "instant"}] }
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues, vec![Cue::new(1500, 1500, "instant")]);
}
#[test]
fn parse_invalid_json_errors() {
let err = parse("{ not json").unwrap_err();
assert!(matches!(err, TranscriptError::ParseError(_)));
assert!(err.to_string().contains("timedtext json3"));
}
#[test]
fn parse_ignores_unknown_event_fields() {
let raw = r#"{
"events": [
{
"tStartMs": 0,
"dDurationMs": 100,
"wWinId": 1,
"wpWinPosId": 2,
"segs": [{"utf8": "x", "tOffsetMs": 0, "acAsrConf": 256}]
}
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues, vec![Cue::new(0, 100, "x")]);
}
#[test]
fn parse_preserves_event_order() {
let raw = r#"{
"events": [
{ "tStartMs": 0, "dDurationMs": 100, "segs": [{"utf8": "first"}] },
{ "tStartMs": 200, "dDurationMs": 100, "segs": [{"utf8": "second"}] },
{ "tStartMs": 1000, "dDurationMs": 100, "segs": [{"utf8": "third"}] }
]
}"#;
let cues = parse(raw).unwrap();
let texts: Vec<_> = cues.iter().map(|c| c.text.as_str()).collect();
assert_eq!(texts, vec!["first", "second", "third"]);
}
#[test]
fn parse_handles_unicode_text() {
let raw = r#"{
"events": [
{ "tStartMs": 0, "dDurationMs": 100, "segs": [{"utf8": "こんにちは "}, {"utf8": "🌍"}] }
]
}"#;
let cues = parse(raw).unwrap();
assert_eq!(cues, vec![Cue::new(0, 100, "こんにちは 🌍")]);
}
#[tokio::test]
async fn fetch_returns_body_for_2xx() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/timedtext"))
.and(query_param("fmt", "json3"))
.respond_with(ResponseTemplate::new(200).set_body_string(FIXTURE_BASIC))
.expect(1)
.mount(&server)
.await;
let http = reqwest::Client::builder().build().unwrap();
let url = format!("{}/api/timedtext?lang=en&fmt=json3", server.uri());
let body = fetch(&http, &url).await.unwrap();
assert_eq!(body, FIXTURE_BASIC);
}
#[tokio::test]
async fn fetch_surfaces_non_2xx_as_http_error() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/timedtext"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let http = reqwest::Client::builder().build().unwrap();
let url = format!("{}/api/timedtext?lang=en&fmt=json3", server.uri());
let err = fetch(&http, &url).await.unwrap_err();
assert!(matches!(err, TranscriptError::Http(_)));
}
#[test]
fn parse_saturates_when_duration_overflows() {
let raw = format!(
r#"{{ "events": [ {{ "tStartMs": {start}, "dDurationMs": {dur}, "segs": [{{"utf8":"x"}}] }} ] }}"#,
start = u64::MAX - 100,
dur = 1000,
);
let cues = parse(&raw).unwrap();
assert_eq!(cues.len(), 1);
assert_eq!(cues[0].end_ms, u64::MAX);
}
}