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:
1 parent
2bdde3d592
commit
73d022ae20
5 files changed
+406
-24
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
Vendored
+69
-20
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user