commit b4f514be3b06a3897179331687cac0844089fa07
parent 04889ba77382af8455a3b56e1a249bb8125920c7
Author: mtmn <miro@haravara.org>
Date: Thu, 14 May 2026 16:15:34 +0200
feat: rework cli, update sled
Diffstat:
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")
+ )
+}