feat(cache): add SQLite-based API cache for albums, tracks, and artists
- Create api_cache module with TTL-based expiration (24h default) - Cache get_album, get_track, and get_artist responses - Check cache before API calls, store results after fetch - Reduces API calls and speeds up Recently Played loading
This commit is contained in:
1 parent
5499add37d
commit
1a2a9ee728
3 files changed
+367
-6
No files matched your search
@@ -0,0 +1,278 @@
|
||||
//! API Response Cache
|
||||
//!
|
||||
//! SQLite-based cache for API responses (albums, artists, etc.)
|
||||
//! with TTL-based expiration.
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// API cache state shared across commands
|
||||
pub struct ApiCacheState {
|
||||
pub cache: Arc<Mutex<ApiCache>>,
|
||||
}
|
||||
|
||||
impl ApiCacheState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz-nix")
|
||||
.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("api_cache.db");
|
||||
let cache = ApiCache::new(&db_path)?;
|
||||
|
||||
log::info!("API cache initialized at {:?}", db_path);
|
||||
|
||||
Ok(Self {
|
||||
cache: Arc::new(Mutex::new(cache)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Default TTL for cached items (24 hours)
|
||||
const DEFAULT_TTL_SECS: i64 = 24 * 60 * 60;
|
||||
|
||||
pub struct ApiCache {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl ApiCache {
|
||||
pub fn new(path: &Path) -> Result<Self, String> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("Failed to open API cache database: {}", e))?;
|
||||
let cache = Self { conn };
|
||||
cache.init()?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn init(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS cached_albums (
|
||||
album_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_albums_fetched ON cached_albums(fetched_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cached_artists (
|
||||
artist_id INTEGER PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_artists_fetched ON cached_artists(fetched_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cached_tracks (
|
||||
track_id INTEGER PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_tracks_fetched ON cached_tracks(fetched_at);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize API cache: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ============ Album Cache ============
|
||||
|
||||
/// Get a cached album if it exists and hasn't expired
|
||||
pub fn get_album(&self, album_id: &str, ttl_secs: Option<i64>) -> Result<Option<String>, String> {
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let min_fetched_at = Self::current_timestamp() - ttl;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM cached_albums WHERE album_id = ? AND fetched_at > ?",
|
||||
params![album_id, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query cached album: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Cache an album response
|
||||
pub fn set_album(&self, album_id: &str, data: &str) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO cached_albums (album_id, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![album_id, data, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache album: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get multiple cached albums at once
|
||||
pub fn get_albums(&self, album_ids: &[String], ttl_secs: Option<i64>) -> Result<Vec<(String, String)>, String> {
|
||||
if album_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let min_fetched_at = Self::current_timestamp() - ttl;
|
||||
|
||||
let placeholders: Vec<&str> = album_ids.iter().map(|_| "?").collect();
|
||||
let query = format!(
|
||||
"SELECT album_id, data FROM cached_albums WHERE album_id IN ({}) AND fetched_at > ?",
|
||||
placeholders.join(",")
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| format!("Failed to prepare cached albums query: {}", e))?;
|
||||
|
||||
// Build params: album_ids + min_fetched_at
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = album_ids
|
||||
.iter()
|
||||
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::ToSql>)
|
||||
.collect();
|
||||
params_vec.push(Box::new(min_fetched_at));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params_refs.as_slice(), |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query cached albums: {}", e))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row.map_err(|e| format!("Failed to read cached album row: {}", e))?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ============ Artist Cache ============
|
||||
|
||||
/// Get a cached artist if it exists and hasn't expired
|
||||
pub fn get_artist(&self, artist_id: u64, ttl_secs: Option<i64>) -> Result<Option<String>, String> {
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let min_fetched_at = Self::current_timestamp() - ttl;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM cached_artists WHERE artist_id = ? AND fetched_at > ?",
|
||||
params![artist_id, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query cached artist: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Cache an artist response
|
||||
pub fn set_artist(&self, artist_id: u64, data: &str) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO cached_artists (artist_id, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![artist_id, data, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache artist: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Track Cache ============
|
||||
|
||||
/// Get a cached track if it exists and hasn't expired
|
||||
pub fn get_track(&self, track_id: u64, ttl_secs: Option<i64>) -> Result<Option<String>, String> {
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let min_fetched_at = Self::current_timestamp() - ttl;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM cached_tracks WHERE track_id = ? AND fetched_at > ?",
|
||||
params![track_id, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query cached track: {}", e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Cache a track response
|
||||
pub fn set_track(&self, track_id: u64, data: &str) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO cached_tracks (track_id, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![track_id, data, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache track: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Maintenance ============
|
||||
|
||||
/// Clear expired entries from all tables
|
||||
pub fn cleanup_expired(&self, ttl_secs: Option<i64>) -> Result<usize, String> {
|
||||
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
|
||||
let min_fetched_at = Self::current_timestamp() - ttl;
|
||||
|
||||
let mut total_deleted = 0;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM cached_albums WHERE fetched_at <= ?",
|
||||
params![min_fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup cached albums: {}", e))?;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM cached_artists WHERE fetched_at <= ?",
|
||||
params![min_fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup cached artists: {}", e))?;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM cached_tracks WHERE fetched_at <= ?",
|
||||
params![min_fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup cached tracks: {}", e))?;
|
||||
|
||||
Ok(total_deleted)
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
pub fn clear_all(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
DELETE FROM cached_albums;
|
||||
DELETE FROM cached_artists;
|
||||
DELETE FROM cached_tracks;
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to clear API cache: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::api::{Album, Artist, SearchResultsPage, Track};
|
||||
use crate::api_cache::ApiCacheState;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -48,9 +49,36 @@ pub async fn search_artists(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_album(album_id: String, state: State<'_, AppState>) -> Result<Album, String> {
|
||||
pub async fn get_album(
|
||||
album_id: String,
|
||||
state: State<'_, AppState>,
|
||||
cache_state: State<'_, ApiCacheState>,
|
||||
) -> Result<Album, String> {
|
||||
// Check cache first
|
||||
{
|
||||
let cache = cache_state.cache.lock().await;
|
||||
if let Some(cached_data) = cache.get_album(&album_id, None)? {
|
||||
log::debug!("Cache hit for album {}", album_id);
|
||||
return serde_json::from_str(&cached_data)
|
||||
.map_err(|e| format!("Failed to parse cached album: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - fetch from API
|
||||
log::debug!("Cache miss for album {}, fetching from API", album_id);
|
||||
let client = state.client.lock().await;
|
||||
client.get_album(&album_id).await.map_err(|e| e.to_string())
|
||||
let album = client.get_album(&album_id).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Cache the result
|
||||
{
|
||||
let cache = cache_state.cache.lock().await;
|
||||
let json = serde_json::to_string(&album)
|
||||
.map_err(|e| format!("Failed to serialize album: {}", e))?;
|
||||
cache.set_album(&album_id, &json)?;
|
||||
log::debug!("Cached album {}", album_id);
|
||||
}
|
||||
|
||||
Ok(album)
|
||||
}
|
||||
|
||||
/// Get featured albums by type (new-releases, press-awards)
|
||||
@@ -69,9 +97,35 @@ pub async fn get_featured_albums(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_track(track_id: u64, state: State<'_, AppState>) -> Result<Track, String> {
|
||||
pub async fn get_track(
|
||||
track_id: u64,
|
||||
state: State<'_, AppState>,
|
||||
cache_state: State<'_, ApiCacheState>,
|
||||
) -> Result<Track, String> {
|
||||
// Check cache first
|
||||
{
|
||||
let cache = cache_state.cache.lock().await;
|
||||
if let Some(cached_data) = cache.get_track(track_id, None)? {
|
||||
log::debug!("Cache hit for track {}", track_id);
|
||||
return serde_json::from_str(&cached_data)
|
||||
.map_err(|e| format!("Failed to parse cached track: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - fetch from API
|
||||
log::debug!("Cache miss for track {}, fetching from API", track_id);
|
||||
let client = state.client.lock().await;
|
||||
client.get_track(track_id).await.map_err(|e| e.to_string())
|
||||
let track = client.get_track(track_id).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Cache the result
|
||||
{
|
||||
let cache = cache_state.cache.lock().await;
|
||||
let json = serde_json::to_string(&track)
|
||||
.map_err(|e| format!("Failed to serialize track: {}", e))?;
|
||||
cache.set_track(track_id, &json)?;
|
||||
}
|
||||
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
/// Get artist with albums
|
||||
@@ -79,11 +133,35 @@ pub async fn get_track(track_id: u64, state: State<'_, AppState>) -> Result<Trac
|
||||
pub async fn get_artist(
|
||||
artist_id: u64,
|
||||
state: State<'_, AppState>,
|
||||
cache_state: State<'_, ApiCacheState>,
|
||||
) -> Result<Artist, String> {
|
||||
log::info!("Command: get_artist {}", artist_id);
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = cache_state.cache.lock().await;
|
||||
if let Some(cached_data) = cache.get_artist(artist_id, None)? {
|
||||
log::debug!("Cache hit for artist {}", artist_id);
|
||||
return serde_json::from_str(&cached_data)
|
||||
.map_err(|e| format!("Failed to parse cached artist: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - fetch from API
|
||||
log::debug!("Cache miss for artist {}, fetching from API", artist_id);
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
let artist = client
|
||||
.get_artist(artist_id, true)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Cache the result
|
||||
{
|
||||
let cache = cache_state.cache.lock().await;
|
||||
let json = serde_json::to_string(&artist)
|
||||
.map_err(|e| format!("Failed to serialize artist: {}", e))?;
|
||||
cache.set_artist(artist_id, &json)?;
|
||||
}
|
||||
|
||||
Ok(artist)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! who need bit-perfect playback without browser sample rate limitations.
|
||||
|
||||
pub mod api;
|
||||
pub mod api_cache;
|
||||
pub mod cache;
|
||||
pub mod cast;
|
||||
pub mod commands;
|
||||
@@ -116,6 +117,9 @@ pub fn run() {
|
||||
// Initialize recommendation store state
|
||||
let reco_state = reco_store::RecoState::new()
|
||||
.expect("Failed to initialize recommendation store");
|
||||
// Initialize API cache state
|
||||
let api_cache_state = api_cache::ApiCacheState::new()
|
||||
.expect("Failed to initialize API cache");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -174,6 +178,7 @@ pub fn run() {
|
||||
.manage(download_cache_state)
|
||||
.manage(lyrics_state)
|
||||
.manage(reco_state)
|
||||
.manage(api_cache_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Auth commands
|
||||
commands::init_client,
|
||||
|
||||
Reference in new issue
Block a user