virittaa

Log | Files | Refs | README | LICENSE

commit b15c402ae352767c978b2637ab0b152054a6df38
parent ff20cd2d83e62c6e2a0bd5dae0b8c77d3b6128e6
Author: mtmn <miro@haravara.org>
Date:   Thu, 14 May 2026 13:42:22 +0200

feat: add timestamp to db entries

Diffstat:
Msrc/db.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/import.rs | 1+
Msrc/main.rs | 1+
3 files changed, 110 insertions(+), 18 deletions(-)

diff --git a/src/db.rs b/src/db.rs @@ -10,6 +10,8 @@ pub struct TrackReport { pub track: Option<String>, pub bpm: Option<f64>, pub key: Option<String>, + #[serde(default)] + pub timestamp: Option<i64>, } impl fmt::Display for TrackReport { @@ -32,15 +34,18 @@ impl fmt::Display for TrackReport { /// - `"artist"` tree: `"artist\0primary_key"` → empty /// - `"key"` tree: `"key\0primary_key"` → empty /// - `"bpm"` tree: `f64_be_bytes + "\0" + primary_key` → empty +/// - `"time"` tree: `i64_be_bytes(timestamp) + "\0" + primary_key` → empty pub struct LibraryDb { db: sled::Db, artist_tree: Tree, key_tree: Tree, bpm_tree: Tree, + time_tree: Tree, } const SEP: u8 = 0x00; const BPM_KEY_LEN: usize = 8; // f64 big-endian +const TIME_KEY_LEN: usize = 8; // i64 big-endian fn prefix_index_key(value: &[u8], primary_key: &[u8]) -> Vec<u8> { let mut buf = Vec::with_capacity(value.len() + 1 + primary_key.len()); @@ -58,6 +63,14 @@ fn bpm_index_key(bpm: f64, primary_key: &[u8]) -> Vec<u8> { buf } +fn time_index_key(timestamp: i64, primary_key: &[u8]) -> Vec<u8> { + let mut buf = Vec::with_capacity(TIME_KEY_LEN + 1 + primary_key.len()); + buf.extend_from_slice(&timestamp.to_be_bytes()); + buf.push(SEP); + buf.extend_from_slice(primary_key); + buf +} + fn extract_primary_key(idx_key: &[u8]) -> Result<&[u8]> { let pos = idx_key .iter() @@ -80,11 +93,15 @@ impl LibraryDb { .context("Failed to open artist tree")?; let key_tree = db.open_tree("key").context("Failed to open key tree")?; let bpm_tree = db.open_tree("bpm").context("Failed to open bpm tree")?; + let time_tree = db + .open_tree("time") + .context("Failed to open time tree")?; Ok(Self { db, artist_tree, key_tree, bpm_tree, + time_tree, }) } @@ -94,7 +111,33 @@ impl LibraryDb { /// /// Returns an error if serialization or any 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")?; + // Remove old time index entry if re-saving an existing record + let old = self.db.get(key).context("Failed to read 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")?; + } + + #[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")?; self.db .insert(key.as_bytes(), value) @@ -124,6 +167,12 @@ impl LibraryDb { .context("Failed to write bpm index")?; } + // Time index + let time_key = time_index_key(now, key.as_bytes()); + self.time_tree + .insert(&time_key, &[][..]) + .context("Failed to write time index")?; + Ok(()) } @@ -190,6 +239,13 @@ impl LibraryDb { .remove(&idx_key) .context("Failed to delete from bpm index")?; } + + 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")?; + } } self.db.flush().context("Failed to flush database")?; @@ -211,15 +267,52 @@ impl LibraryDb { /// /// Returns an error if the database iteration or deserialization fails. pub fn list(&self, limit: usize) -> Result<Vec<(String, TrackReport)>> { - let mut results = Vec::with_capacity(limit.min(1024)); - for item in self.db.iter().take(limit) { - 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)); + let effective_limit = if limit == 0 { usize::MAX } else { limit }; + let mut results = Vec::new(); + + // Iterate time index in reverse (most recent first) + for item in self.time_tree.iter().rev() { + if results.len() >= effective_limit { + break; + } + 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")?; + + 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 { + let time_keys: Vec<_> = self.time_tree.iter().collect::<Result<Vec<_>, _>>().unwrap_or_default(); + let indexed_keys: std::collections::HashSet<Vec<u8>> = time_keys + .iter() + .filter_map(|(k, _)| { + let pos = k.iter().position(|&b| b == SEP)?; + Some(k[pos + 1..].to_vec()) + }) + .collect(); + + for item in self.db.iter() { + if results.len() >= effective_limit { + break; + } + let (key, value) = item.context("Failed to read iterator entry")?; + if indexed_keys.contains(&key.to_vec()) { + continue; + } + 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) } @@ -233,9 +326,6 @@ impl LibraryDb { let mut results = Vec::new(); for item in self.artist_tree.scan_prefix(prefix_lower.as_bytes()) { - if results.len() >= limit { - break; - } 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()) @@ -246,6 +336,8 @@ impl LibraryDb { } } + results.sort_by_key(|b| std::cmp::Reverse(b.1.timestamp)); + results.truncate(limit); Ok(results) } @@ -259,9 +351,6 @@ impl LibraryDb { let mut results = Vec::new(); for item in self.key_tree.scan_prefix(prefix_lower.as_bytes()) { - if results.len() >= limit { - break; - } 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()) @@ -272,6 +361,8 @@ impl LibraryDb { } } + results.sort_by_key(|b| std::cmp::Reverse(b.1.timestamp)); + results.truncate(limit); Ok(results) } @@ -298,9 +389,6 @@ impl LibraryDb { .bpm_tree .range(min.to_be_bytes().as_slice()..upper.as_slice()) { - if results.len() >= limit { - break; - } let (idx_key, _) = item.context("Failed to read bpm index")?; // Primary key starts after 8-byte BPM + 1-byte separator. let primary_key_bytes = &idx_key[BPM_KEY_LEN + 1..]; @@ -312,6 +400,8 @@ impl LibraryDb { } } + results.sort_by_key(|b| std::cmp::Reverse(b.1.timestamp)); + results.truncate(limit); Ok(results) } } diff --git a/src/import.rs b/src/import.rs @@ -62,6 +62,7 @@ pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) -> Result<usize> { track: title, bpm, key, + timestamp: None, }; db.save(&primary_key, &report)?; diff --git a/src/main.rs b/src/main.rs @@ -294,6 +294,7 @@ fn main() -> Result<()> { 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());