Commit Diff


commit - 17d9d80b305ad739183b3cbafdc142811fbd0a12
commit + b44df3563bdf09df96415f324d6d11bf7d9c4c8e
blob - eab1cea1543514b45557c66b9ca85fe828dc3489
blob + e722f8e1f250da6267a45f27e57d4ae552ac7e78
--- Cargo.lock
+++ Cargo.lock
@@ -433,6 +433,27 @@ source = "registry+https://github.com/rust-lang/crates
 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
 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
 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
 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
 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"
blob - 8a702872d9a4c03361fb9ab724f5f0d0f5fcc15a
blob + 584fd353f96ef599f42ad7f6895ce49f6c6f9709
--- Cargo.toml
+++ 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"
blob - 38c7294c408df2003e57f8ad6fab5c4622695ec1
blob + 641b8a25183b7843c731e7945436862cc9db33b1
--- README.md
+++ README.md
@@ -7,6 +7,7 @@ A command-line tool that analyzes audio files to detec
 * 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
 ```
blob - /dev/null
blob + bce429dfac171434fc9ac9cdece3b7077f7f7484 (mode 644)
--- /dev/null
+++ 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}")
+}
blob - c81d7ae4f5b318246e7b55b70773e90c543531cd
blob + 0a1533ae0b599386f86b310ee344d752e8228253
--- src/main.rs
+++ 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) -> bo
     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,13 +62,78 @@ 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()
             .num_threads(jobs)
@@ -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)?;
     }