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:
1 parent
a66d5a2af3
commit
384459d4d7
7 files changed
+345
-235
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user