Add two-level audio cache system (L1: memory, L2: disk)

L1 (Memory): 300MB for ~3-4 Hi-Res tracks, instant access
L2 (Disk): 500MB playback cache at ~/.cache/qbz/playback/

Flow:
- When track evicted from memory, saved to disk cache
- When loading: check memory → disk → download cache → network
- Tracks promoted back to memory when accessed from disk

This reduces re-downloads when navigating back to recently played
tracks in mixed playlists.
This commit is contained in:
vicrodh committed 2026-01-16 08:49:56 -06:00
1 parent 2bdde3d592
commit 73d022ae20
5 files changed
+406 -24

No files matched your search

+1
View File
@@ -66,6 +66,7 @@ walkdir = "2"
lofty = "0.18"
rusqlite = { version = "0.31", features = ["bundled"] }
dirs = "5"
filetime = "0.2"
# File dialogs - platform specific (see target dependencies below)
+69 -20
View File
@@ -1,12 +1,19 @@
//! Audio caching module
//!
//! Provides in-memory caching for downloaded audio data:
//! - LRU cache with configurable size limit
//! - Pre-fetching of next track in queue
//! - Background download tasks
//! Provides two-level caching for audio data:
//! - L1: In-memory LRU cache (fast, limited to ~300MB)
//! - L2: Disk-based playback cache (slower, larger ~500MB)
//!
//! Flow:
//! 1. When a track is evicted from memory, it's saved to disk cache
//! 2. When loading, check memory -> disk -> network
pub mod playback_cache;
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
/// Cached audio data for a track
#[derive(Clone)]
@@ -28,16 +35,18 @@ struct CacheState {
fetching: HashSet<u64>,
}
/// Audio cache manager with LRU eviction
/// Audio cache manager with LRU eviction and disk spillover
pub struct AudioCache {
state: Mutex<CacheState>,
/// Maximum cache size in bytes (default: 200MB)
/// 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(200 * 1024 * 1024) // 200MB default (reduced from 500MB for lower memory footprint)
Self::new(300 * 1024 * 1024) // 300MB for ~3-4 Hi-Res tracks
}
}
@@ -52,9 +61,34 @@ impl AudioCache {
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();
@@ -93,7 +127,7 @@ impl AudioCache {
self.state.lock().unwrap().fetching.remove(&track_id);
}
/// Insert a track into cache, evicting old entries if needed
/// 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();
@@ -108,21 +142,36 @@ impl AudioCache {
return;
}
let mut state = self.state.lock().unwrap();
// Collect tracks to evict (to avoid holding lock while writing to disk)
let mut tracks_to_spill: Vec<CachedTrack> = Vec::new();
// 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!(
"Evicted track {} ({} bytes) from cache",
oldest_id,
track.size_bytes
);
{
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);
+295
View File
@@ -0,0 +1,295 @@
//! Disk-based playback cache (L2)
//!
//! Secondary cache for audio data that was 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 {
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
pub struct PlaybackCache {
state: Mutex<PlaybackCacheState>,
/// Cache directory path
cache_dir: PathBuf,
/// Maximum cache size in bytes (default: 500MB)
max_size_bytes: u64,
}
impl PlaybackCache {
/// Create a new playback cache
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");
// 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,
}
}
}
/// 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,
}
+23 -2
View File
@@ -53,7 +53,7 @@ pub async fn play_track(
let cache = state.audio_cache.clone();
// Check if track is in memory cache
// Check if track is in memory cache (L1)
if let Some(cached) = cache.get(track_id) {
log::info!("Playing track {} from memory cache ({} bytes)", track_id, cached.size_bytes);
state.player.play_data(cached.data, track_id)?;
@@ -68,8 +68,29 @@ pub async fn play_track(
return Ok(());
}
// Check if track is in playback cache (L2 - disk)
if let Some(playback_cache) = cache.get_playback_cache() {
if let Some(audio_data) = playback_cache.get(track_id) {
log::info!("Playing track {} from playback cache ({} bytes)", track_id, audio_data.len());
// Promote back to memory cache
cache.insert(track_id, audio_data.clone());
state.player.play_data(audio_data, track_id)?;
// Prefetch next track in background
spawn_prefetch(
state.client.clone(),
state.audio_cache.clone(),
&state.queue,
);
return Ok(());
}
}
// Not in any cache - download and cache in memory
log::info!("Track {} not in cache, streaming...", track_id);
log::info!("Track {} not in any cache, streaming...", track_id);
let client = state.client.lock().await;
+18 -2
View File
@@ -32,7 +32,7 @@ use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use api::QobuzClient;
use cache::AudioCache;
use cache::{AudioCache, PlaybackCache};
use lastfm::LastFmClient;
use media_controls::{MediaControlsManager, TrackInfo};
use player::Player;
@@ -56,12 +56,28 @@ impl AppState {
}
pub fn with_device(device_name: Option<String>) -> Self {
// Create playback cache (L2 - disk, 500MB)
let playback_cache = match PlaybackCache::new(500 * 1024 * 1024) {
Ok(cache) => Some(Arc::new(cache)),
Err(e) => {
log::warn!("Failed to create playback cache: {}. Disk spillover disabled.", e);
None
}
};
// Create audio cache (L1 - memory, 300MB) with optional disk spillover
let audio_cache = if let Some(pc) = playback_cache {
Arc::new(AudioCache::with_playback_cache(300 * 1024 * 1024, pc))
} else {
Arc::new(AudioCache::default())
};
Self {
client: Arc::new(Mutex::new(QobuzClient::default())),
player: Player::new(device_name),
queue: QueueManager::new(),
media_controls: MediaControlsManager::new(),
audio_cache: Arc::new(AudioCache::default()),
audio_cache,
lastfm: Arc::new(Mutex::new(LastFmClient::default())),
songlink: SongLinkClient::new(),
}