virittaa

Log | Files | Refs | README | LICENSE

commit 1701ad61c5a4e80e10cf115fa88ca1589909893d
parent 48dfccd5d56b01f40f86c515b5feb6c54358d7c0
Author: mtmn <miro@haravara.org>
Date:   Thu, 14 May 2026 12:00:07 +0200

sled: add regex search; queries

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
MREADME.md | 24+++++++++++++++++++-----
Msrc/db.rs | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/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(())