Skip to main content

omni_dev/cli/transcript/youtube/
info.rs

1//! `omni-dev transcript youtube info` — show top-level metadata about a video.
2
3use anyhow::{Context, Result};
4use clap::{Parser, ValueEnum};
5
6use crate::transcript::source::{MediaInfo, TrackKind, TranscriptSource};
7use crate::transcript::sources::youtube::Youtube;
8
9/// Shows top-level metadata (title, channel, duration, languages) for a YouTube video.
10#[derive(Parser)]
11pub struct InfoCommand {
12    /// YouTube video URL or bare 11-character video ID.
13    pub url: String,
14
15    /// Output format.
16    #[arg(long, value_enum, default_value_t = InfoOutput::Table)]
17    pub output: InfoOutput,
18}
19
20/// Output format for `info`.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
22#[value(rename_all = "lowercase")]
23pub enum InfoOutput {
24    /// Human-readable key/value listing.
25    Table,
26    /// Pretty-printed JSON of `MediaInfo`.
27    Json,
28}
29
30impl InfoCommand {
31    /// Fetches the metadata and prints it.
32    pub async fn execute(self) -> Result<()> {
33        let yt = Youtube::new()?;
34        let info = yt.info(&self.url).await?;
35        match self.output {
36            InfoOutput::Table => print_table(&info),
37            InfoOutput::Json => print_json(&info)?,
38        }
39        Ok(())
40    }
41}
42
43fn print_table(info: &MediaInfo) {
44    println!("Source:    {}", info.source);
45    println!("ID:        {}", info.locator_id);
46    println!("Title:     {}", info.title);
47    if let Some(author) = &info.author {
48        println!("Channel:   {author}");
49    }
50    if let Some(duration_ms) = info.duration_ms {
51        println!("Duration:  {}", format_duration(duration_ms));
52    }
53    if info.languages.is_empty() {
54        println!("Languages: (none)");
55    } else {
56        println!("Languages:");
57        for lang in &info.languages {
58            println!("  - {} [{}] {}", lang.code, kind_str(lang.kind), lang.name);
59        }
60    }
61}
62
63fn print_json(info: &MediaInfo) -> Result<()> {
64    let json =
65        serde_json::to_string_pretty(info).context("Failed to serialize MediaInfo as JSON")?;
66    println!("{json}");
67    Ok(())
68}
69
70fn kind_str(kind: TrackKind) -> &'static str {
71    match kind {
72        TrackKind::Manual => "manual",
73        TrackKind::Auto => "auto",
74        TrackKind::Translated => "translated",
75    }
76}
77
78fn format_duration(ms: u64) -> String {
79    let total_secs = ms / 1_000;
80    let hours = total_secs / 3_600;
81    let minutes = (total_secs % 3_600) / 60;
82    let seconds = total_secs % 60;
83    if hours > 0 {
84        format!("{hours:02}:{minutes:02}:{seconds:02}")
85    } else {
86        format!("{minutes:02}:{seconds:02}")
87    }
88}
89
90#[cfg(test)]
91#[allow(clippy::unwrap_used, clippy::expect_used)]
92mod tests {
93    use super::*;
94    use crate::transcript::source::LanguageInfo;
95    use clap::{CommandFactory, FromArgMatches};
96
97    fn parse(args: &[&str]) -> InfoCommand {
98        let cmd = InfoCommand::command().no_binary_name(true);
99        let matches = cmd.try_get_matches_from(args).unwrap();
100        InfoCommand::from_arg_matches(&matches).unwrap()
101    }
102
103    #[test]
104    fn info_command_defaults() {
105        let cmd = parse(&["abc"]);
106        assert_eq!(cmd.url, "abc");
107        assert_eq!(cmd.output, InfoOutput::Table);
108    }
109
110    #[test]
111    fn info_command_json_output() {
112        let cmd = parse(&["abc", "--output", "json"]);
113        assert_eq!(cmd.output, InfoOutput::Json);
114    }
115
116    #[test]
117    fn format_duration_under_an_hour() {
118        assert_eq!(format_duration(0), "00:00");
119        assert_eq!(format_duration(59_000), "00:59");
120        assert_eq!(format_duration(212_000), "03:32");
121    }
122
123    #[test]
124    fn format_duration_over_an_hour() {
125        assert_eq!(format_duration(3_600_000), "01:00:00");
126        assert_eq!(format_duration(3_661_000), "01:01:01");
127    }
128
129    #[test]
130    fn print_table_handles_full_info() {
131        let info = MediaInfo {
132            source: "youtube".into(),
133            locator_id: "abc".into(),
134            title: "Title".into(),
135            author: Some("Channel".into()),
136            duration_ms: Some(60_000),
137            languages: vec![LanguageInfo {
138                code: "en".into(),
139                name: "English".into(),
140                kind: TrackKind::Manual,
141            }],
142        };
143        print_table(&info);
144    }
145
146    #[test]
147    fn print_table_handles_minimal_info() {
148        let info = MediaInfo {
149            source: "youtube".into(),
150            locator_id: "abc".into(),
151            title: "Title".into(),
152            author: None,
153            duration_ms: None,
154            languages: vec![],
155        };
156        print_table(&info);
157    }
158
159    #[test]
160    fn print_json_round_trips() {
161        let info = MediaInfo {
162            source: "youtube".into(),
163            locator_id: "abc".into(),
164            title: "Title".into(),
165            author: None,
166            duration_ms: None,
167            languages: vec![],
168        };
169        print_json(&info).unwrap();
170    }
171}