From 3864fa8e4b178dec47a4ec84d47d99a6cf29e05d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:18:06 -0600 Subject: [PATCH] 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 --- DOWNLOAD_INTEGRATION_PLAN.md | 324 +++++++++++++++++++++ src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/playback.rs | 45 ++- src-tauri/src/download_cache/commands.rs | 308 ++++++++++++++++++++ src-tauri/src/download_cache/db.rs | 324 +++++++++++++++++++++ src-tauri/src/download_cache/downloader.rs | 163 +++++++++++ src-tauri/src/download_cache/mod.rs | 168 +++++++++++ src-tauri/src/lib.rs | 17 ++ 8 files changed, 1345 insertions(+), 6 deletions(-) create mode 100644 DOWNLOAD_INTEGRATION_PLAN.md create mode 100644 src-tauri/src/download_cache/commands.rs create mode 100644 src-tauri/src/download_cache/db.rs create mode 100644 src-tauri/src/download_cache/downloader.rs create mode 100644 src-tauri/src/download_cache/mod.rs diff --git a/DOWNLOAD_INTEGRATION_PLAN.md b/DOWNLOAD_INTEGRATION_PLAN.md new file mode 100644 index 000000000..38c94f6b0 --- /dev/null +++ b/DOWNLOAD_INTEGRATION_PLAN.md @@ -0,0 +1,324 @@ +# Plan de Integración: Cache para Offline Listening + +## Alcance + +Solo implementaremos **cache para offline listening** (no descarga libre a carpetas). +Incluiremos un botón "Open cache folder" que abre la ubicación en el file manager del sistema. + +--- + +## Arquitectura + +### Estructura de Archivos + +``` +~/.cache/qbz-nix/audio/ +├── index.db # SQLite con metadata de tracks cacheados +├── tracks/ +│ ├── {track_id}.flac # Archivos de audio (formato original) +│ ├── {track_id}.flac +│ └── ... +└── artwork/ + ├── {album_id}.jpg # Artwork para mostrar offline + └── ... +``` + +### Base de Datos (SQLite) + +```sql +CREATE TABLE cached_tracks ( + id INTEGER PRIMARY KEY, + track_id INTEGER UNIQUE NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT, + album_id TEXT, + duration_secs INTEGER, + file_path TEXT NOT NULL, + file_size_bytes INTEGER NOT NULL, + format TEXT NOT NULL, -- 'flac', 'mp3', etc. + quality TEXT, -- 'CD', 'Hi-Res 24/96', etc. + bit_depth INTEGER, + sample_rate REAL, + artwork_path TEXT, + status TEXT NOT NULL DEFAULT 'downloading', -- 'downloading', 'ready', 'failed' + progress_percent INTEGER DEFAULT 0, + error_message TEXT, + created_at TEXT NOT NULL, + last_accessed_at TEXT NOT NULL +); + +CREATE INDEX idx_track_id ON cached_tracks(track_id); +CREATE INDEX idx_status ON cached_tracks(status); +CREATE INDEX idx_last_accessed ON cached_tracks(last_accessed_at); +``` + +--- + +## Módulo Backend: `src-tauri/src/download_cache/` + +### Archivos + +``` +src-tauri/src/download_cache/ +├── mod.rs # Exports y tipos públicos +├── db.rs # Operaciones SQLite +├── downloader.rs # Lógica de descarga +└── commands.rs # Comandos Tauri +``` + +### Comandos Tauri + +```rust +// Iniciar descarga de un track +#[tauri::command] +async fn download_track(track_id: u64, state: State) -> Result<(), String> + +// Iniciar descarga de un album completo +#[tauri::command] +async fn download_album(album_id: String, state: State) -> Result<(), String> + +// Iniciar descarga de una playlist +#[tauri::command] +async fn download_playlist(playlist_id: u64, state: State) -> Result<(), String> + +// Cancelar descarga en progreso +#[tauri::command] +async fn cancel_download(track_id: u64, state: State) -> Result<(), String> + +// Eliminar track del cache +#[tauri::command] +async fn remove_cached_track(track_id: u64, state: State) -> Result<(), String> + +// Obtener estado del cache +#[tauri::command] +async fn get_download_cache_stats(state: State) -> Result + +// Obtener lista de tracks cacheados +#[tauri::command] +async fn get_cached_tracks(state: State) -> Result, String> + +// Verificar si un track está cacheado +#[tauri::command] +async fn is_track_cached(track_id: u64, state: State) -> Result + +// Limpiar todo el cache +#[tauri::command] +async fn clear_download_cache(state: State) -> Result<(), String> + +// Abrir carpeta de cache en file manager +#[tauri::command] +async fn open_cache_folder() -> Result<(), String> + +// Configurar límite de cache +#[tauri::command] +async fn set_cache_limit(limit_mb: Option, state: State) -> Result<(), String> +``` + +### Tipos de Datos + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct CachedTrackInfo { + pub track_id: u64, + pub title: String, + pub artist: String, + pub album: Option, + pub duration_secs: u64, + pub file_size_bytes: u64, + pub quality: String, + pub status: DownloadStatus, + pub progress_percent: u8, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub enum DownloadStatus { + Queued, + Downloading, + Ready, + Failed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DownloadCacheStats { + pub total_tracks: usize, + pub ready_tracks: usize, + pub downloading_tracks: usize, + pub total_size_bytes: u64, + pub limit_bytes: Option, + pub cache_path: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DownloadProgress { + pub track_id: u64, + pub progress_percent: u8, + pub bytes_downloaded: u64, + pub total_bytes: u64, + pub status: DownloadStatus, +} +``` + +--- + +## Flujo de Descarga + +### 1. Usuario solicita descarga + +``` +Usuario -> Click "Download" -> download_track(track_id) + | + v + Insertar en DB con status='queued' + | + v + Iniciar task de descarga + | + v + Emitir evento: download_started +``` + +### 2. Proceso de descarga + +``` +Task de descarga: + 1. Obtener stream URL (get_stream_url_with_fallback) + 2. Actualizar status='downloading' + 3. Descargar bytes en chunks + - Emitir eventos de progreso cada 5% + - Guardar a archivo temporal + 4. Al completar: + - Mover archivo a ubicación final + - Actualizar status='ready' + - Emitir evento: download_completed + 5. Si falla: + - Actualizar status='failed' + error_message + - Emitir evento: download_failed +``` + +### 3. Playback con cache + +``` +play_track(track_id): + 1. Verificar si track está en download cache (status='ready') + 2. Si está: reproducir desde archivo local + 3. Si no: streaming normal + cachear en memoria +``` + +--- + +## Eventos Frontend (Tauri Events) + +```typescript +// Eventos emitidos por el backend +interface DownloadEvents { + 'download:started': { trackId: number }; + 'download:progress': { trackId: number; percent: number; bytesDownloaded: number }; + 'download:completed': { trackId: number }; + 'download:failed': { trackId: number; error: string }; + 'download:cancelled': { trackId: number }; +} +``` + +--- + +## UI Frontend + +### Componentes + +1. **DownloadButton** - Botón de descarga para tracks/albums + - Estados: idle, downloading (con %), downloaded, failed + - Click en downloaded -> opción de remover + +2. **DownloadManager** - Panel/Modal de gestión de descargas + - Lista de descargas en progreso + - Lista de tracks descargados + - Botón "Open cache folder" + - Estadísticas de uso + +3. **Settings Section** - En SettingsView + - Cache limit slider (500MB - 10GB - Unlimited) + - Current usage display + - "Clear all downloads" button + - "Open cache folder" button + +### Indicadores Visuales + +``` +TrackRow: + [Play] [Title] [Artist] [Duration] [Quality] [♥] [⬇️] [⋮] + ^ + Download indicator: + - No icon: not cached + - ↓ animado: downloading + - ✓ verde: cached + - ⚠️ rojo: failed +``` + +--- + +## Política de Eviction + +1. **Límite configurable**: Default 2GB, opciones 500MB-10GB-Unlimited +2. **LRU por last_accessed_at**: Al exceder límite, eliminar tracks menos usados +3. **Protección**: No eliminar tracks en reproducción actual o queue +4. **Manual**: Usuario puede eliminar tracks individuales o limpiar todo + +--- + +## Implementación por Fases + +### Fase 1: Infraestructura básica +- [ ] Crear módulo `download_cache` +- [ ] Inicializar SQLite DB +- [ ] Comandos básicos: `download_track`, `is_track_cached`, `get_cached_tracks` +- [ ] Integrar con `play_track` para usar cache local + +### Fase 2: UI de descarga individual +- [ ] DownloadButton component +- [ ] Indicadores en TrackRow +- [ ] Progress events y actualización en tiempo real + +### Fase 3: Descarga por lotes +- [ ] `download_album`, `download_playlist` +- [ ] Cola de descargas con límite de concurrencia (3 simultáneas) +- [ ] DownloadManager panel + +### Fase 4: Settings y gestión +- [ ] Cache limit configuration +- [ ] Eviction automático +- [ ] "Open cache folder" button +- [ ] Clear cache functionality + +--- + +## Consideraciones Legales + +- **NO** exponemos esto como "descargar música" +- **SÍ** lo presentamos como "disponible offline" (igual que Spotify/Apple Music) +- El botón "Open cache folder" es un easter egg, no una feature documentada +- Los archivos en cache NO tienen metadata de tags (son raw streams) +- Si Qobuz cambia sus ToS, el cache se puede desactivar con un flag + +--- + +## Dependencias Adicionales + +```toml +# Ya tenemos +rusqlite = { version = "0.31", features = ["bundled"] } +dirs = "5" + +# Podríamos necesitar +tokio-util = "0.7" # Para streaming de archivos +``` + +--- + +## Estimación + +- Fase 1: Core functionality +- Fase 2: UI básica +- Fase 3: Batch downloads +- Fase 4: Polish y settings diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58a003a3b..79512ed3c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 5a569bc80..162a246f3 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -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; diff --git a/src-tauri/src/download_cache/commands.rs b/src-tauri/src/download_cache/commands.rs new file mode 100644 index 000000000..cb8a7bb0a --- /dev/null +++ b/src-tauri/src/download_cache/commands.rs @@ -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, + album_id: Option, + duration_secs: u64, + quality: String, + bit_depth: Option, + sample_rate: Option, + 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 { + 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, 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, 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, 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 { + 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, + 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(()) +} diff --git a/src-tauri/src/download_cache/db.rs b/src-tauri/src/download_cache/db.rs new file mode 100644 index 000000000..e9bf618ef --- /dev/null +++ b/src-tauri/src/download_cache/db.rs @@ -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 { + 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 { + 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, 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, 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>(7)?.unwrap_or_default(), + bit_depth: row.get::<_, Option>(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, 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>(7)?.unwrap_or_default(), + bit_depth: row.get::<_, Option>(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, String> { + // Get file path before deleting + let file_path: Option = 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) -> Result { + 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, 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, 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 = 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) + } +} diff --git a/src-tauri/src/download_cache/downloader.rs b/src-tauri/src/download_cache/downloader.rs new file mode 100644 index 000000000..4e84b300f --- /dev/null +++ b/src-tauri/src/download_cache/downloader.rs @@ -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 { + 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, 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() + } +} + diff --git a/src-tauri/src/download_cache/mod.rs b/src-tauri/src/download_cache/mod.rs new file mode 100644 index 000000000..ed29f5c56 --- /dev/null +++ b/src-tauri/src/download_cache/mod.rs @@ -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, + pub album_id: Option, + pub duration_secs: u64, + pub file_size_bytes: u64, + pub quality: String, + pub bit_depth: Option, + pub sample_rate: Option, + pub status: DownloadStatus, + pub progress_percent: u8, + pub error_message: Option, + 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, + 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, + 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, + pub album_id: Option, + pub duration_secs: u64, + pub quality: String, + pub bit_depth: Option, + pub sample_rate: Option, +} + +/// Download cache state manager +pub struct DownloadCacheState { + pub db: Arc>, + pub downloader: Arc, + pub cache_dir: PathBuf, + /// Cache limit in bytes (None = unlimited) + pub limit_bytes: Arc>>, +} + +impl DownloadCacheState { + /// Initialize the download cache + pub fn new() -> Result { + 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() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc50dfe63..2326a05e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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");