virittaa

Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 3+++
MREADME.md | 29++++++++++++++++++++++++++---
Asrc/db.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/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)?; }