audiobook_forge/cli/
handlers.rs

1//! CLI command handlers
2
3use crate::cli::commands::{BuildArgs, ConfigCommands, OrganizeArgs, MetadataCommands, MatchArgs};
4use crate::core::{Analyzer, BatchProcessor, Organizer, RetryConfig, Scanner};
5use crate::models::{Config, AudibleRegion, CurrentMetadata, MetadataSource};
6use crate::utils::{ConfigManager, DependencyChecker, AudibleCache, scoring, extraction};
7use crate::audio::{AudibleClient, detect_asin};
8use crate::ui::{prompt_match_selection, confirm_match, 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/// Try to detect if current directory is an audiobook folder
15fn try_detect_current_as_audiobook() -> Result<Option<PathBuf>> {
16    let current_dir = std::env::current_dir()
17        .context("Failed to get current directory")?;
18
19    // Safety check: Don't auto-detect from filesystem root
20    if current_dir.parent().is_none() {
21        return Ok(None);
22    }
23
24    // Check for MP3 files in current directory
25    let entries = std::fs::read_dir(&current_dir)
26        .context("Failed to read current directory")?;
27
28    let mp3_count = entries
29        .filter_map(|e| e.ok())
30        .filter(|e| {
31            e.path()
32                .extension()
33                .and_then(|ext| ext.to_str())
34                .map(|ext| ext.eq_ignore_ascii_case("mp3") || ext.eq_ignore_ascii_case("m4a"))
35                .unwrap_or(false)
36        })
37        .count();
38
39    // Require at least 2 MP3 files to consider it an audiobook (BookCase A)
40    if mp3_count >= 2 {
41        Ok(Some(current_dir))
42    } else {
43        Ok(None)
44    }
45}
46
47/// Handle the build command
48pub async fn handle_build(args: BuildArgs, config: Config) -> Result<()> {
49    // Determine root directory (CLI arg > config > auto-detect > error)
50    let (root, auto_detected) = if let Some(root_path) = args.root.or(config.directories.source.clone()) {
51        (root_path, false)
52    } else {
53        // Try auto-detecting current directory
54        if let Some(current) = try_detect_current_as_audiobook()? {
55            println!(
56                "{} Auto-detected audiobook folder: {}",
57                style("→").cyan(),
58                style(current.display()).yellow()
59            );
60            (current, true)
61        } else {
62            anyhow::bail!(
63                "No root directory specified. Use --root, configure directories.source, or run from inside an audiobook folder"
64            );
65        }
66    };
67
68    if !auto_detected {
69        println!(
70            "{} Scanning audiobooks in: {}",
71            style("→").cyan(),
72            style(root.display()).yellow()
73        );
74    }
75
76    // Scan for audiobooks
77    let scanner = Scanner::new();
78    let mut book_folders = if auto_detected {
79        // Auto-detect mode: treat current dir as single book
80        vec![scanner.scan_single_directory(&root)?]
81    } else {
82        // Normal mode: scan for multiple books
83        scanner
84            .scan_directory(&root)
85            .context("Failed to scan directory")?
86    };
87
88    if book_folders.is_empty() {
89        println!("{} No audiobooks found", style("✗").red());
90        return Ok(());
91    }
92
93    println!(
94        "{} Found {} audiobook(s)",
95        style("✓").green(),
96        style(book_folders.len()).cyan()
97    );
98
99    // Filter by skip_existing if configured
100    if config.processing.skip_existing && !args.force {
101        book_folders.retain(|b| b.m4b_files.is_empty());
102        println!(
103            "{} After filtering existing: {} audiobook(s)",
104            style("→").cyan(),
105            style(book_folders.len()).cyan()
106        );
107    }
108
109    if book_folders.is_empty() {
110        println!(
111            "{} All audiobooks already processed (use --force to reprocess)",
112            style("ℹ").blue()
113        );
114        return Ok(());
115    }
116
117    // Dry run mode
118    if args.dry_run {
119        println!("\n{} DRY RUN MODE - No changes will be made\n", style("ℹ").blue());
120        for book in &book_folders {
121            println!(
122                "  {} {} ({} files, {:.1} min)",
123                style("→").cyan(),
124                style(&book.name).yellow(),
125                book.mp3_files.len(),
126                book.get_total_duration() / 60.0
127            );
128        }
129        return Ok(());
130    }
131
132    // Analyze all books
133    println!("\n{} Analyzing tracks...", style("→").cyan());
134    let analyzer_workers = args.parallel.unwrap_or(config.processing.parallel_workers);
135    let analyzer = Analyzer::with_workers(analyzer_workers as usize)?;
136
137    for book in &mut book_folders {
138        analyzer
139            .analyze_book_folder(book)
140            .await
141            .with_context(|| format!("Failed to analyze {}", book.name))?;
142    }
143
144    println!("{} Analysis complete", style("✓").green());
145
146    // Fetch Audible metadata if enabled
147    if args.fetch_audible || config.metadata.audible.enabled {
148        println!("\n{} Fetching Audible metadata...", style("→").cyan());
149
150        let audible_region = args.audible_region
151            .as_deref()
152            .or(Some(&config.metadata.audible.region))
153            .and_then(|r| AudibleRegion::from_str(r).ok())
154            .unwrap_or(AudibleRegion::US);
155
156        let client = AudibleClient::with_rate_limit(
157            audible_region,
158            config.metadata.audible.rate_limit_per_minute
159        )?;
160        let cache = AudibleCache::with_ttl_hours(config.metadata.audible.cache_duration_hours)?;
161
162        for book in &mut book_folders {
163            // Try ASIN detection first
164            if let Some(asin) = detect_asin(&book.name) {
165                tracing::debug!("Detected ASIN {} in folder: {}", asin, book.name);
166                book.detected_asin = Some(asin.clone());
167
168                // Try cache first
169                match cache.get(&asin).await {
170                    Some(cached) => {
171                        book.audible_metadata = Some(cached);
172                        println!("  {} {} (ASIN: {}, cached)", style("✓").green(), book.name, asin);
173                    }
174                    None => {
175                        // Fetch from API
176                        match client.fetch_by_asin(&asin).await {
177                            Ok(metadata) => {
178                                // Cache the result
179                                let _ = cache.set(&asin, &metadata).await;
180                                book.audible_metadata = Some(metadata);
181                                println!("  {} {} (ASIN: {})", style("✓").green(), book.name, asin);
182                            }
183                            Err(e) => {
184                                tracing::warn!("Failed to fetch metadata for {}: {}", book.name, e);
185                                println!("  {} {} - fetch failed", style("⚠").yellow(), book.name);
186                            }
187                        }
188                    }
189                }
190            } else if args.audible_auto_match || config.metadata.audible.auto_match {
191                // Try auto-matching by title
192                tracing::debug!("Attempting auto-match for: {}", book.name);
193
194                match client.search(Some(&book.name), None).await {
195                    Ok(results) if !results.is_empty() => {
196                        let asin = &results[0].asin;
197                        tracing::debug!("Auto-matched {} to ASIN: {}", book.name, asin);
198                        book.detected_asin = Some(asin.clone());
199
200                        // Try cache first
201                        match cache.get(asin).await {
202                            Some(cached) => {
203                                book.audible_metadata = Some(cached);
204                                println!("  {} {} (matched: {}, cached)", style("✓").green(), book.name, asin);
205                            }
206                            None => {
207                                // Fetch from API
208                                match client.fetch_by_asin(asin).await {
209                                    Ok(metadata) => {
210                                        // Cache the result
211                                        let _ = cache.set(asin, &metadata).await;
212                                        book.audible_metadata = Some(metadata);
213                                        println!("  {} {} (matched: {})", style("✓").green(), book.name, asin);
214                                    }
215                                    Err(e) => {
216                                        tracing::warn!("Failed to fetch metadata after match for {}: {}", book.name, e);
217                                        println!("  {} {} - fetch failed", style("⚠").yellow(), book.name);
218                                    }
219                                }
220                            }
221                        }
222                    }
223                    Ok(_) => {
224                        tracing::debug!("No Audible match found for: {}", book.name);
225                        println!("  {} {} - no match found", style("○").dim(), book.name);
226                    }
227                    Err(e) => {
228                        tracing::warn!("Search failed for {}: {}", book.name, e);
229                        println!("  {} {} - search failed", style("⚠").yellow(), book.name);
230                    }
231                }
232            } else {
233                tracing::debug!("No ASIN detected and auto-match disabled for: {}", book.name);
234            }
235        }
236
237        let fetched_count = book_folders.iter().filter(|b| b.audible_metadata.is_some()).count();
238        println!("{} Fetched metadata for {}/{} books",
239            style("✓").green(),
240            style(fetched_count).cyan(),
241            book_folders.len()
242        );
243    }
244
245    // Determine output directory
246    let output_dir = if auto_detected {
247        // When auto-detected, default to current directory
248        args.out.unwrap_or(root.clone())
249    } else {
250        // Normal mode: respect config
251        args.out.or_else(|| {
252            if config.directories.output == "same_as_source" {
253                Some(root.clone())
254            } else {
255                Some(PathBuf::from(&config.directories.output))
256            }
257        }).context("No output directory specified")?
258    };
259
260    // Create batch processor with config settings
261    let workers = args.parallel.unwrap_or(config.processing.parallel_workers) as usize;
262    let keep_temp = args.keep_temp || config.processing.keep_temp_files;
263
264    // Use Apple Silicon encoder if configured, otherwise auto-detect
265    let use_apple_silicon = config.advanced.use_apple_silicon_encoder.unwrap_or(true);
266
267    // Parse max concurrent encodes from config
268    let max_concurrent = if config.performance.max_concurrent_encodes == "auto" {
269        num_cpus::get() // Use all CPU cores
270    } else {
271        config.performance.max_concurrent_encodes
272            .parse::<usize>()
273            .unwrap_or(num_cpus::get())
274            .clamp(1, 16)
275    };
276
277    // Create retry config from settings
278    let retry_config = RetryConfig::with_settings(
279        config.processing.max_retries as usize,
280        std::time::Duration::from_secs(config.processing.retry_delay),
281        std::time::Duration::from_secs(30),
282        2.0,
283    );
284
285    let batch_processor = BatchProcessor::with_options(
286        workers,
287        keep_temp,
288        use_apple_silicon,
289        config.performance.enable_parallel_encoding,
290        max_concurrent,
291        retry_config,
292    );
293
294    // Process batch
295    println!("\n{} Processing {} audiobook(s)...\n", style("→").cyan(), book_folders.len());
296
297    let results = batch_processor
298        .process_batch(
299            book_folders,
300            &output_dir,
301            &config.quality.chapter_source,
302        )
303        .await;
304
305    // Print results
306    println!();
307    let successful = results.iter().filter(|r| r.success).count();
308    let failed = results.len() - successful;
309
310    for result in &results {
311        if result.success {
312            println!(
313                "  {} {} ({:.1}s, {})",
314                style("✓").green(),
315                style(&result.book_name).yellow(),
316                result.processing_time,
317                if result.used_copy_mode {
318                    "copy mode"
319                } else {
320                    "transcode"
321                }
322            );
323        } else {
324            println!(
325                "  {} {} - {}",
326                style("✗").red(),
327                style(&result.book_name).yellow(),
328                result.error_message.as_deref().unwrap_or("Unknown error")
329            );
330        }
331    }
332
333    println!(
334        "\n{} Batch complete: {} successful, {} failed",
335        style("✓").green(),
336        style(successful).green(),
337        if failed > 0 {
338            style(failed).red()
339        } else {
340            style(failed).dim()
341        }
342    );
343
344    Ok(())
345}
346
347/// Handle the organize command
348pub fn handle_organize(args: OrganizeArgs, config: Config) -> Result<()> {
349    // Determine root directory
350    let root = args
351        .root
352        .or(config.directories.source.clone())
353        .context("No root directory specified. Use --root or configure directories.source")?;
354
355    println!(
356        "{} Scanning audiobooks in: {}",
357        style("→").cyan(),
358        style(root.display()).yellow()
359    );
360
361    // Scan for audiobooks
362    let scanner = Scanner::new();
363    let book_folders = scanner
364        .scan_directory(&root)
365        .context("Failed to scan directory")?;
366
367    if book_folders.is_empty() {
368        println!("{} No audiobooks found", style("✗").red());
369        return Ok(());
370    }
371
372    println!(
373        "{} Found {} audiobook(s)",
374        style("✓").green(),
375        style(book_folders.len()).cyan()
376    );
377
378    // Create organizer
379    let organizer = Organizer::with_dry_run(root, &config, args.dry_run);
380
381    // Dry run notice
382    if args.dry_run {
383        println!("\n{} DRY RUN MODE - No changes will be made\n", style("ℹ").blue());
384    }
385
386    // Organize books
387    let results = organizer.organize_batch(book_folders);
388
389    // Print results
390    println!();
391    for result in &results {
392        let action_str = result.action.description();
393
394        if result.success {
395            match result.destination_path {
396                Some(ref dest) => {
397                    println!(
398                        "  {} {} → {}",
399                        style("✓").green(),
400                        style(&result.book_name).yellow(),
401                        style(dest.display()).cyan()
402                    );
403                }
404                None => {
405                    println!(
406                        "  {} {} ({})",
407                        style("→").dim(),
408                        style(&result.book_name).dim(),
409                        style(action_str).dim()
410                    );
411                }
412            }
413        } else {
414            println!(
415                "  {} {} - {}",
416                style("✗").red(),
417                style(&result.book_name).yellow(),
418                result.error_message.as_deref().unwrap_or("Unknown error")
419            );
420        }
421    }
422
423    let moved = results
424        .iter()
425        .filter(|r| r.success && r.destination_path.is_some())
426        .count();
427    let skipped = results.iter().filter(|r| r.destination_path.is_none()).count();
428    let failed = results.iter().filter(|r| !r.success).count();
429
430    println!(
431        "\n{} Organization complete: {} moved, {} skipped, {} failed",
432        style("✓").green(),
433        style(moved).green(),
434        style(skipped).dim(),
435        if failed > 0 {
436            style(failed).red()
437        } else {
438            style(failed).dim()
439        }
440    );
441
442    Ok(())
443}
444
445/// Handle the config command
446pub fn handle_config(command: ConfigCommands) -> Result<()> {
447    match command {
448        ConfigCommands::Init { force } => {
449            let config_path = ConfigManager::default_config_path()?;
450
451            if config_path.exists() && !force {
452                println!(
453                    "{} Configuration file already exists: {}",
454                    style("✗").red(),
455                    style(config_path.display()).yellow()
456                );
457                println!("Use --force to overwrite");
458                return Ok(());
459            }
460
461            // Create config directory if needed
462            ConfigManager::ensure_config_dir()?;
463
464            // Create default config
465            let config = Config::default();
466            ConfigManager::save(&config, Some(&config_path))?;
467
468            println!(
469                "{} Configuration file created: {}",
470                style("✓").green(),
471                style(config_path.display()).yellow()
472            );
473        }
474
475        ConfigCommands::Show { config: _ } => {
476            let config_path = ConfigManager::default_config_path()?;
477            let config = ConfigManager::load(&config_path)?;
478            let yaml = serde_yaml::to_string(&config)?;
479            println!("{}", yaml);
480        }
481
482        ConfigCommands::Path => {
483            let config_path = ConfigManager::default_config_path()?;
484            println!("{}", config_path.display());
485        }
486
487        ConfigCommands::Validate { config: _ } => {
488            let config_path = ConfigManager::default_config_path()?;
489            ConfigManager::load(&config_path)?;
490            println!(
491                "{} Configuration is valid",
492                style("✓").green()
493            );
494        }
495
496        ConfigCommands::Edit => {
497            let config_path = ConfigManager::default_config_path()?;
498            println!("{} Opening editor for: {}", style("→").cyan(), style(config_path.display()).yellow());
499            // TODO: Implement editor opening
500            println!("{} Editor integration not yet implemented", style("ℹ").blue());
501        }
502    }
503
504    Ok(())
505}
506
507/// Handle the check command
508pub fn handle_check() -> Result<()> {
509    println!("{} Checking system dependencies...\n", style("→").cyan());
510
511    let results = vec![
512        ("FFmpeg", DependencyChecker::check_ffmpeg().found),
513        ("AtomicParsley", DependencyChecker::check_atomic_parsley().found),
514        ("MP4Box", DependencyChecker::check_mp4box().found),
515    ];
516
517    let all_found = results.iter().all(|(_, found)| *found);
518
519    for (tool, found) in &results {
520        if *found {
521            println!("  {} {}", style("✓").green(), style(tool).cyan());
522        } else {
523            println!("  {} {} (not found)", style("✗").red(), style(tool).yellow());
524        }
525    }
526
527    println!();
528    if all_found {
529        println!("{} All dependencies found", style("✓").green());
530    } else {
531        println!("{} Some dependencies are missing", style("✗").red());
532        println!("\nInstall missing dependencies:");
533        println!("  macOS:   brew install ffmpeg atomicparsley gpac");
534        println!("  Ubuntu:  apt install ffmpeg atomicparsley gpac");
535    }
536
537    Ok(())
538}
539
540/// Handle the metadata command
541pub async fn handle_metadata(command: MetadataCommands, config: Config) -> Result<()> {
542    match command {
543        MetadataCommands::Fetch { asin, title, author, region, output } => {
544            println!("{} Fetching Audible metadata...", style("→").cyan());
545
546            // Parse region
547            let audible_region = AudibleRegion::from_str(&region)
548                .unwrap_or(AudibleRegion::US);
549
550            // Create client and cache
551            let client = AudibleClient::with_rate_limit(
552                audible_region,
553                config.metadata.audible.rate_limit_per_minute
554            )?;
555            let cache = AudibleCache::with_ttl_hours(config.metadata.audible.cache_duration_hours)?;
556
557            // Fetch metadata
558            let metadata = if let Some(asin_val) = asin {
559                // Direct ASIN lookup
560                println!("  {} Looking up ASIN: {}", style("→").cyan(), asin_val);
561
562                // Try cache first
563                if let Some(cached) = cache.get(&asin_val).await {
564                    println!("  {} Using cached metadata", style("✓").green());
565                    cached
566                } else {
567                    let fetched = client.fetch_by_asin(&asin_val).await?;
568                    cache.set(&asin_val, &fetched).await?;
569                    fetched
570                }
571            } else if title.is_some() || author.is_some() {
572                // Search by title/author
573                println!("  {} Searching: title={:?}, author={:?}",
574                    style("→").cyan(), title, author);
575
576                let results = client.search(title.as_deref(), author.as_deref()).await?;
577
578                if results.is_empty() {
579                    bail!("No results found for search query");
580                }
581
582                // Display search results
583                println!("\n{} Found {} result(s):", style("✓").green(), results.len());
584                for (i, result) in results.iter().enumerate().take(5) {
585                    println!("  {}. {} by {}",
586                        i + 1,
587                        style(&result.title).yellow(),
588                        style(result.authors_string()).cyan()
589                    );
590                }
591
592                // Fetch first result
593                println!("\n{} Fetching details for first result...", style("→").cyan());
594                let asin_to_fetch = &results[0].asin;
595
596                if let Some(cached) = cache.get(asin_to_fetch).await {
597                    cached
598                } else {
599                    let fetched = client.fetch_by_asin(asin_to_fetch).await?;
600                    cache.set(asin_to_fetch, &fetched).await?;
601                    fetched
602                }
603            } else {
604                bail!("Must provide --asin or --title/--author");
605            };
606
607            // Display metadata
608            println!("\n{}", style("=".repeat(60)).dim());
609            println!("{}: {}", style("Title").bold(), metadata.title);
610            if let Some(subtitle) = &metadata.subtitle {
611                println!("{}: {}", style("Subtitle").bold(), subtitle);
612            }
613            if !metadata.authors.is_empty() {
614                println!("{}: {}", style("Author(s)").bold(), metadata.authors_string());
615            }
616            if !metadata.narrators.is_empty() {
617                println!("{}: {}", style("Narrator(s)").bold(), metadata.narrators_string());
618            }
619            if let Some(publisher) = &metadata.publisher {
620                println!("{}: {}", style("Publisher").bold(), publisher);
621            }
622            if let Some(year) = metadata.published_year {
623                println!("{}: {}", style("Published").bold(), year);
624            }
625            if let Some(duration_min) = metadata.runtime_minutes() {
626                let hours = duration_min / 60;
627                let mins = duration_min % 60;
628                println!("{}: {}h {}m", style("Duration").bold(), hours, mins);
629            }
630            if let Some(lang) = &metadata.language {
631                println!("{}: {}", style("Language").bold(), lang);
632            }
633            if !metadata.genres.is_empty() {
634                println!("{}: {}", style("Genres").bold(), metadata.genres.join(", "));
635            }
636            if !metadata.series.is_empty() {
637                for series in &metadata.series {
638                    let seq_info = if let Some(seq) = &series.sequence {
639                        format!(" (Book {})", seq)
640                    } else {
641                        String::new()
642                    };
643                    println!("{}: {}{}", style("Series").bold(), series.name, seq_info);
644                }
645            }
646            println!("{}: {}", style("ASIN").bold(), metadata.asin);
647            println!("{}", style("=".repeat(60)).dim());
648
649            // Save to file if requested
650            if let Some(output_path) = output {
651                let json = serde_json::to_string_pretty(&metadata)?;
652                std::fs::write(&output_path, json)?;
653                println!("\n{} Saved metadata to: {}",
654                    style("✓").green(),
655                    style(output_path.display()).yellow()
656                );
657            }
658
659            Ok(())
660        }
661
662        MetadataCommands::Enrich { file, asin, auto_detect, region } => {
663            println!("{} Enriching M4B file with Audible metadata...", style("→").cyan());
664
665            if !file.exists() {
666                bail!("File does not exist: {}", file.display());
667            }
668
669            // Detect or use provided ASIN
670            let asin_to_use = if let Some(asin_val) = asin {
671                asin_val
672            } else if auto_detect {
673                detect_asin(&file.display().to_string())
674                    .ok_or_else(|| anyhow::anyhow!("Could not detect ASIN from filename: {}", file.display()))?
675            } else {
676                bail!("Must provide --asin or use --auto-detect");
677            };
678
679            println!("  {} Using ASIN: {}", style("→").cyan(), asin_to_use);
680
681            // Parse region
682            let audible_region = AudibleRegion::from_str(&region)
683                .unwrap_or(AudibleRegion::US);
684
685            // Create client and cache
686            let client = AudibleClient::with_rate_limit(
687                audible_region,
688                config.metadata.audible.rate_limit_per_minute
689            )?;
690            let cache = AudibleCache::with_ttl_hours(config.metadata.audible.cache_duration_hours)?;
691
692            // Fetch metadata
693            let metadata = if let Some(cached) = cache.get(&asin_to_use).await {
694                println!("  {} Using cached metadata", style("✓").green());
695                cached
696            } else {
697                println!("  {} Fetching from Audible...", style("→").cyan());
698                let fetched = client.fetch_by_asin(&asin_to_use).await?;
699                cache.set(&asin_to_use, &fetched).await?;
700                fetched
701            };
702
703            println!("  {} Found: {}", style("✓").green(), metadata.title);
704
705            // Download cover if available and enabled
706            let cover_path = if config.metadata.audible.download_covers {
707                if let Some(cover_url) = &metadata.cover_url {
708                    println!("  {} Downloading cover art...", style("→").cyan());
709                    let temp_cover = std::env::temp_dir().join(format!("{}.jpg", asin_to_use));
710                    client.download_cover(cover_url, &temp_cover).await?;
711                    println!("  {} Cover downloaded", style("✓").green());
712                    Some(temp_cover)
713                } else {
714                    None
715                }
716            } else {
717                None
718            };
719
720            // Inject metadata (this will be implemented in metadata.rs)
721            println!("  {} Injecting metadata...", style("→").cyan());
722            crate::audio::inject_audible_metadata(&file, &metadata, cover_path.as_deref()).await?;
723
724            println!("\n{} Successfully enriched: {}",
725                style("✓").green(),
726                style(file.display()).yellow()
727            );
728
729            Ok(())
730        }
731    }
732}
733
734/// Handle the match command
735pub async fn handle_match(args: MatchArgs, config: Config) -> Result<()> {
736    // Determine files to process
737    let files = get_files_to_process(&args)?;
738
739    if files.is_empty() {
740        println!("{} No M4B files found", style("✗").red());
741        return Ok(());
742    }
743
744    println!(
745        "{} Found {} M4B file(s)",
746        style("✓").green(),
747        style(files.len()).cyan()
748    );
749
750    // Initialize Audible client and cache
751    let region = AudibleRegion::from_str(&args.region)?;
752    let client = AudibleClient::with_rate_limit(
753        region,
754        config.metadata.audible.rate_limit_per_minute
755    )?;
756    let cache = AudibleCache::with_ttl_hours(
757        config.metadata.audible.cache_duration_hours
758    )?;
759
760    // Process each file
761    let mut processed = 0;
762    let mut skipped = 0;
763    let mut failed = 0;
764
765    for (idx, file_path) in files.iter().enumerate() {
766        println!(
767            "\n{} [{}/{}] Processing: {}",
768            style("→").cyan(),
769            idx + 1,
770            files.len(),
771            style(file_path.display()).yellow()
772        );
773
774        match process_single_file(&file_path, &args, &client, &cache, &config).await {
775            Ok(ProcessResult::Applied) => processed += 1,
776            Ok(ProcessResult::Skipped) => skipped += 1,
777            Err(e) => {
778                eprintln!("{} Error: {}", style("✗").red(), e);
779                failed += 1;
780            }
781        }
782    }
783
784    // Summary
785    println!("\n{}", style("Summary:").bold().cyan());
786    println!("  {} Processed: {}", style("✓").green(), processed);
787    println!("  {} Skipped: {}", style("→").yellow(), skipped);
788    if failed > 0 {
789        println!("  {} Failed: {}", style("✗").red(), failed);
790    }
791
792    Ok(())
793}
794
795/// Result of processing a single file
796enum ProcessResult {
797    Applied,
798    Skipped,
799}
800
801/// Process a single M4B file
802async fn process_single_file(
803    file_path: &PathBuf,
804    args: &MatchArgs,
805    client: &AudibleClient,
806    _cache: &AudibleCache,
807    config: &Config,
808) -> Result<ProcessResult> {
809    // Extract current metadata
810    let mut current = if args.title.is_some() || args.author.is_some() {
811        // Manual override
812        CurrentMetadata {
813            title: args.title.clone(),
814            author: args.author.clone(),
815            year: None,
816            duration: None,
817            source: MetadataSource::Manual,
818        }
819    } else {
820        // Auto-extract
821        extraction::extract_current_metadata(file_path)?
822    };
823
824    // Search loop (allows re-search)
825    loop {
826        // Search Audible
827        let search_results = search_audible(&current, client).await?;
828
829        if search_results.is_empty() {
830            println!("{} No matches found on Audible", style("⚠").yellow());
831
832            if args.auto {
833                return Ok(ProcessResult::Skipped);
834            }
835
836            // Offer manual entry or skip
837            match prompt_no_results_action()? {
838                NoResultsAction::ManualEntry => {
839                    let manual_metadata = prompt_manual_metadata()?;
840                    apply_metadata(file_path, &manual_metadata, args, config).await?;
841                    return Ok(ProcessResult::Applied);
842                }
843                NoResultsAction::CustomSearch => {
844                    let (title, author) = prompt_custom_search()?;
845                    current.title = title;
846                    current.author = author;
847                    current.source = MetadataSource::Manual;
848                    continue; // Re-search
849                }
850                NoResultsAction::Skip => {
851                    return Ok(ProcessResult::Skipped);
852                }
853            }
854        }
855
856        // Score and rank candidates
857        let candidates = scoring::score_and_sort(&current, search_results);
858
859        // Auto mode: select best match
860        if args.auto {
861            let best = &candidates[0];
862            println!(
863                "  {} Auto-selected: {} ({:.1}%)",
864                style("✓").green(),
865                best.metadata.title,
866                (1.0 - best.distance.total_distance()) * 100.0
867            );
868
869            if !args.dry_run {
870                apply_metadata(file_path, &best.metadata, args, config).await?;
871            }
872            return Ok(ProcessResult::Applied);
873        }
874
875        // Interactive mode
876        match prompt_match_selection(&current, &candidates)? {
877            UserChoice::SelectMatch(idx) => {
878                let selected = &candidates[idx];
879
880                // Confirm selection
881                if confirm_match(&current, selected)? {
882                    if !args.dry_run {
883                        apply_metadata(file_path, &selected.metadata, args, config).await?;
884                    }
885                    return Ok(ProcessResult::Applied);
886                } else {
887                    // User cancelled, show menu again
888                    continue;
889                }
890            }
891            UserChoice::Skip => {
892                return Ok(ProcessResult::Skipped);
893            }
894            UserChoice::ManualEntry => {
895                let manual_metadata = prompt_manual_metadata()?;
896                if !args.dry_run {
897                    apply_metadata(file_path, &manual_metadata, args, config).await?;
898                }
899                return Ok(ProcessResult::Applied);
900            }
901            UserChoice::CustomSearch => {
902                let (title, author) = prompt_custom_search()?;
903                current.title = title;
904                current.author = author;
905                current.source = MetadataSource::Manual;
906                continue; // Re-search
907            }
908        }
909    }
910}
911
912/// Get list of M4B files to process
913fn get_files_to_process(args: &MatchArgs) -> Result<Vec<PathBuf>> {
914    if let Some(file) = &args.file {
915        // Single file mode
916        if !file.exists() {
917            bail!("File not found: {}", file.display());
918        }
919        if !is_m4b_file(file) {
920            bail!("File is not an M4B: {}", file.display());
921        }
922        Ok(vec![file.clone()])
923    } else if let Some(dir) = &args.dir {
924        // Directory mode
925        if !dir.is_dir() {
926            bail!("Not a directory: {}", dir.display());
927        }
928
929        let mut files = Vec::new();
930        for entry in std::fs::read_dir(dir)? {
931            let entry = entry?;
932            let path = entry.path();
933            if path.is_file() && is_m4b_file(&path) {
934                files.push(path);
935            }
936        }
937
938        files.sort();
939        Ok(files)
940    } else {
941        bail!("Must specify --file or --dir");
942    }
943}
944
945/// Check if file is M4B
946fn is_m4b_file(path: &PathBuf) -> bool {
947    path.extension()
948        .and_then(|e| e.to_str())
949        .map(|e| e.eq_ignore_ascii_case("m4b"))
950        .unwrap_or(false)
951}
952
953/// Search Audible API
954async fn search_audible(
955    current: &CurrentMetadata,
956    client: &AudibleClient,
957) -> Result<Vec<crate::models::AudibleMetadata>> {
958    // Build search query
959    let title = current.title.as_deref();
960    let author = current.author.as_deref();
961
962    if title.is_none() && author.is_none() {
963        bail!("Need at least title or author to search");
964    }
965
966    // Search Audible (now returns full metadata via two-step process)
967    let metadata_results = client.search(title, author).await?;
968
969    Ok(metadata_results)
970}
971
972/// Apply metadata to M4B file
973async fn apply_metadata(
974    file_path: &PathBuf,
975    metadata: &crate::models::AudibleMetadata,
976    args: &MatchArgs,
977    config: &Config,
978) -> Result<()> {
979    // Download cover if needed
980    let cover_path = if !args.keep_cover && metadata.cover_url.is_some() && config.metadata.audible.download_covers {
981        let temp_cover = std::env::temp_dir().join(format!("{}.jpg", metadata.asin));
982
983        if let Some(cover_url) = &metadata.cover_url {
984            let client = AudibleClient::new(AudibleRegion::US)?; // Region doesn't matter for covers
985            client.download_cover(cover_url, &temp_cover).await?;
986            Some(temp_cover)
987        } else {
988            None
989        }
990    } else {
991        None
992    };
993
994    // Inject metadata
995    crate::audio::inject_audible_metadata(file_path, metadata, cover_path.as_deref()).await?;
996
997    println!("  {} Metadata applied successfully", style("✓").green());
998
999    Ok(())
1000}
1001
1002/// Action to take when no results found
1003enum NoResultsAction {
1004    ManualEntry,
1005    CustomSearch,
1006    Skip,
1007}
1008
1009/// Prompt for action when no results found
1010fn prompt_no_results_action() -> Result<NoResultsAction> {
1011    use inquire::Select;
1012
1013    let options = vec![
1014        "Enter metadata manually",
1015        "Search with different terms",
1016        "Skip this file",
1017    ];
1018
1019    let selection = Select::new("What would you like to do?", options).prompt()?;
1020
1021    match selection {
1022        "Enter metadata manually" => Ok(NoResultsAction::ManualEntry),
1023        "Search with different terms" => Ok(NoResultsAction::CustomSearch),
1024        "Skip this file" => Ok(NoResultsAction::Skip),
1025        _ => Ok(NoResultsAction::Skip),
1026    }
1027}