commit 1701ad61c5a4e80e10cf115fa88ca1589909893d
parent 48dfccd5d56b01f40f86c515b5feb6c54358d7c0
Author: mtmn <miro@haravara.org>
Date: Thu, 14 May 2026 12:00:07 +0200
sled: add regex search; queries
Diffstat:
| M | Cargo.lock | | | 1 | + |
| M | Cargo.toml | | | 1 | + |
| M | README.md | | | 24 | +++++++++++++++++++----- |
| M | src/db.rs | | | 219 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
| M | src/main.rs | | | 106 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
5 files changed, 318 insertions(+), 33 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1529,6 +1529,7 @@ dependencies = [
"lofty",
"ndarray 0.17.2",
"rayon",
+ "regex",
"serde",
"serde_json",
"sled",
diff --git a/Cargo.toml b/Cargo.toml
@@ -18,3 +18,4 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sled = "0.34"
dirs = "6"
+regex = "1"
diff --git a/README.md b/README.md
@@ -7,6 +7,7 @@ A command-line tool that analyzes audio files to detect `BPM` and `Key` tags.
* 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
+```
diff --git a/src/db.rs b/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,8 +12,63 @@ 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 {
@@ -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)
+ }
}
diff --git a/src/main.rs b/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) -> bool {
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,7 +95,30 @@ 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")
@@ -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(())