1use std::path::{Path, PathBuf};
2use std::time::UNIX_EPOCH;
3
4use rayon::prelude::*;
5
6use crate::db::connection::Database;
7use crate::db::queries::{self, TrackMeta};
8
9use super::features;
10use super::metadata::{self, is_audio_file};
11
12#[derive(Debug, Default)]
14pub struct ScanResult {
15 pub added: usize,
16 pub updated: usize,
17 pub removed: usize,
18 pub skipped: usize,
19 pub errors: Vec<(PathBuf, String)>,
20}
21
22pub struct ScanEvent<'a> {
24 pub artist: &'a str,
25 pub album: &'a str,
26 pub title: &'a str,
27 pub path: &'a Path,
28 pub is_new: bool,
29}
30
31pub fn scan_folder(
34 db: &Database,
35 path: &Path,
36 force: bool,
37 on_track: Option<&dyn Fn(ScanEvent)>,
38) -> ScanResult {
39 let mut result = ScanResult::default();
40
41 let audio_files: Vec<PathBuf> = walkdir::WalkDir::new(path)
43 .follow_links(true)
44 .into_iter()
45 .filter_map(|e| e.ok())
46 .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
47 .map(|e| e.path().to_path_buf())
48 .collect();
49
50 log::info!(
51 "found {} audio files in {}",
52 audio_files.len(),
53 path.display()
54 );
55
56 let files_to_scan: Vec<PathBuf> = if force {
60 audio_files.clone()
61 } else {
62 let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
63 audio_files
64 .iter()
65 .filter(|file_path| {
66 let Ok(file_meta) = std::fs::metadata(file_path) else {
67 return true;
68 };
69 let mtime = file_meta
70 .modified()
71 .ok()
72 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
73 .map(|d| d.as_secs() as i64)
74 .unwrap_or(0);
75 let size = file_meta.len() as i64;
76 let path_str = file_path.to_string_lossy();
77 match scan_cache.get(path_str.as_ref()) {
78 Some(&(cached_mtime, cached_size)) => {
79 mtime != cached_mtime || size != cached_size
80 }
81 None => true,
82 }
83 })
84 .cloned()
85 .collect()
86 };
87
88 result.skipped = audio_files.len() - files_to_scan.len();
89
90 let metadata_results: Vec<(PathBuf, Result<TrackMeta, String>)> = files_to_scan
92 .par_iter()
93 .map(|file_path| {
94 let meta = metadata::read_metadata(file_path).map_err(|e| format!("{}", e));
95 (file_path.clone(), meta)
96 })
97 .collect();
98
99 let tx = match db.conn.unchecked_transaction() {
101 Ok(tx) => tx,
102 Err(e) => {
103 log::error!("failed to begin scan transaction: {}", e);
104 return result;
105 }
106 };
107
108 for (file_path, meta_result) in metadata_results {
109 match meta_result {
110 Ok(meta) => match queries::upsert_track(&tx, &meta) {
111 Ok(track_id) => {
112 result.added += 1;
113 if let Some(cb) = &on_track {
114 cb(ScanEvent {
115 artist: &meta.artist,
116 album: &meta.album,
117 title: &meta.title,
118 path: &file_path,
119 is_new: true,
120 });
121 }
122 let _ = queries::update_scan_cache(
123 &tx,
124 meta.path.as_deref().unwrap_or(""),
125 meta.mtime.unwrap_or(0),
126 meta.size_bytes.unwrap_or(0),
127 track_id,
128 );
129 }
130 Err(e) => {
131 result.errors.push((file_path, format!("db error: {}", e)));
132 }
133 },
134 Err(e) => {
135 result.errors.push((file_path, e));
136 }
137 }
138 }
139
140 match queries::remove_stale_tracks(&tx, path) {
142 Ok(removed) => result.removed = removed,
143 Err(e) => log::error!("failed to remove stale tracks: {}", e),
144 }
145
146 if let Err(e) = tx.commit() {
147 log::error!("failed to commit scan transaction: {}", e);
148 }
149
150 result
151}
152
153pub fn full_scan(
155 db: &Database,
156 folders: &[PathBuf],
157 force: bool,
158 on_track: Option<&dyn Fn(ScanEvent)>,
159) -> ScanResult {
160 let mut total = ScanResult::default();
161 for folder in folders {
162 if !folder.exists() {
163 log::warn!("library folder does not exist: {}", folder.display());
164 continue;
165 }
166 let r = scan_folder(db, folder, force, on_track);
167 total.added += r.added;
168 total.updated += r.updated;
169 total.removed += r.removed;
170 total.skipped += r.skipped;
171 total.errors.extend(r.errors);
172 }
173 total
174}
175
176pub struct AnalysisEvent<'a> {
178 pub path: &'a str,
179 pub success: bool,
180 pub current: usize,
181 pub total: usize,
182}
183
184pub fn analyze_missing(
187 db: &Database,
188 on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
189) -> (usize, usize) {
190 let missing = match queries::tracks_missing_vectors(&db.conn) {
191 Ok(m) => m,
192 Err(e) => {
193 log::error!("failed to query missing vectors: {}", e);
194 return (0, 0);
195 }
196 };
197
198 if missing.is_empty() {
199 return (0, 0);
200 }
201
202 let total = missing.len();
203 log::info!("analyzing {} tracks for acoustic features", total);
204
205 let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
207 .par_iter()
208 .enumerate()
209 .map(|(i, (track_id, path))| {
210 let result = features::analyze_track(Path::new(path));
211 if let Some(cb) = &on_track {
212 cb(AnalysisEvent {
213 path,
214 success: result.is_ok(),
215 current: i + 1,
216 total,
217 });
218 }
219 (*track_id, path.clone(), result)
220 })
221 .collect();
222
223 let mut analyzed = 0usize;
225 let mut errors = 0usize;
226 let tx = match db.conn.unchecked_transaction() {
227 Ok(tx) => tx,
228 Err(e) => {
229 log::error!("failed to begin analysis transaction: {}", e);
230 return (0, 0);
231 }
232 };
233 for (track_id, path, result) in results {
234 match result {
235 Ok(embedding) => {
236 if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
237 log::warn!("failed to store vector for {}: {}", path, e);
238 errors += 1;
239 } else {
240 analyzed += 1;
241 }
242 }
243 Err(e) => {
244 log::warn!("analysis failed for {}: {}", path, e);
245 errors += 1;
246 }
247 }
248 }
249 if let Err(e) = tx.commit() {
250 log::error!("failed to commit analysis transaction: {}", e);
251 }
252
253 log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
254 (analyzed, errors)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::db::connection::Database;
261 use crate::db::queries;
262 use crate::test_utils;
263
264 fn test_db(dir: &Path) -> Database {
265 let db_path = dir.join("test.db");
266 Database::open(&db_path).unwrap()
267 }
268
269 #[test]
270 fn scan_folder_indexes_new_files() {
271 let dir = tempfile::tempdir().unwrap();
272 let music_dir = dir.path().join("music");
273 std::fs::create_dir_all(&music_dir).unwrap();
274
275 let wav_path = music_dir.join("silence.wav");
277 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
278
279 let db = test_db(dir.path());
280 let result = scan_folder(&db, &music_dir, false, None);
281
282 assert_eq!(result.added, 1, "expected 1 track added");
283 assert_eq!(result.skipped, 0);
284 assert_eq!(result.removed, 0);
285 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
286
287 let stats = queries::library_stats(&db.conn).unwrap();
289 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
290 }
291
292 #[test]
293 fn scan_folder_skips_unchanged_files() {
294 let dir = tempfile::tempdir().unwrap();
295 let music_dir = dir.path().join("music");
296 std::fs::create_dir_all(&music_dir).unwrap();
297
298 let wav_path = music_dir.join("unchanged.wav");
299 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
300
301 let db = test_db(dir.path());
302
303 let r1 = scan_folder(&db, &music_dir, false, None);
305 assert_eq!(r1.added, 1);
306
307 let r2 = scan_folder(&db, &music_dir, false, None);
309 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
310 assert_eq!(r2.added, 0, "no new files should be added");
311 }
312
313 #[test]
314 fn scan_folder_removes_deleted_tracks() {
315 let dir = tempfile::tempdir().unwrap();
316 let music_dir = dir.path().join("music");
317 std::fs::create_dir_all(&music_dir).unwrap();
318
319 let wav_path = music_dir.join("ephemeral.wav");
320 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
321
322 let db = test_db(dir.path());
323
324 let r1 = scan_folder(&db, &music_dir, false, None);
326 assert_eq!(r1.added, 1);
327
328 std::fs::remove_file(&wav_path).unwrap();
330
331 let r2 = scan_folder(&db, &music_dir, false, None);
333 assert_eq!(
334 r2.removed, 1,
335 "expected 1 track removed after file deletion"
336 );
337
338 let stats = queries::library_stats(&db.conn).unwrap();
340 assert_eq!(stats.total_tracks, 0, "expected 0 tracks after removal");
341 }
342
343 #[test]
344 fn scan_folder_updates_modified_files() {
345 let dir = tempfile::tempdir().unwrap();
346 let music_dir = dir.path().join("music");
347 std::fs::create_dir_all(&music_dir).unwrap();
348
349 let wav_path = music_dir.join("modified.wav");
350 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
351
352 let db = test_db(dir.path());
353
354 let r1 = scan_folder(&db, &music_dir, false, None);
356 assert_eq!(r1.added, 1);
357
358 std::thread::sleep(std::time::Duration::from_millis(1100));
361 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
362
363 let r2 = scan_folder(&db, &music_dir, false, None);
365 assert_eq!(
366 r2.added, 1,
367 "modified file should be re-indexed (counted as added by upsert)"
368 );
369 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
370 }
371}