1use 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
14fn 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 if current_dir.parent().is_none() {
21 return Ok(None);
22 }
23
24 let entries = std::fs::read_dir(¤t_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 if mp3_count >= 2 {
41 Ok(Some(current_dir))
42 } else {
43 Ok(None)
44 }
45}
46
47pub async fn handle_build(args: BuildArgs, config: Config) -> Result<()> {
49 let (root, auto_detected) = if let Some(root_path) = args.root.or(config.directories.source.clone()) {
51 (root_path, false)
52 } else {
53 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 let scanner = Scanner::new();
78 let mut book_folders = if auto_detected {
79 vec![scanner.scan_single_directory(&root)?]
81 } else {
82 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 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 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 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 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 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 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 match client.fetch_by_asin(&asin).await {
177 Ok(metadata) => {
178 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 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 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 match client.fetch_by_asin(asin).await {
209 Ok(metadata) => {
210 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 let output_dir = if auto_detected {
247 args.out.unwrap_or(root.clone())
249 } else {
250 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 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 let use_apple_silicon = config.advanced.use_apple_silicon_encoder.unwrap_or(true);
266
267 let max_concurrent = if config.performance.max_concurrent_encodes == "auto" {
269 num_cpus::get() } else {
271 config.performance.max_concurrent_encodes
272 .parse::<usize>()
273 .unwrap_or(num_cpus::get())
274 .clamp(1, 16)
275 };
276
277 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 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 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
347pub fn handle_organize(args: OrganizeArgs, config: Config) -> Result<()> {
349 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 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 let organizer = Organizer::with_dry_run(root, &config, args.dry_run);
380
381 if args.dry_run {
383 println!("\n{} DRY RUN MODE - No changes will be made\n", style("ℹ").blue());
384 }
385
386 let results = organizer.organize_batch(book_folders);
388
389 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
445pub 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 ConfigManager::ensure_config_dir()?;
463
464 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 println!("{} Editor integration not yet implemented", style("ℹ").blue());
501 }
502 }
503
504 Ok(())
505}
506
507pub 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
540pub 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 let audible_region = AudibleRegion::from_str(®ion)
548 .unwrap_or(AudibleRegion::US);
549
550 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 let metadata = if let Some(asin_val) = asin {
559 println!(" {} Looking up ASIN: {}", style("→").cyan(), asin_val);
561
562 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 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 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 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 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 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 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 let audible_region = AudibleRegion::from_str(®ion)
683 .unwrap_or(AudibleRegion::US);
684
685 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 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 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 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
734pub async fn handle_match(args: MatchArgs, config: Config) -> Result<()> {
736 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 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 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 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
795enum ProcessResult {
797 Applied,
798 Skipped,
799}
800
801async fn process_single_file(
803 file_path: &PathBuf,
804 args: &MatchArgs,
805 client: &AudibleClient,
806 _cache: &AudibleCache,
807 config: &Config,
808) -> Result<ProcessResult> {
809 let mut current = if args.title.is_some() || args.author.is_some() {
811 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 extraction::extract_current_metadata(file_path)?
822 };
823
824 loop {
826 let search_results = search_audible(¤t, 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 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; }
850 NoResultsAction::Skip => {
851 return Ok(ProcessResult::Skipped);
852 }
853 }
854 }
855
856 let candidates = scoring::score_and_sort(¤t, search_results);
858
859 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 match prompt_match_selection(¤t, &candidates)? {
877 UserChoice::SelectMatch(idx) => {
878 let selected = &candidates[idx];
879
880 if confirm_match(¤t, 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 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; }
908 }
909 }
910}
911
912fn get_files_to_process(args: &MatchArgs) -> Result<Vec<PathBuf>> {
914 if let Some(file) = &args.file {
915 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 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
945fn 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
953async fn search_audible(
955 current: &CurrentMetadata,
956 client: &AudibleClient,
957) -> Result<Vec<crate::models::AudibleMetadata>> {
958 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 let metadata_results = client.search(title, author).await?;
968
969 Ok(metadata_results)
970}
971
972async fn apply_metadata(
974 file_path: &PathBuf,
975 metadata: &crate::models::AudibleMetadata,
976 args: &MatchArgs,
977 config: &Config,
978) -> Result<()> {
979 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)?; client.download_cover(cover_url, &temp_cover).await?;
986 Some(temp_cover)
987 } else {
988 None
989 }
990 } else {
991 None
992 };
993
994 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
1002enum NoResultsAction {
1004 ManualEntry,
1005 CustomSearch,
1006 Skip,
1007}
1008
1009fn 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}