feat: add download cache for offline listening

- SQLite-based index for tracking cached tracks
- Disk-based storage for audio files (~/.cache/qbz-nix/audio/)
- Download progress events via Tauri events
- LRU eviction with configurable cache size limit
- Integration with play_track for automatic local playback
- Open cache folder command for system file manager
This commit is contained in:
vicrodh committed 2026-01-09 19:18:06 -06:00
1 parent 5bf69bc877
commit 3864fa8e4b
8 files changed
+1345 -6

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP Client
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream"], default-features = false }
# Async Runtime
tokio = { version = "1", features = ["full"] }
+40 -5
View File
@@ -7,20 +7,55 @@ use tokio::sync::Mutex;
use crate::api::client::QobuzClient;
use crate::api::models::Quality;
use crate::cache::AudioCache;
use crate::download_cache::DownloadCacheState;
use crate::player::PlaybackState;
use crate::queue::QueueManager;
use crate::AppState;
/// Play a track by ID (with caching support)
#[tauri::command]
pub async fn play_track(track_id: u64, state: State<'_, AppState>) -> Result<(), String> {
pub async fn play_track(
track_id: u64,
state: State<'_, AppState>,
download_cache: State<'_, DownloadCacheState>,
) -> Result<(), String> {
log::info!("Command: play_track {}", track_id);
// First check download cache (persistent disk cache)
{
let db = download_cache.db.lock().await;
if let Ok(Some(file_path)) = db.get_file_path(track_id) {
let path = std::path::Path::new(&file_path);
if path.exists() {
log::info!("Playing track {} from download cache: {:?}", track_id, path);
// Update last accessed time
let _ = db.touch(track_id);
drop(db); // Release lock before reading file
// Read file and play
let audio_data = std::fs::read(path)
.map_err(|e| format!("Failed to read cached file: {}", e))?;
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(());
}
}
}
let cache = state.audio_cache.clone();
// Check if track is in cache
// Check if track is in memory cache
if let Some(cached) = cache.get(track_id) {
log::info!("Playing track {} from cache ({} bytes)", track_id, cached.size_bytes);
log::info!("Playing track {} from memory cache ({} bytes)", track_id, cached.size_bytes);
state.player.play_data(cached.data, track_id)?;
// Prefetch next track in background
@@ -33,8 +68,8 @@ pub async fn play_track(track_id: u64, state: State<'_, AppState>) -> Result<(),
return Ok(());
}
// Not in cache - download and cache
log::info!("Track {} not in cache, downloading...", track_id);
// Not in any cache - download and cache in memory
log::info!("Track {} not in cache, streaming...", track_id);
let client = state.client.lock().await;
+308
View File
@@ -0,0 +1,308 @@
//! Tauri commands for download cache functionality
use tauri::{AppHandle, Emitter, State};
use crate::api::models::Quality;
use crate::AppState;
use super::{
CachedTrackInfo, DownloadCacheState, DownloadCacheStats, DownloadStatus,
TrackDownloadInfo,
};
/// Download a track for offline listening
#[tauri::command]
pub async fn download_track(
track_id: u64,
title: String,
artist: String,
album: Option<String>,
album_id: Option<String>,
duration_secs: u64,
quality: String,
bit_depth: Option<u32>,
sample_rate: Option<f64>,
state: State<'_, AppState>,
cache_state: State<'_, DownloadCacheState>,
app_handle: AppHandle,
) -> Result<(), String> {
log::info!("Command: download_track {} - {} by {}", track_id, title, artist);
let track_info = TrackDownloadInfo {
track_id,
title,
artist,
album,
album_id,
duration_secs,
quality,
bit_depth,
sample_rate,
};
// Determine file path (we'll use flac as default format)
let file_path = cache_state.track_file_path(track_id, "flac");
let file_path_str = file_path.to_string_lossy().to_string();
// Insert into database as queued
{
let db = cache_state.db.lock().await;
db.insert_track(&track_info, &file_path_str)?;
}
// Emit started event
let _ = app_handle.emit("download:started", serde_json::json!({
"trackId": track_id
}));
// Clone what we need for the spawn
let client = state.client.clone();
let downloader = cache_state.downloader.clone();
let db = cache_state.db.clone();
let app = app_handle.clone();
// Spawn download task
tokio::spawn(async move {
// Update status to downloading
{
let db_guard = db.lock().await;
let _ = db_guard.update_status(track_id, DownloadStatus::Downloading, None);
}
// Get stream URL
let stream_url = {
let client_guard = client.lock().await;
client_guard
.get_stream_url_with_fallback(track_id, Quality::HiRes)
.await
};
let url = match stream_url {
Ok(s) => s.url,
Err(e) => {
log::error!("Failed to get stream URL for track {}: {}", track_id, e);
let db_guard = db.lock().await;
let _ = db_guard.update_status(
track_id,
DownloadStatus::Failed,
Some(&format!("Failed to get stream URL: {}", e)),
);
let _ = app.emit("download:failed", serde_json::json!({
"trackId": track_id,
"error": e.to_string()
}));
return;
}
};
// Download the file
match downloader
.download_to_file(&url, &file_path, track_id, Some(&app))
.await
{
Ok(size) => {
log::info!("Download complete for track {}: {} bytes", track_id, size);
let db_guard = db.lock().await;
let _ = db_guard.mark_complete(track_id, size);
let _ = app.emit("download:completed", serde_json::json!({
"trackId": track_id,
"size": size
}));
}
Err(e) => {
log::error!("Download failed for track {}: {}", track_id, e);
let db_guard = db.lock().await;
let _ = db_guard.update_status(track_id, DownloadStatus::Failed, Some(&e));
let _ = app.emit("download:failed", serde_json::json!({
"trackId": track_id,
"error": e
}));
}
}
});
Ok(())
}
/// Check if a track is cached and ready for playback
#[tauri::command]
pub async fn is_track_downloaded(
track_id: u64,
cache_state: State<'_, DownloadCacheState>,
) -> Result<bool, String> {
let db = cache_state.db.lock().await;
db.is_cached(track_id)
}
/// Get the local file path for a cached track
#[tauri::command]
pub async fn get_downloaded_track_path(
track_id: u64,
cache_state: State<'_, DownloadCacheState>,
) -> Result<Option<String>, String> {
let db = cache_state.db.lock().await;
db.get_file_path(track_id)
}
/// Get info about a specific cached track
#[tauri::command]
pub async fn get_downloaded_track(
track_id: u64,
cache_state: State<'_, DownloadCacheState>,
) -> Result<Option<CachedTrackInfo>, String> {
let db = cache_state.db.lock().await;
db.get_track(track_id)
}
/// Get all cached tracks
#[tauri::command]
pub async fn get_downloaded_tracks(
cache_state: State<'_, DownloadCacheState>,
) -> Result<Vec<CachedTrackInfo>, String> {
let db = cache_state.db.lock().await;
db.get_all_tracks()
}
/// Get download cache statistics
#[tauri::command]
pub async fn get_download_cache_stats(
cache_state: State<'_, DownloadCacheState>,
) -> Result<DownloadCacheStats, String> {
let db = cache_state.db.lock().await;
let limit = *cache_state.limit_bytes.lock().await;
db.get_stats(&cache_state.get_cache_path(), limit)
}
/// Remove a track from the download cache
#[tauri::command]
pub async fn remove_downloaded_track(
track_id: u64,
cache_state: State<'_, DownloadCacheState>,
) -> Result<(), String> {
log::info!("Command: remove_downloaded_track {}", track_id);
let db = cache_state.db.lock().await;
// Get file path and delete from DB
if let Some(file_path) = db.delete_track(track_id)? {
// Delete the actual file
let path = std::path::Path::new(&file_path);
if path.exists() {
std::fs::remove_file(path)
.map_err(|e| format!("Failed to delete file: {}", e))?;
}
}
Ok(())
}
/// Clear entire download cache
#[tauri::command]
pub async fn clear_download_cache(
cache_state: State<'_, DownloadCacheState>,
) -> Result<(), String> {
log::info!("Command: clear_download_cache");
let db = cache_state.db.lock().await;
// Get all file paths and clear DB
let paths = db.clear_all()?;
// Delete all files
for path in paths {
let p = std::path::Path::new(&path);
if p.exists() {
let _ = std::fs::remove_file(p);
}
}
// Also clear the tracks directory
let tracks_dir = cache_state.cache_dir.join("tracks");
if tracks_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&tracks_dir) {
for entry in entries.flatten() {
let _ = std::fs::remove_file(entry.path());
}
}
}
Ok(())
}
/// Set cache size limit
#[tauri::command]
pub async fn set_download_cache_limit(
limit_mb: Option<u64>,
cache_state: State<'_, DownloadCacheState>,
) -> Result<(), String> {
log::info!("Command: set_download_cache_limit {:?} MB", limit_mb);
let limit_bytes = limit_mb.map(|mb| mb * 1024 * 1024);
let mut limit = cache_state.limit_bytes.lock().await;
*limit = limit_bytes;
// Check if we need to evict
if let Some(limit_bytes) = limit_bytes {
drop(limit);
evict_if_needed(&cache_state, limit_bytes).await?;
}
Ok(())
}
/// Open the cache folder in the system file manager
#[tauri::command]
pub async fn open_download_cache_folder(
cache_state: State<'_, DownloadCacheState>,
) -> Result<(), String> {
log::info!("Command: open_download_cache_folder");
let path = cache_state.cache_dir.clone();
// Ensure directory exists
std::fs::create_dir_all(&path)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
// Open with system file manager
open::that(&path).map_err(|e| format!("Failed to open folder: {}", e))?;
Ok(())
}
/// Evict tracks if cache exceeds limit (LRU policy)
async fn evict_if_needed(
cache_state: &DownloadCacheState,
limit_bytes: u64,
) -> Result<(), String> {
let db = cache_state.db.lock().await;
let stats = db.get_stats(&cache_state.get_cache_path(), Some(limit_bytes))?;
if stats.total_size_bytes <= limit_bytes {
return Ok(());
}
let bytes_to_free = stats.total_size_bytes - limit_bytes;
log::info!(
"Cache exceeds limit ({} > {}), need to free {} bytes",
stats.total_size_bytes,
limit_bytes,
bytes_to_free
);
let tracks_to_evict = db.get_tracks_for_eviction(bytes_to_free)?;
for (track_id, file_path) in tracks_to_evict {
log::info!("Evicting track {} from cache", track_id);
// Delete from DB
db.delete_track(track_id)?;
// Delete file
let path = std::path::Path::new(&file_path);
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
Ok(())
}
+324
View File
@@ -0,0 +1,324 @@
//! SQLite database for download cache index
use rusqlite::{Connection, params};
use std::path::Path;
use super::{CachedTrackInfo, DownloadCacheStats, DownloadStatus, TrackDownloadInfo};
/// Database wrapper for cached tracks index
pub struct DownloadCacheDb {
conn: Connection,
}
impl DownloadCacheDb {
/// Open or create the database
pub fn new(path: &Path) -> Result<Self, String> {
let conn = Connection::open(path)
.map_err(|e| format!("Failed to open download cache database: {}", e))?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
/// Initialize database schema
fn init_schema(&self) -> Result<(), String> {
self.conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS cached_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER UNIQUE NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
album_id TEXT,
duration_secs INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_size_bytes INTEGER NOT NULL DEFAULT 0,
format TEXT NOT NULL DEFAULT 'flac',
quality TEXT,
bit_depth INTEGER,
sample_rate REAL,
artwork_path TEXT,
status TEXT NOT NULL DEFAULT 'queued',
progress_percent INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_track_id ON cached_tracks(track_id);
CREATE INDEX IF NOT EXISTS idx_status ON cached_tracks(status);
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_tracks(last_accessed_at);
"
).map_err(|e| format!("Failed to initialize database schema: {}", e))?;
Ok(())
}
/// Insert a new track to download
pub fn insert_track(&self, info: &TrackDownloadInfo, file_path: &str) -> Result<(), String> {
self.conn.execute(
"INSERT OR REPLACE INTO cached_tracks
(track_id, title, artist, album, album_id, duration_secs, file_path, quality, bit_depth, sample_rate, status, progress_percent, created_at, last_accessed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'queued', 0, datetime('now'), datetime('now'))",
params![
info.track_id as i64,
info.title,
info.artist,
info.album,
info.album_id,
info.duration_secs as i64,
file_path,
info.quality,
info.bit_depth.map(|v| v as i64),
info.sample_rate,
],
).map_err(|e| format!("Failed to insert track: {}", e))?;
Ok(())
}
/// Update track status
pub fn update_status(&self, track_id: u64, status: DownloadStatus, error: Option<&str>) -> Result<(), String> {
self.conn.execute(
"UPDATE cached_tracks SET status = ?1, error_message = ?2 WHERE track_id = ?3",
params![status.as_str(), error, track_id as i64],
).map_err(|e| format!("Failed to update status: {}", e))?;
Ok(())
}
/// Update download progress
pub fn update_progress(&self, track_id: u64, progress: u8, size_bytes: u64) -> Result<(), String> {
self.conn.execute(
"UPDATE cached_tracks SET progress_percent = ?1, file_size_bytes = ?2 WHERE track_id = ?3",
params![progress as i64, size_bytes as i64, track_id as i64],
).map_err(|e| format!("Failed to update progress: {}", e))?;
Ok(())
}
/// Mark download as complete
pub fn mark_complete(&self, track_id: u64, file_size: u64) -> Result<(), String> {
self.conn.execute(
"UPDATE cached_tracks SET status = 'ready', progress_percent = 100, file_size_bytes = ?1, last_accessed_at = datetime('now') WHERE track_id = ?2",
params![file_size as i64, track_id as i64],
).map_err(|e| format!("Failed to mark complete: {}", e))?;
Ok(())
}
/// Update last accessed time (for LRU)
pub fn touch(&self, track_id: u64) -> Result<(), String> {
self.conn.execute(
"UPDATE cached_tracks SET last_accessed_at = datetime('now') WHERE track_id = ?1",
params![track_id as i64],
).map_err(|e| format!("Failed to update access time: {}", e))?;
Ok(())
}
/// Check if a track is cached and ready
pub fn is_cached(&self, track_id: u64) -> Result<bool, String> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE track_id = ?1 AND status = 'ready'",
params![track_id as i64],
|row| row.get(0),
).map_err(|e| format!("Failed to check cache: {}", e))?;
Ok(count > 0)
}
/// Get file path for a cached track
pub fn get_file_path(&self, track_id: u64) -> Result<Option<String>, String> {
let result = self.conn.query_row(
"SELECT file_path FROM cached_tracks WHERE track_id = ?1 AND status = 'ready'",
params![track_id as i64],
|row| row.get(0),
);
match result {
Ok(path) => Ok(Some(path)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to get file path: {}", e)),
}
}
/// Get track info
pub fn get_track(&self, track_id: u64) -> Result<Option<CachedTrackInfo>, String> {
let result = self.conn.query_row(
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at
FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
|row| {
Ok(CachedTrackInfo {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
album_id: row.get(4)?,
duration_secs: row.get::<_, i64>(5)? as u64,
file_size_bytes: row.get::<_, i64>(6)? as u64,
quality: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
bit_depth: row.get::<_, Option<i64>>(8)?.map(|v| v as u32),
sample_rate: row.get(9)?,
status: DownloadStatus::from_str(&row.get::<_, String>(10)?),
progress_percent: row.get::<_, i64>(11)? as u8,
error_message: row.get(12)?,
created_at: row.get(13)?,
last_accessed_at: row.get(14)?,
})
},
);
match result {
Ok(info) => Ok(Some(info)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Failed to get track: {}", e)),
}
}
/// Get all cached tracks
pub fn get_all_tracks(&self) -> Result<Vec<CachedTrackInfo>, String> {
let mut stmt = self.conn.prepare(
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at
FROM cached_tracks ORDER BY last_accessed_at DESC"
).map_err(|e| format!("Failed to prepare query: {}", e))?;
let tracks = stmt.query_map([], |row| {
Ok(CachedTrackInfo {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
album_id: row.get(4)?,
duration_secs: row.get::<_, i64>(5)? as u64,
file_size_bytes: row.get::<_, i64>(6)? as u64,
quality: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
bit_depth: row.get::<_, Option<i64>>(8)?.map(|v| v as u32),
sample_rate: row.get(9)?,
status: DownloadStatus::from_str(&row.get::<_, String>(10)?),
progress_percent: row.get::<_, i64>(11)? as u8,
error_message: row.get(12)?,
created_at: row.get(13)?,
last_accessed_at: row.get(14)?,
})
}).map_err(|e| format!("Failed to query tracks: {}", e))?;
let mut result = Vec::new();
for track in tracks {
result.push(track.map_err(|e| format!("Failed to read track: {}", e))?);
}
Ok(result)
}
/// Delete a track from cache
pub fn delete_track(&self, track_id: u64) -> Result<Option<String>, String> {
// Get file path before deleting
let file_path: Option<String> = self.conn.query_row(
"SELECT file_path FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
|row| row.get(0),
).ok();
self.conn.execute(
"DELETE FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
).map_err(|e| format!("Failed to delete track: {}", e))?;
Ok(file_path)
}
/// Get statistics
pub fn get_stats(&self, cache_path: &str, limit_bytes: Option<u64>) -> Result<DownloadCacheStats, String> {
let total_tracks: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM cached_tracks",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to count tracks: {}", e))?;
let ready_tracks: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'ready'",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to count ready tracks: {}", e))?;
let downloading_tracks: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'downloading' OR status = 'queued'",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to count downloading tracks: {}", e))?;
let failed_tracks: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'failed'",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to count failed tracks: {}", e))?;
let total_size: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(file_size_bytes), 0) FROM cached_tracks WHERE status = 'ready'",
[],
|row| row.get(0),
).map_err(|e| format!("Failed to sum sizes: {}", e))?;
Ok(DownloadCacheStats {
total_tracks: total_tracks as usize,
ready_tracks: ready_tracks as usize,
downloading_tracks: downloading_tracks as usize,
failed_tracks: failed_tracks as usize,
total_size_bytes: total_size as u64,
limit_bytes,
cache_path: cache_path.to_string(),
})
}
/// Get tracks to evict (LRU order) to free up space
pub fn get_tracks_for_eviction(&self, bytes_to_free: u64) -> Result<Vec<(u64, String)>, String> {
let mut stmt = self.conn.prepare(
"SELECT track_id, file_path, file_size_bytes FROM cached_tracks
WHERE status = 'ready'
ORDER BY last_accessed_at ASC"
).map_err(|e| format!("Failed to prepare eviction query: {}", e))?;
let mut result = Vec::new();
let mut freed = 0u64;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)? as u64,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? as u64,
))
}).map_err(|e| format!("Failed to query for eviction: {}", e))?;
for row in rows {
if freed >= bytes_to_free {
break;
}
let (track_id, file_path, size) = row.map_err(|e| format!("Failed to read row: {}", e))?;
result.push((track_id, file_path));
freed += size;
}
Ok(result)
}
/// Clear all entries
pub fn clear_all(&self) -> Result<Vec<String>, String> {
// Get all file paths first
let mut stmt = self.conn.prepare("SELECT file_path FROM cached_tracks")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let paths: Vec<String> = stmt.query_map([], |row| row.get(0))
.map_err(|e| format!("Failed to query paths: {}", e))?
.filter_map(|r| r.ok())
.collect();
self.conn.execute("DELETE FROM cached_tracks", [])
.map_err(|e| format!("Failed to clear database: {}", e))?;
Ok(paths)
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Download logic for caching tracks to disk
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use super::{DownloadProgress, DownloadStatus};
/// Downloader handles fetching audio files and saving them to disk
pub struct Downloader {
client: reqwest::Client,
}
impl Downloader {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout for large files
.connect_timeout(Duration::from_secs(15))
.build()
.expect("Failed to create HTTP client");
Self { client }
}
/// Download a file to disk with progress updates
pub async fn download_to_file(
&self,
url: &str,
dest_path: &Path,
track_id: u64,
app_handle: Option<&AppHandle>,
) -> Result<u64, String> {
log::info!("Downloading track {} to {:?}", track_id, dest_path);
// Create parent directories if needed
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
// Start the download
let response = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to start download: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let total_size = response.content_length();
log::info!(
"Download started for track {}, total size: {:?} bytes",
track_id,
total_size
);
// Create temp file for downloading
let temp_path = dest_path.with_extension("tmp");
let mut file = std::fs::File::create(&temp_path)
.map_err(|e| format!("Failed to create temp file: {}", e))?;
let mut downloaded: u64 = 0;
let mut last_progress: u8 = 0;
// Stream the response body
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Download error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
downloaded += chunk.len() as u64;
// Calculate progress
let progress = if let Some(total) = total_size {
((downloaded as f64 / total as f64) * 100.0) as u8
} else {
// If we don't know total size, report bytes downloaded
0
};
// Emit progress event every 2% change
if progress != last_progress && (progress - last_progress >= 2 || progress == 100) {
last_progress = progress;
if let Some(app) = app_handle {
let _ = app.emit(
"download:progress",
DownloadProgress {
track_id,
progress_percent: progress,
bytes_downloaded: downloaded,
total_bytes: total_size,
status: DownloadStatus::Downloading,
},
);
}
log::debug!(
"Download progress for track {}: {}% ({}/{:?} bytes)",
track_id,
progress,
downloaded,
total_size
);
}
}
// Ensure all data is written
file.flush()
.map_err(|e| format!("Failed to flush file: {}", e))?;
drop(file);
// Move temp file to final destination
std::fs::rename(&temp_path, dest_path)
.map_err(|e| format!("Failed to move temp file: {}", e))?;
log::info!(
"Download complete for track {}: {} bytes",
track_id,
downloaded
);
Ok(downloaded)
}
/// Download to memory (for smaller files or streaming)
pub async fn download_to_memory(&self, url: &str) -> Result<Vec<u8>, String> {
let response = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to fetch: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read bytes: {}", e))?;
Ok(bytes.to_vec())
}
}
impl Default for Downloader {
fn default() -> Self {
Self::new()
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Download cache module for offline listening
//!
//! Provides persistent disk-based caching for audio tracks:
//! - SQLite index for track metadata
//! - File-based storage for audio data
//! - LRU eviction with configurable limits
//! - Progress events for UI updates
pub mod commands;
pub mod db;
pub mod downloader;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use serde::{Deserialize, Serialize};
pub use db::DownloadCacheDb;
pub use downloader::Downloader;
/// Download status for a cached track
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DownloadStatus {
Queued,
Downloading,
Ready,
Failed,
}
impl DownloadStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Downloading => "downloading",
Self::Ready => "ready",
Self::Failed => "failed",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"queued" => Self::Queued,
"downloading" => Self::Downloading,
"ready" => Self::Ready,
"failed" => Self::Failed,
_ => Self::Failed,
}
}
}
/// Information about a cached track
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedTrackInfo {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub album_id: Option<String>,
pub duration_secs: u64,
pub file_size_bytes: u64,
pub quality: String,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
pub status: DownloadStatus,
pub progress_percent: u8,
pub error_message: Option<String>,
pub created_at: String,
pub last_accessed_at: String,
}
/// Statistics about the download cache
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadCacheStats {
pub total_tracks: usize,
pub ready_tracks: usize,
pub downloading_tracks: usize,
pub failed_tracks: usize,
pub total_size_bytes: u64,
pub limit_bytes: Option<u64>,
pub cache_path: String,
}
/// Progress update for a download
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
pub track_id: u64,
pub progress_percent: u8,
pub bytes_downloaded: u64,
pub total_bytes: Option<u64>,
pub status: DownloadStatus,
}
/// Track metadata for initiating a download
#[derive(Debug, Clone)]
pub struct TrackDownloadInfo {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub album_id: Option<String>,
pub duration_secs: u64,
pub quality: String,
pub bit_depth: Option<u32>,
pub sample_rate: Option<f64>,
}
/// Download cache state manager
pub struct DownloadCacheState {
pub db: Arc<Mutex<DownloadCacheDb>>,
pub downloader: Arc<Downloader>,
pub cache_dir: PathBuf,
/// Cache limit in bytes (None = unlimited)
pub limit_bytes: Arc<Mutex<Option<u64>>>,
}
impl DownloadCacheState {
/// Initialize the download cache
pub fn new() -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz-nix")
.join("audio");
// Create directories
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
std::fs::create_dir_all(cache_dir.join("tracks"))
.map_err(|e| format!("Failed to create tracks directory: {}", e))?;
std::fs::create_dir_all(cache_dir.join("artwork"))
.map_err(|e| format!("Failed to create artwork directory: {}", e))?;
let db_path = cache_dir.join("index.db");
let db = DownloadCacheDb::new(&db_path)?;
// Default limit: 2GB
let default_limit = Some(2 * 1024 * 1024 * 1024u64);
let state = Self {
db: Arc::new(Mutex::new(db)),
downloader: Arc::new(Downloader::new()),
cache_dir: cache_dir.clone(),
limit_bytes: Arc::new(Mutex::new(default_limit)),
};
log::info!("Download cache initialized at: {:?}", cache_dir);
Ok(state)
}
/// Get the path for a track's audio file
pub fn track_file_path(&self, track_id: u64, format: &str) -> PathBuf {
self.cache_dir.join("tracks").join(format!("{}.{}", track_id, format))
}
/// Get the path for an album's artwork
pub fn artwork_path(&self, album_id: &str) -> PathBuf {
self.cache_dir.join("artwork").join(format!("{}.jpg", album_id))
}
/// Get the cache directory path
pub fn get_cache_path(&self) -> String {
self.cache_dir.to_string_lossy().to_string()
}
}
+17
View File
@@ -10,6 +10,7 @@ pub mod commands;
pub mod config;
pub mod credentials;
pub mod discogs;
pub mod download_cache;
pub mod lastfm;
pub mod library;
pub mod media_controls;
@@ -99,6 +100,10 @@ pub fn run() {
let cast_state = cast::CastState::new()
.expect("Failed to initialize casting state");
// Initialize download cache state
let download_cache_state = download_cache::DownloadCacheState::new()
.expect("Failed to initialize download cache");
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
@@ -106,6 +111,7 @@ pub fn run() {
.manage(AppState::new())
.manage(library_state)
.manage(cast_state)
.manage(download_cache_state)
.invoke_handler(tauri::generate_handler![
// Auth commands
commands::init_client,
@@ -230,6 +236,17 @@ pub fn run() {
cast::commands::cast_stop,
cast::commands::cast_seek,
cast::commands::cast_set_volume,
// Download cache commands
download_cache::commands::download_track,
download_cache::commands::is_track_downloaded,
download_cache::commands::get_downloaded_track_path,
download_cache::commands::get_downloaded_track,
download_cache::commands::get_downloaded_tracks,
download_cache::commands::get_download_cache_stats,
download_cache::commands::remove_downloaded_track,
download_cache::commands::clear_download_cache,
download_cache::commands::set_download_cache_limit,
download_cache::commands::open_download_cache_folder,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");