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:
1 parent
5bf69bc877
commit
3864fa8e4b
8 files changed
+1345
-6
No files matched your search
@@ -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<AppState>) -> Result<(), String>
|
||||||
|
|
||||||
|
// Iniciar descarga de un album completo
|
||||||
|
#[tauri::command]
|
||||||
|
async fn download_album(album_id: String, state: State<AppState>) -> Result<(), String>
|
||||||
|
|
||||||
|
// Iniciar descarga de una playlist
|
||||||
|
#[tauri::command]
|
||||||
|
async fn download_playlist(playlist_id: u64, state: State<AppState>) -> Result<(), String>
|
||||||
|
|
||||||
|
// Cancelar descarga en progreso
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cancel_download(track_id: u64, state: State<AppState>) -> Result<(), String>
|
||||||
|
|
||||||
|
// Eliminar track del cache
|
||||||
|
#[tauri::command]
|
||||||
|
async fn remove_cached_track(track_id: u64, state: State<AppState>) -> Result<(), String>
|
||||||
|
|
||||||
|
// Obtener estado del cache
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_download_cache_stats(state: State<AppState>) -> Result<DownloadCacheStats, String>
|
||||||
|
|
||||||
|
// Obtener lista de tracks cacheados
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_cached_tracks(state: State<AppState>) -> Result<Vec<CachedTrackInfo>, String>
|
||||||
|
|
||||||
|
// Verificar si un track está cacheado
|
||||||
|
#[tauri::command]
|
||||||
|
async fn is_track_cached(track_id: u64, state: State<AppState>) -> Result<bool, String>
|
||||||
|
|
||||||
|
// Limpiar todo el cache
|
||||||
|
#[tauri::command]
|
||||||
|
async fn clear_download_cache(state: State<AppState>) -> 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<u64>, state: State<AppState>) -> 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<String>,
|
||||||
|
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<u64>,
|
||||||
|
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
|
||||||
@@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
# HTTP Client
|
# 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
|
# Async Runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -7,20 +7,55 @@ use tokio::sync::Mutex;
|
|||||||
use crate::api::client::QobuzClient;
|
use crate::api::client::QobuzClient;
|
||||||
use crate::api::models::Quality;
|
use crate::api::models::Quality;
|
||||||
use crate::cache::AudioCache;
|
use crate::cache::AudioCache;
|
||||||
|
use crate::download_cache::DownloadCacheState;
|
||||||
use crate::player::PlaybackState;
|
use crate::player::PlaybackState;
|
||||||
use crate::queue::QueueManager;
|
use crate::queue::QueueManager;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Play a track by ID (with caching support)
|
/// Play a track by ID (with caching support)
|
||||||
#[tauri::command]
|
#[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);
|
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();
|
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) {
|
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)?;
|
state.player.play_data(cached.data, track_id)?;
|
||||||
|
|
||||||
// Prefetch next track in background
|
// Prefetch next track in background
|
||||||
@@ -33,8 +68,8 @@ pub async fn play_track(track_id: u64, state: State<'_, AppState>) -> Result<(),
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not in cache - download and cache
|
// Not in any cache - download and cache in memory
|
||||||
log::info!("Track {} not in cache, downloading...", track_id);
|
log::info!("Track {} not in cache, streaming...", track_id);
|
||||||
|
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod commands;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod discogs;
|
pub mod discogs;
|
||||||
|
pub mod download_cache;
|
||||||
pub mod lastfm;
|
pub mod lastfm;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod media_controls;
|
pub mod media_controls;
|
||||||
@@ -99,6 +100,10 @@ pub fn run() {
|
|||||||
let cast_state = cast::CastState::new()
|
let cast_state = cast::CastState::new()
|
||||||
.expect("Failed to initialize casting state");
|
.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()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
@@ -106,6 +111,7 @@ pub fn run() {
|
|||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
.manage(library_state)
|
.manage(library_state)
|
||||||
.manage(cast_state)
|
.manage(cast_state)
|
||||||
|
.manage(download_cache_state)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// Auth commands
|
// Auth commands
|
||||||
commands::init_client,
|
commands::init_client,
|
||||||
@@ -230,6 +236,17 @@ pub fn run() {
|
|||||||
cast::commands::cast_stop,
|
cast::commands::cast_stop,
|
||||||
cast::commands::cast_seek,
|
cast::commands::cast_seek,
|
||||||
cast::commands::cast_set_volume,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
Reference in new issue
Block a user