Skip to main content

omni_dev/cli/transcript/youtube/
list_langs.rs

1//! `omni-dev transcript youtube list-langs` — show all caption tracks on a
2//! video.
3
4use anyhow::{Context, Result};
5use clap::{Parser, ValueEnum};
6
7use crate::transcript::source::{LanguageInfo, TrackKind, TranscriptSource};
8use crate::transcript::sources::youtube::Youtube;
9
10/// Lists the caption tracks available on a YouTube video.
11#[derive(Parser)]
12pub struct ListLangsCommand {
13    /// YouTube video URL or bare 11-character video ID.
14    pub url: String,
15
16    /// Output format.
17    #[arg(long, value_enum, default_value_t = ListLangsOutput::Table)]
18    pub output: ListLangsOutput,
19}
20
21/// Output format for `list-langs`.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
23#[value(rename_all = "lowercase")]
24pub enum ListLangsOutput {
25    /// Human-readable table with `code`, `kind`, `name` columns.
26    Table,
27    /// Pretty-printed JSON array of `LanguageInfo`.
28    Json,
29}
30
31impl ListLangsCommand {
32    /// Fetches the caption-track list and prints it.
33    pub async fn execute(self) -> Result<()> {
34        let yt = Youtube::new()?;
35        let langs = yt.list_languages(&self.url).await?;
36        match self.output {
37            ListLangsOutput::Table => print_table(&langs),
38            ListLangsOutput::Json => print_json(&langs)?,
39        }
40        Ok(())
41    }
42}
43
44fn print_table(langs: &[LanguageInfo]) {
45    if langs.is_empty() {
46        println!("(no caption tracks available)");
47        return;
48    }
49
50    let code_width = "code"
51        .len()
52        .max(langs.iter().map(|l| l.code.len()).max().unwrap_or(0));
53    let kind_width = "kind".len().max(
54        langs
55            .iter()
56            .map(|l| kind_str(l.kind).len())
57            .max()
58            .unwrap_or(0),
59    );
60
61    println!(
62        "{:<code_w$}  {:<kind_w$}  name",
63        "code",
64        "kind",
65        code_w = code_width,
66        kind_w = kind_width,
67    );
68    println!(
69        "{:-<code_w$}  {:-<kind_w$}  {:-<name_w$}",
70        "",
71        "",
72        "",
73        code_w = code_width,
74        kind_w = kind_width,
75        name_w = "name".len(),
76    );
77    for lang in langs {
78        println!(
79            "{:<code_w$}  {:<kind_w$}  {}",
80            lang.code,
81            kind_str(lang.kind),
82            lang.name,
83            code_w = code_width,
84            kind_w = kind_width,
85        );
86    }
87}
88
89fn print_json(langs: &[LanguageInfo]) -> Result<()> {
90    let json =
91        serde_json::to_string_pretty(langs).context("Failed to serialize languages as JSON")?;
92    println!("{json}");
93    Ok(())
94}
95
96fn kind_str(kind: TrackKind) -> &'static str {
97    match kind {
98        TrackKind::Manual => "manual",
99        TrackKind::Auto => "auto",
100        TrackKind::Translated => "translated",
101    }
102}
103
104#[cfg(test)]
105#[allow(clippy::unwrap_used, clippy::expect_used)]
106mod tests {
107    use super::*;
108    use clap::{CommandFactory, FromArgMatches};
109
110    fn parse(args: &[&str]) -> ListLangsCommand {
111        let cmd = ListLangsCommand::command().no_binary_name(true);
112        let matches = cmd.try_get_matches_from(args).unwrap();
113        ListLangsCommand::from_arg_matches(&matches).unwrap()
114    }
115
116    #[test]
117    fn list_langs_command_defaults() {
118        let cmd = parse(&["abc"]);
119        assert_eq!(cmd.url, "abc");
120        assert_eq!(cmd.output, ListLangsOutput::Table);
121    }
122
123    #[test]
124    fn list_langs_command_json_output() {
125        let cmd = parse(&["abc", "--output", "json"]);
126        assert_eq!(cmd.output, ListLangsOutput::Json);
127    }
128
129    #[test]
130    fn kind_str_lowercase() {
131        assert_eq!(kind_str(TrackKind::Manual), "manual");
132        assert_eq!(kind_str(TrackKind::Auto), "auto");
133        assert_eq!(kind_str(TrackKind::Translated), "translated");
134    }
135
136    #[test]
137    fn print_json_round_trips() {
138        // Sanity check that LanguageInfo serializes to JSON without error.
139        let langs = vec![LanguageInfo {
140            code: "en".into(),
141            name: "English".into(),
142            kind: TrackKind::Manual,
143        }];
144        print_json(&langs).unwrap();
145    }
146
147    #[test]
148    fn print_table_handles_empty_input() {
149        // No panic on an empty language list.
150        print_table(&[]);
151    }
152
153    #[test]
154    fn print_table_handles_populated_input() {
155        let langs = vec![
156            LanguageInfo {
157                code: "en".into(),
158                name: "English".into(),
159                kind: TrackKind::Manual,
160            },
161            LanguageInfo {
162                code: "es-419".into(),
163                name: "Spanish (Latin America)".into(),
164                kind: TrackKind::Auto,
165            },
166        ];
167        print_table(&langs);
168    }
169}