Skip to main content

omni_dev/cli/transcript/youtube/
fetch.rs

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