1use 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
14fn resolve_encoder(config: &Config, cli_override: Option<&str>) -> AacEncoder {
16 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 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 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
61fn 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 if current_dir.parent().is_none() {
68 return Ok(None);
69 }
70
71 let entries = std::fs::read_dir(¤t_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 if mp3_count >= 1 {
88 Ok(Some(current_dir))
89 } else {
90 Ok(None)
91 }
92}
93
94fn 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
121pub async fn handle_build(args: BuildArgs, config: Config) -> Result<()> {
123 let (root, auto_detected) = if let Some(root_path) = args.root.or(config.directories.source.clone()) {
125 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 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 let scanner = Scanner::from_config(&config);
162 let mut book_folders = if auto_detected {
163 vec![scanner.scan_single_directory(&root)?]
165 } else {
166 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 if config.processing.skip_existing && !args.force {
185 book_folders.retain(|b| {
186 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 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 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 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 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 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 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 match client.fetch_by_asin(&asin).await {
284 Ok(metadata) => {
285 let _ = cache.set(&asin, &metadata).await;
287 book.audible_metadata = Some(metadata);
288 println!(" {} {} (ASIN: {})", style("✓").green(), book.name, asin);
289
290 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 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 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 match client.fetch_by_asin(asin).await {
328 Ok(metadata) => {
329 let _ = cache.set(asin, &metadata).await;
331 book.audible_metadata = Some(metadata);
332 println!(" {} {} (matched: {})", style("✓").green(), book.name, asin);
333
334 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 let output_dir = if auto_detected {
378 args.out.unwrap_or(root.clone())
380 } else {
381 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 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 let encoder = resolve_encoder(&config, args.aac_encoder.as_deref());
397
398 let max_concurrent = if config.performance.max_concurrent_encodes == "auto" {
400 num_cpus::get() } else {
402 config.performance.max_concurrent_encodes
403 .parse::<usize>()
404 .unwrap_or(num_cpus::get())
405 .clamp(1, 16)
406 };
407
408 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 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 let (merge_books, convert_books): (Vec<_>, Vec<_>) = book_folders
439 .into_iter()
440 .partition(|b| b.case == BookCase::E);
441
442 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 let book_folders = convert_books;
482
483 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 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
538pub fn handle_organize(args: OrganizeArgs, config: Config) -> Result<()> {
540 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 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 let organizer = Organizer::with_dry_run(root, &config, args.dry_run);
571
572 if args.dry_run {
574 println!("\n{} DRY RUN MODE - No changes will be made\n", style("ℹ").blue());
575 }
576
577 let results = organizer.organize_batch(book_folders);
579
580 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
636pub 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 ConfigManager::ensure_config_dir()?;
654
655 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 println!("{} Editor integration not yet implemented", style("ℹ").blue());
692 }
693 }
694
695 Ok(())
696}
697
698pub 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 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
752pub 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 let audible_region = AudibleRegion::from_str(®ion)
760 .unwrap_or(AudibleRegion::US);
761
762 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 let metadata = if let Some(asin_val) = asin {
771 println!(" {} Looking up ASIN: {}", style("→").cyan(), asin_val);
773
774 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 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 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 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 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 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 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 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 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(®ion).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 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 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 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 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 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 let audible_region = AudibleRegion::from_str(®ion)
982 .unwrap_or(AudibleRegion::US);
983
984 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 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 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 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
1033pub async fn handle_match(args: MatchArgs, config: Config) -> Result<()> {
1035 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 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 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 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
1101enum ProcessResult {
1103 Applied,
1104 Skipped,
1105}
1106
1107async fn process_single_file(
1109 file_path: &PathBuf,
1110 args: &MatchArgs,
1111 client: &AudibleClient,
1112 _cache: &AudibleCache,
1113 config: &Config,
1114) -> Result<ProcessResult> {
1115 let mut current = if args.title.is_some() || args.author.is_some() {
1117 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 extraction::extract_current_metadata(file_path)?
1128 };
1129
1130 loop {
1132 let search_results = search_audible(¤t, 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 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; }
1156 NoResultsAction::Skip => {
1157 return Ok(ProcessResult::Skipped);
1158 }
1159 }
1160 }
1161
1162 let candidates = scoring::score_and_sort(¤t, search_results);
1164
1165 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 match prompt_match_selection(¤t, &candidates)? {
1183 UserChoice::SelectMatch(idx) => {
1184 let selected = &candidates[idx];
1185
1186 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 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; }
1219 }
1220 }
1221}
1222
1223fn get_files_to_process(args: &MatchArgs) -> Result<Vec<PathBuf>> {
1225 if let Some(file) = &args.file {
1226 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 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
1256fn 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
1264async fn search_audible(
1266 current: &CurrentMetadata,
1267 client: &AudibleClient,
1268) -> Result<Vec<crate::models::AudibleMetadata>> {
1269 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 let metadata_results = client.search(title, author).await?;
1279
1280 Ok(metadata_results)
1281}
1282
1283async fn apply_metadata(
1285 file_path: &PathBuf,
1286 metadata: &crate::models::AudibleMetadata,
1287 args: &MatchArgs,
1288 config: &Config,
1289) -> Result<()> {
1290 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, 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 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
1331enum NoResultsAction {
1333 ManualEntry,
1334 CustomSearch,
1335 Skip,
1336}
1337
1338fn 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}