Commit Diff


commit - 1a2fc99e702712236695b83983e00b20337a5ecb
commit + 1805b3d5f73e1c839b2de8b9855c0a1c2ebafb4b
blob - eb5a316cbd195d26e3f768c7dd8e1b47299e17f8
blob + 4075fb24e83adb9df763f0eaa25b60c73b1c0a37
--- .gitignore
+++ .gitignore
@@ -1 +1,2 @@
 target
+result
blob - 36f06a613b10a35bb8fcdc44bfe7ff157bd1ee3e
blob + b79129b2ba523e12f7a3a6136cacf96dbe6565bd
--- Cargo.lock
+++ Cargo.lock
@@ -487,6 +487,18 @@ dependencies = [
 ]
 
 [[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
 name = "ffmpeg-next"
 version = "8.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -528,6 +540,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
 name = "fs2"
 version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -612,11 +630,29 @@ dependencies = [
 
 [[package]]
 name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
 version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
 
 [[package]]
+name = "hashlink"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
 name = "heck"
 version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -668,7 +704,7 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
 dependencies = [
  "equivalent",
- "hashbrown",
+ "hashbrown 0.17.1",
 ]
 
 [[package]]
@@ -787,6 +823,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "libsqlite3-sys"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
 name = "linux-raw-sys"
 version = "0.4.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1257,6 +1303,31 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
 
 [[package]]
+name = "rsqlite-vfs"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
+dependencies = [
+ "hashbrown 0.16.1",
+ "thiserror",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
+dependencies = [
+ "bitflags 2.11.1",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+ "sqlite-wasm-rs",
+]
+
+[[package]]
 name = "rustc-hash"
 version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1406,6 +1477,18 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
 
 [[package]]
+name = "sqlite-wasm-rs"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
+dependencies = [
+ "cc",
+ "js-sys",
+ "rsqlite-vfs",
+ "wasm-bindgen",
+]
+
+[[package]]
 name = "strength_reduce"
 version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1530,6 +1613,7 @@ dependencies = [
  "ndarray 0.17.2",
  "rayon",
  "regex",
+ "rusqlite",
  "serde",
  "serde_json",
  "sled",
blob - af192adb39983f94301aa40649cbac56b4a1767a
blob + 896e928a25cd93bb922b9fe9305ba98ecea7c572
--- Cargo.toml
+++ Cargo.toml
@@ -19,3 +19,4 @@ serde_json = "1.0"
 sled = "0.34"
 dirs = "6"
 regex = "1"
+rusqlite = "0.39"
blob - e2d7d885deca8d542e03d284197bb666d2e0e6c1
blob + ee4d962199856141e6dc8e5b2ee23e1b1295f8f2
--- README.md
+++ README.md
@@ -8,6 +8,7 @@ A command-line tool that analyzes audio files to detec
 * Write detected BPM and Key to metadata tags
 * Save track metadata to a database
 * Query the database by artist, key, BPM range, or regex
+* Import tracks from a Mixxx SQLite database
 
 ## Building
 
@@ -25,21 +26,23 @@ cargo build --release
 
 ### Arguments
 
-*   `[PATH]...`: One or more files or directories to analyze. Not required when using database query flags.
+*   `[PATH]...`: Files or directories to analyze. Not required when using database query flags.
 
 ### Options
 
 *   `-w`, `--write`: Write detected BPM and Key to audio file metadata.
-*   `-f`, `--force`: Re-analyze files even if they already have BPM/Key tags.
-*   `-j`, `--jobs <JOBS>`: Number of threads to use (0 = all cores).
-*   `-S`, `--save`: Save track reports (artist, track, BPM, key) to the database.
-*   `-l`, `--list`: List all track reports stored in the database.
-*   `--query <KEY>`: Look up a track report by exact artist - track key.
-*   `-s`, `--search <PATTERN>`: Search track reports by regex pattern (matched against key, artist, track, BPM, key).
-*   `-a`, `--artist <PREFIX>`: Find tracks by artist name prefix (uses database index).
-*   `--key <PREFIX>`: Find tracks by key prefix (uses database index).
-*   `--bpm <MIN> <MAX>`: Find tracks with BPM in the given range (uses database index).
-*   `--db-path <PATH>`: Path to the database directory (default: `~/.local/share/virittaa`).
+*   `-f`, `--force`: Re-analyze files that already have BPM/Key tags.
+*   `-j`, `--jobs <JOBS>`: Number of threads (0 = all cores).
+*   `-S`, `--save`: Save track reports to the database.
+*   `-l`, `--list`: List track reports in the database.
+*   `--limit <N>`: Max entries to list (default: 1000, 0 = all).
+*   `--query <KEY>`: Look up a track by exact key.
+*   `-s`, `--search <PATTERN>`: Search track reports by regex.
+*   `-a`, `--artist <PREFIX>`: Find tracks by artist name prefix.
+*   `--key <PREFIX>`: Find tracks by key prefix (e.g. 'C Major', 'Am').
+*   `--bpm <MIN> <MAX>`: Find tracks with BPM in range MIN MAX.
+*   `--import-mixxx <PATH>`: Import tracks from a Mixxx SQLite database.
+*   `--db-path <PATH>`: Database directory (default: `~/.local/share/virittaa`).
 *   `-h`, `--help`: Print help information.
 *   `-V`, `--version`: Print version information.
 
@@ -64,9 +67,12 @@ cargo build --release
 # Write tags and save to database with custom path
 ./target/release/virittaa -w --save --db-path ~/my_library /path/to/music/
 
-# List all tracks in the database
+# List tracks in the database (default limit: 1000)
 ./target/release/virittaa --list
 
+# List all tracks (no limit)
+./target/release/virittaa --list --limit 0
+
 # Look up a specific track
 ./target/release/virittaa --query "Aphex Twin - Xtal"
 
@@ -74,12 +80,15 @@ cargo build --release
 ./target/release/virittaa --search 'weed'
 ./target/release/virittaa --search '^Aphex'
 
-# Find all tracks by an artist (indexed, fast prefix scan)
+# Find all tracks by an artist
 ./target/release/virittaa --artist 'Aphex Twin'
 
-# Find all tracks in a key (indexed)
+# Find all tracks in a key
 ./target/release/virittaa --key 'C Major'
 
-# Find tracks with BPM between 120 and 140 (indexed range query)
+# Find tracks with BPM between 120 and 140
 ./target/release/virittaa --bpm 120 140
+
+# Import from a Mixxx database
+./target/release/virittaa --import-mixxx ~/.mixxx/mixxxdb.sqlite
 ```
blob - 7ab2c4316898048f0f414cd969998be758fcf799
blob + d2a3c776841c370e0c92e468ffc380f7565ace62
--- src/db.rs
+++ src/db.rs
@@ -25,16 +25,13 @@ impl fmt::Display for TrackReport {
     }
 }
 
-/// Sled-backed library database using multiple trees as indexes.
+/// Sled-backed library database with secondary indexes for artist, key, and BPM.
 ///
 /// Schema:
 /// - default tree: `"Artist - Track"` → JSON `TrackReport`
-/// - `"artist"` tree: `"artist\x00primary_key"` → empty
-/// - `"key"` tree: `"key\x00primary_key"` → empty
-/// - `"bpm"` tree: `f64_be_bytes + "\x00" + primary_key` → empty
-///
-/// This enables fast prefix scans by artist, key, and BPM range queries
-/// using sled's native `scan_prefix` and `range` operations.
+/// - `"artist"` tree: `"artist\0primary_key"` → empty
+/// - `"key"` tree: `"key\0primary_key"` → empty
+/// - `"bpm"` tree: `f64_be_bytes + "\0" + primary_key` → empty
 pub struct LibraryDb {
     db: sled::Db,
     artist_tree: Tree,
@@ -44,7 +41,6 @@ pub struct LibraryDb {
 
 const SEP: u8 = 0x00;
 
-/// Build a prefix-style index key: `value\0primary_key`.
 fn prefix_index_key(value: &[u8], primary_key: &[u8]) -> Vec<u8> {
     let mut buf = Vec::with_capacity(value.len() + 1 + primary_key.len());
     buf.extend_from_slice(value);
@@ -53,7 +49,6 @@ fn prefix_index_key(value: &[u8], primary_key: &[u8]) 
     buf
 }
 
-/// Build a BPM range index key: `f64_be_bytes\0primary_key`.
 fn bpm_index_key(bpm: f64, primary_key: &[u8]) -> Vec<u8> {
     let mut buf = Vec::with_capacity(8 + 1 + primary_key.len());
     buf.extend_from_slice(&bpm.to_be_bytes());
@@ -62,7 +57,6 @@ fn bpm_index_key(bpm: f64, primary_key: &[u8]) -> Vec<
     buf
 }
 
-/// Extract the primary key portion after the first separator from an index key.
 fn extract_primary_key(idx_key: &[u8]) -> Result<&[u8]> {
     let pos = idx_key
         .iter()
@@ -72,6 +66,8 @@ fn extract_primary_key(idx_key: &[u8]) -> Result<&[u8]
 }
 
 impl LibraryDb {
+    /// Open or create the database at the given path.
+    ///
     /// # Errors
     ///
     /// Returns an error if the database cannot be opened or created.
@@ -91,13 +87,14 @@ impl LibraryDb {
         })
     }
 
+    /// Save a track report and update all secondary indexes.
+    ///
     /// # Errors
     ///
-    /// Returns an error if serialization or the database write fails.
+    /// Returns an error if serialization or any database write fails.
     pub fn save(&self, key: &str, report: &TrackReport) -> Result<()> {
         let value = serde_json::to_vec(report).context("Failed to serialize track report")?;
 
-        // Primary store
         self.db
             .insert(key.as_bytes(), value)
             .context("Failed to write to database")?;
@@ -109,7 +106,7 @@ impl LibraryDb {
             .insert(&idx_key, &[][..])
             .context("Failed to write artist index")?;
 
-        // Key signature index
+        // Key index
         if let Some(key_val) = &report.key {
             let key_lower = key_val.to_lowercase();
             let idx_key = prefix_index_key(key_lower.as_bytes(), key.as_bytes());
@@ -139,6 +136,8 @@ impl LibraryDb {
         Ok(())
     }
 
+    /// Look up a track report by exact key.
+    ///
     /// # Errors
     ///
     /// Returns an error if the database read or deserialization fails.
@@ -154,9 +153,11 @@ impl LibraryDb {
         }
     }
 
+    /// Delete a track report and remove its secondary index entries.
+    ///
     /// # Errors
     ///
-    /// Returns an error if the database delete fails.
+    /// Returns an error if the database delete or deserialization fails.
     #[allow(dead_code)]
     pub fn delete(&self, key: &str) -> Result<()> {
         let old = self
@@ -194,14 +195,23 @@ impl LibraryDb {
         Ok(())
     }
 
-    /// List all entries in the primary tree.
+    /// Count all entries in the primary tree.
     ///
     /// # Errors
     ///
+    /// Returns an error if the database iteration fails.
+    pub fn count(&self) -> Result<usize> {
+        Ok(self.db.len())
+    }
+
+    /// List entries in the primary tree, up to `limit` items.
+    ///
+    /// # Errors
+    ///
     /// Returns an error if the database iteration or deserialization fails.
-    pub fn list(&self) -> Result<Vec<(String, TrackReport)>> {
-        let mut results = Vec::new();
-        for item in self.db.iter() {
+    pub fn list(&self, limit: usize) -> Result<Vec<(String, TrackReport)>> {
+        let mut results = Vec::with_capacity(limit.min(1024));
+        for item in self.db.iter().take(limit) {
             let (key, value) = item.context("Failed to read iterator entry")?;
             let key_str =
                 String::from_utf8(key.to_vec()).context("Invalid UTF-8 key in database")?;
@@ -212,7 +222,7 @@ impl LibraryDb {
         Ok(results)
     }
 
-    /// Find all tracks by a given artist prefix (case-insensitive).
+    /// Find tracks by artist name prefix (case-insensitive).
     ///
     /// # Errors
     ///
@@ -235,7 +245,7 @@ impl LibraryDb {
         Ok(results)
     }
 
-    /// Find all tracks in a given key signature (case-insensitive prefix match).
+    /// Find tracks by key signature prefix (case-insensitive).
     ///
     /// # Errors
     ///
@@ -245,7 +255,7 @@ impl LibraryDb {
         let mut results = Vec::new();
 
         for item in self.key_tree.scan_prefix(prefix_lower.as_bytes()) {
-            let (idx_key, _) = item.context("Failed to read Key index")?;
+            let (idx_key, _) = item.context("Failed to read key index")?;
             let primary_key_bytes = extract_primary_key(&idx_key)?;
             let primary_key = String::from_utf8(primary_key_bytes.to_vec())
                 .context("Invalid UTF-8 in primary key")?;
@@ -258,7 +268,7 @@ impl LibraryDb {
         Ok(results)
     }
 
-    /// Find all tracks with BPM in the given range (inclusive).
+    /// Find tracks with BPM in the given range (inclusive).
     ///
     /// # Errors
     ///
blob - 47501d6b9ca68faf776a6effea7117b8c03d879c
blob + afe479fb9723f92bbf9dc437dddd4ae64fbf9871
--- src/file_io.rs
+++ src/file_io.rs
@@ -32,11 +32,11 @@ fn build_track_info(path: &Path, metadata: FileMetadat
     }
 }
 
-/// Process a single file.
+/// Process a single file, detecting BPM and key.
 ///
 /// # Errors
 ///
-/// Returns an error if the file cannot be processed.
+/// Returns an error string if the file cannot be decoded or key detection fails.
 pub fn process_file(path: &Path, write_tags: bool, force: bool) -> Result<TrackInfo, String> {
     let (metadata, existing_tags) = read_tags(path);
 
@@ -116,11 +116,11 @@ struct ReportRecord<'a> {
     error: Option<&'a str>,
 }
 
-/// Write a CSV report of the processed tracks.
+/// Write a CSV report of processed tracks.
 ///
 /// # Errors
 ///
-/// Returns an error if the report cannot be written.
+/// Returns an error if the CSV file cannot be created or written.
 pub fn write_report(results: &[TrackInfo], errors: &[TrackError]) -> Result<()> {
     let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
     let filename = format!("virittaa-report-{timestamp}.csv");
@@ -164,7 +164,7 @@ pub fn write_report(results: &[TrackInfo], errors: &[T
 ///
 /// # Errors
 ///
-/// Returns an error if the metadata cannot be written.
+/// Returns an error if the file cannot be opened, read, or written.
 ///
 /// # Panics
 ///
@@ -177,7 +177,6 @@ pub fn write_metadata(path: &Path, bpm: f32, key: &str
             if let Some(first_tag) = tagged_file.first_tag_mut() {
                 first_tag
             } else {
-                // If there are no tags, create a new one based on the file type
                 let tag_type = tagged_file.primary_tag_type();
                 tagged_file.insert_tag(Tag::new(tag_type));
                 tagged_file
blob - 59c5439655e2e8da66ba67affc886bab81bab266
blob + 72073007134f750cc2ebde9fb63715792d56dbe0
--- src/main.rs
+++ src/main.rs
@@ -2,6 +2,7 @@ pub mod audio_processing;
 pub mod constants;
 pub mod db;
 pub mod file_io;
+pub mod import;
 pub mod types;
 pub mod utils;
 
@@ -15,6 +16,7 @@ use walkdir::WalkDir;
 use crate::db::LibraryDb;
 use crate::db::TrackReport;
 use crate::file_io::process_file;
+use crate::import::import_mixxx;
 use crate::types::TrackError;
 use crate::utils::{is_supported_audio_file, print_entries};
 
@@ -34,7 +36,7 @@ fn main() -> Result<()> {
         .arg(
             Arg::new("path")
                 .num_args(1..)
-                .help("One or more files or directories to analyze (not required with --list, --query, or --search)"),
+                .help("Files or directories to analyze"),
         )
         .arg(
             Arg::new("write")
@@ -48,13 +50,13 @@ fn main() -> Result<()> {
                 .short('f')
                 .long("force")
                 .action(clap::ArgAction::SetTrue)
-                .help("Re-analyze files even if they already have BPM/Key tags"),
+                .help("Re-analyze files that already have BPM/Key tags"),
         )
         .arg(
             Arg::new("jobs")
                 .short('j')
                 .long("jobs")
-                .help("Number of threads to use (0 = all cores)")
+                .help("Number of threads (0 = all cores)")
                 .default_value("0")
                 .value_parser(clap::value_parser!(usize)),
         )
@@ -63,47 +65,59 @@ fn main() -> Result<()> {
                 .short('S')
                 .long("save")
                 .action(clap::ArgAction::SetTrue)
-                .help("Save track reports (artist, track, BPM, key) to a database"),
+                .help("Save track reports to the database"),
         )
         .arg(
             Arg::new("list")
                 .short('l')
                 .long("list")
                 .action(clap::ArgAction::SetTrue)
-                .help("List all track reports stored in the database"),
+                .help("List track reports in the database"),
         )
         .arg(
+            Arg::new("limit")
+                .long("limit")
+                .help("Max entries to list (default: 1000, 0 = all)")
+                .default_value("1000")
+                .value_parser(clap::value_parser!(usize)),
+        )
+        .arg(
             Arg::new("query")
                 .long("query")
-                .help("Look up a track report by exact artist - track key"),
+                .help("Look up a track by exact key"),
         )
         .arg(
             Arg::new("search")
                 .short('s')
                 .long("search")
-                .help("Search track reports by regex pattern (matched against key, artist, track, BPM, key)"),
+                .help("Search track reports by regex"),
         )
         .arg(
             Arg::new("artist")
                 .short('a')
                 .long("artist")
-                .help("Find tracks by artist name prefix (uses database index)"),
+                .help("Find tracks by artist name prefix"),
         )
         .arg(
             Arg::new("key")
                 .long("key")
-                .help("Find tracks by key prefix, e.g. 'C Major', 'Am' (uses database index)"),
+                .help("Find tracks by key prefix (e.g. 'C Major', 'Am')"),
         )
         .arg(
             Arg::new("bpm")
                 .long("bpm")
                 .num_args(2)
-                .help("Find tracks with BPM in range MIN MAX (uses database index)"),
+                .help("Find tracks with BPM in range MIN MAX"),
         )
         .arg(
+            Arg::new("import-mixxx")
+                .long("import-mixxx")
+                .help("Import tracks from a Mixxx SQLite database"),
+        )
+        .arg(
             Arg::new("db-path")
                 .long("db-path")
-                .help("Path to the database directory for saved reports (default: ~/.local/share/virittaa)"),
+                .help("Database directory (default: ~/.local/share/virittaa)"),
         )
         .get_matches();
 
@@ -116,6 +130,8 @@ fn main() -> Result<()> {
     let search_pattern = matches.get_one::<String>("search");
     let artist_prefix = matches.get_one::<String>("artist").map(String::as_str);
     let key_prefix = matches.get_one::<String>("key").map(String::as_str);
+    let import_mixxx_path = matches.get_one::<String>("import-mixxx");
+    let limit = *matches.get_one::<usize>("limit").unwrap();
     let bpm_range: Option<(f64, f64)> = matches.get_many::<String>("bpm").map(|vals| {
         let v: Vec<&String> = vals.collect();
         let min = v[0].parse::<f64>().expect("Invalid BPM min value");
@@ -130,17 +146,23 @@ fn main() -> Result<()> {
         dir.to_string_lossy().to_string()
     };
 
-    // Read-only DB operations that don't need file paths
     let is_db_query = list_db
         || query_key.is_some()
         || search_pattern.is_some()
         || artist_prefix.is_some()
         || key_prefix.is_some()
-        || bpm_range.is_some();
+        || bpm_range.is_some()
+        || import_mixxx_path.is_some();
 
     if is_db_query {
         let db = LibraryDb::open(std::path::Path::new(&db_path))?;
 
+        if let Some(mixxx_path) = import_mixxx_path {
+            let count = import_mixxx(&db, mixxx_path)?;
+            println!("Imported {count} tracks from Mixxx database.");
+            return Ok(());
+        }
+
         if let Some(query_key) = query_key {
             match db.get(query_key)? {
                 Some(report) => println!("{query_key}: {report}"),
@@ -169,7 +191,8 @@ fn main() -> Result<()> {
             );
         } else if let Some(pattern) = search_pattern {
             let re = Regex::new(pattern)?;
-            let entries = db.list()?;
+            let list_limit = if limit == 0 { usize::MAX } else { limit };
+            let entries = db.list(list_limit)?;
             let matches_found: Vec<_> = entries
                 .into_iter()
                 .filter(|(key, report)| re.is_match(key) || re.is_match(&report.to_string()))
@@ -180,14 +203,20 @@ fn main() -> Result<()> {
                 "match(es) found.",
             );
         } else {
-            let entries = db.list()?;
+            let effective_limit = if limit == 0 { usize::MAX } else { limit };
+            let entries = db.list(effective_limit)?;
+            let total = db.count()?;
             if entries.is_empty() {
                 println!("No track reports found in database.");
             } else {
                 for (key, report) in &entries {
                     println!("{key}: {report}");
                 }
-                println!("\n{} track(s) in database.", entries.len());
+                if total > entries.len() {
+                    println!("\n{} of {} track(s) shown.", entries.len(), total);
+                } else {
+                    println!("\n{} track(s) in database.", entries.len());
+                }
             }
         }
         return Ok(());
@@ -197,7 +226,7 @@ fn main() -> Result<()> {
         .get_many::<String>("path")
         .ok_or_else(|| {
             anyhow::anyhow!(
-                "Provide file paths, or use --list / --query / --search / --artist / --key / --bpm to read the database"
+                "Provide file paths, or use --list / --query / --search / --artist / --key / --bpm / --import-mixxx to read the database"
             )
         })?
         .cloned()
blob - /dev/null
blob + 12d9f8b707ccb8ef10506661f2e9e4d48c50d60c (mode 644)
--- /dev/null
+++ src/import.rs
@@ -0,0 +1,73 @@
+use anyhow::{Context, Result};
+use rusqlite::Connection;
+
+use crate::db::{LibraryDb, TrackReport};
+
+/// Convert a Mixxx key string (e.g. "Cm", "D", "Bbm") to virittaa format
+/// (e.g. "C Minor", "D Major", "Bb Minor"). Returns `None` for empty or
+/// unknown keys.
+fn convert_mixxx_key(raw: &str) -> Option<String> {
+    let raw = raw.trim();
+    if raw.is_empty() {
+        return None;
+    }
+    // Mixxx minor keys end with 'm': "Cm", "C#m", "Bbm", etc.
+    if raw.ends_with('m') && raw.len() > 1 {
+        let root = &raw[..raw.len() - 1];
+        Some(format!("{root} Minor"))
+    } else {
+        Some(format!("{raw} Major"))
+    }
+}
+
+/// Import tracks from a Mixxx `SQLite` database into the sled library database.
+///
+/// Reads the `library` and `track_locations` tables, skipping entries marked as
+/// deleted, and saves each track with its BPM and key.
+///
+/// # Errors
+///
+/// Returns an error if the Mixxx database cannot be opened or queried, or if
+/// sled writes fail.
+pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) -> Result<usize> {
+    let conn = Connection::open(mixxx_path).with_context(|| "Failed to open Mixxx database")?;
+
+    let mut stmt = conn.prepare(
+        "SELECT l.artist, l.title, l.bpm, l.key \
+         FROM library l \
+         WHERE l.mixxx_deleted = 0 AND l.bpm IS NOT NULL AND l.bpm > 0",
+    )?;
+
+    let rows = stmt.query_map([], |row| {
+        let artist: Option<String> = row.get(0)?;
+        let title: Option<String> = row.get(1)?;
+        let bpm: Option<f64> = row.get(2)?;
+        let key_raw: Option<String> = row.get(3)?;
+        Ok((artist, title, bpm, key_raw))
+    })?;
+
+    let mut count = 0usize;
+    for row in rows {
+        let (artist, title, bpm, key_raw) = row?;
+        let key = key_raw.as_deref().and_then(convert_mixxx_key);
+
+        let primary_key = format!(
+            "{} - {}",
+            artist.as_deref().unwrap_or("Unknown"),
+            title.as_deref().unwrap_or("Unknown")
+        );
+
+        let report = TrackReport {
+            artist,
+            track: title,
+            bpm,
+            key,
+        };
+
+        db.save(&primary_key, &report)?;
+        count += 1;
+    }
+
+    db.flush()?;
+    Ok(count)
+}