youtube-legend-cli 0.4.0

Non-interactive Rust CLI that downloads YouTube subtitles through third-party providers, using a native Unix stdin/stdout interface.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Srv3 (XML) and JSON3 subtitle formats -> `SubRip` (SRT) conversion.
//!
//! `YouTube` serves timed-text in one of three formats depending on
//! the `fmt` query parameter:
//!
//! - `fmt=json3` — nested JSON, the most common. Each event carries
//!   `tStartMs` (ms) and `dDurationMs` (ms), with the rendered text
//!   spread across one or more `segs[].utf8` chunks.
//! - `fmt=srv3` — flat XML where each `<text start="..." dur="...">`
//!   element holds the cue body. Newlines inside a cue are literal
//!   (the source carries a raw `\n`).
//! - `fmt=srv1` — binary protobuf. **Not implemented in M2**; we
//!   return `AppError::Internal` so the caller can fall back to
//!   `json3` or `srv3` before giving up.
//!
//! Both Srv3 and JSON3 inputs are converted to the `SubRip` wire
//! format the rest of this crate already understands:
//!
//! ```text
//! 1
//! 00:00:01,000 --> 00:00:03,500
//! First cue
//!
//! 2
//! 00:00:04,000 --> 00:00:06,000
//! Second cue
//! ```
//!
//! Index numbers are 1-based. Timestamps use the
//! `HH:MM:SS,mmm` convention with a literal comma between seconds
//! and milliseconds (`SubRip`, not `WebVTT`). Cue text is
//! line-split on raw newlines and the literal ` -->` sequence
//! (which the SRT parser would otherwise mistake for a new
//! timestamp) is escaped to a zero-width-space-prefixed form so
//! `srt_to_text` round-trips cleanly.

use std::fmt::Write as _;
use std::sync::OnceLock;

use regex::Regex;

use crate::error::{AppError, AppResult};

/// Cap on a single timed-text body. Anything above this is rejected
/// before any parse attempt. Matches the 5 MiB figure in the v0.3.0
/// plan (gaps.md GAP-001 / T2).
///
/// Compiled default behind `net.max_body_bytes`.
pub const DEFAULT_MAX_BODY_BYTES: usize = 5 * 1024 * 1024;

/// Cap on a single timed-text body, as the operator configured it.
///
/// Resolves `net.max_body_bytes`.
fn max_body_bytes() -> usize {
    crate::config::tuning_usize_in_range(
        "net.max_body_bytes",
        DEFAULT_MAX_BODY_BYTES,
        1_024,
        1_073_741_824,
    )
}

/// Regex that locates every `<text start="…" dur="…">…</text>` cue
/// in an Srv3 XML body. Compiled once per process via [`OnceLock`].
///
/// Notes on the pattern:
/// - `(?s)` enables dot-matches-newline so `.*?` can span a cue
///   body that contains literal `\n` characters.
/// - `start` and `dur` are required and parsed as floats; the
///   `YouTube` timed-text service emits seconds with a decimal
///   point (e.g. `start="2.5"`).
/// - The body of the cue is captured into group 1.
fn srv3_text_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r#"(?s)<text\s+start="([0-9.]+)"\s+dur="([0-9.]+)"[^>]*>(.*?)</text>"#)
            .expect("static srv3 text regex is valid")
    })
}

/// Convert a `fmt=srv3` XML body into a `SubRip` (SRT) string.
///
/// # Errors
///
/// - [`AppError::InvalidInput`] when the body is empty or contains
///   no `<text>` cues.
/// - [`AppError::SubtitleTooLarge`] when the body exceeds
///   `net.max_body_bytes` (default [`DEFAULT_MAX_BODY_BYTES`]).
#[tracing::instrument(level = "debug", err, skip(xml), fields(len_bytes = xml.len()))]
pub fn srv3_to_srt(xml: &str) -> AppResult<String> {
    let body = xml.trim();
    if body.is_empty() {
        // GAP-E2E-032: the body comes from the YouTube timedtext
        // endpoint, not from the operator. The previous classification
        // as InvalidInput (exit 64 EX_USAGE) was semantically wrong —
        // the operator did not pass a bad value, the upstream returned
        // an empty body. Reclassify as TimedtextUpstreamError (exit 76
        // EX_SOFTWARE) so the category matches the cause.
        return Err(AppError::TimedtextUpstreamError(
            "empty srv3 body".to_string(),
        ));
    }
    if body.len() > max_body_bytes() {
        return Err(AppError::SubtitleTooLarge(body.len()));
    }

    // The SubRip framing (index line + timestamp line) roughly offsets
    // the XML markup it replaces, so the source length is a cheap and
    // close upper-bound estimate for the output buffer. One reservation
    // here removes the repeated doubling of a multi-megabyte `String`.
    let mut out = String::with_capacity(body.len());
    let mut index: usize = 0;
    for cap in srv3_text_re().captures_iter(body) {
        let start = cap.get(1).map_or("", |m| m.as_str());
        let dur = cap.get(2).map_or("", |m| m.as_str());
        let text = cap.get(3).map_or("", |m| m.as_str());

        // GAP-E2E-032: parse failures of start/dur are upstream
        // issues (the YouTube payload was malformed), not operator
        // input errors. Reclassify both as TimedtextUpstreamError
        // (exit 76).
        let start_secs: f64 = start.parse().map_err(|_| {
            AppError::TimedtextUpstreamError(format!("srv3 start={start:?} not a float"))
        })?;
        let dur_secs: f64 = dur.parse().map_err(|_| {
            AppError::TimedtextUpstreamError(format!("srv3 dur={dur:?} not a float"))
        })?;
        let end_secs = start_secs + dur_secs;

        index += 1;
        // Writing straight into `out` avoids four throwaway
        // allocations per cue (two `format!` strings and two
        // timestamp `String`s). Formatting into a `String` is
        // infallible, so the `Result` carries no information.
        let _ = writeln!(out, "{index}");
        write_timestamp(&mut out, start_secs);
        out.push_str(" --> ");
        write_timestamp(&mut out, end_secs);
        out.push('\n');
        out.push_str(&sanitize_cue_text(text));
        out.push('\n');
    }

    if index == 0 {
        // GAP-E2E-032: an Srv3 body without any <text> cues is an
        // upstream structural problem, not operator input. Same
        // reclassification as the empty-body case above.
        return Err(AppError::TimedtextUpstreamError(
            "srv3 body has no <text> cues".to_string(),
        ));
    }

    Ok(out)
}

/// Convert a `fmt=json3` JSON body into a `SubRip` (SRT) string.
///
/// The JSON is parsed with `serde_json::from_str` using a narrow
/// `serde_json::Value` projection — only the `events[*].tStartMs`,
/// `dDurationMs`, and `segs[*].utf8` fields are read; everything
/// else (timingRef, voice, formatting markers, etc.) is ignored.
///
/// # Errors
///
/// - [`AppError::InvalidInput`] when the body is empty, not JSON,
///   or contains no usable events.
/// - [`AppError::SubtitleTooLarge`] when the body exceeds
///   `net.max_body_bytes` (default [`DEFAULT_MAX_BODY_BYTES`]).
/// - [`AppError::Serde`] when the JSON is structurally invalid.
#[tracing::instrument(level = "debug", err, skip(json), fields(len_bytes = json.len()))]
pub fn json3_to_srt(json: &str) -> AppResult<String> {
    let body = json.trim();
    if body.is_empty() {
        // GAP-E2E-032: same reclassification as srv3_to_srt — the
        // body is upstream-originated, not operator input.
        return Err(AppError::TimedtextUpstreamError(
            "empty json3 body".to_string(),
        ));
    }
    if body.len() > max_body_bytes() {
        return Err(AppError::SubtitleTooLarge(body.len()));
    }

    let value: serde_json::Value = serde_json::from_str(body).map_err(AppError::Serde)?;
    let events = value
        .get("events")
        .and_then(serde_json::Value::as_array)
        .ok_or_else(|| {
            // GAP-E2E-032: missing events[] array is an upstream
            // structural problem, not operator input.
            AppError::TimedtextUpstreamError("json3 body has no events[] array".to_string())
        })?;

    // JSON3 is markup-heavy, so the source length is a generous upper
    // bound for the SRT it produces; one reservation replaces the
    // repeated doubling of the output buffer.
    let mut out = String::with_capacity(body.len());
    let mut index: usize = 0;
    for event in events {
        let t_start_ms = event.get("tStartMs").and_then(serde_json::Value::as_i64);
        let d_dur_ms = event
            .get("dDurationMs")
            .and_then(serde_json::Value::as_i64)
            .unwrap_or(0);
        let (Some(start_ms), dur_ms) = (t_start_ms, d_dur_ms) else {
            continue;
        };

        let segs = match event.get("segs").and_then(serde_json::Value::as_array) {
            Some(segs) => segs,
            None => continue,
        };

        let mut text = String::new();
        let mut first = true;
        for seg in segs {
            if let Some(utf8) = seg.get("utf8").and_then(serde_json::Value::as_str) {
                if !first && !utf8.is_empty() {
                    text.push('\n');
                }
                text.push_str(utf8);
                first = false;
            }
        }
        if text.is_empty() {
            continue;
        }

        let start_secs = start_ms as f64 / 1000.0;
        let end_secs = (start_ms + dur_ms) as f64 / 1000.0;

        index += 1;
        // Same rationale as `srv3_to_srt`: format straight into the
        // output buffer instead of building four throwaway strings.
        let _ = writeln!(out, "{index}");
        write_timestamp(&mut out, start_secs);
        out.push_str(" --> ");
        write_timestamp(&mut out, end_secs);
        out.push('\n');
        out.push_str(&sanitize_cue_text(&text));
        out.push('\n');
    }

    if index == 0 {
        // GAP-E2E-032: zero usable events is an upstream
        // structural problem, not operator input.
        return Err(AppError::TimedtextUpstreamError(
            "json3 body has no usable events".to_string(),
        ));
    }

    Ok(out)
}

/// Append a `seconds` value to `out` as `HH:MM:SS,mmm` per the
/// `SubRip` convention. Negative values and `NaN` clamp to
/// `00:00:00,000` so the function never panics on hostile input.
///
/// Writes in place instead of returning a `String`: the caller emits
/// two timestamps per cue and a one-hour transcript carries thousands
/// of cues.
fn write_timestamp(out: &mut String, seconds: f64) {
    if !seconds.is_finite() || seconds < 0.0 {
        out.push_str("00:00:00,000");
        return;
    }
    let total_ms = (seconds * 1000.0).round() as u64;
    let hours = total_ms / 3_600_000;
    let minutes = (total_ms / 60_000) % 60;
    let secs = (total_ms / 1000) % 60;
    let ms = total_ms % 1000;
    // Formatting into a `String` is infallible.
    let _ = write!(out, "{hours:02}:{minutes:02}:{secs:02},{ms:03}");
}

/// Replace the literal ` -->` sequence (which the SRT parser
/// interprets as a new cue timestamp) with a zero-width-space-
/// prefixed form. The character `\u{200B}` is invisible in every
/// SRT renderer we know of, so the cue reads identically while
/// staying parse-safe.
fn sanitize_cue_text(text: &str) -> String {
    let stripped = text.replace("\r\n", "\n").replace('\r', "\n");
    stripped.replace(" -->", "\u{200B}-->")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_minimal_srv3() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<transcript>
  <text start="0.0" dur="2.5">Hello world</text>
  <text start="2.5" dur="3.0">Second cue</text>
</transcript>"#;
        let srt = srv3_to_srt(xml).expect("srv3 parses");
        assert!(srt.contains("1\n00:00:00,000 --> 00:00:02,500\nHello world\n"));
        assert!(srt.contains("2\n00:00:02,500 --> 00:00:05,500\nSecond cue\n"));
    }

    // The SRT framing is now written straight into the output buffer;
    // this pins the exact byte layout of a single cue.
    #[test]
    fn single_cue_has_exact_srt_layout() {
        let xml = r#"<transcript><text start="1.5" dur="2.25">Only cue</text></transcript>"#;
        let srt = srv3_to_srt(xml).expect("srv3 parses");
        assert_eq!(srt, "1\n00:00:01,500 --> 00:00:03,750\nOnly cue\n");
    }

    #[test]
    fn json3_single_cue_has_exact_srt_layout() {
        let json =
            r#"{"events":[{"tStartMs":1500,"dDurationMs":2250,"segs":[{"utf8":"Only cue"}]}]}"#;
        let srt = json3_to_srt(json).expect("json3 parses");
        assert_eq!(srt, "1\n00:00:01,500 --> 00:00:03,750\nOnly cue\n");
    }

    #[test]
    fn write_timestamp_clamps_hostile_values() {
        let mut out = String::new();
        write_timestamp(&mut out, -1.0);
        write_timestamp(&mut out, f64::NAN);
        write_timestamp(&mut out, f64::INFINITY);
        assert_eq!(out, "00:00:00,00000:00:00,00000:00:00,000");
    }

    #[test]
    fn write_timestamp_formats_hours_minutes_and_millis() {
        let mut out = String::new();
        write_timestamp(&mut out, 3661.007);
        assert_eq!(out, "01:01:01,007");
    }

    #[test]
    fn parses_multiline_cue() {
        let xml = r#"<?xml version="1.0"?>
<transcript>
  <text start="0.0" dur="4.0">Line 1
Line 2
Line 3</text>
</transcript>"#;
        let srt = srv3_to_srt(xml).expect("multiline parses");
        assert!(srt.contains("Line 1\nLine 2\nLine 3"));
        assert!(srt.contains("00:00:00,000 --> 00:00:04,000"));
    }

    #[test]
    fn parses_unicode_cue() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<transcript>
  <text start="0.0" dur="2.0">café 日本</text>
</transcript>"#;
        let srt = srv3_to_srt(xml).expect("unicode parses");
        assert!(srt.contains("café 日本"));
    }

    #[test]
    fn rejects_empty_body() {
        // GAP-E2E-032: empty body comes from upstream, not the
        // operator — reclassified to TimedtextUpstreamError.
        let err = srv3_to_srt("").unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
    }

    // GAP-E2E-032: parse failures of start/dur are upstream-originated
    // (the YouTube payload was malformed). The previous code
    // returned InvalidInput (exit 64); the new code returns
    // TimedtextUpstreamError (exit 76) so the exit code matches the
    // cause.
    #[test]
    fn srv3_invalid_start_returns_upstream_error() {
        let xml = r#"<?xml version="1.0"?>
<transcript>
  <text start="abc" dur="2.0">Hello</text>
</transcript>"#;
        let err = srv3_to_srt(xml).unwrap_err();
        assert!(
            matches!(err, AppError::TimedtextUpstreamError(_)),
            "expected TimedtextUpstreamError, got {err:?}"
        );
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn srv3_invalid_dur_returns_upstream_error() {
        let xml = r#"<?xml version="1.0"?>
<transcript>
  <text start="0.0" dur="xyz">Hello</text>
</transcript>"#;
        let err = srv3_to_srt(xml).unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn srv3_empty_body_returns_upstream_error() {
        let err = srv3_to_srt("").unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn srv3_no_text_cues_returns_upstream_error() {
        // Srv3 body with valid XML but no <text> cues — the upstream
        // returned structurally empty content.
        let xml = r#"<?xml version="1.0"?>
<transcript>
  <note>no cues here</note>
</transcript>"#;
        let err = srv3_to_srt(xml).unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn json3_empty_body_returns_upstream_error() {
        let err = json3_to_srt("").unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn json3_no_events_array_returns_upstream_error() {
        // Valid JSON but no `events` array — the upstream payload
        // is missing the expected field.
        let json = r#"{"other": "data"}"#;
        let err = json3_to_srt(json).unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn json3_no_usable_events_returns_upstream_error() {
        // Valid JSON with events[] but every event lacks segs or
        // text. Upstream produced no usable cues.
        let json = r#"{
            "events": [
                {"tStartMs": 0, "dDurationMs": 1000}
            ]
        }"#;
        let err = json3_to_srt(json).unwrap_err();
        assert!(matches!(err, AppError::TimedtextUpstreamError(_)));
        assert_eq!(err.exit_code(), 76);
    }

    #[test]
    fn parses_json3_format() {
        let json = r#"{
            "events": [
                {"tStartMs": 0, "dDurationMs": 2500, "segs": [{"utf8": "Hello world"}]},
                {"tStartMs": 2500, "dDurationMs": 3000, "segs": [{"utf8": "Line 1"}, {"utf8": "Line 2"}]}
            ]
        }"#;
        let srt = json3_to_srt(json).expect("json3 parses");
        assert!(srt.contains("1\n00:00:00,000 --> 00:00:02,500\nHello world\n"));
        assert!(srt.contains("2\n00:00:02,500 --> 00:00:05,500\nLine 1\nLine 2\n"));
    }
}