audiobook_forge/cli/
commands.rs

1//! CLI commands and arguments
2
3use clap::{Parser, Subcommand, Args};
4use std::path::PathBuf;
5use anyhow::Result;
6
7use crate::utils::{ConfigManager, DependencyChecker};
8use crate::VERSION;
9
10/// Audiobook Forge - Convert audiobook directories to M4B format
11#[derive(Parser)]
12#[command(name = "audiobook-forge")]
13#[command(version = VERSION)]
14#[command(about = "Convert audiobook directories to M4B format with chapters and metadata")]
15#[command(long_about = "
16Audiobook Forge is a CLI tool that converts audiobook directories containing
17MP3 files into high-quality M4B audiobook files with proper chapters and metadata.
18
19Features:
20• Automatic quality detection and preservation
21• Smart chapter generation from multiple sources
22• Parallel batch processing
23• Metadata extraction and enhancement
24• Cover art embedding
25")]
26pub struct Cli {
27    #[command(subcommand)]
28    pub command: Commands,
29
30    /// Enable verbose output
31    #[arg(global = true, short, long)]
32    pub verbose: bool,
33}
34
35#[derive(Subcommand)]
36pub enum Commands {
37    /// Process audiobooks and convert to M4B
38    Build(BuildArgs),
39
40    /// Organize audiobooks into M4B and To_Convert folders
41    Organize(OrganizeArgs),
42
43    /// Manage configuration
44    #[command(subcommand)]
45    Config(ConfigCommands),
46
47    /// Fetch and manage Audible metadata
48    #[command(subcommand)]
49    Metadata(MetadataCommands),
50
51    /// Interactive metadata matching for M4B files
52    Match(MatchArgs),
53
54    /// Check system dependencies
55    Check,
56
57    /// Show version information
58    Version,
59}
60
61#[derive(Args)]
62pub struct BuildArgs {
63    /// Root directory containing audiobook folders
64    #[arg(short, long)]
65    pub root: Option<PathBuf>,
66
67    /// Output directory (defaults to same as root)
68    #[arg(short, long)]
69    pub out: Option<PathBuf>,
70
71    /// Number of parallel workers (1-8)
72    #[arg(short = 'j', long, value_parser = clap::value_parser!(u8).range(1..=8))]
73    pub parallel: Option<u8>,
74
75    /// Skip folders with existing M4B files
76    #[arg(long)]
77    pub skip_existing: Option<bool>,
78
79    /// Force reprocessing (overwrite existing)
80    #[arg(long)]
81    pub force: bool,
82
83    /// Normalize existing M4B files (fix metadata)
84    #[arg(long)]
85    pub normalize: bool,
86
87    /// Dry run (analyze without creating files)
88    #[arg(long)]
89    pub dry_run: bool,
90
91    /// Prefer stereo over mono
92    #[arg(long)]
93    pub prefer_stereo: Option<bool>,
94
95    /// Chapter source priority
96    #[arg(long, value_parser = ["auto", "files", "cue", "id3", "none"])]
97    pub chapter_source: Option<String>,
98
99    /// Cover art filenames (comma-separated)
100    #[arg(long)]
101    pub cover_names: Option<String>,
102
103    /// Default language for metadata
104    #[arg(long)]
105    pub language: Option<String>,
106
107    /// Keep temporary files for debugging
108    #[arg(long)]
109    pub keep_temp: bool,
110
111    /// Delete original files after conversion
112    #[arg(long)]
113    pub delete_originals: bool,
114
115    /// Use Apple Silicon encoder (aac_at)
116    #[arg(long)]
117    pub use_apple_silicon_encoder: Option<bool>,
118
119    /// Fetch metadata from Audible during build
120    #[arg(long)]
121    pub fetch_audible: bool,
122
123    /// Audible region (us, uk, ca, au, fr, de, jp, it, in, es)
124    #[arg(long)]
125    pub audible_region: Option<String>,
126
127    /// Auto-match books with Audible by folder name
128    #[arg(long)]
129    pub audible_auto_match: bool,
130
131    /// Configuration file path
132    #[arg(long)]
133    pub config: Option<PathBuf>,
134}
135
136#[derive(Args)]
137pub struct OrganizeArgs {
138    /// Root directory to organize
139    #[arg(short, long)]
140    pub root: Option<PathBuf>,
141
142    /// Dry run (show what would be done)
143    #[arg(long)]
144    pub dry_run: bool,
145
146    /// Configuration file path
147    #[arg(long)]
148    pub config: Option<PathBuf>,
149}
150
151#[derive(Subcommand)]
152pub enum ConfigCommands {
153    /// Initialize config file with defaults
154    Init {
155        /// Overwrite existing config file
156        #[arg(long)]
157        force: bool,
158    },
159
160    /// Show current configuration
161    Show {
162        /// Configuration file path
163        #[arg(long)]
164        config: Option<PathBuf>,
165    },
166
167    /// Validate configuration file
168    Validate {
169        /// Configuration file path
170        #[arg(long)]
171        config: Option<PathBuf>,
172    },
173
174    /// Show config file path
175    Path,
176
177    /// Edit config file in default editor
178    Edit,
179}
180
181#[derive(Subcommand)]
182pub enum MetadataCommands {
183    /// Fetch metadata from Audible
184    Fetch {
185        /// Audible ASIN (B002V5D7RU format)
186        #[arg(long)]
187        asin: Option<String>,
188
189        /// Search by title
190        #[arg(long)]
191        title: Option<String>,
192
193        /// Search by author
194        #[arg(long)]
195        author: Option<String>,
196
197        /// Audible region (us, uk, ca, au, fr, de, jp, it, in, es)
198        #[arg(long, default_value = "us")]
199        region: String,
200
201        /// Save metadata to JSON file
202        #[arg(long)]
203        output: Option<PathBuf>,
204    },
205
206    /// Enrich M4B file with Audible metadata
207    Enrich {
208        /// M4B file to enrich
209        #[arg(long)]
210        file: PathBuf,
211
212        /// Audible ASIN
213        #[arg(long)]
214        asin: Option<String>,
215
216        /// Auto-detect ASIN from filename
217        #[arg(long)]
218        auto_detect: bool,
219
220        /// Audible region
221        #[arg(long, default_value = "us")]
222        region: String,
223    },
224}
225
226/// Arguments for the match command
227#[derive(Args)]
228pub struct MatchArgs {
229    /// M4B file to match
230    #[arg(long, short = 'f', conflicts_with = "dir")]
231    pub file: Option<PathBuf>,
232
233    /// Directory of M4B files
234    #[arg(long, short = 'd', conflicts_with = "file")]
235    pub dir: Option<PathBuf>,
236
237    /// Manual title override
238    #[arg(long)]
239    pub title: Option<String>,
240
241    /// Manual author override
242    #[arg(long)]
243    pub author: Option<String>,
244
245    /// Auto mode (non-interactive, select best match)
246    #[arg(long)]
247    pub auto: bool,
248
249    /// Audible region
250    #[arg(long, default_value = "us")]
251    pub region: String,
252
253    /// Keep existing cover art instead of downloading
254    #[arg(long)]
255    pub keep_cover: bool,
256
257    /// Dry run (show matches but don't apply)
258    #[arg(long)]
259    pub dry_run: bool,
260}
261
262/// Run the CLI application
263pub fn run() -> Result<()> {
264    let cli = Cli::parse();
265
266    // Set up logging based on verbosity
267    let log_level = if cli.verbose { "debug" } else { "info" };
268    tracing_subscriber::fmt()
269        .with_env_filter(
270            tracing_subscriber::EnvFilter::try_from_default_env()
271                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level))
272        )
273        .init();
274
275    // Execute command
276    match cli.command {
277        Commands::Build(args) => run_build(args),
278        Commands::Organize(args) => run_organize(args),
279        Commands::Config(cmd) => run_config(cmd),
280        Commands::Metadata(cmd) => run_metadata(cmd),
281        Commands::Match(args) => run_match(args),
282        Commands::Check => run_check(),
283        Commands::Version => run_version(),
284    }
285}
286
287fn run_build(args: BuildArgs) -> Result<()> {
288    println!("Build command - Phase 2-4 implementation");
289    println!("Args: root={:?}, out={:?}, parallel={:?}",
290        args.root, args.out, args.parallel);
291
292    // TODO: Implement in Phase 2-4
293    anyhow::bail!("Build command not yet implemented. Coming in Phase 2!");
294}
295
296fn run_organize(args: OrganizeArgs) -> Result<()> {
297    println!("Organize command - Phase 5 implementation");
298    println!("Args: root={:?}, dry_run={}",
299        args.root, args.dry_run);
300
301    // TODO: Implement in Phase 5
302    anyhow::bail!("Organize command not yet implemented. Coming in Phase 5!");
303}
304
305fn run_config(cmd: ConfigCommands) -> Result<()> {
306    match cmd {
307        ConfigCommands::Init { force } => {
308            let path = ConfigManager::init(force)?;
309            println!("✓ Config file created at: {}", path.display());
310            println!("\nEdit the file to customize your settings:");
311            println!("  audiobook-forge config edit");
312            Ok(())
313        }
314
315        ConfigCommands::Show { config } => {
316            let yaml = ConfigManager::show(config.as_ref())?;
317            println!("{}", yaml);
318            Ok(())
319        }
320
321        ConfigCommands::Validate { config } => {
322            let cfg = ConfigManager::load_or_default(config.as_ref())?;
323            let warnings = ConfigManager::validate(&cfg)?;
324
325            if warnings.is_empty() {
326                println!("✓ Configuration is valid");
327            } else {
328                println!("⚠ Configuration warnings:");
329                for warning in warnings {
330                    println!("  • {}", warning);
331                }
332            }
333            Ok(())
334        }
335
336        ConfigCommands::Path => {
337            let path = ConfigManager::default_config_path()?;
338            println!("{}", path.display());
339            Ok(())
340        }
341
342        ConfigCommands::Edit => {
343            let path = ConfigManager::default_config_path()?;
344
345            // Create config if it doesn't exist
346            if !path.exists() {
347                ConfigManager::init(false)?;
348            }
349
350            // Open in default editor
351            let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
352            let status = std::process::Command::new(&editor)
353                .arg(&path)
354                .status()?;
355
356            if !status.success() {
357                anyhow::bail!("Editor exited with non-zero status");
358            }
359
360            Ok(())
361        }
362    }
363}
364
365fn run_check() -> Result<()> {
366    println!("Audiobook Forge v{}", VERSION);
367    println!("\nChecking system dependencies...\n");
368
369    let deps = DependencyChecker::check_all();
370    let all_met = deps.iter().all(|d| d.found);
371
372    for dep in &deps {
373        println!("{}", dep);
374    }
375
376    if all_met {
377        println!("\n✓ All dependencies are installed");
378
379        // Check for Apple Silicon encoder
380        if std::env::consts::OS == "macos" {
381            if DependencyChecker::check_aac_at_support() {
382                println!("✓ Apple Silicon encoder (aac_at) is available");
383            } else {
384                println!("ℹ Apple Silicon encoder (aac_at) not available");
385            }
386        }
387
388        Ok(())
389    } else {
390        println!("\n✗ Some dependencies are missing");
391        println!("\nInstallation instructions:");
392        println!("  macOS:   brew install ffmpeg atomicparsley gpac");
393        println!("  Ubuntu:  apt install ffmpeg atomicparsley gpac");
394        println!("  Arch:    pacman -S ffmpeg atomicparsley gpac");
395
396        anyhow::bail!("Missing required dependencies");
397    }
398}
399
400fn run_metadata(cmd: MetadataCommands) -> Result<()> {
401    use crate::cli::handlers::handle_metadata;
402    use crate::utils::ConfigManager;
403
404    // Load config
405    let config = ConfigManager::load_or_default(None)?;
406
407    // Run async handler
408    let runtime = tokio::runtime::Runtime::new()?;
409    runtime.block_on(handle_metadata(cmd, config))
410}
411
412fn run_match(args: MatchArgs) -> Result<()> {
413    use crate::cli::handlers::handle_match;
414    use crate::utils::ConfigManager;
415
416    // Load config
417    let config = ConfigManager::load_or_default(None)?;
418
419    // Run async handler
420    let runtime = tokio::runtime::Runtime::new()?;
421    runtime.block_on(handle_match(args, config))
422}
423
424fn run_version() -> Result<()> {
425    println!("Audiobook Forge v{}", VERSION);
426    println!("Rust rewrite - High-performance audiobook processing");
427    Ok(())
428}