Skip to main content

omni_dev/cli/transcript/
fetch.rs

1//! `omni-dev transcript fetch` — auto-detect the source from the locator and
2//! download a transcript, rendering it in the requested format.
3//!
4//! Unlike the per-source `transcript <source> fetch` subcommands, this probes
5//! every registered source's `matches` and dispatches to the one that
6//! recognises the URL (#1187). The flags mirror the per-source `fetch`.
7
8use std::fs;
9
10use anyhow::{Context, Result};
11use clap::Parser;
12
13use crate::cli::transcript::format::CliFormat;
14use crate::transcript::detect::detect;
15use crate::transcript::format::Format;
16use crate::transcript::source::{FetchOpts, Transcript};
17
18/// Fetches a transcript, auto-detecting the source from the locator.
19#[derive(Parser)]
20pub struct FetchCommand {
21    /// Media URL or source-specific locator (e.g. a bare YouTube video ID).
22    /// The source is auto-detected from its shape.
23    pub url: String,
24
25    /// Preferred caption language (e.g. `en`, `en-US`). Prefix fallback is
26    /// applied — `en` matches `en-US`.
27    #[arg(long, default_value = "en")]
28    pub lang: String,
29
30    /// Output format.
31    #[arg(long, value_enum, default_value_t = CliFormat::Srt)]
32    pub format: CliFormat,
33
34    /// Allow falling through to auto-generated (ASR) captions when no manual
35    /// track matches.
36    #[arg(long)]
37    pub auto: bool,
38
39    /// Synthesise a translated track in this target language when no native
40    /// track matches.
41    #[arg(long, value_name = "LANG")]
42    pub translate: Option<String>,
43
44    /// Output file (writes to stdout if omitted).
45    #[arg(short, long)]
46    pub output: Option<String>,
47}
48
49impl FetchCommand {
50    /// Detects the source from the locator, then fetches, renders, and writes.
51    pub async fn execute(self) -> Result<()> {
52        let source = detect(&self.url)?;
53        let transcript = source.fetch(&self.url, &self.fetch_opts()).await?;
54        render_and_write(&transcript, self.format, self.output.as_deref())
55    }
56
57    /// Map the parsed CLI flags onto the library's [`FetchOpts`].
58    fn fetch_opts(&self) -> FetchOpts {
59        FetchOpts {
60            language: self.lang.clone(),
61            allow_auto: self.auto,
62            translate_to: self.translate.clone(),
63        }
64    }
65}
66
67/// Render `transcript` in `format` and write it to stdout or `output`.
68///
69/// Split from [`FetchCommand::execute`] so the render + write wiring is
70/// unit-testable without the network-bound fetch that precedes it.
71fn render_and_write(
72    transcript: &Transcript,
73    format: CliFormat,
74    output: Option<&str>,
75) -> Result<()> {
76    let rendered = Format::from(format).render(transcript)?;
77    write_output(&rendered, output)
78}
79
80fn write_output(text: &str, file: Option<&str>) -> Result<()> {
81    if let Some(path) = file {
82        fs::write(path, text).with_context(|| format!("Failed to write to {path}"))
83    } else {
84        print!("{text}");
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used, clippy::expect_used)]
91mod tests {
92    use super::*;
93    use clap::{CommandFactory, FromArgMatches};
94
95    /// Build a parser for `FetchCommand` rooted at `fetch` so we can drive
96    /// it with realistic argv vectors.
97    fn parse(args: &[&str]) -> FetchCommand {
98        let cmd = FetchCommand::command().no_binary_name(true);
99        let matches = cmd.try_get_matches_from(args).unwrap();
100        FetchCommand::from_arg_matches(&matches).unwrap()
101    }
102
103    #[test]
104    fn fetch_command_defaults() {
105        let cmd = parse(&["https://youtu.be/dQw4w9WgXcQ"]);
106        assert_eq!(cmd.url, "https://youtu.be/dQw4w9WgXcQ");
107        assert_eq!(cmd.lang, "en");
108        assert_eq!(cmd.format, CliFormat::Srt);
109        assert!(!cmd.auto);
110        assert_eq!(cmd.translate, None);
111        assert_eq!(cmd.output, None);
112    }
113
114    #[test]
115    fn fetch_command_all_flags() {
116        let cmd = parse(&[
117            "abc",
118            "--lang",
119            "fr",
120            "--format",
121            "vtt",
122            "--auto",
123            "--translate",
124            "en",
125            "--output",
126            "out.vtt",
127        ]);
128        assert_eq!(cmd.url, "abc");
129        assert_eq!(cmd.lang, "fr");
130        assert_eq!(cmd.format, CliFormat::Vtt);
131        assert!(cmd.auto);
132        assert_eq!(cmd.translate.as_deref(), Some("en"));
133        assert_eq!(cmd.output.as_deref(), Some("out.vtt"));
134    }
135
136    #[test]
137    fn fetch_command_short_output_flag() {
138        let cmd = parse(&["abc", "-o", "out.srt"]);
139        assert_eq!(cmd.output.as_deref(), Some("out.srt"));
140    }
141
142    #[test]
143    fn write_output_to_file_writes_bytes() {
144        let dir = tempfile::tempdir().unwrap();
145        let path = dir.path().join("out.txt");
146        write_output("hello\n", Some(path.to_str().unwrap())).unwrap();
147        let read = std::fs::read_to_string(&path).unwrap();
148        assert_eq!(read, "hello\n");
149    }
150
151    #[test]
152    fn write_output_to_stdout_returns_ok() {
153        // Cannot easily capture stdout here; just exercise the branch.
154        write_output("noop", None).unwrap();
155    }
156
157    #[test]
158    fn write_output_invalid_path_errors() {
159        let err = write_output("x", Some("/nonexistent_dir_for_test/out.txt")).unwrap_err();
160        assert!(err.to_string().contains("Failed to write"));
161    }
162
163    use crate::transcript::cue::Cue;
164    use crate::transcript::source::{TrackKind, Transcript};
165
166    #[test]
167    fn fetch_opts_maps_flags() {
168        let cmd = parse(&["abc", "--lang", "fr", "--auto", "--translate", "en"]);
169        let opts = cmd.fetch_opts();
170        assert_eq!(opts.language, "fr");
171        assert!(opts.allow_auto);
172        assert_eq!(opts.translate_to.as_deref(), Some("en"));
173    }
174
175    #[test]
176    fn render_and_write_renders_selected_format_to_file() {
177        let dir = tempfile::tempdir().unwrap();
178        let path = dir.path().join("out.txt");
179        let transcript = Transcript {
180            source: "stub".into(),
181            locator_id: "vid".into(),
182            language: "en".into(),
183            kind: TrackKind::Manual,
184            cues: vec![Cue::new(0, 1_000, "hello world")],
185        };
186        render_and_write(&transcript, CliFormat::Txt, Some(path.to_str().unwrap())).unwrap();
187        let out = std::fs::read_to_string(&path).unwrap();
188        assert!(out.contains("hello world"));
189    }
190}