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(long, value_enum, default_value_t = CliFormat::Srt)]
27    pub format: CliFormat,
28
29    /// Allow falling through to auto-generated (ASR) captions when no manual
30    /// track matches.
31    #[arg(long)]
32    pub auto: bool,
33
34    /// Synthesise a translated track in this target language when no native
35    /// track matches.
36    #[arg(long, value_name = "LANG")]
37    pub translate: Option<String>,
38
39    /// Output file (writes to stdout if omitted).
40    #[arg(short, long)]
41    pub output: Option<String>,
42}
43
44impl FetchCommand {
45    /// Fetches the transcript and writes it to stdout or `--output`.
46    pub async fn execute(self) -> Result<()> {
47        let yt = Youtube::new()?;
48        let opts = FetchOpts {
49            language: self.lang,
50            allow_auto: self.auto,
51            translate_to: self.translate,
52        };
53        let transcript = yt.fetch(&self.url, &opts).await?;
54        let rendered = Format::from(self.format).render(&transcript)?;
55        write_output(&rendered, self.output.as_deref())
56    }
57}
58
59fn write_output(text: &str, file: Option<&str>) -> Result<()> {
60    if let Some(path) = file {
61        fs::write(path, text).with_context(|| format!("Failed to write to {path}"))
62    } else {
63        print!("{text}");
64        Ok(())
65    }
66}
67
68#[cfg(test)]
69#[allow(clippy::unwrap_used, clippy::expect_used)]
70mod tests {
71    use super::*;
72    use clap::CommandFactory;
73
74    /// Build a parser for `FetchCommand` rooted at `fetch` so we can drive
75    /// it with realistic argv vectors.
76    fn parse(args: &[&str]) -> FetchCommand {
77        let cmd = FetchCommand::command().no_binary_name(true);
78        let matches = cmd.try_get_matches_from(args).unwrap();
79        FetchCommand::from_arg_matches(&matches).unwrap()
80    }
81
82    use clap::FromArgMatches;
83
84    #[test]
85    fn fetch_command_defaults() {
86        let cmd = parse(&["https://youtu.be/dQw4w9WgXcQ"]);
87        assert_eq!(cmd.url, "https://youtu.be/dQw4w9WgXcQ");
88        assert_eq!(cmd.lang, "en");
89        assert_eq!(cmd.format, CliFormat::Srt);
90        assert!(!cmd.auto);
91        assert_eq!(cmd.translate, None);
92        assert_eq!(cmd.output, None);
93    }
94
95    #[test]
96    fn fetch_command_all_flags() {
97        let cmd = parse(&[
98            "abc",
99            "--lang",
100            "fr",
101            "--format",
102            "vtt",
103            "--auto",
104            "--translate",
105            "en",
106            "--output",
107            "out.vtt",
108        ]);
109        assert_eq!(cmd.url, "abc");
110        assert_eq!(cmd.lang, "fr");
111        assert_eq!(cmd.format, CliFormat::Vtt);
112        assert!(cmd.auto);
113        assert_eq!(cmd.translate.as_deref(), Some("en"));
114        assert_eq!(cmd.output.as_deref(), Some("out.vtt"));
115    }
116
117    #[test]
118    fn fetch_command_short_output_flag() {
119        let cmd = parse(&["abc", "-o", "out.srt"]);
120        assert_eq!(cmd.output.as_deref(), Some("out.srt"));
121    }
122
123    #[test]
124    fn fetch_command_format_accepts_each_variant() {
125        for (arg, want) in [
126            ("srt", CliFormat::Srt),
127            ("vtt", CliFormat::Vtt),
128            ("txt", CliFormat::Txt),
129            ("json", CliFormat::Json),
130        ] {
131            let cmd = parse(&["abc", "--format", arg]);
132            assert_eq!(cmd.format, want);
133        }
134    }
135
136    #[test]
137    fn write_output_to_file_writes_bytes() {
138        let dir = tempfile::tempdir().unwrap();
139        let path = dir.path().join("out.txt");
140        write_output("hello\n", Some(path.to_str().unwrap())).unwrap();
141        let read = std::fs::read_to_string(&path).unwrap();
142        assert_eq!(read, "hello\n");
143    }
144
145    #[test]
146    fn write_output_to_stdout_returns_ok() {
147        // Cannot easily capture stdout here; just exercise the branch.
148        write_output("noop", None).unwrap();
149    }
150
151    #[test]
152    fn write_output_invalid_path_errors() {
153        let err = write_output("x", Some("/nonexistent_dir_for_test/out.txt")).unwrap_err();
154        assert!(err.to_string().contains("Failed to write"));
155    }
156}