Commit Diff


commit - 48dfccd5d56b01f40f86c515b5feb6c54358d7c0
commit + 1701ad61c5a4e80e10cf115fa88ca1589909893d
blob - e722f8e1f250da6267a45f27e57d4ae552ac7e78
blob + 36f06a613b10a35bb8fcdc44bfe7ff157bd1ee3e
--- Cargo.lock
+++ Cargo.lock
@@ -1529,6 +1529,7 @@ dependencies = [
  "lofty",
  "ndarray 0.17.2",
  "rayon",
+ "regex",
  "serde",
  "serde_json",
  "sled",
blob - 584fd353f96ef599f42ad7f6895ce49f6c6f9709
blob + af192adb39983f94301aa40649cbac56b4a1767a
--- Cargo.toml
+++ Cargo.toml
@@ -18,3 +18,4 @@ serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 sled = "0.34"
 dirs = "6"
+regex = "1"
blob - 61e57e39f84f71ceebfdfa6d867277ce13a87bc6
blob + e2d7d885deca8d542e03d284197bb666d2e0e6c1
--- README.md
+++ README.md
@@ -7,6 +7,7 @@ A command-line tool that analyzes audio files to detec
 * Detect BPM and Key of audio files
 * Write detected BPM and Key to metadata tags
 * Save track metadata to a database
+* Query the database by artist, key, BPM range, or regex
 
 ## Building
 
@@ -24,7 +25,7 @@ cargo build --release
 
 ### Arguments
 
-*   `[PATH]...`: One or more files or directories to analyze. Not required when using `--list` or `--query`.
+*   `[PATH]...`: One or more files or directories to analyze. Not required when using database query flags.
 
 ### Options
 
@@ -33,7 +34,11 @@ cargo build --release
 *   `-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 artist - track key.
+*   `--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`).
 *   `-h`, `--help`: Print help information.
 *   `-V`, `--version`: Print version information.
@@ -65,7 +70,16 @@ cargo build --release
 # Look up a specific track
 ./target/release/virittaa --query "Aphex Twin - Xtal"
 
-# Query using a custom database path
-./target/release/virittaa --list --db-path ~/my_library
+# Search by regex
+./target/release/virittaa --search 'weed'
+./target/release/virittaa --search '^Aphex'
+
+# Find all tracks by an artist (indexed, fast prefix scan)
+./target/release/virittaa --artist 'Aphex Twin'
+
+# Find all tracks in a key (indexed)
+./target/release/virittaa --key 'C Major'
+
+# Find tracks with BPM between 120 and 140 (indexed range query)
+./target/release/virittaa --bpm 120 140
 ```
-
blob - f9fd7179629407b9551c025aa363cb8b9baa2382
blob + 7ab2c4316898048f0f414cd969998be758fcf799
--- src/db.rs
+++ src/db.rs
@@ -1,6 +1,7 @@
 use anyhow::{Context, Result};
 use serde::{Deserialize, Serialize};
-use sled::Db;
+use sled::Tree;
+use std::fmt;
 use std::path::Path;
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -11,10 +12,65 @@ pub struct TrackReport {
     pub key: Option<String>,
 }
 
+impl fmt::Display for TrackReport {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let bpm = self
+            .bpm
+            .as_ref()
+            .map_or_else(|| "-".to_string(), |b| format!("{b:.1}"));
+        let key = self.key.as_deref().unwrap_or("-");
+        let artist = self.artist.as_deref().unwrap_or("Unknown");
+        let track = self.track.as_deref().unwrap_or("Unknown");
+        write!(f, "{artist} - {track} | BPM: {bpm} | Key: {key}")
+    }
+}
+
+/// Sled-backed library database using multiple trees as indexes.
+///
+/// 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.
 pub struct LibraryDb {
-    db: Db,
+    db: sled::Db,
+    artist_tree: Tree,
+    key_tree: Tree,
+    bpm_tree: Tree,
 }
 
+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);
+    buf.push(SEP);
+    buf.extend_from_slice(primary_key);
+    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());
+    buf.push(SEP);
+    buf.extend_from_slice(primary_key);
+    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()
+        .position(|&b| b == SEP)
+        .context("Malformed index key: no separator found")?;
+    Ok(&idx_key[pos + 1..])
+}
+
 impl LibraryDb {
     /// # Errors
     ///
@@ -22,7 +78,17 @@ impl LibraryDb {
     pub fn open(path: &Path) -> Result<Self> {
         let db = sled::open(path)
             .with_context(|| format!("Failed to open database at {}", path.display()))?;
-        Ok(Self { db })
+        let artist_tree = db
+            .open_tree("artist")
+            .context("Failed to open artist tree")?;
+        let key_tree = db.open_tree("key").context("Failed to open key tree")?;
+        let bpm_tree = db.open_tree("bpm").context("Failed to open bpm tree")?;
+        Ok(Self {
+            db,
+            artist_tree,
+            key_tree,
+            bpm_tree,
+        })
     }
 
     /// # Errors
@@ -30,9 +96,45 @@ impl LibraryDb {
     /// Returns an error if serialization or the 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")?;
+
+        // Artist index
+        let artist_lower = report.artist.as_deref().unwrap_or("unknown").to_lowercase();
+        let idx_key = prefix_index_key(artist_lower.as_bytes(), key.as_bytes());
+        self.artist_tree
+            .insert(&idx_key, &[][..])
+            .context("Failed to write artist index")?;
+
+        // Key signature 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());
+            self.key_tree
+                .insert(&idx_key, &[][..])
+                .context("Failed to write key index")?;
+        }
+
+        // BPM index
+        if let Some(bpm) = report.bpm {
+            let idx_key = bpm_index_key(bpm, key.as_bytes());
+            self.bpm_tree
+                .insert(&idx_key, &[][..])
+                .context("Failed to write bpm index")?;
+        }
+
+        Ok(())
+    }
+
+    /// Flush pending writes to disk.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the flush fails.
+    pub fn flush(&self) -> Result<()> {
         self.db.flush().context("Failed to flush database")?;
         Ok(())
     }
@@ -40,7 +142,6 @@ impl LibraryDb {
     /// # Errors
     ///
     /// Returns an error if the database read or deserialization fails.
-    #[allow(dead_code)]
     pub fn get(&self, key: &str) -> Result<Option<TrackReport>> {
         let ivec = self.db.get(key).context("Failed to read from database")?;
         match ivec {
@@ -58,13 +159,43 @@ impl LibraryDb {
     /// Returns an error if the database delete fails.
     #[allow(dead_code)]
     pub fn delete(&self, key: &str) -> Result<()> {
-        self.db
+        let old = self
+            .db
             .remove(key)
             .context("Failed to delete from database")?;
+
+        if let Some(bytes) = old {
+            let report: TrackReport =
+                serde_json::from_slice(&bytes).context("Failed to deserialize old record")?;
+
+            let artist_lower = report.artist.as_deref().unwrap_or("unknown").to_lowercase();
+            let idx_key = prefix_index_key(artist_lower.as_bytes(), key.as_bytes());
+            self.artist_tree
+                .remove(&idx_key)
+                .context("Failed to delete from artist 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());
+                self.key_tree
+                    .remove(&idx_key)
+                    .context("Failed to delete from key index")?;
+            }
+
+            if let Some(bpm) = report.bpm {
+                let idx_key = bpm_index_key(bpm, key.as_bytes());
+                self.bpm_tree
+                    .remove(&idx_key)
+                    .context("Failed to delete from bpm index")?;
+            }
+        }
+
         self.db.flush().context("Failed to flush database")?;
         Ok(())
     }
 
+    /// List all entries in the primary tree.
+    ///
     /// # Errors
     ///
     /// Returns an error if the database iteration or deserialization fails.
@@ -80,16 +211,72 @@ impl LibraryDb {
         }
         Ok(results)
     }
-}
 
-#[must_use]
-pub fn format_report(report: &TrackReport) -> String {
-    let bpm = report
-        .bpm
-        .as_ref()
-        .map_or_else(|| "-".to_string(), |b| format!("{b:.1}"));
-    let key = report.key.as_deref().unwrap_or("-");
-    let artist = report.artist.as_deref().unwrap_or("Unknown");
-    let track = report.track.as_deref().unwrap_or("Unknown");
-    format!("{artist} - {track} | BPM: {bpm} | Key: {key}")
+    /// Find all tracks by a given artist prefix (case-insensitive).
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the index scan or deserialization fails.
+    pub fn find_by_artist(&self, prefix: &str) -> Result<Vec<(String, TrackReport)>> {
+        let prefix_lower = prefix.to_lowercase();
+        let mut results = Vec::new();
+
+        for item in self.artist_tree.scan_prefix(prefix_lower.as_bytes()) {
+            let (idx_key, _) = item.context("Failed to read artist 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")?;
+
+            if let Some(report) = self.get(&primary_key)? {
+                results.push((primary_key, report));
+            }
+        }
+
+        Ok(results)
+    }
+
+    /// Find all tracks in a given key signature (case-insensitive prefix match).
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the index scan or deserialization fails.
+    pub fn find_by_key(&self, prefix: &str) -> Result<Vec<(String, TrackReport)>> {
+        let prefix_lower = prefix.to_lowercase();
+        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 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")?;
+
+            if let Some(report) = self.get(&primary_key)? {
+                results.push((primary_key, report));
+            }
+        }
+
+        Ok(results)
+    }
+
+    /// Find all tracks with BPM in the given range (inclusive).
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the index scan or deserialization fails.
+    pub fn find_by_bpm_range(&self, min: f64, max: f64) -> Result<Vec<(String, TrackReport)>> {
+        let mut results = Vec::new();
+
+        for item in self.bpm_tree.range(min.to_be_bytes()..=max.to_be_bytes()) {
+            let (idx_key, _) = item.context("Failed to read bpm 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")?;
+
+            if let Some(report) = self.get(&primary_key)? {
+                results.push((primary_key, report));
+            }
+        }
+
+        Ok(results)
+    }
 }
blob - 3499129f3a8755f0aa9864b858f3ba81bc57e87c
blob + 982457c87913165949952018259198e0cbae9a7d
--- src/main.rs
+++ src/main.rs
@@ -7,10 +7,12 @@ pub mod types;
 use anyhow::Result;
 use clap::{Arg, Command};
 use rayon::prelude::*;
+use regex::Regex;
 use std::path::PathBuf;
 use walkdir::WalkDir;
 
 use crate::db::LibraryDb;
+use crate::db::TrackReport;
 use crate::file_io::process_file;
 use crate::types::TrackError;
 
@@ -25,15 +27,34 @@ fn is_supported_extension(ext: &std::ffi::OsStr) -> bo
     SUPPORTED_EXTENSIONS.contains(&*ext_lower)
 }
 
+fn print_entries(entries: &[(String, TrackReport)], empty_msg: &str, count_suffix: &str) {
+    if entries.is_empty() {
+        eprintln!("{empty_msg}");
+    } else {
+        for (key, report) in entries {
+            println!("{key}: {report}");
+        }
+        println!("\n{} {count_suffix}", entries.len());
+    }
+}
+
 #[allow(clippy::too_many_lines)]
 fn main() -> Result<()> {
+    if std::env::args().len() == 1 {
+        let _ = Command::new("virittaa")
+            .version(clap::crate_version!())
+            .about("Analyze audio files to detect BPM and Key")
+            .print_help();
+        return Ok(());
+    }
+
     let matches = Command::new("virittaa")
         .version(clap::crate_version!())
         .about("Analyze audio files to detect BPM and Key")
         .arg(
             Arg::new("path")
                 .num_args(1..)
-                .help("One or more files or directories to analyze (not required with --list or --query)"),
+                .help("One or more files or directories to analyze (not required with --list, --query, or --search)"),
         )
         .arg(
             Arg::new("write")
@@ -74,9 +95,32 @@ fn main() -> Result<()> {
         .arg(
             Arg::new("query")
                 .long("query")
-                .help("Look up a track report by artist - track key"),
+                .help("Look up a track report by exact artist - track key"),
         )
         .arg(
+            Arg::new("search")
+                .short('s')
+                .long("search")
+                .help("Search track reports by regex pattern (matched against key, artist, track, BPM, key)"),
+        )
+        .arg(
+            Arg::new("artist")
+                .short('a')
+                .long("artist")
+                .help("Find tracks by artist name prefix (uses database index)"),
+        )
+        .arg(
+            Arg::new("key")
+                .long("key")
+                .help("Find tracks by key prefix, e.g. 'C Major', 'Am' (uses database index)"),
+        )
+        .arg(
+            Arg::new("bpm")
+                .long("bpm")
+                .num_args(2)
+                .help("Find tracks with BPM in range MIN MAX (uses database index)"),
+        )
+        .arg(
             Arg::new("db-path")
                 .long("db-path")
                 .help("Path to the database directory for saved reports (default: ~/.local/share/virittaa)"),
@@ -88,7 +132,18 @@ fn main() -> Result<()> {
     let jobs = *matches.get_one::<usize>("jobs").unwrap();
     let save_to_db = matches.get_flag("save");
     let list_db = matches.get_flag("list");
-    let query_path = matches.get_one::<String>("query").map(String::as_str);
+    let query_key = matches.get_one::<String>("query").map(String::as_str);
+    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 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");
+            let max = v[1].parse::<f64>().expect("Invalid BPM max value");
+            (min, max)
+        });
     let db_path = if let Some(p) = matches.get_one::<String>("db-path") {
         p.clone()
     } else {
@@ -98,21 +153,45 @@ fn main() -> Result<()> {
     };
 
     // Read-only DB operations that don't need file paths
-    if list_db || query_path.is_some() {
+    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();
+
+    if is_db_query {
         let db = LibraryDb::open(std::path::Path::new(&db_path))?;
 
-        if let Some(path) = query_path {
-            match db.get(path)? {
-                Some(report) => println!("{}", db::format_report(&report)),
-                None => eprintln!("No report found for: {path}"),
+        if let Some(query_key) = query_key {
+            match db.get(query_key)? {
+                Some(report) => println!("{query_key}: {report}"),
+                None => eprintln!("No track found for key: {query_key}"),
             }
+        } else if let Some(prefix) = artist_prefix {
+            let entries = db.find_by_artist(prefix)?;
+            print_entries(&entries, &format!("No tracks found for artist prefix: {prefix}"), "track(s) found.");
+        } else if let Some(prefix) = key_prefix {
+            let entries = db.find_by_key(prefix)?;
+            print_entries(&entries, &format!("No tracks found for key prefix: {prefix}"), "track(s) found.");
+        } else if let Some((min, max)) = bpm_range {
+            let entries = db.find_by_bpm_range(min, max)?;
+            print_entries(&entries, &format!("No tracks found with BPM between {min} and {max}"), "track(s) found.");
+        } else if let Some(pattern) = search_pattern {
+            let re = Regex::new(pattern)?;
+            let entries = db.list()?;
+            let matches_found: Vec<_> = entries
+                .into_iter()
+                .filter(|(key, report)| re.is_match(key) || re.is_match(&report.to_string()))
+                .collect();
+            print_entries(&matches_found, &format!("No matches for: {pattern}"), "match(es) found.");
         } else {
             let entries = db.list()?;
             if entries.is_empty() {
                 println!("No track reports found in database.");
             } else {
                 for (key, report) in &entries {
-                    println!("{key}: {}", db::format_report(report));
+                    println!("{key}: {report}");
                 }
                 println!("\n{} track(s) in database.", entries.len());
             }
@@ -123,7 +202,9 @@ fn main() -> Result<()> {
     let paths: Vec<String> = matches
         .get_many::<String>("path")
         .ok_or_else(|| {
-            anyhow::anyhow!("Provide file paths, or use --list / --query to read the database")
+            anyhow::anyhow!(
+                "Provide file paths, or use --list / --query / --search / --artist / --key / --bpm to read the database"
+            )
         })?
         .cloned()
         .collect();
@@ -186,7 +267,7 @@ fn main() -> Result<()> {
                 track.artist.as_deref().unwrap_or("Unknown"),
                 track.track.as_deref().unwrap_or("Unknown")
             );
-            let report = db::TrackReport {
+            let report = TrackReport {
                 artist: track.artist.clone(),
                 track: track.track.clone(),
                 bpm: Some(f64::from(track.bpm)),
@@ -195,9 +276,10 @@ fn main() -> Result<()> {
             if let Err(e) = db.save(&key, &report) {
                 eprintln!("Failed to save report for {}: {e:?}", track.path.display());
             } else {
-                println!("  Saved: {}", db::format_report(&report));
+                println!("  Saved: {report}");
             }
         }
+        db.flush()?;
     }
 
     Ok(())