audiobook_forge/cli/
handlers.rs

1//! CLI command handlers
2
3use crate::cli::commands::{BuildArgs, ConfigCommands, OrganizeArgs, MetadataCommands, MatchArgs};
4use crate::core::{Analyzer, BatchProcessor, M4bMerger, Organizer, RetryConfig, Scanner};
5use crate::models::{BookCase, Config, AudibleRegion, CurrentMetadata, MetadataSource};
6use crate::utils::{ConfigManager, DependencyChecker, AudibleCache, scoring, extraction};
7use crate::audio::{AacEncoder, AudibleClient, detect_asin};
8use crate::ui::{prompt_match_selection, prompt_manual_metadata, prompt_custom_search, UserChoice};
9use anyhow::{Context, Result, bail};
10use console::style;
11use std::path::PathBuf;
12use std::str::FromStr;
13
14/// Resolve which AAC encoder to use based on config (handles backward compatibility)
15fn resolve_encoder(config: &Config, cli_override: Option<&str>) -> AacEncoder {
16    // CLI argument takes highest priority
17    if let Some(encoder_str) = cli_override {
18        if let Some(encoder) = AacEncoder::from_str(encoder_str) {
19            tracing::info!("Using encoder from CLI argument: {}", encoder.name());
20            return encoder;
21        } else {
22            tracing::warn!("Unknown encoder '{}', falling back to auto-detection", encoder_str);
23        }
24    }
25
26    // Handle backward compatibility with old use_apple_silicon_encoder field
27    if let Some(use_apple_silicon) = config.advanced.use_apple_silicon_encoder {
28        let encoder = if use_apple_silicon {
29            AacEncoder::AppleSilicon
30        } else {
31            AacEncoder::Native
32        };
33        tracing::info!("Using encoder from legacy config: {}", encoder.name());
34        return encoder;
35    }
36
37    // Use new aac_encoder field
38    match config.advanced.aac_encoder.to_lowercase().as_str() {
39        "auto" => {
40            let encoder = crate::audio::get_encoder();
41            tracing::info!("Auto-detected encoder: {}", encoder.name());
42            encoder
43        }
44        encoder_str => {
45            if let Some(encoder) = AacEncoder::from_str(encoder_str) {
46                tracing::info!("Using configured encoder: {}", encoder.name());
47                encoder
48            } else {
49                tracing::warn!(
50                    "Unknown encoder '{}' in config, falling back to auto-detection",
51                    encoder_str
52                );
53                let encoder = crate::audio::get_encoder();
54                tracing::info!("Auto-detected encoder: {}", encoder.name());
55                encoder
56            }
57        }
58    }
59}
60
61/// Try to detect if current directory is an audiobook folder
62fn try_detect_current_as_audiobook() -> Result<Option<PathBuf>> {
63    let current_dir = std::env::current_dir()
64        .context("Failed to get current directory")?;
65
66    // Safety check: Don't auto-detect from filesystem root
67    if current_dir.parent().is_none() {
68        return Ok(None);
69    }
70
71    // Check for MP3 files in current directory
72    let entries = std::fs::read_dir(&current_dir)
73        .context("Failed to read current directory")?;
74
75    let mp3_count = entries
76        .filter_map(|e| e.ok())
77        .filter(|e| {
78            e.path()
79                .extension()
80                .and_then(|ext| ext.to_str())
81                .map(|ext| ext.eq_ignore_ascii_case("mp3") || ext.eq_ignore_ascii_case("m4a"))
82                .unwrap_or(false)
83        })
84        .count();
85
86    // Require at least 1 MP3 file to consider it an audiobook (BookCase A or B)
87    if mp3_count >= 1 {
88        Ok(Some(current_dir))
89    } else {
90        Ok(None)
91    }
92}
93
94/// Check if a directory is itself an audiobook folder
95fn is_audiobook_folder(path: &std::path::Path) -> Result<bool> {
96    if !path.is_dir() {
97        return Ok(false);
98    }
99
100    let entries = std::fs::read_dir(path)
101        .context("Failed to read directory")?;
102
103    let audio_count = entries
104        .filter_map(|e| e.ok())
105        .filter(|e| {
106            e.path()
107                .extension()
108                .and_then(|ext| ext.to_str())
109                .map(|ext| {
110                    ext.eq_ignore_ascii_case("mp3") ||
111                    ext.eq_ignore_ascii_case("m4a") ||
112                    ext.eq_ignore_ascii_case("m4b")
113                })
114                .unwrap_or(false)
115        })
116        .count();
117
118    Ok(audio_count >= 1)
119}
120
121/// Handle the build command
122pub async fn handle_build(args: BuildArgs, config: Config) -> Result<()> {
123    // Determine root directory (CLI arg > config > auto-detect > error)
124    let (root, auto_detected) = if let Some(root_path) = args.root.or(config.directories.source.clone()) {
125        // Check if root itself is an audiobook folder
126        if is_audiobook_folder(&root_path)? {
127            println!(
128                "{} Detected audiobook folder (not library): {}",
129                style("→").cyan(),
130                style(root_path.display()).yellow()
131            );
132            (root_path, true)
133        } else {
134            (root_path, false)
135        }
136    } else {
137        // Try auto-detecting current directory
138        if let Some(current) = try_detect_current_as_audiobook()? {
139            println!(
140                "{} Auto-detected audiobook folder: {}",
141                style("→").cyan(),
142                style(current.display()).yellow()
143            );
144            (current, true)
145        } else {
146            anyhow::bail!(
147                "No root directory specified. Use --root, configure directories.source, or run from inside an audiobook folder"
148            );
149        }
150    };
151
152    if !auto_detected {
153        println!(
154            "{} Scanning audiobooks in: {}",
155            style("→").cyan(),
156            style(root.display()).yellow()
157        );
158    }
159
160    // Scan for audiobooks
161    let scanner = Scanner::from_config(&config);
162    let mut book_folders = if auto_detected {
163        // Auto-detect mode: treat current dir as single book
164        vec![scanner.scan_single_directory(&root)?]
165    } else {
166        // Normal mode: scan for multiple books
167        scanner
168            .scan_directory(&root)
169            .context("Failed to scan directory")?
170    };
171
172    if book_folders.is_empty() {
173        println!("{} No audiobooks found", style("✗").red());
174        return Ok(());
175    }
176
177    println!(
178        "{} Found {} audiobook(s)",
179        style("✓").green(),
180        style(book_folders.len()).cyan()
181    );
182
183    // Filter by skip_existing if configured
184    if config.processing.skip_existing && !args.force {
185        book_folders.retain(|b| {
186            // Keep if no M4B files OR if it's a mergeable case (E)
187            b.m4b_files.is_empty() || b.case == BookCase::E
188        });
189        println!(
190            "{} After filtering existing: {} audiobook(s)",
191            style("→").cyan(),
192            style(book_folders.len()).cyan()
193        );
194    }
195
196    // Handle --merge-m4b flag: force Case E for multi-M4B folders
197    if args.merge_m4b {
198        for book in &mut book_folders {
199            if book.m4b_files.len() > 1 && book.case == BookCase::C {
200                tracing::info!(
201                    "Forcing merge for {} (--merge-m4b flag)",
202                    book.name
203                );
204                book.case = BookCase::E;
205            }
206        }
207    }
208
209    if book_folders.is_empty() {
210        println!(
211            "{} All audiobooks already processed (use --force to reprocess)",
212            style("ℹ").blue()
213        );
214        return Ok(());
215    }
216
217    // Dry run mode
218    if args.dry_run {
219        println!("\n{} DRY RUN MODE - No changes will be made\n", style("ℹ").blue());
220        for book in &book_folders {
221            println!(
222                "  {} {} ({} files, {:.1} min)",
223                style("→").cyan(),
224                style(&book.name).yellow(),
225                book.mp3_files.len(),
226                book.get_total_duration() / 60.0
227            );
228        }
229        return Ok(());
230    }
231
232    // Analyze all books
233    println!("\n{} Analyzing tracks...", style("→").cyan());
234    let analyzer_workers = args.parallel.unwrap_or(config.processing.parallel_workers);
235    let analyzer = Analyzer::with_workers(analyzer_workers as usize)?;
236
237    for book in &mut book_folders {
238        analyzer
239            .analyze_book_folder(book)
240            .await
241            .with_context(|| format!("Failed to analyze {}", book.name))?;
242    }
243
244    println!("{} Analysis complete", style("✓").green());
245
246    // Fetch Audible metadata if enabled
247    if args.fetch_audible || config.metadata.audible.enabled {
248        println!("\n{} Fetching Audible metadata...", style("→").cyan());
249
250        let audible_region = args.audible_region
251            .as_deref()
252            .or(Some(&config.metadata.audible.region))
253            .and_then(|r| AudibleRegion::from_str(r).ok())
254            .unwrap_or(AudibleRegion::US);
255
256        let retry_config = crate::core::RetryConfig::with_settings(
257            config.metadata.audible.api_max_retries as usize,
258            std::time::Duration::from_secs(config.metadata.audible.api_retry_delay_secs),
259            std::time::Duration::from_secs(config.metadata.audible.api_max_retry_delay_secs),
260            2.0,
261        );
262        let client = AudibleClient::with_config(
263            audible_region,
264            config.metadata.audible.rate_limit_per_minute,
265            retry_config,
266        )?;
267        let cache = AudibleCache::with_ttl_hours(config.metadata.audible.cache_duration_hours)?;
268
269        for book in &mut book_folders {
270            // Try ASIN detection first
271            if let Some(asin) = detect_asin(&book.name) {
272                tracing::debug!("Detected ASIN {} in folder: {}", asin, book.name);
273                book.detected_asin = Some(asin.clone());
274
275                // Try cache first
276                match cache.get(&asin).await {
277                    Some(cached) => {
278                        book.audible_metadata = Some(cached);
279                        println!("  {} {} (ASIN: {}, cached)", style("✓").green(), book.name, asin);
280                    }
281                    None => {
282                        // Fetch from API
283                        match client.fetch_by_asin(&asin).await {
284                            Ok(metadata) => {
285                                // Cache the result
286                                let _ = cache.set(&asin, &metadata).await;
287                                book.audible_metadata = Some(metadata);
288                                println!("  {} {} (ASIN: {})", style("✓").green(), book.name, asin);
289
290                                // Fetch chapters if enabled
291                                if config.metadata.audible.fetch_chapters {
292                                    match client.fetch_chapters(&asin).await {
293                                        Ok(chapters) => {
294                                            tracing::debug!("Fetched {} chapters for ASIN: {}", chapters.len(), asin);
295                                        }
296                                        Err(e) => {
297                                            tracing::debug!("No chapters available for ASIN {}: {:?}", asin, e);
298                                        }
299                                    }
300                                }
301                            }
302                            Err(e) => {
303                                tracing::warn!("Failed to fetch metadata for {}: {:?}", book.name, e);
304                                println!("  {} {} - fetch failed", style("⚠").yellow(), book.name);
305                            }
306                        }
307                    }
308                }
309            } else if args.audible_auto_match || config.metadata.audible.auto_match {
310                // Try auto-matching by title
311                tracing::debug!("Attempting auto-match for: {}", book.name);
312
313                match client.search(Some(&book.name), None).await {
314                    Ok(results) if !results.is_empty() => {
315                        let asin = &results[0].asin;
316                        tracing::debug!("Auto-matched {} to ASIN: {}", book.name, asin);
317                        book.detected_asin = Some(asin.clone());
318
319                        // Try cache first
320                        match cache.get(asin).await {
321                            Some(cached) => {
322                                book.audible_metadata = Some(cached);
323                                println!("  {} {} (matched: {}, cached)", style("✓").green(), book.name, asin);
324                            }
325                            None => {
326                                // Fetch from API
327                                match client.fetch_by_asin(asin).await {
328                                    Ok(metadata) => {
329                                        // Cache the result
330                                        let _ = cache.set(asin, &metadata).await;
331                                        book.audible_metadata = Some(metadata);
332                                        println!("  {} {} (matched: {})", style("✓").green(), book.name, asin);
333
334                                        // Fetch chapters if enabled
335                                        if config.metadata.audible.fetch_chapters {
336                                            match client.fetch_chapters(asin).await {
337                                                Ok(chapters) => {
338                                                    tracing::debug!("Fetched {} chapters for ASIN: {}", chapters.len(), asin);
339                                                }
340                                                Err(e) => {
341                                                    tracing::debug!("No chapters available for ASIN {}: {:?}", asin, e);
342                                                }
343                                            }
344                                        }
345                                    }
346                                    Err(e) => {
347                                        tracing::warn!("Failed to fetch metadata after match for {}: {:?}", book.name, e);
348                                        println!("  {} {} - fetch failed", style("⚠").yellow(), book.name);
349                                    }
350                                }
351                            }
352                        }
353                    }
354                    Ok(_) => {
355                        tracing::debug!("No Audible match found for: {}", book.name);
356                        println!("  {} {} - no match found", style("○").dim(), book.name);
357                    }
358                    Err(e) => {
359                        tracing::warn!("Search failed for {}: {:?}", book.name, e);
360                        println!("  {} {} - search failed", style("⚠").yellow(), book.name);
361                    }
362                }
363            } else {
364                tracing::debug!("No ASIN detected and auto-match disabled for: {}", book.name);
365            }
366        }
367
368        let fetched_count = book_folders.iter().filter(|b| b.audible_metadata.is_some()).count();
369        println!("{} Fetched metadata for {}/{} books",
370            style("✓").green(),
371            style(fetched_count).cyan(),
372            book_folders.len()
373        );
374    }
375
376    // Determine output directory
377    let output_dir = if auto_detected {
378        // When auto-detected, default to current directory
379        args.out.unwrap_or(root.clone())
380    } else {
381        // Normal mode: respect config
382        args.out.or_else(|| {
383            if config.directories.output == "same_as_source" {
384                Some(root.clone())
385            } else {
386                Some(PathBuf::from(&config.directories.output))
387            }
388        }).context("No output directory specified")?
389    };
390
391    // Create batch processor with config settings
392    let workers = args.parallel.unwrap_or(config.processing.parallel_workers) as usize;
393    let keep_temp = args.keep_temp || config.processing.keep_temp_files;
394
395    // Resolve encoder (handles backward compatibility with legacy config)
396    let encoder = resolve_encoder(&config, args.aac_encoder.as_deref());
397
398    // Parse max concurrent encodes from config
399    let max_concurrent = if config.performance.max_concurrent_encodes == "auto" {
400        num_cpus::get() // Use all CPU cores
401    } else {
402        config.performance.max_concurrent_encodes
403            .parse::<usize>()
404            .unwrap_or(num_cpus::get())
405            .clamp(1, 16)
406    };
407
408    // Parse max concurrent files per book from config
409    let max_concurrent_files = if config.performance.max_concurrent_files_per_book == "auto" {
410        num_cpus::get()
411    } else {
412        config.performance.max_concurrent_files_per_book
413            .parse::<usize>()
414            .unwrap_or(8)
415            .clamp(1, 32)
416    };
417
418    // Create retry config from settings
419    let retry_config = RetryConfig::with_settings(
420        config.processing.max_retries as usize,
421        std::time::Duration::from_secs(config.processing.retry_delay),
422        std::time::Duration::from_secs(30),
423        2.0,
424    );
425
426    let batch_processor = BatchProcessor::with_options(
427        workers,
428        keep_temp,
429        encoder,
430        config.performance.enable_parallel_encoding,
431        max_concurrent,
432        max_concurrent_files,
433        args.quality.clone(),
434        retry_config,
435    );
436
437    // Separate Case E (M4B merge) from other cases
438    let (merge_books, convert_books): (Vec<_>, Vec<_>) = book_folders
439        .into_iter()
440        .partition(|b| b.case == BookCase::E);
441
442    // Process M4B merges
443    if !merge_books.is_empty() {
444        println!(
445            "\n{} Merging {} M4B audiobook(s)...",
446            style("→").cyan(),
447            style(merge_books.len()).cyan()
448        );
449
450        let merger = M4bMerger::with_options(args.keep_temp)?;
451
452        for book in merge_books {
453            println!(
454                "  {} {} ({} files)",
455                style("→").cyan(),
456                style(&book.name).yellow(),
457                book.m4b_files.len()
458            );
459
460            match merger.merge_m4b_files(&book, &output_dir).await {
461                Ok(output_path) => {
462                    println!(
463                        "  {} Merged: {}",
464                        style("✓").green(),
465                        output_path.display()
466                    );
467                }
468                Err(e) => {
469                    println!(
470                        "  {} Failed to merge {}: {}",
471                        style("✗").red(),
472                        book.name,
473                        e
474                    );
475                }
476            }
477        }
478    }
479
480    // Continue with regular conversion for remaining books
481    let book_folders = convert_books;
482
483    // Process batch (regular conversions)
484    if !book_folders.is_empty() {
485        println!("\n{} Processing {} audiobook(s)...\n", style("→").cyan(), book_folders.len());
486    }
487
488    let results = batch_processor
489        .process_batch(
490            book_folders,
491            &output_dir,
492            &config.quality.chapter_source,
493        )
494        .await;
495
496    // Print results
497    println!();
498    let successful = results.iter().filter(|r| r.success).count();
499    let failed = results.len() - successful;
500
501    for result in &results {
502        if result.success {
503            println!(
504                "  {} {} ({:.1}s, {})",
505                style("✓").green(),
506                style(&result.book_name).yellow(),
507                result.processing_time,
508                if result.used_copy_mode {
509                    "copy mode"
510                } else {
511                    "transcode"
512                }
513            );
514        } else {
515            println!(
516                "  {} {} - {}",
517                style("✗").red(),
518                style(&result.book_name).yellow(),
519                result.error_message.as_deref().unwrap_or("Unknown error")
520            );
521        }
522    }
523
524    println!(
525        "\n{} Batch complete: {} successful, {} failed",
526        style("✓").green(),
527        style(successful).green(),
528        if failed > 0 {
529            style(failed).red()
530        } else {
531            style(failed).dim()
532        }
533    );
534
535    Ok(())
536}
537
538/// Handle the organize command
539pub fn handle_organize(args: OrganizeArgs, config: Config) -> Result<()> {
540    // Determine root directory
541    let root = args
542        .root
543        .or(config.directories.source.clone())
544        .context("No root directory specified. Use --root or configure directories.source")?;
545
546    println!(
547        "{} Scanning audiobooks in: {}",
548        style("→").cyan(),
549        style(root.display()).yellow()
550    );
551
552    // Scan for audiobooks
553    let scanner = Scanner::from_config(&config);
554    let book_folders = scanner
555        .scan_directory(&root)
556        .context("Failed to scan directory")?;
557
558    if book_folders.is_empty() {
559        println!("{} No audiobooks found", style("✗").red());
560        return Ok(());
561    }
562
563    println!(
564        "{} Found {} audiobook(s)",
565        style("✓").green(),
566        style(book_folders.len()).cyan()
567    );
568
569    // Create organizer
570    let organizer = Organizer::with_dry_run(root, &config, args.dry_run);
571
572    // Dry run notice
573    if args.dry_run {
574        println!("\n{} DRY RUN MODE - No changes will be made\n", style("ℹ").blue());
575    }
576
577    // Organize books
578    let results = organizer.organize_batch(book_folders);
579
580    // Print results
581    println!();
582    for result in &results {
583        let action_str = result.action.description();
584
585        if result.success {
586            match result.destination_path {
587                Some(ref dest) => {
588                    println!(
589                        "  {} {} → {}",
590                        style("✓").green(),
591                        style(&result.book_name).yellow(),
592                        style(dest.display()).cyan()
593                    );
594                }
595                None => {
596                    println!(
597                        "  {} {} ({})",
598                        style("→").dim(),
599                        style(&result.book_name).dim(),
600                        style(action_str).dim()
601                    );
602                }
603            }
604        } else {
605            println!(
606                "  {} {} - {}",
607                style("✗").red(),
608                style(&result.book_name).yellow(),
609                result.error_message.as_deref().unwrap_or("Unknown error")
610            );
611        }
612    }
613
614    let moved = results
615        .iter()
616        .filter(|r| r.success && r.destination_path.is_some())
617        .count();
618    let skipped = results.iter().filter(|r| r.destination_path.is_none()).count();
619    let failed = results.iter().filter(|r| !r.success).count();
620
621    println!(
622        "\n{} Organization complete: {} moved, {} skipped, {} failed",
623        style("✓").green(),
624        style(moved).green(),
625        style(skipped).dim(),
626        if failed > 0 {
627            style(failed).red()
628        } else {
629            style(failed).dim()
630        }
631    );
632
633    Ok(())
634}
635
636/// Handle the config command
637pub fn handle_config(command: ConfigCommands) -> Result<()> {
638    match command {
639        ConfigCommands::Init { force } => {
640            let config_path = ConfigManager::default_config_path()?;
641
642            if config_path.exists() && !force {
643                println!(
644                    "{} Configuration file already exists: {}",
645                    style("✗").red(),
646                    style(config_path.display()).yellow()
647                );
648                println!("Use --force to overwrite");
649                return Ok(());
650            }
651
652            // Create config directory if needed
653            ConfigManager::ensure_config_dir()?;
654
655            // Create default config
656            let config = Config::default();
657            ConfigManager::save(&config, Some(&config_path))?;
658
659            println!(
660                "{} Configuration file created: {}",
661                style("✓").green(),
662                style(config_path.display()).yellow()
663            );
664        }
665
666        ConfigCommands::Show { config: _ } => {
667            let config_path = ConfigManager::default_config_path()?;
668            let config = ConfigManager::load(&config_path)?;
669            let yaml = serde_yaml::to_string(&config)?;
670            println!("{}", yaml);
671        }
672
673        ConfigCommands::Path => {
674            let config_path = ConfigManager::default_config_path()?;
675            println!("{}", config_path.display());
676        }
677
678        ConfigCommands::Validate { config: _ } => {
679            let config_path = ConfigManager::default_config_path()?;
680            ConfigManager::load(&config_path)?;
681            println!(
682                "{} Configuration is valid",
683                style("✓").green()
684            );
685        }
686
687        ConfigCommands::Edit => {
688            let config_path = ConfigManager::default_config_path()?;
689            println!("{} Opening editor for: {}", style("→").cyan(), style(config_path.display()).yellow());
690            // TODO: Implement editor opening
691            println!("{} Editor integration not yet implemented", style("ℹ").blue());
692        }
693    }
694
695    Ok(())
696}
697
698/// Handle the check command
699pub fn handle_check() -> Result<()> {
700    println!("{} Checking system dependencies...\n", style("→").cyan());
701
702    let results = vec![
703        ("FFmpeg", DependencyChecker::check_ffmpeg().found),
704        ("AtomicParsley", DependencyChecker::check_atomic_parsley().found),
705        ("MP4Box", DependencyChecker::check_mp4box().found),
706    ];
707
708    let all_found = results.iter().all(|(_, found)| *found);
709
710    for (tool, found) in &results {
711        if *found {
712            println!("  {} {}", style("✓").green(), style(tool).cyan());
713
714            // Show encoder information for FFmpeg
715            if *tool == "FFmpeg" {
716                let available_encoders = DependencyChecker::get_available_encoders();
717                let selected_encoder = DependencyChecker::get_selected_encoder();
718
719                if !available_encoders.is_empty() {
720                    print!("    AAC Encoders: ");
721                    for (i, encoder) in available_encoders.iter().enumerate() {
722                        if i > 0 {
723                            print!(", ");
724                        }
725                        if *encoder == selected_encoder {
726                            print!("{} {}", style(encoder).green(), style("(selected)").dim());
727                        } else {
728                            print!("{}", style(encoder).dim());
729                        }
730                    }
731                    println!();
732                }
733            }
734        } else {
735            println!("  {} {} (not found)", style("✗").red(), style(tool).yellow());
736        }
737    }
738
739    println!();
740    if all_found {
741        println!("{} All dependencies found", style("✓").green());
742    } else {
743        println!("{} Some dependencies are missing", style("✗").red());
744        println!("\nInstall missing dependencies:");
745        println!("  macOS:   brew install ffmpeg atomicparsley gpac");
746        println!("  Ubuntu:  apt install ffmpeg atomicparsley gpac");
747    }
748
749    Ok(())
750}
751
752/// Handle the metadata command
753pub async fn handle_metadata(command: MetadataCommands, config: Config) -> Result<()> {
754    match command {
755        MetadataCommands::Fetch { asin, title, author, region, output } => {
756            println!("{} Fetching Audible metadata...", style("→").cyan());
757
758            // Parse region
759            let audible_region = AudibleRegion::from_str(&region)
760                .unwrap_or(AudibleRegion::US);
761
762            // Create client and cache
763            let client = AudibleClient::with_rate_limit(
764                audible_region,
765                config.metadata.audible.rate_limit_per_minute
766            )?;
767            let cache = AudibleCache::with_ttl_hours(config.metadata.audible.cache_duration_hours)?;
768
769            // Fetch metadata
770            let metadata = if let Some(asin_val) = asin {
771                // Direct ASIN lookup
772                println!("  {} Looking up ASIN: {}", style("→").cyan(), asin_val);
773
774                // Try cache first
775                if let Some(cached) = cache.get(&asin_val).await {
776                    println!("  {} Using cached metadata", style("✓").green());
777                    cached
778                } else {
779                    let fetched = client.fetch_by_asin(&asin_val).await?;
780                    cache.set(&asin_val, &fetched).await?;
781                    fetched
782                }
783            } else if title.is_some() || author.is_some() {
784                // Search by title/author
785                println!("  {} Searching: title={:?}, author={:?}",
786                    style("→").cyan(), title, author);
787
788                let results = client.search(title.as_deref(), author.as_deref()).await?;
789
790                if results.is_empty() {
791                    bail!("No results found for search query");
792                }
793
794                // Display search results
795                println!("\n{} Found {} result(s):", style("✓").green(), results.len());
796                for (i, result) in results.iter().enumerate().take(5) {
797                    println!("  {}. {} by {}",
798                        i + 1,
799                        style(&result.title).yellow(),
800                        style(result.authors_string()).cyan()
801                    );
802                }
803
804                // Fetch first result
805                println!("\n{} Fetching details for first result...", style("→").cyan());
806                let asin_to_fetch = &results[0].asin;
807
808                if let Some(cached) = cache.get(asin_to_fetch).await {
809                    cached
810                } else {
811                    let fetched = client.fetch_by_asin(asin_to_fetch).await?;
812                    cache.set(asin_to_fetch, &fetched).await?;
813                    fetched
814                }
815            } else {
816                bail!("Must provide --asin or --title/--author");
817            };
818
819            // Display metadata
820            println!("\n{}", style("=".repeat(60)).dim());
821            println!("{}: {}", style("Title").bold(), metadata.title);
822            if let Some(subtitle) = &metadata.subtitle {
823                println!("{}: {}", style("Subtitle").bold(), subtitle);
824            }
825            if !metadata.authors.is_empty() {
826                println!("{}: {}", style("Author(s)").bold(), metadata.authors_string());
827            }
828            if !metadata.narrators.is_empty() {
829                println!("{}: {}", style("Narrator(s)").bold(), metadata.narrators_string());
830            }
831            if let Some(publisher) = &metadata.publisher {
832                println!("{}: {}", style("Publisher").bold(), publisher);
833            }
834            if let Some(year) = metadata.published_year {
835                println!("{}: {}", style("Published").bold(), year);
836            }
837            if let Some(duration_min) = metadata.runtime_minutes() {
838                let hours = duration_min / 60;
839                let mins = duration_min % 60;
840                println!("{}: {}h {}m", style("Duration").bold(), hours, mins);
841            }
842            if let Some(lang) = &metadata.language {
843                println!("{}: {}", style("Language").bold(), lang);
844            }
845            if !metadata.genres.is_empty() {
846                println!("{}: {}", style("Genres").bold(), metadata.genres.join(", "));
847            }
848            if !metadata.series.is_empty() {
849                for series in &metadata.series {
850                    let seq_info = if let Some(seq) = &series.sequence {
851                        format!(" (Book {})", seq)
852                    } else {
853                        String::new()
854                    };
855                    println!("{}: {}{}", style("Series").bold(), series.name, seq_info);
856                }
857            }
858            println!("{}: {}", style("ASIN").bold(), metadata.asin);
859            println!("{}", style("=".repeat(60)).dim());
860
861            // Save to file if requested
862            if let Some(output_path) = output {
863                let json = serde_json::to_string_pretty(&metadata)?;
864                std::fs::write(&output_path, json)?;
865                println!("\n{} Saved metadata to: {}",
866                    style("✓").green(),
867                    style(output_path.display()).yellow()
868                );
869            }
870
871            Ok(())
872        }
873
874        MetadataCommands::Enrich {
875            file,
876            asin,
877            auto_detect,
878            region,
879            chapters,
880            chapters_asin,
881            update_chapters_only,
882            merge_strategy,
883        } => {
884            use crate::audio::{read_m4b_chapters, parse_text_chapters, parse_epub_chapters, merge_chapters, inject_chapters_mp4box, write_mp4box_chapters, ChapterMergeStrategy};
885            use std::str::FromStr;
886
887            let action = if update_chapters_only {
888                "Updating chapters"
889            } else {
890                "Enriching M4B file with Audible metadata"
891            };
892            println!("{} {}...", style("→").cyan(), action);
893
894            if !file.exists() {
895                bail!("File does not exist: {}", file.display());
896            }
897
898            // Parse merge strategy
899            let strategy = match merge_strategy.as_str() {
900                "keep-timestamps" => ChapterMergeStrategy::KeepTimestamps,
901                "replace-all" => ChapterMergeStrategy::ReplaceAll,
902                "skip-on-mismatch" => ChapterMergeStrategy::SkipOnMismatch,
903                "interactive" => ChapterMergeStrategy::Interactive,
904                _ => bail!("Invalid merge strategy: {}. Valid options: keep-timestamps, replace-all, skip-on-mismatch, interactive", merge_strategy),
905            };
906
907            // Handle chapter update if requested
908            let chapter_update_performed = if chapters.is_some() || chapters_asin.is_some() {
909                println!("  {} Reading existing chapters from M4B...", style("→").cyan());
910                let existing_chapters = read_m4b_chapters(&file).await?;
911                println!("  {} Found {} existing chapters", style("✓").green(), existing_chapters.len());
912
913                // Fetch new chapters based on source
914                let new_chapters = if let Some(chapters_file) = chapters {
915                    println!("  {} Parsing chapters from file...", style("→").cyan());
916                    if chapters_file.extension().and_then(|s| s.to_str()) == Some("epub") {
917                        parse_epub_chapters(&chapters_file)?
918                    } else {
919                        parse_text_chapters(&chapters_file)?
920                    }
921                } else if let Some(asin_val) = chapters_asin {
922                    println!("  {} Fetching chapters from Audnex API...", style("→").cyan());
923                    let audible_region = AudibleRegion::from_str(&region).unwrap_or(AudibleRegion::US);
924                    let client = crate::audio::AudibleClient::with_rate_limit(
925                        audible_region,
926                        config.metadata.audible.rate_limit_per_minute
927                    )?;
928                    let audible_chapters = client.fetch_chapters(&asin_val).await?;
929                    audible_chapters.into_iter().enumerate().map(|(i, ch)| ch.to_chapter((i + 1) as u32)).collect()
930                } else {
931                    vec![]
932                };
933
934                println!("  {} Loaded {} new chapters", style("✓").green(), new_chapters.len());
935
936                // Merge chapters
937                println!("  {} Merging chapters (strategy: {})...", style("→").cyan(), merge_strategy);
938                let merged = merge_chapters(&existing_chapters, &new_chapters, strategy)?;
939                println!("  {} Merged into {} chapters", style("✓").green(), merged.len());
940
941                // Write chapters to temp file
942                let temp_chapters = std::env::temp_dir().join(format!("chapters_{}.txt", file.file_stem().unwrap().to_string_lossy()));
943                write_mp4box_chapters(&merged, &temp_chapters)?;
944
945                // Inject chapters back into M4B
946                println!("  {} Injecting chapters into M4B...", style("→").cyan());
947                inject_chapters_mp4box(&file, &temp_chapters).await?;
948                std::fs::remove_file(&temp_chapters)?;
949
950                println!("  {} Chapters updated successfully", style("✓").green());
951                true
952            } else {
953                false
954            };
955
956            // Skip metadata enrichment if only updating chapters
957            if update_chapters_only {
958                if !chapter_update_performed {
959                    bail!("--update-chapters-only specified but no chapter source provided (use --chapters or --chapters-asin)");
960                }
961                println!("\n{} Successfully updated chapters: {}",
962                    style("✓").green(),
963                    style(file.display()).yellow()
964                );
965                return Ok(());
966            }
967
968            // Detect or use provided ASIN
969            let asin_to_use = if let Some(asin_val) = asin {
970                asin_val
971            } else if auto_detect {
972                detect_asin(&file.display().to_string())
973                    .ok_or_else(|| anyhow::anyhow!("Could not detect ASIN from filename: {}", file.display()))?
974            } else {
975                bail!("Must provide --asin or use --auto-detect");
976            };
977
978            println!("  {} Using ASIN: {}", style("→").cyan(), asin_to_use);
979
980            // Parse region
981            let audible_region = AudibleRegion::from_str(&region)
982                .unwrap_or(AudibleRegion::US);
983
984            // Create client and cache
985            let client = AudibleClient::with_rate_limit(
986                audible_region,
987                config.metadata.audible.rate_limit_per_minute
988            )?;
989            let cache = AudibleCache::with_ttl_hours(config.metadata.audible.cache_duration_hours)?;
990
991            // Fetch metadata
992            let metadata = if let Some(cached) = cache.get(&asin_to_use).await {
993                println!("  {} Using cached metadata", style("✓").green());
994                cached
995            } else {
996                println!("  {} Fetching from Audible...", style("→").cyan());
997                let fetched = client.fetch_by_asin(&asin_to_use).await?;
998                cache.set(&asin_to_use, &fetched).await?;
999                fetched
1000            };
1001
1002            println!("  {} Found: {}", style("✓").green(), metadata.title);
1003
1004            // Download cover if available and enabled
1005            let cover_path = if config.metadata.audible.download_covers {
1006                if let Some(cover_url) = &metadata.cover_url {
1007                    println!("  {} Downloading cover art...", style("→").cyan());
1008                    let temp_cover = std::env::temp_dir().join(format!("{}.jpg", asin_to_use));
1009                    client.download_cover(cover_url, &temp_cover).await?;
1010                    println!("  {} Cover downloaded", style("✓").green());
1011                    Some(temp_cover)
1012                } else {
1013                    None
1014                }
1015            } else {
1016                None
1017            };
1018
1019            // Inject metadata (this will be implemented in metadata.rs)
1020            println!("  {} Injecting metadata...", style("→").cyan());
1021            crate::audio::inject_audible_metadata(&file, &metadata, cover_path.as_deref()).await?;
1022
1023            println!("\n{} Successfully enriched: {}",
1024                style("✓").green(),
1025                style(file.display()).yellow()
1026            );
1027
1028            Ok(())
1029        }
1030    }
1031}
1032
1033/// Handle the match command
1034pub async fn handle_match(args: MatchArgs, config: Config) -> Result<()> {
1035    // Determine files to process
1036    let files = get_files_to_process(&args)?;
1037
1038    if files.is_empty() {
1039        println!("{} No M4B files found", style("✗").red());
1040        return Ok(());
1041    }
1042
1043    println!(
1044        "{} Found {} M4B file(s)",
1045        style("✓").green(),
1046        style(files.len()).cyan()
1047    );
1048
1049    // Initialize Audible client and cache
1050    let region = AudibleRegion::from_str(&args.region)?;
1051    let retry_config = crate::core::RetryConfig::with_settings(
1052        config.metadata.audible.api_max_retries as usize,
1053        std::time::Duration::from_secs(config.metadata.audible.api_retry_delay_secs),
1054        std::time::Duration::from_secs(config.metadata.audible.api_max_retry_delay_secs),
1055        2.0,
1056    );
1057    let client = AudibleClient::with_config(
1058        region,
1059        config.metadata.audible.rate_limit_per_minute,
1060        retry_config,
1061    )?;
1062    let cache = AudibleCache::with_ttl_hours(
1063        config.metadata.audible.cache_duration_hours
1064    )?;
1065
1066    // Process each file
1067    let mut processed = 0;
1068    let mut skipped = 0;
1069    let mut failed = 0;
1070
1071    for (idx, file_path) in files.iter().enumerate() {
1072        println!(
1073            "\n{} [{}/{}] Processing: {}",
1074            style("→").cyan(),
1075            idx + 1,
1076            files.len(),
1077            style(file_path.display()).yellow()
1078        );
1079
1080        match process_single_file(&file_path, &args, &client, &cache, &config).await {
1081            Ok(ProcessResult::Applied) => processed += 1,
1082            Ok(ProcessResult::Skipped) => skipped += 1,
1083            Err(e) => {
1084                eprintln!("{} Error: {}", style("✗").red(), e);
1085                failed += 1;
1086            }
1087        }
1088    }
1089
1090    // Summary
1091    println!("\n{}", style("Summary:").bold().cyan());
1092    println!("  {} Processed: {}", style("✓").green(), processed);
1093    println!("  {} Skipped: {}", style("→").yellow(), skipped);
1094    if failed > 0 {
1095        println!("  {} Failed: {}", style("✗").red(), failed);
1096    }
1097
1098    Ok(())
1099}
1100
1101/// Result of processing a single file
1102enum ProcessResult {
1103    Applied,
1104    Skipped,
1105}
1106
1107/// Process a single M4B file
1108async fn process_single_file(
1109    file_path: &PathBuf,
1110    args: &MatchArgs,
1111    client: &AudibleClient,
1112    _cache: &AudibleCache,
1113    config: &Config,
1114) -> Result<ProcessResult> {
1115    // Extract current metadata
1116    let mut current = if args.title.is_some() || args.author.is_some() {
1117        // Manual override
1118        CurrentMetadata {
1119            title: args.title.clone(),
1120            author: args.author.clone(),
1121            year: None,
1122            duration: None,
1123            source: MetadataSource::Manual,
1124        }
1125    } else {
1126        // Auto-extract
1127        extraction::extract_current_metadata(file_path)?
1128    };
1129
1130    // Search loop (allows re-search)
1131    loop {
1132        // Search Audible
1133        let search_results = search_audible(&current, client).await?;
1134
1135        if search_results.is_empty() {
1136            println!("{} No matches found on Audible", style("⚠").yellow());
1137
1138            if args.auto {
1139                return Ok(ProcessResult::Skipped);
1140            }
1141
1142            // Offer manual entry or skip
1143            match prompt_no_results_action()? {
1144                NoResultsAction::ManualEntry => {
1145                    let manual_metadata = prompt_manual_metadata()?;
1146                    apply_metadata(file_path, &manual_metadata, args, config).await?;
1147                    return Ok(ProcessResult::Applied);
1148                }
1149                NoResultsAction::CustomSearch => {
1150                    let (title, author) = prompt_custom_search()?;
1151                    current.title = title;
1152                    current.author = author;
1153                    current.source = MetadataSource::Manual;
1154                    continue; // Re-search
1155                }
1156                NoResultsAction::Skip => {
1157                    return Ok(ProcessResult::Skipped);
1158                }
1159            }
1160        }
1161
1162        // Score and rank candidates
1163        let candidates = scoring::score_and_sort(&current, search_results);
1164
1165        // Auto mode: select best match
1166        if args.auto {
1167            let best = &candidates[0];
1168            println!(
1169                "  {} Auto-selected: {} ({:.1}%)",
1170                style("✓").green(),
1171                best.metadata.title,
1172                (1.0 - best.distance.total_distance()) * 100.0
1173            );
1174
1175            if !args.dry_run {
1176                apply_metadata(file_path, &best.metadata, args, config).await?;
1177            }
1178            return Ok(ProcessResult::Applied);
1179        }
1180
1181        // Interactive mode
1182        match prompt_match_selection(&current, &candidates)? {
1183            UserChoice::SelectMatch(idx) => {
1184                let selected = &candidates[idx];
1185
1186                // Show what's about to be applied
1187                println!(
1188                    "  {} Applying: {} by {}",
1189                    style("→").cyan(),
1190                    style(&selected.metadata.title).yellow(),
1191                    style(selected.metadata.authors.first().map(|a| a.name.as_str()).unwrap_or("Unknown")).cyan()
1192                );
1193
1194                // Apply directly - selecting is confirming
1195                if !args.dry_run {
1196                    apply_metadata(file_path, &selected.metadata, args, config).await?;
1197                } else {
1198                    println!("  {} Dry run - metadata not applied", style("→").yellow());
1199                }
1200                return Ok(ProcessResult::Applied);
1201            }
1202            UserChoice::Skip => {
1203                return Ok(ProcessResult::Skipped);
1204            }
1205            UserChoice::ManualEntry => {
1206                let manual_metadata = prompt_manual_metadata()?;
1207                if !args.dry_run {
1208                    apply_metadata(file_path, &manual_metadata, args, config).await?;
1209                }
1210                return Ok(ProcessResult::Applied);
1211            }
1212            UserChoice::CustomSearch => {
1213                let (title, author) = prompt_custom_search()?;
1214                current.title = title;
1215                current.author = author;
1216                current.source = MetadataSource::Manual;
1217                continue; // Re-search
1218            }
1219        }
1220    }
1221}
1222
1223/// Get list of M4B files to process
1224fn get_files_to_process(args: &MatchArgs) -> Result<Vec<PathBuf>> {
1225    if let Some(file) = &args.file {
1226        // Single file mode
1227        if !file.exists() {
1228            bail!("File not found: {}", file.display());
1229        }
1230        if !is_m4b_file(file) {
1231            bail!("File is not an M4B: {}", file.display());
1232        }
1233        Ok(vec![file.clone()])
1234    } else if let Some(dir) = &args.dir {
1235        // Directory mode
1236        if !dir.is_dir() {
1237            bail!("Not a directory: {}", dir.display());
1238        }
1239
1240        let mut files = Vec::new();
1241        for entry in std::fs::read_dir(dir)? {
1242            let entry = entry?;
1243            let path = entry.path();
1244            if path.is_file() && is_m4b_file(&path) {
1245                files.push(path);
1246            }
1247        }
1248
1249        files.sort();
1250        Ok(files)
1251    } else {
1252        bail!("Must specify --file or --dir");
1253    }
1254}
1255
1256/// Check if file is M4B
1257fn is_m4b_file(path: &PathBuf) -> bool {
1258    path.extension()
1259        .and_then(|e| e.to_str())
1260        .map(|e| e.eq_ignore_ascii_case("m4b"))
1261        .unwrap_or(false)
1262}
1263
1264/// Search Audible API
1265async fn search_audible(
1266    current: &CurrentMetadata,
1267    client: &AudibleClient,
1268) -> Result<Vec<crate::models::AudibleMetadata>> {
1269    // Build search query
1270    let title = current.title.as_deref();
1271    let author = current.author.as_deref();
1272
1273    if title.is_none() && author.is_none() {
1274        bail!("Need at least title or author to search");
1275    }
1276
1277    // Search Audible (now returns full metadata via two-step process)
1278    let metadata_results = client.search(title, author).await?;
1279
1280    Ok(metadata_results)
1281}
1282
1283/// Apply metadata to M4B file
1284async fn apply_metadata(
1285    file_path: &PathBuf,
1286    metadata: &crate::models::AudibleMetadata,
1287    args: &MatchArgs,
1288    config: &Config,
1289) -> Result<()> {
1290    // Download cover if needed
1291    let cover_path = if !args.keep_cover && metadata.cover_url.is_some() && config.metadata.audible.download_covers {
1292        let temp_cover = std::env::temp_dir().join(format!("{}.jpg", metadata.asin));
1293
1294        if let Some(cover_url) = &metadata.cover_url {
1295            let retry_config = crate::core::RetryConfig::with_settings(
1296                config.metadata.audible.api_max_retries as usize,
1297                std::time::Duration::from_secs(config.metadata.audible.api_retry_delay_secs),
1298                std::time::Duration::from_secs(config.metadata.audible.api_max_retry_delay_secs),
1299                2.0,
1300            );
1301            let client = AudibleClient::with_config(
1302                AudibleRegion::US, // Region doesn't matter for covers
1303                config.metadata.audible.rate_limit_per_minute,
1304                retry_config,
1305            )?;
1306            client.download_cover(cover_url, &temp_cover).await?;
1307            Some(temp_cover)
1308        } else {
1309            None
1310        }
1311    } else {
1312        None
1313    };
1314
1315    // Inject metadata
1316    crate::audio::inject_audible_metadata(file_path, metadata, cover_path.as_deref()).await?;
1317
1318    println!(
1319        "  {} Metadata applied successfully{}",
1320        style("✓").green(),
1321        if cover_path.is_some() {
1322            " (including cover art)"
1323        } else {
1324            ""
1325        }
1326    );
1327
1328    Ok(())
1329}
1330
1331/// Action to take when no results found
1332enum NoResultsAction {
1333    ManualEntry,
1334    CustomSearch,
1335    Skip,
1336}
1337
1338/// Prompt for action when no results found
1339fn prompt_no_results_action() -> Result<NoResultsAction> {
1340    use inquire::Select;
1341
1342    let options = vec![
1343        "[S]kip this file",
1344        "Search with [D]ifferent terms",
1345        "Enter metadata [M]anually",
1346    ];
1347
1348    let selection = Select::new("What would you like to do?", options).prompt()?;
1349
1350    match selection {
1351        "[S]kip this file" => Ok(NoResultsAction::Skip),
1352        "Search with [D]ifferent terms" => Ok(NoResultsAction::CustomSearch),
1353        "Enter metadata [M]anually" => Ok(NoResultsAction::ManualEntry),
1354        _ => Ok(NoResultsAction::Skip),
1355    }
1356}