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.
//! Timed cue model shared by the two JSON providers.
//!
//! `decopy` and `noiz` both return a flat list of timed segments — one
//! with `HH:MM:SS` string bounds, the other with floating-point second
//! offsets plus a duration. Both are lossless enough to render real
//! `SubRip`, which is what makes `--format srt` reachable from them.
//!
//! # Placement
//!
//! This module lives under `provider::decopy` rather than beside the
//! other provider modules because the v0.3.5 work order confined the
//! Providers team to the three new provider directories plus a
//! declaration-only edit of `provider/mod.rs`. A follow-up that already
//! touches `provider/mod.rs` should lift the file to
//! `src/provider/cue.rs`; nothing here depends on the current path.

/// One timed subtitle segment.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Cue {
    /// Start offset from the beginning of the video, in seconds.
    pub start_secs: f64,
    /// End offset from the beginning of the video, in seconds.
    pub end_secs: f64,
    /// Cue text, already whitespace-trimmed.
    pub text: String,
}

/// Milliseconds per second, spelled out so the arithmetic below reads
/// as unit conversion rather than as a magic factor.
const MILLIS_PER_SEC: f64 = 1000.0;

/// Parse an `HH:MM:SS` (or `MM:SS`) wall-clock stamp into seconds.
///
/// Fractional seconds written as `HH:MM:SS.mmm` are honoured. Returns
/// `None` when any component is missing or non-numeric, so a malformed
/// upstream payload is skipped rather than silently timed at zero.
pub(crate) fn parse_clock(raw: &str) -> Option<f64> {
    let mut secs = 0.0_f64;
    let mut seen = 0_u32;
    for part in raw.trim().split(':') {
        let value: f64 = part.trim().parse().ok()?;
        if value < 0.0 {
            return None;
        }
        secs = secs * 60.0 + value;
        seen += 1;
    }
    // A bare number is ambiguous (seconds? minutes?) and never appears
    // in either upstream, so it is rejected along with the empty string.
    if (2..=3).contains(&seen) {
        Some(secs)
    } else {
        None
    }
}

/// Render seconds as a `SubRip` timestamp (`HH:MM:SS,mmm`).
///
/// Negative input is clamped to zero: a cue cannot start before the
/// video does, and clamping keeps the output parseable instead of
/// emitting a stamp no `SubRip` reader accepts.
pub(crate) fn srt_timestamp(secs: f64) -> String {
    let total_ms = (secs.max(0.0) * MILLIS_PER_SEC).round() as u64;
    let ms = total_ms % 1000;
    let total_secs = total_ms / 1000;
    let s = total_secs % 60;
    let m = (total_secs / 60) % 60;
    let h = total_secs / 3600;
    format!("{h:02}:{m:02}:{s:02},{ms:03}")
}

/// Render cues as a `SubRip` document.
///
/// Cues whose text is empty after trimming are dropped, and the
/// sequence numbers are assigned after that filter so the output is
/// contiguous. An end bound that is not strictly after its start is
/// nudged forward by [`min_cue_millis`], because a zero-length cue is
/// invalid `SubRip` and some players discard the whole file over one.
pub(crate) fn render_srt(cues: &[Cue]) -> String {
    let mut out = String::with_capacity(cues.len() * 64);
    let mut index = 0_usize;
    for cue in cues {
        let text = cue.text.trim();
        if text.is_empty() {
            continue;
        }
        index += 1;
        let start = cue.start_secs.max(0.0);
        let end = if cue.end_secs > start {
            cue.end_secs
        } else {
            start + min_cue_millis() as f64 / MILLIS_PER_SEC
        };
        out.push_str(&index.to_string());
        out.push('\n');
        out.push_str(&srt_timestamp(start));
        out.push_str(" --> ");
        out.push_str(&srt_timestamp(end));
        out.push('\n');
        out.push_str(text);
        out.push_str("\n\n");
    }
    out
}

/// Duration given to a cue whose upstream end bound is missing or not
/// after its start.
///
/// Compiled default behind `providers.cue.min_cue_millis`.
const DEFAULT_MIN_CUE_MILLIS: u64 = 100;

/// Shortest cue duration kept when normalising timings.
///
/// Resolves `providers.cue.min_cue_millis`. A zero would reinstate the
/// zero-length cue this bound exists to prevent, so the range starts at
/// one millisecond.
fn min_cue_millis() -> u64 {
    crate::config::tuning_u64_in_range(
        "providers.cue.min_cue_millis",
        DEFAULT_MIN_CUE_MILLIS,
        1,
        600_000,
    )
}

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

    #[test]
    fn parse_clock_reads_hours_minutes_seconds() {
        assert_eq!(parse_clock("00:01:02"), Some(62.0));
        assert_eq!(parse_clock("01:00:00"), Some(3600.0));
    }

    #[test]
    fn parse_clock_reads_minutes_seconds() {
        assert_eq!(parse_clock("02:03"), Some(123.0));
    }

    #[test]
    fn parse_clock_honours_fractional_seconds() {
        assert_eq!(parse_clock("00:00:01.5"), Some(1.5));
    }

    #[test]
    fn parse_clock_rejects_malformed_input() {
        assert_eq!(parse_clock(""), None);
        assert_eq!(parse_clock("12"), None);
        assert_eq!(parse_clock("aa:bb"), None);
        assert_eq!(parse_clock("00:00:00:00"), None);
        assert_eq!(parse_clock("-1:00"), None);
    }

    #[test]
    fn srt_timestamp_pads_every_field() {
        assert_eq!(srt_timestamp(0.0), "00:00:00,000");
        assert_eq!(srt_timestamp(3661.5), "01:01:01,500");
    }

    #[test]
    fn srt_timestamp_clamps_negative_input() {
        assert_eq!(srt_timestamp(-5.0), "00:00:00,000");
    }

    #[test]
    fn render_srt_numbers_cues_contiguously() {
        let cues = vec![
            Cue {
                start_secs: 0.0,
                end_secs: 1.0,
                text: "one".to_string(),
            },
            Cue {
                start_secs: 1.0,
                end_secs: 2.0,
                text: "   ".to_string(),
            },
            Cue {
                start_secs: 2.0,
                end_secs: 3.0,
                text: "two".to_string(),
            },
        ];
        let srt = render_srt(&cues);
        assert!(srt.starts_with("1\n00:00:00,000 --> 00:00:01,000\none\n\n"));
        assert!(
            srt.contains("2\n00:00:02,000 --> 00:00:03,000\ntwo"),
            "blank cue must not consume a sequence number: {srt}"
        );
    }

    #[test]
    fn render_srt_extends_a_zero_length_cue() {
        let cues = vec![Cue {
            start_secs: 5.0,
            end_secs: 5.0,
            text: "flash".to_string(),
        }];
        let srt = render_srt(&cues);
        assert!(
            srt.contains("00:00:05,000 --> 00:00:05,100"),
            "zero-length cue must be widened: {srt}"
        );
    }

    #[test]
    fn render_srt_of_nothing_is_empty() {
        assert!(render_srt(&[]).is_empty());
    }
}