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 (check scan_cache on the main thread).
57    let files_to_scan: Vec<PathBuf> = if force {
58        audio_files.clone()
59    } else {
60        audio_files
61            .iter()
62            .filter(|file_path| {
63                let Ok(file_meta) = std::fs::metadata(file_path) else {
64                    return true;
65                };
66                let mtime = file_meta
67                    .modified()
68                    .ok()
69                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
70                    .map(|d| d.as_secs() as i64)
71                    .unwrap_or(0);
72                let size = file_meta.len() as i64;
73                let path_str = file_path.to_string_lossy();
74                queries::needs_rescan(&db.conn, &path_str, mtime, size).unwrap_or(true)
75            })
76            .cloned()
77            .collect()
78    };
79
80    result.skipped = audio_files.len() - files_to_scan.len();
81
82    // Read metadata in parallel (CPU-bound tag parsing — no DB access here).
83    let metadata_results: Vec<(PathBuf, Result<TrackMeta, String>)> = files_to_scan
84        .par_iter()
85        .map(|file_path| {
86            let meta = metadata::read_metadata(file_path).map_err(|e| format!("{}", e));
87            (file_path.clone(), meta)
88        })
89        .collect();
90
91    // Insert into DB sequentially in a single transaction.
92    let tx = match db.conn.unchecked_transaction() {
93        Ok(tx) => tx,
94        Err(e) => {
95            log::error!("failed to begin scan transaction: {}", e);
96            return result;
97        }
98    };
99
100    for (file_path, meta_result) in metadata_results {
101        match meta_result {
102            Ok(meta) => match queries::upsert_track(&tx, &meta) {
103                Ok(track_id) => {
104                    result.added += 1;
105                    if let Some(cb) = &on_track {
106                        cb(ScanEvent {
107                            artist: &meta.artist,
108                            album: &meta.album,
109                            title: &meta.title,
110                            path: &file_path,
111                            is_new: true,
112                        });
113                    }
114                    let _ = queries::update_scan_cache(
115                        &tx,
116                        meta.path.as_deref().unwrap_or(""),
117                        meta.mtime.unwrap_or(0),
118                        meta.size_bytes.unwrap_or(0),
119                        track_id,
120                    );
121                }
122                Err(e) => {
123                    result.errors.push((file_path, format!("db error: {}", e)));
124                }
125            },
126            Err(e) => {
127                result.errors.push((file_path, e));
128            }
129        }
130    }
131
132    // Remove tracks for files that no longer exist.
133    match queries::remove_stale_tracks(&tx, path) {
134        Ok(removed) => result.removed = removed,
135        Err(e) => log::error!("failed to remove stale tracks: {}", e),
136    }
137
138    if let Err(e) = tx.commit() {
139        log::error!("failed to commit scan transaction: {}", e);
140    }
141
142    result
143}
144
145/// Scan all configured library folders.
146pub fn full_scan(
147    db: &Database,
148    folders: &[PathBuf],
149    force: bool,
150    on_track: Option<&dyn Fn(ScanEvent)>,
151) -> ScanResult {
152    let mut total = ScanResult::default();
153    for folder in folders {
154        if !folder.exists() {
155            log::warn!("library folder does not exist: {}", folder.display());
156            continue;
157        }
158        let r = scan_folder(db, folder, force, on_track);
159        total.added += r.added;
160        total.updated += r.updated;
161        total.removed += r.removed;
162        total.skipped += r.skipped;
163        total.errors.extend(r.errors);
164    }
165    total
166}
167
168/// Info about an analyzed track, passed to the progress callback.
169pub struct AnalysisEvent<'a> {
170    pub path: &'a str,
171    pub success: bool,
172    pub current: usize,
173    pub total: usize,
174}
175
176/// Run acoustic analysis on all tracks missing vectors.
177/// Uses rayon for parallel analysis, stores results sequentially.
178pub fn analyze_missing(
179    db: &Database,
180    on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
181) -> (usize, usize) {
182    let missing = match queries::tracks_missing_vectors(&db.conn) {
183        Ok(m) => m,
184        Err(e) => {
185            log::error!("failed to query missing vectors: {}", e);
186            return (0, 0);
187        }
188    };
189
190    if missing.is_empty() {
191        return (0, 0);
192    }
193
194    let total = missing.len();
195    log::info!("analyzing {} tracks for acoustic features", total);
196
197    // Analyze in parallel.
198    let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
199        .par_iter()
200        .enumerate()
201        .map(|(i, (track_id, path))| {
202            let result = features::analyze_track(Path::new(path));
203            if let Some(cb) = &on_track {
204                cb(AnalysisEvent {
205                    path,
206                    success: result.is_ok(),
207                    current: i + 1,
208                    total,
209                });
210            }
211            (*track_id, path.clone(), result)
212        })
213        .collect();
214
215    // Store sequentially.
216    let mut analyzed = 0usize;
217    let mut errors = 0usize;
218    let tx = match db.conn.unchecked_transaction() {
219        Ok(tx) => tx,
220        Err(e) => {
221            log::error!("failed to begin analysis transaction: {}", e);
222            return (0, 0);
223        }
224    };
225    for (track_id, path, result) in results {
226        match result {
227            Ok(embedding) => {
228                if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
229                    log::warn!("failed to store vector for {}: {}", path, e);
230                    errors += 1;
231                } else {
232                    analyzed += 1;
233                }
234            }
235            Err(e) => {
236                log::warn!("analysis failed for {}: {}", path, e);
237                errors += 1;
238            }
239        }
240    }
241    if let Err(e) = tx.commit() {
242        log::error!("failed to commit analysis transaction: {}", e);
243    }
244
245    log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
246    (analyzed, errors)
247}