commit b44df3563bdf09df96415f324d6d11bf7d9c4c8e
parent 17d9d80b305ad739183b3cbafdc142811fbd0a12
Author: mtmn <miro@haravara.org>
Date: Thu, 14 May 2026 00:48:01 +0200
feat: store metadata in virittaa.db
Diffstat:
| M | Cargo.lock | | | 171 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | Cargo.toml | | | 3 | +++ |
| M | README.md | | | 29 | ++++++++++++++++++++++++++--- |
| A | src/db.rs | | | 95 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main.rs | | | 102 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
5 files changed, 390 insertions(+), 10 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -433,6 +433,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -507,6 +528,16 @@ dependencies = [
]
[[package]]
+name = "fs2"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -531,6 +562,15 @@ 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"
@@ -632,6 +672,15 @@ dependencies = [
]
[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -729,12 +778,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
name = "lofty"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -929,6 +996,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "parking_lot"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
+dependencies = [
+ "cfg-if",
+ "instant",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "winapi",
+]
+
+[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1110,6 +1208,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fca1481d62f18158646de2ec552dd63f8bdc5be6448389b192ba95c939df997e"
[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1199,6 +1317,12 @@ dependencies = [
]
[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1260,6 +1384,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
+name = "sled"
+version = "0.34.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
+dependencies = [
+ "crc32fast",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "fs2",
+ "fxhash",
+ "libc",
+ "log",
+ "parking_lot",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1379,10 +1525,13 @@ dependencies = [
"chrono",
"clap",
"csv",
+ "dirs",
"lofty",
"ndarray 0.17.2",
"rayon",
"serde",
+ "serde_json",
+ "sled",
"walkdir",
]
@@ -1479,6 +1628,22 @@ dependencies = [
]
[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1488,6 +1653,12 @@ dependencies = [
]
[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -15,3 +15,6 @@ rayon = "1.10"
chrono = "0.4.43"
csv = "1.3"
serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+sled = "0.34"
+dirs = "6"
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
* Generate CSV reports of analyzed tracks
+* Save track metadata to a database
## Building
@@ -19,12 +20,12 @@ cargo build --release
## Usage
```sh
-./target/release/virittaa [OPTIONS] <PATH>...
+./target/release/virittaa [OPTIONS] [PATH]...
```
### Arguments
-* `<PATH>...`: One or more files or directories to analyze.
+* `[PATH]...`: One or more files or directories to analyze. Not required when using `--list` or `--query`.
### Options
@@ -32,10 +33,14 @@ cargo build --release
* `-f`, `--force`: Re-analyze files even if they already have BPM/Key tags.
* `-j`, `--jobs <JOBS>`: Number of threads to use (0 = all cores).
* `-r`, `--report`: Generate a CSV report with timestamped filename.
+* `-S`, `--save`: Save track reports (artist, track, BPM, key) to the database.
+* `-l`, `--list`: List all track reports stored in the database.
+* `--query <PATH>`: Look up a track report by file path.
+* `--db-path <PATH>`: Path to the database directory (default: `~/.local/share/virittaa.db`).
* `-h`, `--help`: Print help information.
* `-V`, `--version`: Print version information.
-### Example
+### Examples
```sh
# Analyze a single file
@@ -52,4 +57,22 @@ cargo build --release
# Generate a CSV report
./target/release/virittaa --report /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.db /path/to/music/
+
+# Generate CSV report and save to database
+./target/release/virittaa -r --save /path/to/music/
+
+# List all tracks in the database
+./target/release/virittaa --list
+
+# Look up a specific track by file path
+./target/release/virittaa --query /path/to/track.flac
+
+# Query using a custom database path
+./target/release/virittaa --list --db-path ~/my_library.db
```
diff --git a/src/db.rs b/src/db.rs
@@ -0,0 +1,95 @@
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use sled::Db;
+use std::path::Path;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TrackReport {
+ pub artist: Option<String>,
+ pub track: Option<String>,
+ pub bpm: Option<f64>,
+ pub key: Option<String>,
+}
+
+pub struct ReportDb {
+ db: Db,
+}
+
+impl ReportDb {
+ /// # Errors
+ ///
+ /// Returns an error if the database cannot be opened or created.
+ pub fn open(path: &Path) -> Result<Self> {
+ let db = sled::open(path)
+ .with_context(|| format!("Failed to open sled database at {}", path.display()))?;
+ Ok(Self { db })
+ }
+
+ /// # Errors
+ ///
+ /// 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")?;
+ self.db
+ .insert(key.as_bytes(), value)
+ .context("Failed to write to database")?;
+ self.db.flush().context("Failed to flush database")?;
+ Ok(())
+ }
+
+ /// # 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 {
+ Some(bytes) => {
+ let report: TrackReport =
+ serde_json::from_slice(&bytes).context("Failed to deserialize track report")?;
+ Ok(Some(report))
+ }
+ None => Ok(None),
+ }
+ }
+
+ /// # Errors
+ ///
+ /// Returns an error if the database delete fails.
+ #[allow(dead_code)]
+ pub fn delete(&self, key: &str) -> Result<()> {
+ self.db
+ .remove(key)
+ .context("Failed to delete from database")?;
+ self.db.flush().context("Failed to flush database")?;
+ Ok(())
+ }
+
+ /// # Errors
+ ///
+ /// Returns an error if the database iteration or deserialization fails.
+ pub fn list(&self) -> Result<Vec<(String, TrackReport)>> {
+ let mut results = Vec::new();
+ for item in self.db.iter() {
+ let (key, value) = item.context("Failed to read iterator entry")?;
+ let key_str =
+ String::from_utf8(key.to_vec()).context("Invalid UTF-8 key in database")?;
+ let report: TrackReport =
+ serde_json::from_slice(&value).context("Failed to deserialize track report")?;
+ results.push((key_str, report));
+ }
+ 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}")
+}
diff --git a/src/main.rs b/src/main.rs
@@ -1,5 +1,6 @@
pub mod audio_processing;
pub mod constants;
+pub mod db;
pub mod file_io;
pub mod types;
@@ -9,6 +10,7 @@ use rayon::prelude::*;
use std::path::PathBuf;
use walkdir::WalkDir;
+use crate::db::ReportDb;
use crate::file_io::{process_file, write_report};
use crate::types::TrackError;
@@ -23,16 +25,20 @@ fn is_supported_extension(ext: &std::ffi::OsStr) -> bool {
SUPPORTED_EXTENSIONS.contains(&*ext_lower)
}
+#[allow(clippy::too_many_lines)]
fn main() -> Result<()> {
let matches = Command::new("virittaa")
.version(clap::crate_version!())
- .arg(Arg::new("path").required(true).num_args(1..))
+ .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)",
+ ))
.arg(
Arg::new("write")
.short('w')
.long("write")
.action(clap::ArgAction::SetTrue)
- .help("Write BPM and Key to file metadata"),
+ .help("Write detected BPM and Key to file metadata"),
)
.arg(
Arg::new("force")
@@ -56,12 +62,77 @@ fn main() -> Result<()> {
.action(clap::ArgAction::SetTrue)
.help("Generate a CSV report file in the current directory"),
)
+ .arg(
+ Arg::new("save")
+ .short('S')
+ .long("save")
+ .action(clap::ArgAction::SetTrue)
+ .help("Save track metadata to a database"),
+ )
+ .arg(
+ Arg::new("list")
+ .short('l')
+ .long("list")
+ .action(clap::ArgAction::SetTrue)
+ .help("List all track reports stored in the database"),
+ )
+ .arg(
+ Arg::new("query")
+ .long("query")
+ .help("Look up a track report by file path"),
+ )
+ .arg(
+ Arg::new("db-path")
+ .long("db-path")
+ .help("Path to the database directory for saved reports (default: ~/.local/share/virittaa.db)"),
+ )
.get_matches();
let write_tags = matches.get_flag("write");
let force = matches.get_flag("force");
let jobs = *matches.get_one::<usize>("jobs").unwrap();
let generate_report = matches.get_flag("report");
+ 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 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.db");
+ dir.to_string_lossy().to_string()
+ };
+
+ // Read-only DB operations that don't need file paths
+ if list_db || query_path.is_some() {
+ let db = ReportDb::open(std::path::Path::new(&db_path))?;
+
+ if let Some(path) = query_path {
+ match db.get(path)? {
+ Some(report) => println!("{path}: {}", db::format_report(&report)),
+ None => eprintln!("No report found for: {path}"),
+ }
+ } 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!("\n{} track(s) in database.", entries.len());
+ }
+ }
+ return Ok(());
+ }
+
+ 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")
+ })?
+ .cloned()
+ .collect();
if jobs > 0 {
rayon::ThreadPoolBuilder::new()
@@ -70,11 +141,11 @@ fn main() -> Result<()> {
.expect("Failed to build thread pool");
}
- let paths: Vec<String> = matches
- .get_many::<String>("path")
- .expect("Failed to get path")
- .cloned()
- .collect();
+ let report_db = if save_to_db {
+ Some(ReportDb::open(std::path::Path::new(&db_path))?)
+ } else {
+ None
+ };
let files_to_process: Vec<PathBuf> = paths
.into_iter()
@@ -108,6 +179,23 @@ fn main() -> Result<()> {
let results: Vec<_> = results.into_iter().map(|r| r.unwrap()).collect();
let errors: Vec<_> = errors.into_iter().map(|r| r.unwrap_err()).collect();
+ if let Some(db) = &report_db {
+ for track in &results {
+ let key = track.path.to_string_lossy();
+ let report = db::TrackReport {
+ artist: track.artist.clone(),
+ track: track.track.clone(),
+ bpm: Some(f64::from(track.bpm)),
+ key: Some(track.key.clone()),
+ };
+ 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));
+ }
+ }
+ }
+
if generate_report {
write_report(&results, &errors)?;
}