Skip to main content

koan_core/index/
scanner.rs

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/// Result of a folder scan.
13#[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
22/// Info about a scanned track, passed to the progress callback.
23pub 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
31/// Scan a folder recursively for audio files and index them into the database.
32/// The optional `on_track` callback is invoked for each successfully indexed track.
33pub 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    // Collect audio files via walkdir.
42    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    // Filter to files that need scanning.
57    // Batch-load the entire scan_cache into a HashMap to avoid O(N) individual
58    // DB lookups (one per file). For 100k+ file libraries this is dramatically faster.
59    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    // Read metadata in parallel (CPU-bound tag parsing — no DB access here).
91    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    // Insert into DB sequentially in a single transaction.
100    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    // Remove tracks for files that no longer exist.
141    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
153/// Scan all configured library folders.
154pub 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
176/// Info about an analyzed track, passed to the progress callback.
177pub struct AnalysisEvent<'a> {
178    pub path: &'a str,
179    pub success: bool,
180    pub current: usize,
181    pub total: usize,
182}
183
184/// Run acoustic analysis on all tracks missing vectors.
185/// Uses rayon for parallel analysis, stores results sequentially.
186pub 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    // Analyze in parallel.
206    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    // Store sequentially.
224    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        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
276        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        // Verify the track exists in the DB.
288        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        // First scan: adds the file.
304        let r1 = scan_folder(&db, &music_dir, false, None);
305        assert_eq!(r1.added, 1);
306
307        // Second scan: file unchanged, should be skipped.
308        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        // First scan: adds the file.
325        let r1 = scan_folder(&db, &music_dir, false, None);
326        assert_eq!(r1.added, 1);
327
328        // Delete the file.
329        std::fs::remove_file(&wav_path).unwrap();
330
331        // Second scan: should detect removal.
332        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        // DB should have 0 tracks.
339        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        // First scan.
355        let r1 = scan_folder(&db, &music_dir, false, None);
356        assert_eq!(r1.added, 1);
357
358        // Modify the file (rewrite with different duration → different size + mtime).
359        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
360        std::thread::sleep(std::time::Duration::from_millis(1100));
361        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
362
363        // Second scan: should detect the modification.
364        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}