virittaa

Log | Files | Refs | README | LICENSE

commit b4f514be3b06a3897179331687cac0844089fa07
parent 04889ba77382af8455a3b56e1a249bb8125920c7
Author: mtmn <miro@haravara.org>
Date:   Thu, 14 May 2026 16:15:34 +0200

feat: rework cli, update sled

Diffstat:
MCargo.lock | 274++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
MCargo.toml | 4++--
MREADME.md | 24++++++++++++------------
Msrc/db.rs | 401+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Msrc/file_io.rs | 6------
Msrc/import.rs | 23+++++++++++++----------
Msrc/main.rs | 150++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/types.rs | 2--
Msrc/utils.rs | 10++++++++++
9 files changed, 580 insertions(+), 314 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -86,12 +86,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] name = "bindgen" version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -198,6 +216,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] +name = "cache-advisor" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f89ab55ca4e6a46a0740a1c5346db1ad66e4a76598bbfa060dc3259935a7450" +dependencies = [ + "crossbeam-queue", +] + +[[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -296,12 +323,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] +name = "concurrent-map" +version = "5.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6542c565fbcba786db59307d7840f0bf5cd9e0aba6502755337e15f0e06fd65" +dependencies = [ + "ebr", + "serde", + "stack-map", +] + +[[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -347,6 +394,12 @@ dependencies = [ ] [[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -366,6 +419,15 @@ dependencies = [ ] [[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -405,12 +467,33 @@ dependencies = [ ] [[package]] +name = "ebr" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b1ea3b18359d566f360eaf811a2d69bc6c8eb6faaeecc8839975633860a076e" +dependencies = [ + "shared-local-state", +] + +[[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -450,6 +533,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] +name = "fault-injection" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3d175246dec3fddef3b1fcd57acdb023e4c562d032e9eccc5f246da3d7fed3" + +[[package]] name = "ffmpeg-next" version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -491,6 +580,12 @@ dependencies = [ ] [[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -531,15 +626,6 @@ dependencies = [ ] [[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -580,6 +666,15 @@ dependencies = [ ] [[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -604,6 +699,20 @@ dependencies = [ ] [[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -635,12 +744,13 @@ dependencies = [ ] [[package]] -name = "instant" -version = "0.1.13" +name = "inline-array" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +checksum = "45e8b42f7d66073247744b2971fcc4df24afe3e686616c20a98439ec4f156d43" dependencies = [ - "cfg-if", + "concurrent-map", + "serde", ] [[package]] @@ -975,28 +1085,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] +name = "pagetable" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b57830c885fc057ecbf2f1f99f0427c3d102cf2ee5e80a52c09948d45a460e" + +[[package]] name = "parking_lot" -version = "0.11.2" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "instant", "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" -version = "0.8.6" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", - "instant", "libc", "redox_syscall", "smallvec", - "winapi", + "windows-link", ] [[package]] @@ -1067,6 +1181,19 @@ dependencies = [ ] [[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1182,11 +1309,11 @@ checksum = "fca1481d62f18158646de2ec552dd63f8bdc5be6448389b192ba95c939df997e" [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", ] [[package]] @@ -1267,6 +1394,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] name = "rustfft" version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1315,6 +1451,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1358,6 +1500,15 @@ dependencies = [ ] [[package]] +name = "shared-local-state" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a50ccb2f45251772ed15abfd1e5f10a305288187b1582ab2e4295b29bbb4929" +dependencies = [ + "parking_lot", +] + +[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1377,18 +1528,26 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sled" -version = "0.34.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +version = "1.0.0-alpha.124" +source = "git+https://github.com/spacejam/sled?rev=e449d171#e449d17111f4a097e1c66b6db241962ccb6a4136" dependencies = [ + "bincode", + "cache-advisor", + "concurrent-map", "crc32fast", - "crossbeam-epoch", - "crossbeam-utils", + "crossbeam-queue", + "ebr", + "fault-injection", + "fnv", "fs2", - "fxhash", - "libc", + "inline-array", "log", + "pagetable", "parking_lot", + "rayon", + "serde", + "stack-map", + "zstd", ] [[package]] @@ -1398,6 +1557,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] name = "sqlite-wasm-rs" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1410,6 +1578,21 @@ dependencies = [ ] [[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-map" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49d6d36fee60faad91e23603db2356677b58ec2429237b39d5c60c26868f37c" +dependencies = [ + "serde", +] + +[[package]] name = "strength_reduce" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1530,11 +1713,11 @@ dependencies = [ "dirs", "lofty", "ndarray 0.17.2", + "postcard", "rayon", "regex", "rusqlite", "serde", - "serde_json", "sled", "walkdir", ] @@ -1781,3 +1964,32 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "6.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee98ffd0b48ee95e6c5168188e44a54550b1564d9d530ee21d5f0eaed1069581" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml @@ -13,8 +13,8 @@ walkdir = "2.5" lofty = "0.24.0" rayon = "1.10" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -sled = "0.34" +postcard = { version = "1", features = ["alloc"] } +sled = { git = "https://github.com/spacejam/sled", rev = "e449d171" } dirs = "6" regex = "1" rusqlite = "0.39" diff --git a/README.md b/README.md @@ -6,7 +6,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 +* Save track metadata to a database (on by default) * Query the database by artist, key, BPM range, or regex * Import tracks from a Mixxx SQLite database @@ -30,14 +30,14 @@ cargo build --release ### Options -* `-w`, `--write`: Write detected BPM and Key to audio file metadata. +* `-t`, `--write-tags`: Write detected BPM and Key to audio file metadata. +* `--no-store`: Skip saving track reports to the database. * `-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. +* `-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. @@ -49,23 +49,23 @@ cargo build --release ### Examples ```sh -# Analyze a single file +# Analyze a single file (saves to DB by default) ./target/release/virittaa /path/to/track.mp3 # Analyze a directory and write metadata tags -./target/release/virittaa --write /path/to/music/ +./target/release/virittaa --write-tags /path/to/music/ # Re-analyze files that already have tags -./target/release/virittaa --force --write /path/to/music/ +./target/release/virittaa --force --write-tags /path/to/music/ + +# Analyze without saving to the database +./target/release/virittaa --no-store /path/to/music/ # Limit to 4 threads ./target/release/virittaa -j 4 /path/to/music/ -# Analyze and save reports to database -./target/release/virittaa --save /path/to/music/ - -# Write tags and save to database with custom path -./target/release/virittaa -w --save --db-path ~/my_library /path/to/music/ +# Write tags and use a custom database path +./target/release/virittaa -t --db-path ~/my_library /path/to/music/ # List tracks in the database (default limit: 1000) ./target/release/virittaa --list diff --git a/src/db.rs b/src/db.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result}; +use regex::Regex; use serde::{Deserialize, Serialize}; use sled::Tree; +use std::cmp::Reverse; use std::fmt; use std::path::Path; @@ -30,10 +32,10 @@ impl fmt::Display for TrackReport { /// Sled-backed library database with secondary indexes for artist, key, and BPM. /// /// Schema: -/// - default tree: `"Artist - Track"` → JSON `TrackReport` -/// - `"artist"` tree: `"artist\0primary_key"` → empty -/// - `"key"` tree: `"key\0primary_key"` → empty -/// - `"bpm"` tree: `f64_be_bytes + "\0" + primary_key` → empty +/// - default tree: `"Artist - Track"` → postcard-encoded `TrackReport` +/// - `"artist"` tree: `"artist\0primary_key"` → 8-byte BE timestamp +/// - `"key"` tree: `"key\0primary_key"` → 8-byte BE timestamp +/// - `"bpm"` tree: `f64_be_bytes + "\0" + primary_key` → 8-byte BE timestamp /// - `"time"` tree: `i64_be_bytes(timestamp) + "\0" + primary_key` → empty pub struct LibraryDb { db: sled::Db, @@ -79,6 +81,31 @@ fn extract_primary_key(idx_key: &[u8]) -> Result<&[u8]> { Ok(&idx_key[pos + 1..]) } +fn extract_primary_key_string(idx_key: &[u8]) -> Result<String> { + let bytes = extract_primary_key(idx_key)?; + String::from_utf8(bytes.to_vec()).context("Invalid UTF-8 in primary key") +} + +fn parse_timestamp(value: &[u8]) -> i64 { + if value.len() == 8 { + i64::from_be_bytes(value[..8].try_into().unwrap_or([0; 8])) + } else { + 0 + } +} + +fn serialize_report(report: &TrackReport) -> Result<Vec<u8>> { + postcard::to_allocvec(report).context("Failed to serialize track report") +} + +fn deserialize_report(bytes: &[u8]) -> Result<TrackReport> { + postcard::from_bytes(bytes).context("Failed to deserialize track report") +} + +fn effective_limit(limit: usize) -> usize { + if limit == 0 { usize::MAX } else { limit } +} + impl LibraryDb { /// Open or create the database at the given path. /// @@ -103,206 +130,176 @@ impl LibraryDb { }) } - /// Save a track report and update all secondary indexes. - /// - /// # Errors - /// - /// Returns an error if serialization or any database write fails. - pub fn save(&self, key: &str, report: &TrackReport) -> Result<()> { - #[allow(clippy::cast_possible_wrap)] - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - - let report = TrackReport { - artist: report.artist.clone(), - track: report.track.clone(), - bpm: report.bpm, - key: report.key.clone(), - timestamp: Some(now), - }; - - let value = serde_json::to_vec(&report).context("Failed to serialize track report")?; - - let old = self - .db - .insert(key.as_bytes(), value) - .context("Failed to write to database")?; - - // Remove old time index entry if overwriting an existing record - if let Some(old_bytes) = old - && let Ok(old_report) = serde_json::from_slice::<TrackReport>(&old_bytes) - && let Some(old_ts) = old_report.timestamp - { - let old_time_key = time_index_key(old_ts, key.as_bytes()); - self.time_tree - .remove(&old_time_key) - .context("Failed to remove old time index")?; - } + /// Write secondary index entries for a report. + fn update_indexes(&self, key: &str, report: &TrackReport) -> Result<()> { + let ts = report.timestamp.unwrap_or(0); + let ts_bytes = ts.to_be_bytes(); // 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 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")?; - } + self.artist_tree.insert(&idx_key, &ts_bytes[..])?; + + // Key-signature index + let key_lower = report.key.as_deref().unwrap_or("unknown").to_lowercase(); + let idx_key = prefix_index_key(key_lower.as_bytes(), key.as_bytes()); + self.key_tree.insert(&idx_key, &ts_bytes[..])?; // 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")?; + self.bpm_tree.insert(&idx_key, &ts_bytes[..])?; } - // Time index - let time_key = time_index_key(now, key.as_bytes()); - self.time_tree - .insert(&time_key, &[][..]) - .context("Failed to write time index")?; + // Time index (always written, timestamp=0 for entries without one) + let idx_key = time_index_key(ts, key.as_bytes()); + self.time_tree.insert(&idx_key, &[][..])?; 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")?; + /// Remove all secondary index entries for a report. + fn remove_indexes(&self, key: &str, report: &TrackReport) -> Result<()> { + 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)?; + + let key_lower = report.key.as_deref().unwrap_or("unknown").to_lowercase(); + let idx_key = prefix_index_key(key_lower.as_bytes(), key.as_bytes()); + self.key_tree.remove(&idx_key)?; + + if let Some(bpm) = report.bpm { + let idx_key = bpm_index_key(bpm, key.as_bytes()); + self.bpm_tree.remove(&idx_key)?; + } + + let ts = report.timestamp.unwrap_or(0); + let idx_key = time_index_key(ts, key.as_bytes()); + self.time_tree.remove(&idx_key)?; + Ok(()) } - /// Look up a track report by exact key. + /// Get a track report by exact primary key. /// /// # Errors /// - /// Returns an error if the database read or deserialization fails. + /// Returns an error if the database operation fails. pub fn get(&self, key: &str) -> Result<Option<TrackReport>> { - let ivec = self.db.get(key).context("Failed to read from database")?; - match ivec { - Some(bytes) => { - let report: TrackReport = - serde_json::from_slice(&bytes).context("Failed to deserialize track report")?; - Ok(Some(report)) - } - None => Ok(None), - } + self.db + .get(key.as_bytes())? + .map(|bytes| deserialize_report(&bytes)) + .transpose() } - /// Delete a track report and remove its secondary index entries. + /// Save a single track report, inserting or updating as needed. /// /// # Errors /// - /// Returns an error if the database delete or deserialization fails. - #[allow(dead_code)] - pub fn delete(&self, key: &str) -> Result<()> { - let old = self + /// Returns an error if the database operation fails. + pub fn save(&self, key: &str, report: &TrackReport) -> Result<()> { + // Remove stale indexes if overwriting an existing entry. + if let Some(old_bytes) = 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")?; - } + .insert(key.as_bytes(), serialize_report(report)?.as_slice())? + { + let old_report = deserialize_report(&old_bytes)?; + self.remove_indexes(key, &old_report)?; + } - 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.update_indexes(key, report)?; + Ok(()) + } - if let Some(ts) = report.timestamp { - let time_key = time_index_key(ts, key.as_bytes()); - self.time_tree - .remove(&time_key) - .context("Failed to delete from time index")?; + /// Save a batch of track reports, inserting or updating as needed. + /// + /// Uses sled's batch API for the default tree to reduce write amplification. + /// + /// # Errors + /// + /// Returns an error if the database operation fails. + pub fn save_batch(&self, entries: &[(String, TrackReport)]) -> Result<()> { + for (key, report) in entries { + // Remove stale indexes for any existing entries. + if let Some(old_bytes) = self.db.get(key.as_bytes())? { + let old_report = deserialize_report(&old_bytes)?; + self.remove_indexes(key, &old_report)?; } + self.update_indexes(key, report)?; } - self.db.flush().context("Failed to flush database")?; + let mut batch = sled::Batch::default(); + for (key, report) in entries { + let serialized = serialize_report(report)?; + batch.insert(key.as_bytes(), serialized.as_slice()); + } + self.db.apply_batch(batch)?; Ok(()) } - /// Count all entries in the primary tree. + /// Delete a track report and its secondary index entries. /// /// # Errors /// - /// Returns an error if the database iteration fails. - pub fn count(&self) -> Result<usize> { - Ok(self.db.len()) + /// Returns an error if the database operation fails. + pub fn delete(&self, key: &str) -> Result<()> { + if let Some(old_bytes) = self.db.remove(key.as_bytes())? { + let old_report = deserialize_report(&old_bytes)?; + self.remove_indexes(key, &old_report)?; + } + Ok(()) } - /// List entries in the primary tree, up to `limit` items. + /// List up to `limit` track reports (pass `0` for no limit), + /// ordered chronologically (oldest first, most recent at the bottom). /// /// # Errors /// - /// Returns an error if the database iteration or deserialization fails. + /// Returns an error if the database operation fails. pub fn list(&self, limit: usize) -> Result<Vec<(String, TrackReport)>> { - let effective_limit = if limit == 0 { usize::MAX } else { limit }; - let mut results = Vec::new(); + let mut candidates: Vec<(String, i64)> = Vec::new(); - // Iterate time index in reverse to collect the most recent entries first, - // then reverse so newest prints at the bottom. - for item in self.time_tree.iter().rev() { - if results.len() >= effective_limit { - break; - } + for item in &self.time_tree { let (idx_key, _) = item.context("Failed to read time index")?; - // Primary key starts after 8-byte timestamp + 1-byte separator. let primary_key_bytes = &idx_key[TIME_KEY_LEN + 1..]; let primary_key = String::from_utf8(primary_key_bytes.to_vec()) .context("Invalid UTF-8 in primary key")?; + let ts = i64::from_be_bytes(idx_key[..TIME_KEY_LEN].try_into().unwrap_or([0; 8])); + candidates.push((primary_key, ts)); + } + + self.resolve_candidates(candidates, limit) + } + /// Count the total number of entries in the database. + /// + /// # Errors + /// + /// Returns an error if the database operation fails. + pub fn count(&self) -> Result<usize> { + Ok(self.db.len()?) + } + + /// Resolve a list of (`primary_key`, timestamp) candidates into report entries. + /// + /// Sorts by timestamp descending, truncates to `limit`, and looks up each + /// primary key in the default tree. Returns results in reverse (oldest-first) + /// order so that most recently added entries appear at the bottom. + fn resolve_candidates( + &self, + mut candidates: Vec<(String, i64)>, + limit: usize, + ) -> Result<Vec<(String, TrackReport)>> { + let lim = effective_limit(limit); + candidates.sort_by_key(|b| Reverse(b.1)); + candidates.truncate(lim); + + let mut results = Vec::with_capacity(candidates.len()); + for (primary_key, _) in candidates { if let Some(report) = self.get(&primary_key)? { results.push((primary_key, report)); } } - - // Fallback: entries without a time index (migrated from old format) - if results.len() < effective_limit { - for item in self.db.iter() { - if results.len() >= effective_limit { - break; - } - let (key, value) = item.context("Failed to read iterator entry")?; - let report: TrackReport = - serde_json::from_slice(&value).context("Failed to deserialize track report")?; - if report.timestamp.is_some() { - continue; // already included via time index - } - let key_str = - String::from_utf8(key.to_vec()).context("Invalid UTF-8 key in database")?; - results.push((key_str, report)); - } - } - results.reverse(); Ok(results) } @@ -311,64 +308,52 @@ impl LibraryDb { /// /// # Errors /// - /// Returns an error if the index scan or deserialization fails. + /// Returns an error if the database operation fails. pub fn find_by_artist(&self, prefix: &str, limit: usize) -> Result<Vec<(String, TrackReport)>> { let prefix_lower = prefix.to_lowercase(); - let mut results = Vec::new(); + let mut candidates: Vec<(String, i64)> = 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)); - } + let (idx_key, value) = item.context("Failed to read artist index")?; + let primary_key = extract_primary_key_string(&idx_key)?; + let ts = parse_timestamp(&value); + candidates.push((primary_key, ts)); } - results.sort_by_key(|b| b.1.timestamp); - results.truncate(limit); - Ok(results) + self.resolve_candidates(candidates, limit) } /// Find tracks by key signature prefix (case-insensitive), up to `limit` items. /// /// # Errors /// - /// Returns an error if the index scan or deserialization fails. + /// Returns an error if the database operation fails. pub fn find_by_key(&self, prefix: &str, limit: usize) -> Result<Vec<(String, TrackReport)>> { let prefix_lower = prefix.to_lowercase(); - let mut results = Vec::new(); + let mut candidates: Vec<(String, i64)> = 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)); - } + let (idx_key, value) = item.context("Failed to read key index")?; + let primary_key = extract_primary_key_string(&idx_key)?; + let ts = parse_timestamp(&value); + candidates.push((primary_key, ts)); } - results.sort_by_key(|b| b.1.timestamp); - results.truncate(limit); - Ok(results) + self.resolve_candidates(candidates, limit) } /// Find tracks with BPM in the given range (inclusive), up to `limit` items. /// /// # Errors /// - /// Returns an error if the index scan or deserialization fails. + /// Returns an error if the database operation fails. pub fn find_by_bpm_range( &self, min: f64, max: f64, limit: usize, ) -> Result<Vec<(String, TrackReport)>> { - let mut results = Vec::new(); + let mut candidates: Vec<(String, i64)> = Vec::new(); // Sled sorts longer keys after their prefix, so a bare 8-byte bound would // exclude keys like [max_be, 0x00, primary_key]. @@ -380,19 +365,67 @@ impl LibraryDb { .bpm_tree .range(min.to_be_bytes().as_slice()..upper.as_slice()) { - let (idx_key, _) = item.context("Failed to read bpm index")?; - // Primary key starts after 8-byte BPM + 1-byte separator. + let (idx_key, value) = item.context("Failed to read bpm index")?; let primary_key_bytes = &idx_key[BPM_KEY_LEN + 1..]; let primary_key = String::from_utf8(primary_key_bytes.to_vec()) .context("Invalid UTF-8 in primary key")?; + let ts = parse_timestamp(&value); + candidates.push((primary_key, ts)); + } - if let Some(report) = self.get(&primary_key)? { - results.push((primary_key, report)); + self.resolve_candidates(candidates, limit) + } + + /// Search tracks by regex pattern. + /// + /// Checks the primary key first (cheap, no deserialization), then + /// falls back to checking individual report fields. Returns up to + /// `limit` matches (pass `0` for no limit). + /// + /// # Errors + /// + /// Returns an error if the database operation fails. + pub fn search(&self, pattern: &Regex, limit: usize) -> Result<Vec<(String, TrackReport)>> { + let lim = effective_limit(limit); + let mut results = Vec::new(); + + for item in &self.db { + let (key, value) = item.context("Failed to read entry")?; + let key_str = String::from_utf8(key.to_vec()).context("Invalid UTF-8 key")?; + + let report = deserialize_report(&value)?; + + // Check the primary key and individual fields — avoids + // allocating a display String for every non-matching entry. + let matches = pattern.is_match(&key_str) + || report + .artist + .as_deref() + .is_some_and(|a| pattern.is_match(a)) + || report.track.as_deref().is_some_and(|t| pattern.is_match(t)) + || report.key.as_deref().is_some_and(|k| pattern.is_match(k)) + || report + .bpm + .is_some_and(|b| pattern.is_match(&format!("{b:.1}"))); + + if matches { + results.push((key_str, report)); + if results.len() >= lim { + break; + } } } - results.sort_by_key(|b| b.1.timestamp); - results.truncate(limit); Ok(results) } + + /// Flush pending writes to disk. + /// + /// # Errors + /// + /// Returns an error if the database operation fails. + pub fn flush(&self) -> Result<()> { + self.db.flush()?; + Ok(()) + } } diff --git a/src/file_io.rs b/src/file_io.rs @@ -13,18 +13,14 @@ use bliss_audio::decoder::ffmpeg::FFmpegDecoder as Decoder; #[derive(Debug, Default)] pub struct FileMetadata { pub artist: Option<String>, - pub album: Option<String>, pub track: Option<String>, - pub label: Option<String>, } fn build_track_info(path: &Path, metadata: FileMetadata, bpm: f32, key: String) -> TrackInfo { TrackInfo { path: path.to_path_buf(), artist: metadata.artist, - album: metadata.album, track: metadata.track, - label: metadata.label, bpm, key, } @@ -78,9 +74,7 @@ fn read_tags(path: &Path) -> (FileMetadata, Option<(f32, String)>) { .get_string(ItemKey::TrackArtist) .or_else(|| t.get_string(ItemKey::AlbumArtist)) .map(String::from), - album: t.get_string(ItemKey::AlbumTitle).map(String::from), track: t.get_string(ItemKey::TrackTitle).map(String::from), - label: t.get_string(ItemKey::Label).map(String::from), }; let existing_tags = t diff --git a/src/import.rs b/src/import.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; use crate::db::{LibraryDb, TrackReport}; +use crate::utils::format_primary_key; /// 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 @@ -46,29 +47,31 @@ pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) -> Result<usize> { Ok((artist, title, bpm, key_raw)) })?; - let mut count = 0usize; + let mut entries = Vec::new(); 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 primary_key = format_primary_key(artist.as_deref(), title.as_deref()); let report = TrackReport { artist, track: title, bpm, key, - timestamp: None, + timestamp: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .cast_signed(), + ), }; - db.save(&primary_key, &report)?; - count += 1; + entries.push((primary_key, report)); } - db.flush()?; + let count = entries.len(); + db.save_batch(&entries)?; Ok(count) } diff --git a/src/main.rs b/src/main.rs @@ -18,19 +18,10 @@ 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}; +use crate::utils::{format_primary_key, is_supported_audio_file, print_entries}; -#[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") +fn build_cli() -> Command { + Command::new("virittaa") .version(clap::crate_version!()) .about("Analyze audio files to detect BPM and Key") .arg( @@ -39,13 +30,19 @@ fn main() -> Result<()> { .help("Files or directories to analyze"), ) .arg( - Arg::new("write") - .short('w') - .long("write") + Arg::new("write-tags") + .short('t') + .long("write-tags") .action(clap::ArgAction::SetTrue) .help("Write detected BPM and Key to file metadata"), ) .arg( + Arg::new("no-store") + .long("no-store") + .action(clap::ArgAction::SetTrue) + .help("Skip saving track reports to the database"), + ) + .arg( Arg::new("force") .short('f') .long("force") @@ -61,13 +58,6 @@ fn main() -> Result<()> { .value_parser(clap::value_parser!(usize)), ) .arg( - Arg::new("save") - .short('S') - .long("save") - .action(clap::ArgAction::SetTrue) - .help("Save track reports to the database"), - ) - .arg( Arg::new("list") .short('l') .long("list") @@ -88,7 +78,7 @@ fn main() -> Result<()> { ) .arg( Arg::new("search") - .short('s') + .short('S') .long("search") .help("Search track reports by regex"), ) @@ -119,12 +109,34 @@ fn main() -> Result<()> { .long("db-path") .help("Database directory (default: ~/.local/share/virittaa)"), ) - .get_matches(); +} - let write_tags = matches.get_flag("write"); +#[allow(clippy::too_many_lines)] +fn main() -> Result<()> { + // Sled's B-tree operations can overflow the default stack on large databases. + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(real_main) + .expect("Failed to spawn main thread") + .join() + .expect("Main thread panicked") +} + +#[allow(clippy::too_many_lines)] +fn real_main() -> Result<()> { + let mut cmd = build_cli(); + + if std::env::args().len() == 1 { + let _ = cmd.print_help(); + return Ok(()); + } + + let matches = cmd.get_matches(); + + let write_tags = matches.get_flag("write-tags"); + let no_store = matches.get_flag("no-store"); let force = matches.get_flag("force"); 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_key = matches.get_one::<String>("query").map(String::as_str); let search_pattern = matches.get_one::<String>("search"); @@ -145,13 +157,16 @@ fn main() -> Result<()> { } None => None, }; - let db_path = if let Some(p) = matches.get_one::<String>("db-path") { - p.clone() - } else { - let mut dir = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - dir.push("virittaa"); - dir.to_string_lossy().to_string() - }; + + let db_path = matches.get_one::<String>("db-path").map_or_else( + || { + dirs::data_dir().map_or_else( + || "~/.local/share/virittaa".to_string(), + |p| p.join("virittaa").to_string_lossy().to_string(), + ) + }, + String::from, + ); let is_db_query = list_db || query_key.is_some() @@ -163,7 +178,6 @@ fn main() -> Result<()> { if is_db_query { let db = LibraryDb::open(std::path::Path::new(&db_path))?; - let effective_limit = if limit == 0 { usize::MAX } else { limit }; if let Some(mixxx_path) = import_mixxx_path { let count = import_mixxx(&db, mixxx_path)?; @@ -177,21 +191,21 @@ fn main() -> Result<()> { None => eprintln!("No track found for key: {query_key}"), } } else if let Some(prefix) = artist_prefix { - let entries = db.find_by_artist(prefix, effective_limit)?; + let entries = db.find_by_artist(prefix, limit)?; 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, effective_limit)?; + let entries = db.find_by_key(prefix, limit)?; 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, effective_limit)?; + let entries = db.find_by_bpm_range(min, max, limit)?; print_entries( &entries, &format!("No tracks found with BPM between {min} and {max}"), @@ -199,19 +213,14 @@ fn main() -> Result<()> { ); } else if let Some(pattern) = search_pattern { let re = Regex::new(pattern)?; - let entries = db.list(0)?; // search all entries regardless of limit - let matches_found: Vec<_> = entries - .into_iter() - .filter(|(key, report)| re.is_match(key) || re.is_match(&report.to_string())) - .take(effective_limit) - .collect(); + let entries = db.search(&re, limit)?; print_entries( - &matches_found, + &entries, &format!("No matches for: {pattern}"), "match(es) found.", ); } else { - let entries = db.list(effective_limit)?; + let entries = db.list(limit)?; let total = db.count()?; if entries.is_empty() { println!("No track reports found in database."); @@ -246,10 +255,10 @@ fn main() -> Result<()> { .expect("Failed to build thread pool"); } - let report_db = if save_to_db { - Some(LibraryDb::open(std::path::Path::new(&db_path))?) - } else { + let report_db = if no_store { None + } else { + Some(LibraryDb::open(std::path::Path::new(&db_path))?) }; let files_to_process: Vec<PathBuf> = paths @@ -291,26 +300,33 @@ fn main() -> Result<()> { } if let Some(db) = &report_db { - for track in &results { - let key = format!( - "{} - {}", - track.artist.as_deref().unwrap_or("Unknown"), - track.track.as_deref().unwrap_or("Unknown") - ); - let report = TrackReport { - artist: track.artist.clone(), - track: track.track.clone(), - bpm: Some(f64::from(track.bpm)), - key: Some(track.key.clone()), - timestamp: None, - }; - if let Err(e) = db.save(&key, &report) { - eprintln!("Failed to save report for {}: {e:?}", track.path.display()); - } else { - println!(" Saved: {report}"); - } - } + let entries: Vec<(String, TrackReport)> = results + .iter() + .map(|track| { + let key = format_primary_key(track.artist.as_deref(), track.track.as_deref()); + let report = TrackReport { + artist: track.artist.clone(), + track: track.track.clone(), + bpm: Some(f64::from(track.bpm)), + key: Some(track.key.clone()), + timestamp: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .cast_signed(), + ), + }; + (key, report) + }) + .collect(); + + db.save_batch(&entries)?; db.flush()?; + + for report in entries.iter().map(|(_, r)| r) { + println!(" Saved: {report}"); + } } Ok(()) diff --git a/src/types.rs b/src/types.rs @@ -4,9 +4,7 @@ use std::path::PathBuf; pub struct TrackInfo { pub path: PathBuf, pub artist: Option<String>, - pub album: Option<String>, pub track: Option<String>, - pub label: Option<String>, pub bpm: f32, pub key: String, } diff --git a/src/utils.rs b/src/utils.rs @@ -22,3 +22,13 @@ pub fn print_entries(entries: &[(String, TrackReport)], empty_msg: &str, count_s println!("\n{} {count_suffix}", entries.len()); } } + +/// Format a primary database key from artist and track name. +#[must_use] +pub fn format_primary_key(artist: Option<&str>, track: Option<&str>) -> String { + format!( + "{} - {}", + artist.unwrap_or("Unknown"), + track.unwrap_or("Unknown") + ) +}