feat(qbz-cache): extract audio caching to standalone crate

Create qbz-cache crate with:
- AudioCache: L1 in-memory LRU cache (~300MB)
- PlaybackCache: L2 disk-based cache (~500MB)
- LRU eviction with spillover from L1 to L2

src-tauri now re-exports from qbz-cache for backwards compatibility.
This enables TUI/headless clients to use caching without Tauri.
This commit is contained in:
vicrodh committed 2026-02-15 11:27:38 -06:00
1 parent a66d5a2af3
commit 384459d4d7
7 files changed
+345 -235

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
//! L1 Memory Cache - In-memory LRU cache for audio data
//!
//! Fast access cache with configurable size limit and LRU eviction.
//! Evicted tracks can optionally spill to L2 disk cache.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use crate::PlaybackCache;
/// Cached audio data for a track
#[derive(Clone)]
pub struct CachedTrack {
pub track_id: u64,
pub data: Vec<u8>,
pub size_bytes: usize,
}
/// Internal cache state - all in one struct to avoid deadlocks
struct CacheState {
/// Cached tracks keyed by track ID
tracks: HashMap<u64, CachedTrack>,
/// Order of access for LRU eviction (most recent at back)
access_order: Vec<u64>,
/// Current cache size in bytes
current_size: usize,
/// Track IDs currently being fetched
fetching: HashSet<u64>,
}
/// Audio cache manager with LRU eviction and optional disk spillover
///
/// Provides fast in-memory caching with automatic eviction when the
/// size limit is reached. Evicted tracks are written to the L2 disk
/// cache (if configured) for later retrieval.
pub struct AudioCache {
state: Mutex<CacheState>,
/// Maximum cache size in bytes
max_size_bytes: usize,
/// Optional disk-based L2 cache for evicted tracks
playback_cache: Option<Arc<PlaybackCache>>,
}
impl Default for AudioCache {
fn default() -> Self {
Self::new(300 * 1024 * 1024) // 300MB for ~3-4 Hi-Res tracks
}
}
impl AudioCache {
/// Create a new cache with specified max size in bytes
pub fn new(max_size_bytes: usize) -> Self {
Self {
state: Mutex::new(CacheState {
tracks: HashMap::new(),
access_order: Vec::new(),
current_size: 0,
fetching: HashSet::new(),
}),
max_size_bytes,
playback_cache: None,
}
}
/// Create cache with disk spillover enabled
pub fn with_playback_cache(max_size_bytes: usize, playback_cache: Arc<PlaybackCache>) -> Self {
Self {
state: Mutex::new(CacheState {
tracks: HashMap::new(),
access_order: Vec::new(),
current_size: 0,
fetching: HashSet::new(),
}),
max_size_bytes,
playback_cache: Some(playback_cache),
}
}
/// Set the playback cache for disk spillover
pub fn set_playback_cache(&mut self, cache: Arc<PlaybackCache>) {
self.playback_cache = Some(cache);
}
/// Get the playback cache reference
pub fn get_playback_cache(&self) -> Option<&Arc<PlaybackCache>> {
self.playback_cache.as_ref()
}
/// Get a track from cache if available
pub fn get(&self, track_id: u64) -> Option<CachedTrack> {
let mut state = self.state.lock().unwrap();
let track = state.tracks.get(&track_id).cloned();
if track.is_some() {
// Update access order (move to back = most recently used)
state.access_order.retain(|&id| id != track_id);
state.access_order.push(track_id);
log::debug!("Cache hit for track {}", track_id);
} else {
log::debug!("Cache miss for track {}", track_id);
}
track
}
/// Check if a track is in cache without updating access order
pub fn contains(&self, track_id: u64) -> bool {
self.state.lock().unwrap().tracks.contains_key(&track_id)
}
/// Check if a track is currently being fetched
pub fn is_fetching(&self, track_id: u64) -> bool {
self.state.lock().unwrap().fetching.contains(&track_id)
}
/// Mark a track as being fetched
pub fn mark_fetching(&self, track_id: u64) {
self.state.lock().unwrap().fetching.insert(track_id);
}
/// Unmark a track as being fetched
pub fn unmark_fetching(&self, track_id: u64) {
self.state.lock().unwrap().fetching.remove(&track_id);
}
/// Insert a track into cache, evicting old entries to disk if needed
pub fn insert(&self, track_id: u64, data: Vec<u8>) {
let size = data.len();
// Don't cache if track is larger than max cache size
if size > self.max_size_bytes {
log::warn!(
"Track {} ({} bytes) too large for cache (max {} bytes)",
track_id,
size,
self.max_size_bytes
);
return;
}
// Collect tracks to evict (to avoid holding lock while writing to disk)
let mut tracks_to_spill: Vec<CachedTrack> = Vec::new();
{
let mut state = self.state.lock().unwrap();
// Evict old entries to make room
while state.current_size + size > self.max_size_bytes && !state.access_order.is_empty() {
let oldest_id = state.access_order.remove(0);
if let Some(track) = state.tracks.remove(&oldest_id) {
state.current_size = state.current_size.saturating_sub(track.size_bytes);
log::debug!(
"Evicting track {} ({} bytes) from memory cache",
oldest_id,
track.size_bytes
);
tracks_to_spill.push(track);
}
}
}
// Spill evicted tracks to disk cache (outside of lock)
if let Some(playback_cache) = &self.playback_cache {
for track in tracks_to_spill {
playback_cache.insert(track.track_id, &track.data);
}
}
let mut state = self.state.lock().unwrap();
// If track already exists, update size tracking
if let Some(existing) = state.tracks.get(&track_id) {
state.current_size = state.current_size.saturating_sub(existing.size_bytes);
}
let cached = CachedTrack {
track_id,
data,
size_bytes: size,
};
state.tracks.insert(track_id, cached);
state.current_size += size;
// Update access order
state.access_order.retain(|&id| id != track_id);
state.access_order.push(track_id);
log::info!(
"Cached track {} ({} bytes). Cache size: {}/{} bytes",
track_id,
size,
state.current_size,
self.max_size_bytes
);
}
/// Clear all cached data (both L1 memory and L2 disk caches)
pub fn clear(&self) {
let mut state = self.state.lock().unwrap();
state.tracks.clear();
state.access_order.clear();
state.current_size = 0;
state.fetching.clear();
log::info!("L1 memory cache cleared");
// Also clear L2 disk cache if present
if let Some(ref playback_cache) = self.playback_cache {
playback_cache.clear();
log::info!("L2 playback cache cleared");
}
}
/// Get cache statistics
pub fn stats(&self) -> CacheStats {
let state = self.state.lock().unwrap();
CacheStats {
cached_tracks: state.tracks.len(),
current_size_bytes: state.current_size,
max_size_bytes: self.max_size_bytes,
fetching_count: state.fetching.len(),
}
}
}
/// Cache statistics
#[derive(Debug, Clone, serde::Serialize)]
pub struct CacheStats {
pub cached_tracks: usize,
pub current_size_bytes: usize,
pub max_size_bytes: usize,
pub fetching_count: usize,
}
+62
View File
@@ -0,0 +1,62 @@
//! QBZ Cache - Audio caching system
//!
//! Provides two-level caching for audio data:
//! - **L1 (Memory)**: In-memory LRU cache (~300MB, fast access)
//! - **L2 (Disk)**: Disk-based playback cache (~500MB, persistent)
//!
//! ## Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────┐
//! │ Audio Request │
//! └─────────────────────────────────────────────────────────┘
//! │
//! ▼
//! ┌─────────────────────────────────────────────────────────┐
//! │ L1 Memory Cache (AudioCache) │
//! │ - Fast HashMap + LRU tracking │
//! │ - ~300MB limit (3-4 Hi-Res tracks) │
//! │ - Evicted tracks spill to L2 │
//! └─────────────────────────────────────────────────────────┘
//! │ miss/evict
//! ▼
//! ┌─────────────────────────────────────────────────────────┐
//! │ L2 Disk Cache (PlaybackCache) │
//! │ - File-based storage │
//! │ - ~500MB limit │
//! │ - LRU eviction by access time │
//! └─────────────────────────────────────────────────────────┘
//! │ miss
//! ▼
//! [ Network ]
//! ```
//!
//! ## Usage
//!
//! ```no_run
//! use qbz_cache::{AudioCache, PlaybackCache};
//! use std::sync::Arc;
//!
//! // Create L2 disk cache
//! let playback_cache = Arc::new(PlaybackCache::new(500 * 1024 * 1024).unwrap());
//!
//! // Create L1 memory cache with L2 spillover
//! let audio_cache = AudioCache::with_playback_cache(
//! 300 * 1024 * 1024,
//! playback_cache.clone()
//! );
//!
//! // Insert track
//! audio_cache.insert(12345, audio_data);
//!
//! // Retrieve track (checks L1, then L2 if configured)
//! if let Some(cached) = audio_cache.get(12345) {
//! // Use cached.data
//! }
//! ```
mod audio_cache;
mod playback_cache;
pub use audio_cache::{AudioCache, CachedTrack, CacheStats};
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
+311
View File
@@ -0,0 +1,311 @@
//! L2 Disk Cache - File-based playback cache
//!
//! Secondary cache for audio data evicted from memory.
//! Provides faster access than re-downloading from network.
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::SystemTime;
/// Entry metadata for tracking cache usage
#[derive(Debug, Clone)]
struct CacheEntry {
#[allow(dead_code)]
track_id: u64,
size_bytes: u64,
last_accessed: SystemTime,
}
/// Disk-based playback cache state
struct PlaybackCacheState {
/// Track metadata keyed by track ID
entries: HashMap<u64, CacheEntry>,
/// Current total size in bytes
current_size: u64,
}
/// Disk-based playback cache for evicted tracks
///
/// Stores audio data as files on disk with LRU eviction.
/// Files are named `{track_id}.audio` in the cache directory.
pub struct PlaybackCache {
state: Mutex<PlaybackCacheState>,
/// Cache directory path
cache_dir: PathBuf,
/// Maximum cache size in bytes
max_size_bytes: u64,
}
impl PlaybackCache {
/// Create a new playback cache with default location
///
/// Default path: `~/.cache/qbz/playback/`
pub fn new(max_size_bytes: u64) -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("playback");
Self::with_path(cache_dir, max_size_bytes)
}
/// Create a new playback cache at a specific path
pub fn with_path(cache_dir: PathBuf, max_size_bytes: u64) -> Result<Self, String> {
// Create directory
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create playback cache directory: {}", e))?;
let cache = Self {
state: Mutex::new(PlaybackCacheState {
entries: HashMap::new(),
current_size: 0,
}),
cache_dir,
max_size_bytes,
};
// Scan existing files to rebuild state
cache.rebuild_state();
log::info!(
"Playback cache initialized at {:?} (max {} MB)",
cache.cache_dir,
max_size_bytes / (1024 * 1024)
);
Ok(cache)
}
/// Rebuild cache state from existing files on disk
fn rebuild_state(&self) {
let mut state = self.state.lock().unwrap();
state.entries.clear();
state.current_size = 0;
if let Ok(entries) = fs::read_dir(&self.cache_dir) {
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
// Parse track ID from filename (format: {track_id}.audio)
if let Some(filename) = entry.file_name().to_str() {
if let Some(id_str) = filename.strip_suffix(".audio") {
if let Ok(track_id) = id_str.parse::<u64>() {
let size = metadata.len();
let last_accessed = metadata
.accessed()
.unwrap_or_else(|_| SystemTime::now());
state.entries.insert(
track_id,
CacheEntry {
track_id,
size_bytes: size,
last_accessed,
},
);
state.current_size += size;
}
}
}
}
}
}
}
log::info!(
"Playback cache rebuilt: {} tracks, {} MB",
state.entries.len(),
state.current_size / (1024 * 1024)
);
}
/// Get file path for a track
fn track_path(&self, track_id: u64) -> PathBuf {
self.cache_dir.join(format!("{}.audio", track_id))
}
/// Check if a track is in the cache
pub fn contains(&self, track_id: u64) -> bool {
self.state.lock().unwrap().entries.contains_key(&track_id)
}
/// Get a track from the cache
pub fn get(&self, track_id: u64) -> Option<Vec<u8>> {
let path = self.track_path(track_id);
// Check if file exists and read it
if !path.exists() {
// File was deleted externally, update state
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.entries.remove(&track_id) {
state.current_size = state.current_size.saturating_sub(entry.size_bytes);
}
return None;
}
match fs::File::open(&path) {
Ok(mut file) => {
let mut data = Vec::new();
if file.read_to_end(&mut data).is_ok() {
// Update last accessed time
let mut state = self.state.lock().unwrap();
if let Some(entry) = state.entries.get_mut(&track_id) {
entry.last_accessed = SystemTime::now();
}
// Touch file to update filesystem access time
let _ = filetime::set_file_atime(&path, filetime::FileTime::now());
log::debug!(
"Playback cache hit for track {} ({} bytes)",
track_id,
data.len()
);
Some(data)
} else {
log::warn!("Failed to read playback cache file for track {}", track_id);
None
}
}
Err(e) => {
log::warn!("Failed to open playback cache file for track {}: {}", track_id, e);
None
}
}
}
/// Insert a track into the cache (called when evicting from memory cache)
pub fn insert(&self, track_id: u64, data: &[u8]) {
let size = data.len() as u64;
// Don't cache if larger than max size
if size > self.max_size_bytes {
log::debug!(
"Track {} too large for playback cache ({} MB > {} MB)",
track_id,
size / (1024 * 1024),
self.max_size_bytes / (1024 * 1024)
);
return;
}
// Evict old entries if needed
self.evict_if_needed(size);
let path = self.track_path(track_id);
// Write file
match fs::File::create(&path) {
Ok(mut file) => {
if file.write_all(data).is_ok() {
let mut state = self.state.lock().unwrap();
// Remove old entry if exists
if let Some(old) = state.entries.remove(&track_id) {
state.current_size = state.current_size.saturating_sub(old.size_bytes);
}
// Add new entry
state.entries.insert(
track_id,
CacheEntry {
track_id,
size_bytes: size,
last_accessed: SystemTime::now(),
},
);
state.current_size += size;
log::info!(
"Saved track {} to playback cache ({} KB). Total: {} MB / {} MB",
track_id,
size / 1024,
state.current_size / (1024 * 1024),
self.max_size_bytes / (1024 * 1024)
);
} else {
log::warn!("Failed to write playback cache file for track {}", track_id);
let _ = fs::remove_file(&path);
}
}
Err(e) => {
log::warn!("Failed to create playback cache file for track {}: {}", track_id, e);
}
}
}
/// Evict oldest entries to make room for new data
fn evict_if_needed(&self, needed_bytes: u64) {
let mut state = self.state.lock().unwrap();
while state.current_size + needed_bytes > self.max_size_bytes && !state.entries.is_empty() {
// Find oldest entry
let oldest_id = state
.entries
.iter()
.min_by_key(|(_, e)| e.last_accessed)
.map(|(id, _)| *id);
if let Some(track_id) = oldest_id {
if let Some(entry) = state.entries.remove(&track_id) {
state.current_size = state.current_size.saturating_sub(entry.size_bytes);
// Delete file
let path = self.cache_dir.join(format!("{}.audio", track_id));
if let Err(e) = fs::remove_file(&path) {
log::debug!("Failed to delete playback cache file: {}", e);
} else {
log::debug!(
"Evicted track {} from playback cache ({} KB)",
track_id,
entry.size_bytes / 1024
);
}
}
} else {
break;
}
}
}
/// Clear the entire cache
pub fn clear(&self) {
let mut state = self.state.lock().unwrap();
for track_id in state.entries.keys() {
let path = self.cache_dir.join(format!("{}.audio", track_id));
let _ = fs::remove_file(&path);
}
state.entries.clear();
state.current_size = 0;
log::info!("Playback cache cleared");
}
/// Get cache statistics
pub fn stats(&self) -> PlaybackCacheStats {
let state = self.state.lock().unwrap();
PlaybackCacheStats {
cached_tracks: state.entries.len(),
current_size_bytes: state.current_size,
max_size_bytes: self.max_size_bytes,
}
}
/// Get the cache directory path
pub fn cache_dir(&self) -> &PathBuf {
&self.cache_dir
}
}
/// Playback cache statistics
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlaybackCacheStats {
pub cached_tracks: usize,
pub current_size_bytes: u64,
pub max_size_bytes: u64,
}