import.rs (5077B)
1 use anyhow::{Context, Result}; 2 use rusqlite::Connection; 3 4 use crate::db::{LibraryDb, TrackReport}; 5 use crate::utils::format_primary_key; 6 7 /// Convert a Mixxx key string (e.g. "Cm", "D", "Bbm") to virittaa format 8 /// (e.g. "C Minor", "D Major", "Bb Minor"). Returns `None` for empty or 9 /// unknown keys. 10 fn convert_mixxx_key(raw: &str) -> Option<String> { 11 let raw = raw.trim(); 12 if raw.is_empty() { 13 return None; 14 } 15 // Mixxx minor keys end with 'm': "Cm", "C#m", "Bbm", etc. 16 if raw.ends_with('m') && raw.len() > 1 { 17 let root = &raw[..raw.len() - 1]; 18 Some(format!("{root} Minor")) 19 } else { 20 Some(format!("{raw} Major")) 21 } 22 } 23 24 /// Import tracks from a Mixxx `SQLite` database into the sled library database. 25 /// 26 /// Reads the `library` and `track_locations` tables, skipping entries marked as 27 /// deleted, and saves each track with its BPM and key. 28 /// 29 /// # Errors 30 /// 31 /// Returns an error if the Mixxx database cannot be opened or queried, or if 32 /// sled writes fail. 33 pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) -> Result<usize> { 34 let conn = Connection::open(mixxx_path).with_context(|| "Failed to open Mixxx database")?; 35 36 let mut stmt = conn.prepare( 37 "SELECT l.artist, l.title, l.bpm, l.key \ 38 FROM library l \ 39 WHERE l.mixxx_deleted = 0 AND l.bpm IS NOT NULL AND l.bpm > 0", 40 )?; 41 42 let rows = stmt.query_map([], |row| { 43 let artist: Option<String> = row.get(0)?; 44 let title: Option<String> = row.get(1)?; 45 let bpm: Option<f64> = row.get(2)?; 46 let key_raw: Option<String> = row.get(3)?; 47 Ok((artist, title, bpm, key_raw)) 48 })?; 49 50 let mut entries = Vec::new(); 51 for row in rows { 52 let (artist, title, bpm, key_raw) = row?; 53 let key = key_raw.as_deref().and_then(convert_mixxx_key); 54 55 let primary_key = format_primary_key(artist.as_deref(), title.as_deref()); 56 57 let report = TrackReport { 58 artist, 59 track: title, 60 bpm, 61 key, 62 timestamp: Some( 63 std::time::SystemTime::now() 64 .duration_since(std::time::UNIX_EPOCH) 65 .unwrap_or_default() 66 .as_secs() 67 .cast_signed(), 68 ), 69 }; 70 71 entries.push((primary_key, report)); 72 } 73 74 let count = entries.len(); 75 db.save_batch(&entries)?; 76 Ok(count) 77 } 78 79 #[cfg(test)] 80 mod tests { 81 use super::*; 82 83 #[test] 84 fn convert_mixxx_major_keys() { 85 assert_eq!(convert_mixxx_key("D"), Some("D Major".to_string())); 86 assert_eq!(convert_mixxx_key("F#"), Some("F# Major".to_string())); 87 assert_eq!(convert_mixxx_key("Bb"), Some("Bb Major".to_string())); 88 } 89 90 #[test] 91 fn convert_mixxx_minor_keys() { 92 assert_eq!(convert_mixxx_key("Cm"), Some("C Minor".to_string())); 93 assert_eq!(convert_mixxx_key("C#m"), Some("C# Minor".to_string())); 94 assert_eq!(convert_mixxx_key("Bbm"), Some("Bb Minor".to_string())); 95 } 96 97 #[test] 98 fn convert_mixxx_key_trims_and_rejects_empty() { 99 assert_eq!(convert_mixxx_key(""), None); 100 assert_eq!(convert_mixxx_key(" "), None); 101 assert_eq!(convert_mixxx_key(" Am "), Some("A Minor".to_string())); 102 } 103 104 fn make_mixxx_db(path: &std::path::Path) { 105 let conn = Connection::open(path).unwrap(); 106 conn.execute_batch( 107 "CREATE TABLE library ( 108 artist TEXT, 109 title TEXT, 110 bpm REAL, 111 key TEXT, 112 mixxx_deleted INTEGER 113 ); 114 INSERT INTO library VALUES ('Aphex Twin', 'Xtal', 120.0, 'Am', 0); 115 INSERT INTO library VALUES ('Boards of Canada', 'Roygbiv', 95.5, 'C', 0); 116 -- Deleted rows are skipped. 117 INSERT INTO library VALUES ('Deleted', 'Track', 100.0, 'D', 1); 118 -- Rows without a usable BPM are skipped. 119 INSERT INTO library VALUES ('No', 'Bpm', NULL, 'E', 0); 120 INSERT INTO library VALUES ('Zero', 'Bpm', 0.0, 'F', 0);", 121 ) 122 .unwrap(); 123 } 124 125 #[test] 126 fn import_mixxx_filters_and_converts() { 127 let dir = tempfile::tempdir().unwrap(); 128 let mixxx_path = dir.path().join("mixxxdb.sqlite"); 129 make_mixxx_db(&mixxx_path); 130 131 let db = LibraryDb::open(&dir.path().join("library")).unwrap(); 132 let count = import_mixxx(&db, mixxx_path.to_str().unwrap()).unwrap(); 133 134 // Only the two non-deleted, positive-BPM rows are imported. 135 assert_eq!(count, 2); 136 assert_eq!(db.count().unwrap(), 2); 137 138 let xtal = db.get("Aphex Twin - Xtal").unwrap().unwrap(); 139 assert_eq!(xtal.key.as_deref(), Some("A Minor")); 140 assert_eq!(xtal.bpm, Some(120.0)); 141 142 let roygbiv = db.get("Boards of Canada - Roygbiv").unwrap().unwrap(); 143 assert_eq!(roygbiv.key.as_deref(), Some("C Major")); 144 145 assert!(db.get("Deleted - Track").unwrap().is_none()); 146 assert!(db.get("Zero - Bpm").unwrap().is_none()); 147 } 148 }