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 {
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 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 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 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
145pub 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
168pub struct AnalysisEvent<'a> {
170 pub path: &'a str,
171 pub success: bool,
172 pub current: usize,
173 pub total: usize,
174}
175
176pub 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 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 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}