Files
qbz/src-tauri/src/musicbrainz/cache.rs
T
vicrodh 55693e66ba feat(musicbrainz): add MusicBrainz integration engine (Stage 1)
- Add MusicBrainz client with rate limiting (1 req/sec)
- Add SQLite cache with TTL for recordings, artists, releases
- Add Tauri commands for entity resolution
- Support ISRC, UPC/barcode, and name-based lookups
- Include artist relationship extraction (Stage 3 prep)

cc
2026-01-25 06:33:35 -06:00

392 lines
13 KiB
Rust

//! MusicBrainz cache layer
//!
//! SQLite-based cache for MusicBrainz lookups with TTL expiration
use rusqlite::{params, Connection, OptionalExtension};
use std::path::Path;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex;
use super::models::*;
/// TTL for recording cache (30 days)
const RECORDING_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for artist cache (7 days)
const ARTIST_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// TTL for release cache (30 days)
const RELEASE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for artist relationships cache (7 days)
const RELATIONS_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// MusicBrainz cache state shared across commands
pub struct MusicBrainzCacheState {
pub cache: Arc<Mutex<MusicBrainzCache>>,
}
impl MusicBrainzCacheState {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz")
.join("cache");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
let db_path = data_dir.join("musicbrainz_cache.db");
let cache = MusicBrainzCache::new(&db_path)?;
log::info!("MusicBrainz cache initialized at {:?}", db_path);
Ok(Self {
cache: Arc::new(Mutex::new(cache)),
})
}
}
/// MusicBrainz SQLite cache
pub struct MusicBrainzCache {
conn: Connection,
}
impl MusicBrainzCache {
pub fn new(path: &Path) -> Result<Self, String> {
let conn = Connection::open(path)
.map_err(|e| format!("Failed to open MusicBrainz cache: {}", e))?;
// Enable WAL mode for better concurrency
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
let cache = Self { conn };
cache.init()?;
Ok(cache)
}
fn init(&self) -> Result<(), String> {
self.conn
.execute_batch(
r#"
-- Recordings indexed by ISRC
CREATE TABLE IF NOT EXISTS mb_recordings (
isrc TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_recordings_fetched ON mb_recordings(fetched_at);
-- Artists indexed by normalized name
CREATE TABLE IF NOT EXISTS mb_artists (
name_normalized TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_artists_fetched ON mb_artists(fetched_at);
-- Releases indexed by UPC/barcode
CREATE TABLE IF NOT EXISTS mb_releases (
barcode TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_releases_fetched ON mb_releases(fetched_at);
-- Artist relationships indexed by MBID
CREATE TABLE IF NOT EXISTS mb_artist_relations (
mbid TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_relations_fetched ON mb_artist_relations(fetched_at);
"#,
)
.map_err(|e| format!("Failed to initialize MusicBrainz cache: {}", e))?;
Ok(())
}
fn current_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Normalize artist name for consistent cache keys
pub fn normalize_name(name: &str) -> String {
name.to_lowercase()
.trim()
.replace(['\'', '"', '.', ','], "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
// ============ Recording Cache ============
/// Get cached recording by ISRC
pub fn get_recording(&self, isrc: &str) -> Result<Option<ResolvedTrack>, String> {
let min_fetched_at = Self::current_timestamp() - RECORDING_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_recordings WHERE isrc = ? AND fetched_at > ?",
params![isrc, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query recording cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached recording: {}", e))
} else {
Ok(None)
}
}
/// Cache a resolved recording
pub fn set_recording(&self, isrc: &str, data: &ResolvedTrack) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize recording: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_recordings (isrc, data, fetched_at) VALUES (?, ?, ?)",
params![isrc, json, fetched_at],
)
.map_err(|e| format!("Failed to cache recording: {}", e))?;
Ok(())
}
// ============ Artist Cache ============
/// Get cached artist by name
pub fn get_artist(&self, name: &str) -> Result<Option<ResolvedArtist>, String> {
let normalized = Self::normalize_name(name);
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_artists WHERE name_normalized = ? AND fetched_at > ?",
params![normalized, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query artist cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached artist: {}", e))
} else {
Ok(None)
}
}
/// Cache a resolved artist
pub fn set_artist(&self, name: &str, data: &ResolvedArtist) -> Result<(), String> {
let normalized = Self::normalize_name(name);
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize artist: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_artists (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
params![normalized, json, fetched_at],
)
.map_err(|e| format!("Failed to cache artist: {}", e))?;
Ok(())
}
// ============ Release Cache ============
/// Get cached release by barcode
pub fn get_release(&self, barcode: &str) -> Result<Option<ResolvedRelease>, String> {
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_releases WHERE barcode = ? AND fetched_at > ?",
params![barcode, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query release cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached release: {}", e))
} else {
Ok(None)
}
}
/// Cache a resolved release
pub fn set_release(&self, barcode: &str, data: &ResolvedRelease) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize release: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_releases (barcode, data, fetched_at) VALUES (?, ?, ?)",
params![barcode, json, fetched_at],
)
.map_err(|e| format!("Failed to cache release: {}", e))?;
Ok(())
}
// ============ Artist Relations Cache ============
/// Get cached artist relationships by MBID
pub fn get_artist_relations(&self, mbid: &str) -> Result<Option<ArtistRelationships>, String> {
let min_fetched_at = Self::current_timestamp() - RELATIONS_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_artist_relations WHERE mbid = ? AND fetched_at > ?",
params![mbid, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query relations cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached relations: {}", e))
} else {
Ok(None)
}
}
/// Cache artist relationships
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_artist_relations (mbid, data, fetched_at) VALUES (?, ?, ?)",
params![mbid, json, fetched_at],
)
.map_err(|e| format!("Failed to cache relations: {}", e))?;
Ok(())
}
// ============ Maintenance ============
/// Clear expired entries from all tables
pub fn cleanup_expired(&self) -> Result<usize, String> {
let now = Self::current_timestamp();
let mut total_deleted = 0;
total_deleted += self
.conn
.execute(
"DELETE FROM mb_recordings WHERE fetched_at <= ?",
params![now - RECORDING_TTL_SECS],
)
.map_err(|e| format!("Failed to cleanup recordings: {}", e))?;
total_deleted += self
.conn
.execute(
"DELETE FROM mb_artists WHERE fetched_at <= ?",
params![now - ARTIST_TTL_SECS],
)
.map_err(|e| format!("Failed to cleanup artists: {}", e))?;
total_deleted += self
.conn
.execute(
"DELETE FROM mb_releases WHERE fetched_at <= ?",
params![now - RELEASE_TTL_SECS],
)
.map_err(|e| format!("Failed to cleanup releases: {}", e))?;
total_deleted += self
.conn
.execute(
"DELETE FROM mb_artist_relations WHERE fetched_at <= ?",
params![now - RELATIONS_TTL_SECS],
)
.map_err(|e| format!("Failed to cleanup relations: {}", e))?;
if total_deleted > 0 {
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted);
}
Ok(total_deleted)
}
/// Clear all cached data
pub fn clear_all(&self) -> Result<(), String> {
self.conn
.execute_batch(
r#"
DELETE FROM mb_recordings;
DELETE FROM mb_artists;
DELETE FROM mb_releases;
DELETE FROM mb_artist_relations;
"#,
)
.map_err(|e| format!("Failed to clear MusicBrainz cache: {}", e))?;
log::info!("MusicBrainz cache cleared");
Ok(())
}
/// Get cache statistics
pub fn get_stats(&self) -> Result<CacheStats, String> {
let recordings: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
.unwrap_or(0);
let artists: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
.unwrap_or(0);
let releases: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
.unwrap_or(0);
let relations: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
.unwrap_or(0);
Ok(CacheStats {
recordings: recordings as u64,
artists: artists as u64,
releases: releases as u64,
relations: relations as u64,
})
}
}
/// Cache statistics
#[derive(Debug, Clone, serde::Serialize)]
pub struct CacheStats {
pub recordings: u64,
pub artists: u64,
pub releases: u64,
pub relations: u64,
}