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:
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(×tamp.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());