1use rusqlite::{params, Connection, OptionalExtension};
7use std::cell::Cell;
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10
11pub const CURRENT_SCAN_VERSION: i32 = 1;
14
15pub const DEFAULT_STREAMING_TARGET_LUFS: f64 = -14.0;
17
18pub const DEFAULT_BROADCAST_TARGET_LUFS: f64 = -23.0;
20
21#[derive(Debug, Clone)]
27pub struct TrackLoudness {
28 pub track_id: String,
30 pub file_path: String,
32 pub integrated_lufs: f64,
34 pub true_peak_dbtp: f64,
36 pub loudness_range: Option<f64>,
38 pub track_gain_db: f64,
40 pub album_gain_db: Option<f64>,
42 pub scan_version: i32,
44 pub scanned_at: i64,
46 pub file_mtime: Option<i64>,
49 pub file_size: Option<i64>,
52 cached_gain_target_lufs: Cell<Option<f64>>,
53 cached_gain_linear: Cell<f32>,
54}
55
56impl TrackLoudness {
57 pub fn new(
62 file_path: &str,
63 integrated_lufs: f64,
64 true_peak_dbtp: f64,
65 loudness_range: Option<f64>,
66 target_lufs: f64,
67 ) -> Self {
68 let track_id = Self::compute_track_id(file_path);
69 let track_gain_db = target_lufs - integrated_lufs;
70
71 let (file_mtime, file_size) = Self::get_file_metadata(file_path);
73
74 Self {
75 track_id,
76 file_path: file_path.to_string(),
77 integrated_lufs,
78 true_peak_dbtp,
79 loudness_range,
80 track_gain_db,
81 album_gain_db: None,
82 scan_version: CURRENT_SCAN_VERSION,
83 scanned_at: chrono_timestamp(),
84 file_mtime,
85 file_size,
86 cached_gain_target_lufs: Cell::new(None),
87 cached_gain_linear: Cell::new(1.0),
88 }
89 }
90
91 fn get_file_metadata(path: &str) -> (Option<i64>, Option<i64>) {
94 if path.starts_with("http://") || path.starts_with("https://") {
96 return (None, None);
97 }
98
99 std::fs::metadata(path)
100 .ok()
101 .map(|m| {
102 let mtime = m
103 .modified()
104 .ok()
105 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
106 .map(|d| d.as_secs() as i64);
107 let size = Some(m.len() as i64);
108 (mtime, size)
109 })
110 .unwrap_or((None, None))
111 }
112
113 fn compute_track_id(path: &str) -> String {
115 path.replace('\\', "/").to_lowercase()
118 }
119
120 pub fn gain_for_target(&self, target_lufs: f64) -> f64 {
122 target_lufs - self.integrated_lufs
123 }
124
125 pub fn gain_linear(&self, target_lufs: f64) -> f32 {
127 if self.cached_gain_target_lufs.get() == Some(target_lufs) {
128 return self.cached_gain_linear.get();
129 }
130
131 let gain_db = self.gain_for_target(target_lufs);
132 let gain = 10.0_f64.powf(gain_db / 20.0) as f32;
133 self.cached_gain_target_lufs.set(Some(target_lufs));
134 self.cached_gain_linear.set(gain);
135 gain
136 }
137}
138
139pub struct LoudnessDatabase {
145 conn: Mutex<Connection>,
146 db_path: PathBuf,
147}
148
149impl LoudnessDatabase {
150 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, String> {
152 let db_path = path.as_ref().to_path_buf();
153
154 if let Some(parent) = db_path.parent() {
156 if !parent.exists() {
157 std::fs::create_dir_all(parent)
158 .map_err(|e| format!("Failed to create database directory: {}", e))?;
159 }
160 }
161
162 let conn =
163 Connection::open(&db_path).map_err(|e| format!("Failed to open database: {}", e))?;
164
165 let db = Self {
166 conn: Mutex::new(conn),
167 db_path,
168 };
169
170 db.init_schema()?;
171 Ok(db)
172 }
173
174 pub fn in_memory() -> Result<Self, String> {
176 let conn = Connection::open_in_memory()
177 .map_err(|e| format!("Failed to create in-memory database: {}", e))?;
178
179 let db = Self {
180 conn: Mutex::new(conn),
181 db_path: PathBuf::from(":memory:"),
182 };
183
184 db.init_schema()?;
185 Ok(db)
186 }
187
188 fn init_schema(&self) -> Result<(), String> {
190 let conn = self.conn.lock().map_err(|e| e.to_string())?;
191
192 conn.execute_batch(
193 r#"
194 CREATE TABLE IF NOT EXISTS track_loudness (
195 track_id TEXT PRIMARY KEY,
196 file_path TEXT NOT NULL,
197 integrated_lufs REAL NOT NULL,
198 true_peak_dbtp REAL NOT NULL,
199 loudness_range REAL,
200 track_gain_db REAL NOT NULL,
201 album_gain_db REAL,
202 scan_version INTEGER NOT NULL,
203 scanned_at INTEGER NOT NULL,
204 file_mtime INTEGER,
205 file_size INTEGER
206 );
207
208 CREATE INDEX IF NOT EXISTS idx_file_path ON track_loudness(file_path);
209 CREATE INDEX IF NOT EXISTS idx_scan_version ON track_loudness(scan_version);
210 "#,
211 )
212 .map_err(|e| format!("Failed to initialize schema: {}", e))?;
213
214 let mut stmt = conn
215 .prepare("PRAGMA table_info(track_loudness)")
216 .map_err(|e| format!("Failed to inspect schema: {}", e))?;
217 let existing_columns: std::collections::HashSet<String> = stmt
218 .query_map([], |row| row.get::<_, String>(1))
219 .map_err(|e| format!("Failed to read schema info: {}", e))?
220 .collect::<Result<_, _>>()
221 .map_err(|e| format!("Failed to collect schema info: {}", e))?;
222
223 if !existing_columns.contains("file_mtime") {
224 conn.execute(
225 "ALTER TABLE track_loudness ADD COLUMN file_mtime INTEGER",
226 [],
227 )
228 .map_err(|e| format!("Failed to migrate schema (file_mtime): {}", e))?;
229 }
230 if !existing_columns.contains("file_size") {
231 conn.execute(
232 "ALTER TABLE track_loudness ADD COLUMN file_size INTEGER",
233 [],
234 )
235 .map_err(|e| format!("Failed to migrate schema (file_size): {}", e))?;
236 }
237
238 Ok(())
239 }
240
241 pub fn upsert(&self, track: &TrackLoudness) -> Result<(), String> {
243 let conn = self.conn.lock().map_err(|e| e.to_string())?;
244
245 conn.execute(
246 r#"
247 INSERT INTO track_loudness
248 (track_id, file_path, integrated_lufs, true_peak_dbtp,
249 loudness_range, track_gain_db, album_gain_db, scan_version, scanned_at,
250 file_mtime, file_size)
251 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
252 ON CONFLICT(track_id) DO UPDATE SET
253 file_path = excluded.file_path,
254 integrated_lufs = excluded.integrated_lufs,
255 true_peak_dbtp = excluded.true_peak_dbtp,
256 loudness_range = excluded.loudness_range,
257 track_gain_db = excluded.track_gain_db,
258 album_gain_db = excluded.album_gain_db,
259 scan_version = excluded.scan_version,
260 scanned_at = excluded.scanned_at,
261 file_mtime = excluded.file_mtime,
262 file_size = excluded.file_size
263 "#,
264 params![
265 track.track_id,
266 track.file_path,
267 track.integrated_lufs,
268 track.true_peak_dbtp,
269 track.loudness_range,
270 track.track_gain_db,
271 track.album_gain_db,
272 track.scan_version,
273 track.scanned_at,
274 track.file_mtime,
275 track.file_size,
276 ],
277 )
278 .map_err(|e| format!("Failed to upsert track: {}", e))?;
279
280 Ok(())
281 }
282
283 pub fn get(&self, file_path: &str) -> Result<Option<TrackLoudness>, String> {
285 let conn = self.conn.lock().map_err(|e| e.to_string())?;
286 let track_id = TrackLoudness::compute_track_id(file_path);
287
288 let result = conn
289 .query_row(
290 r#"
291 SELECT track_id, file_path, integrated_lufs, true_peak_dbtp,
292 loudness_range, track_gain_db, album_gain_db, scan_version, scanned_at,
293 file_mtime, file_size
294 FROM track_loudness
295 WHERE track_id = ?1
296 "#,
297 params![track_id],
298 |row| {
299 Ok(TrackLoudness {
300 track_id: row.get(0)?,
301 file_path: row.get(1)?,
302 integrated_lufs: row.get(2)?,
303 true_peak_dbtp: row.get(3)?,
304 loudness_range: row.get(4)?,
305 track_gain_db: row.get(5)?,
306 album_gain_db: row.get(6)?,
307 scan_version: row.get(7)?,
308 scanned_at: row.get(8)?,
309 file_mtime: row.get(9)?,
310 file_size: row.get(10)?,
311 cached_gain_target_lufs: Cell::new(None),
312 cached_gain_linear: Cell::new(1.0),
313 })
314 },
315 )
316 .optional()
317 .map_err(|e| format!("Failed to query track: {}", e))?;
318
319 Ok(result)
320 }
321
322 pub fn get_fresh(&self, file_path: &str) -> Result<Option<TrackLoudness>, String> {
328 if self.needs_scan(file_path)? {
329 return Ok(None);
330 }
331
332 self.get(file_path)
333 }
334
335 pub fn needs_scan(&self, file_path: &str) -> Result<bool, String> {
340 let conn = self.conn.lock().map_err(|e| e.to_string())?;
341 let track_id = TrackLoudness::compute_track_id(file_path);
342
343 let result: Option<(i32, Option<i64>, Option<i64>)> = conn.query_row(
344 "SELECT scan_version, file_mtime, file_size FROM track_loudness WHERE track_id = ?1",
345 params![track_id],
346 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
347 ).optional().map_err(|e| format!("Failed to check track: {}", e))?;
348
349 match result {
350 None => Ok(true), Some((version, db_mtime, db_size)) => {
352 if version < CURRENT_SCAN_VERSION {
354 return Ok(true); }
356
357 if !file_path.starts_with("http://") && !file_path.starts_with("https://") {
360 if let Ok(metadata) = std::fs::metadata(file_path) {
361 let current_mtime = metadata
362 .modified()
363 .ok()
364 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
365 .map(|d| d.as_secs() as i64);
366 let current_size = Some(metadata.len() as i64);
367
368 if current_mtime != db_mtime || current_size != db_size {
370 log::info!(
371 "File changed, needs rescan: {} (mtime: {:?} -> {:?}, size: {:?} -> {:?})",
372 file_path, db_mtime, current_mtime, db_size, current_size
373 );
374 return Ok(true);
375 }
376 }
377 }
378
379 Ok(false) }
381 }
382 }
383
384 pub fn get_outdated_tracks(&self) -> Result<Vec<String>, String> {
386 let conn = self.conn.lock().map_err(|e| e.to_string())?;
387
388 let mut stmt = conn
389 .prepare("SELECT file_path FROM track_loudness WHERE scan_version < ?1")
390 .map_err(|e| format!("Failed to prepare statement: {}", e))?;
391
392 let tracks: Vec<String> = stmt
393 .query_map(params![CURRENT_SCAN_VERSION], |row| row.get(0))
394 .map_err(|e| format!("Failed to query outdated tracks: {}", e))?
395 .filter_map(|r| r.ok())
396 .collect();
397
398 Ok(tracks)
399 }
400
401 pub fn batch_upsert(&self, tracks: &[TrackLoudness]) -> Result<usize, String> {
403 let mut conn = self.conn.lock().map_err(|e| e.to_string())?;
404 let tx = conn
405 .transaction()
406 .map_err(|e| format!("Failed to begin transaction: {}", e))?;
407
408 let mut count = 0;
409 for track in tracks {
410 tx.execute(
411 r#"
412 INSERT INTO track_loudness
413 (track_id, file_path, integrated_lufs, true_peak_dbtp,
414 loudness_range, track_gain_db, album_gain_db, scan_version, scanned_at,
415 file_mtime, file_size)
416 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
417 ON CONFLICT(track_id) DO UPDATE SET
418 file_path = excluded.file_path,
419 integrated_lufs = excluded.integrated_lufs,
420 true_peak_dbtp = excluded.true_peak_dbtp,
421 loudness_range = excluded.loudness_range,
422 track_gain_db = excluded.track_gain_db,
423 album_gain_db = excluded.album_gain_db,
424 scan_version = excluded.scan_version,
425 scanned_at = excluded.scanned_at,
426 file_mtime = excluded.file_mtime,
427 file_size = excluded.file_size
428 "#,
429 params![
430 track.track_id,
431 track.file_path,
432 track.integrated_lufs,
433 track.true_peak_dbtp,
434 track.loudness_range,
435 track.track_gain_db,
436 track.album_gain_db,
437 track.scan_version,
438 track.scanned_at,
439 track.file_mtime,
440 track.file_size,
441 ],
442 )
443 .map_err(|e| format!("Failed to upsert track {}: {}", track.file_path, e))?;
444 count += 1;
445 }
446
447 tx.commit()
448 .map_err(|e| format!("Failed to commit transaction: {}", e))?;
449 Ok(count)
450 }
451
452 pub fn set_album_gain(&self, track_ids: &[&str], album_gain_db: f64) -> Result<(), String> {
457 let mut conn = self.conn.lock().map_err(|e| e.to_string())?;
458
459 let tx = conn
461 .transaction()
462 .map_err(|e| format!("Failed to begin transaction: {}", e))?;
463
464 for track_id in track_ids {
465 tx.execute(
466 "UPDATE track_loudness SET album_gain_db = ?1 WHERE track_id = ?2",
467 params![album_gain_db, track_id],
468 )
469 .map_err(|e| format!("Failed to update album gain for {}: {}", track_id, e))?;
470 }
471
472 tx.commit()
473 .map_err(|e| format!("Failed to commit album gain transaction: {}", e))?;
474
475 Ok(())
476 }
477
478 pub fn delete(&self, file_path: &str) -> Result<bool, String> {
480 let conn = self.conn.lock().map_err(|e| e.to_string())?;
481 let track_id = TrackLoudness::compute_track_id(file_path);
482
483 let affected = conn
484 .execute(
485 "DELETE FROM track_loudness WHERE track_id = ?1",
486 params![track_id],
487 )
488 .map_err(|e| format!("Failed to delete track: {}", e))?;
489
490 Ok(affected > 0)
491 }
492
493 pub fn stats(&self) -> Result<DatabaseStats, String> {
495 let conn = self.conn.lock().map_err(|e| e.to_string())?;
496
497 let total_tracks: i64 = conn
498 .query_row("SELECT COUNT(*) FROM track_loudness", [], |row| row.get(0))
499 .map_err(|e| format!("Failed to count tracks: {}", e))?;
500
501 let outdated_tracks: i64 = conn
502 .query_row(
503 "SELECT COUNT(*) FROM track_loudness WHERE scan_version < ?1",
504 params![CURRENT_SCAN_VERSION],
505 |row| row.get(0),
506 )
507 .map_err(|e| format!("Failed to count outdated tracks: {}", e))?;
508
509 let with_album_gain: i64 = conn
510 .query_row(
511 "SELECT COUNT(*) FROM track_loudness WHERE album_gain_db IS NOT NULL",
512 [],
513 |row| row.get(0),
514 )
515 .map_err(|e| format!("Failed to count album gain tracks: {}", e))?;
516
517 Ok(DatabaseStats {
518 total_tracks,
519 outdated_tracks,
520 with_album_gain,
521 current_scan_version: CURRENT_SCAN_VERSION,
522 })
523 }
524
525 pub fn path(&self) -> &Path {
527 &self.db_path
528 }
529}
530
531#[derive(Debug, Clone, serde::Serialize)]
537pub struct DatabaseStats {
538 pub total_tracks: i64,
539 pub outdated_tracks: i64,
540 pub with_album_gain: i64,
541 pub current_scan_version: i32,
542}
543
544fn chrono_timestamp() -> i64 {
550 std::time::SystemTime::now()
551 .duration_since(std::time::UNIX_EPOCH)
552 .map(|d| d.as_secs() as i64)
553 .unwrap_or(0)
554}
555
556#[cfg(test)]
561mod tests {
562 use super::*;
563 use std::time::{SystemTime, UNIX_EPOCH};
564
565 #[test]
566 fn test_database_basic_operations() {
567 let db = LoudnessDatabase::in_memory().unwrap();
568
569 let track = TrackLoudness::new(
570 "/music/test.flac",
571 -18.5, -0.5, Some(6.2), DEFAULT_STREAMING_TARGET_LUFS,
575 );
576
577 db.upsert(&track).unwrap();
579
580 let retrieved = db.get("/music/test.flac").unwrap().unwrap();
582 assert_eq!(retrieved.integrated_lufs, -18.5);
583 assert_eq!(retrieved.track_gain_db, 4.5); assert!(!db.needs_scan("/music/test.flac").unwrap());
587 assert!(db.needs_scan("/music/other.flac").unwrap());
588 }
589
590 #[test]
591 fn test_gain_calculation() {
592 let track = TrackLoudness::new("/test.flac", -20.0, -1.0, None, -14.0);
593
594 assert_eq!(track.track_gain_db, 6.0); assert!((track.gain_linear(-14.0) - 1.995).abs() < 0.01);
596
597 assert_eq!(track.gain_for_target(-23.0), -3.0);
599 }
600
601 #[test]
602 fn track_gain_linear_reuses_same_target_and_invalidates_on_change() {
603 let track = TrackLoudness::new("/test.flac", -20.0, -1.0, None, -14.0);
604
605 let first = track.gain_linear(-14.0);
606 let second = track.gain_linear(-14.0);
607 let third = track.gain_linear(-23.0);
608
609 assert_eq!(first.to_bits(), second.to_bits());
610 assert_eq!(first.to_bits(), track.gain_linear(-14.0).to_bits());
611 assert_eq!(third.to_bits(), track.gain_linear(-23.0).to_bits());
612 assert_ne!(first.to_bits(), third.to_bits());
613 }
614
615 #[test]
616 fn get_fresh_rejects_changed_local_file() {
617 let db = LoudnessDatabase::in_memory().unwrap();
618 let unique = SystemTime::now()
619 .duration_since(UNIX_EPOCH)
620 .unwrap_or_default()
621 .as_nanos();
622 let path = std::env::temp_dir().join(format!(
623 "audio_player_loudness_fresh_{}_{}.flac",
624 std::process::id(),
625 unique
626 ));
627 std::fs::write(&path, b"initial").unwrap();
628 let path_string = path.to_string_lossy().to_string();
629
630 let track = TrackLoudness::new(&path_string, -18.0, -1.0, None, -14.0);
631 db.upsert(&track).unwrap();
632 assert!(db.get_fresh(&path_string).unwrap().is_some());
633
634 std::fs::write(&path, b"changed file contents").unwrap();
635 assert!(db.get_fresh(&path_string).unwrap().is_none());
636
637 let _ = std::fs::remove_file(path);
638 }
639}