1use super::metadata::read;
2use super::models::Library;
3use super::utils::is_artist_image_file;
4use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8
9fn normalize_artist_key(value: &str) -> Option<String> {
10 let normalized = value.trim().to_lowercase();
11 if normalized.is_empty() {
12 None
13 } else {
14 Some(normalized)
15 }
16}
17
18#[tracing::instrument(name = "library.scan", skip(cover_cache, library, on_progress), fields(dir = %dir.display()))]
19pub async fn scan_directory(
20 dir: PathBuf,
21 cover_cache: PathBuf,
22 library: &mut Library,
23 on_progress: Arc<dyn Fn(String) + Send + Sync>,
24) -> std::io::Result<()> {
25 library
26 .local_artist_images
27 .retain(|_, image_path| !image_path.starts_with(&dir));
28
29 let existing_paths: Arc<HashSet<PathBuf>> = Arc::new(
30 library
31 .tracks
32 .iter()
33 .filter_map(|t| t.id.local_path().map(Path::to_path_buf))
34 .collect(),
35 );
36
37 let (all_audio, artist_image_dirs) = collect_audio_files(&dir, &existing_paths).await;
38 tracing::info!(
39 new_files = all_audio.len(),
40 existing = existing_paths.len(),
41 "scanning library directory"
42 );
43
44 let lib_arc = Arc::new(Mutex::new(std::mem::take(library)));
45
46 let handles: Vec<_> = all_audio
47 .chunks(100)
48 .map(|chunk| {
49 let chunk = chunk.to_vec();
50 let cc = cover_cache.clone();
51 let pr = on_progress.clone();
52 let lb = lib_arc.clone();
53
54 tokio::task::spawn_blocking(move || {
55 let mut lib = lb.blocking_lock();
56 for path in chunk {
57 if let Some(name) = path.file_name() {
58 pr(name.to_string_lossy().into_owned());
59 }
60 read(&path, &cc, &mut lib);
61 }
62 })
63 })
64 .collect();
65
66 for handle in handles {
67 if let Err(e) = handle.await {
68 tracing::warn!("scan task failed: {e}");
69 }
70 }
71
72 *library = Arc::try_unwrap(lib_arc)
73 .unwrap_or_else(|_| panic!("library Arc should be uniquely owned after scanning"))
74 .into_inner();
75
76 for (img_dir, img_path) in artist_image_dirs {
77 let artists: HashSet<String> = library
78 .tracks
79 .iter()
80 .filter(|t| t.id.local_path().is_some_and(|p| p.starts_with(&img_dir)))
81 .filter_map(|t| {
82 let mut set = HashSet::new();
83 if let Some(a) = normalize_artist_key(&t.artist) {
84 set.insert(a);
85 }
86 for a in &t.artists {
87 if let Some(a) = normalize_artist_key(a) {
88 set.insert(a);
89 }
90 }
91 if set.is_empty() { None } else { Some(set) }
92 })
93 .flatten()
94 .collect();
95
96 if artists.len() == 1
97 && let Some(artist) = artists.iter().next()
98 {
99 library
100 .local_artist_images
101 .entry(artist.clone())
102 .or_insert(img_path);
103 }
104 }
105
106 tracing::info!(total_tracks = library.tracks.len(), "library scan complete");
107 Ok(())
108}
109
110async fn collect_audio_files(
111 root: &Path,
112 existing_paths: &HashSet<PathBuf>,
113) -> (Vec<PathBuf>, Vec<(PathBuf, PathBuf)>) {
114 let mut audio_files = Vec::new();
115 let mut artist_image_dirs = Vec::new();
116 let mut dirs = vec![root.to_path_buf()];
117
118 while let Some(dir) = dirs.pop() {
119 let mut entries = match tokio::fs::read_dir(&dir).await {
120 Ok(e) => e,
121 Err(_) => continue,
122 };
123
124 while let Ok(Some(entry)) = entries.next_entry().await {
125 let path = entry.path();
126 let is_dir = tokio::fs::metadata(&path)
127 .await
128 .map(|t| t.is_dir())
129 .unwrap_or(false);
130 if is_dir {
131 dirs.push(path);
132 } else if is_artist_image_file(&path) {
133 artist_image_dirs.push((dir.clone(), path));
134 } else if is_audio_file(&path) && !existing_paths.contains(&path) {
135 audio_files.push(path);
136 }
137 }
138 }
139
140 (audio_files, artist_image_dirs)
141}
142
143pub fn is_audio_file(path: &Path) -> bool {
144 let extensions = ["mp3", "flac", "m4a", "wav", "ogg", "opus", "mp4", "mka"];
145 path.extension()
146 .and_then(|s| s.to_str())
147 .is_some_and(|s| extensions.iter().any(|e| s.eq_ignore_ascii_case(e)))
148}