chore: move plan doc to qbz-nix-docs
This commit is contained in:
1 parent
3864fa8e4b
commit
45fb498b8b
3 files changed
+137
-330
No files matched your search
@@ -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
|
|
||||||
@@ -5,12 +5,27 @@ use tauri::State;
|
|||||||
use crate::share::{ContentType, ShareError, SongLinkResponse};
|
use crate::share::{ContentType, ShareError, SongLinkResponse};
|
||||||
use crate::AppState;
|
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
|
/// 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
|
/// Qobuz isn't supported by Odesli, so we prefer ISRC but can fall back to URL
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn share_track_songlink(
|
pub async fn share_track_songlink(
|
||||||
isrc: Option<String>,
|
isrc: Option<String>,
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
|
track_id: Option<u64>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<SongLinkResponse, String> {
|
) -> Result<SongLinkResponse, String> {
|
||||||
let isrc = isrc.unwrap_or_default().trim().to_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),
|
Ok(result) => return Ok(result),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!("ISRC lookup failed: {}", err);
|
log::warn!("ISRC lookup failed: {}", err);
|
||||||
if url.is_empty() {
|
if url.is_empty() && track_id.is_none() {
|
||||||
return Err(err.to_string());
|
return Err(err.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,15 +45,130 @@ pub async fn share_track_songlink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if url.is_empty() {
|
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());
|
return Err(ShareError::MissingIdentifier.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Command: share_track_songlink URL={}", url);
|
log::info!("Command: share_track_songlink URL={}", url);
|
||||||
state
|
match state.songlink.get_by_url(&url, ContentType::Track).await {
|
||||||
.songlink
|
Ok(result) => Ok(result),
|
||||||
.get_by_url(&url, ContentType::Track)
|
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
|
.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
|
/// Get song.link URL for an album
|
||||||
|
|||||||
@@ -479,7 +479,8 @@
|
|||||||
showToast('Fetching Song.link...', 'info');
|
showToast('Fetching Song.link...', 'info');
|
||||||
const response = await invoke<SongLinkResponse>('share_track_songlink', {
|
const response = await invoke<SongLinkResponse>('share_track_songlink', {
|
||||||
isrc: resolvedIsrc?.length ? resolvedIsrc : null,
|
isrc: resolvedIsrc?.length ? resolvedIsrc : null,
|
||||||
url: qobuzUrl
|
url: qobuzUrl,
|
||||||
|
trackId
|
||||||
});
|
});
|
||||||
await copyToClipboard(response.pageUrl, 'Song.link copied');
|
await copyToClipboard(response.pageUrl, 'Song.link copied');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in new issue
Block a user