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"
|
||||
|
||||
# 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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 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");
|
||||
|
||||
Reference in new issue
Block a user