chore: move plan doc to qbz-nix-docs

This commit is contained in:
vicrodh committed 2026-01-09 19:37:22 -06:00
1 parent 3864fa8e4b
commit 45fb498b8b
3 files changed
+137 -330

No files matched your search

-324
View File
@@ -1,324 +0,0 @@
# 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
+135 -5
View File
@@ -5,12 +5,27 @@ use tauri::State;
use crate::share::{ContentType, ShareError, SongLinkResponse};
use crate::AppState;
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ItunesLookupResponse {
result_count: u32,
results: Vec<ItunesTrackResult>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ItunesTrackResult {
track_view_url: Option<String>,
collection_view_url: Option<String>,
}
/// Get song.link URL for a track using ISRC or a direct URL fallback
/// Qobuz isn't supported by Odesli, so we prefer ISRC but can fall back to URL
#[tauri::command]
pub async fn share_track_songlink(
isrc: Option<String>,
url: Option<String>,
track_id: Option<u64>,
state: State<'_, AppState>,
) -> Result<SongLinkResponse, String> {
let isrc = isrc.unwrap_or_default().trim().to_string();
@@ -22,7 +37,7 @@ pub async fn share_track_songlink(
Ok(result) => return Ok(result),
Err(err) => {
log::warn!("ISRC lookup failed: {}", err);
if url.is_empty() {
if url.is_empty() && track_id.is_none() {
return Err(err.to_string());
}
}
@@ -30,15 +45,130 @@ pub async fn share_track_songlink(
}
if url.is_empty() {
if let Some(track_id) = track_id {
if let Some(itunes_url) = resolve_itunes_url(track_id, &isrc, &state).await {
return state
.songlink
.get_by_url(&itunes_url, ContentType::Track)
.await
.map_err(|e| e.to_string());
}
}
return Err(ShareError::MissingIdentifier.to_string());
}
log::info!("Command: share_track_songlink URL={}", url);
state
.songlink
.get_by_url(&url, ContentType::Track)
match state.songlink.get_by_url(&url, ContentType::Track).await {
Ok(result) => Ok(result),
Err(err) => {
log::warn!("URL lookup failed: {}", err);
if let Some(track_id) = track_id {
if let Some(itunes_url) = resolve_itunes_url(track_id, &isrc, &state).await {
return state
.songlink
.get_by_url(&itunes_url, ContentType::Track)
.await
.map_err(|e| e.to_string());
}
}
Err(err.to_string())
}
}
}
async fn resolve_itunes_url(
track_id: u64,
isrc: &str,
state: &State<'_, AppState>,
) -> Option<String> {
if let Some(url) = lookup_itunes_by_isrc(isrc).await {
return Some(url);
}
let (title, artist) = match fetch_track_metadata(track_id, state).await {
Some(meta) => meta,
None => return None,
};
search_itunes_by_term(&title, &artist).await
}
async fn fetch_track_metadata(
track_id: u64,
state: &State<'_, AppState>,
) -> Option<(String, String)> {
let client = state.client.lock().await;
let track = client.get_track(track_id).await.ok()?;
let title = track.title.trim().to_string();
let artist = track
.performer
.map(|a| a.name)
.unwrap_or_else(|| "".to_string())
.trim()
.to_string();
if title.is_empty() || artist.is_empty() {
return None;
}
Some((title, artist))
}
async fn lookup_itunes_by_isrc(isrc: &str) -> Option<String> {
if isrc.trim().is_empty() {
return None;
}
let url = "https://itunes.apple.com/lookup";
let response = reqwest::Client::new()
.get(url)
.query(&[("isrc", isrc), ("country", "US")])
.send()
.await
.map_err(|e| e.to_string())
.ok()?;
if !response.status().is_success() {
return None;
}
let data: ItunesLookupResponse = response.json().await.ok()?;
if data.result_count == 0 {
return None;
}
data.results
.into_iter()
.find_map(|result| result.track_view_url.or(result.collection_view_url))
}
async fn search_itunes_by_term(title: &str, artist: &str) -> Option<String> {
let term = format!("{} {}", artist, title);
let url = "https://itunes.apple.com/search";
let response = reqwest::Client::new()
.get(url)
.query(&[
("term", term),
("entity", "song".to_string()),
("limit", "1".to_string()),
("country", "US".to_string()),
])
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let data: ItunesLookupResponse = response.json().await.ok()?;
if data.result_count == 0 {
return None;
}
data.results
.into_iter()
.find_map(|result| result.track_view_url.or(result.collection_view_url))
}
/// Get song.link URL for an album
+2 -1
View File
@@ -479,7 +479,8 @@
showToast('Fetching Song.link...', 'info');
const response = await invoke<SongLinkResponse>('share_track_songlink', {
isrc: resolvedIsrc?.length ? resolvedIsrc : null,
url: qobuzUrl
url: qobuzUrl,
trackId
});
await copyToClipboard(response.pageUrl, 'Song.link copied');
} catch (err) {