288 lines
8.2 KiB
Markdown
288 lines
8.2 KiB
Markdown
# Download Manager & Offline Architecture
|
|
|
|
## Overview
|
|
|
|
**Location**: `src-tauri/src/download/`
|
|
|
|
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Frontend["Frontend"]
|
|
DownloadButton["DownloadButton.svelte"]
|
|
DownloadsPage["/downloads"]
|
|
DownloadsStore["downloads.ts store"]
|
|
end
|
|
|
|
subgraph Backend["Rust Backend"]
|
|
Commands["Download Commands"]
|
|
DownloadManager["DownloadManager"]
|
|
DownloadWorker["DownloadWorker"]
|
|
SmartCache["SmartCache Engine"]
|
|
end
|
|
|
|
subgraph Storage["Storage"]
|
|
SQLite[("SQLite DB")]
|
|
MediaFiles[("Downloaded Files")]
|
|
end
|
|
|
|
DownloadButton -->|"invoke('download_item')"| Commands
|
|
DownloadsPage -->|"invoke('get_downloads')"| Commands
|
|
Commands --> DownloadManager
|
|
DownloadManager --> DownloadWorker
|
|
DownloadManager --> SmartCache
|
|
DownloadWorker -->|"HTTP Stream"| MediaFiles
|
|
DownloadWorker -->|"Events"| DownloadsStore
|
|
Commands <--> SQLite
|
|
SmartCache <--> SQLite
|
|
```
|
|
|
|
## Download Worker
|
|
|
|
**Location**: `src-tauri/src/download/worker.rs`
|
|
|
|
The download worker handles HTTP streaming with retry logic and resume support:
|
|
|
|
```rust
|
|
pub struct DownloadWorker {
|
|
client: reqwest::Client,
|
|
max_retries: u32,
|
|
}
|
|
|
|
pub struct DownloadTask {
|
|
pub id: i64,
|
|
pub item_id: String,
|
|
pub user_id: String,
|
|
pub priority: i32,
|
|
pub url: String,
|
|
pub target_path: PathBuf,
|
|
pub mime_type: Option<String>,
|
|
pub expected_size: Option<i64>,
|
|
}
|
|
```
|
|
|
|
**Retry Strategy**:
|
|
- Exponential backoff: 5s, 15s, 45s
|
|
- Maximum 3 retry attempts
|
|
- HTTP Range requests for resume support
|
|
- Progress events emitted every 1MB
|
|
|
|
**Download Flow**:
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant UI
|
|
participant Command as download_item
|
|
participant DB as SQLite
|
|
participant Worker as DownloadWorker
|
|
participant Jellyfin as Jellyfin Server
|
|
participant Store as downloads store
|
|
|
|
UI->>Command: download_item(itemId, userId)
|
|
Command->>DB: INSERT INTO downloads
|
|
Command->>Worker: Start download task
|
|
Worker->>Jellyfin: GET /Items/{id}/Download
|
|
|
|
loop Progress Updates
|
|
Jellyfin->>Worker: Stream chunks
|
|
Worker->>Worker: Write to .part file
|
|
Worker->>Store: Emit progress event
|
|
Store->>UI: Update progress bar
|
|
end
|
|
|
|
Worker->>Worker: Rename .part to final
|
|
Worker->>DB: UPDATE status='completed'
|
|
Worker->>Store: Emit completed event
|
|
Store->>UI: Show completed
|
|
```
|
|
|
|
## Smart Caching Engine
|
|
|
|
**Location**: `src-tauri/src/download/cache.rs`
|
|
|
|
The smart caching system provides predictive downloads based on listening patterns:
|
|
|
|
```rust
|
|
pub struct SmartCache {
|
|
config: Arc<Mutex<CacheConfig>>,
|
|
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
|
}
|
|
|
|
pub struct CacheConfig {
|
|
pub queue_precache_enabled: bool,
|
|
pub queue_precache_count: usize, // Default: 5
|
|
pub album_affinity_enabled: bool,
|
|
pub album_affinity_threshold: usize, // Default: 3
|
|
pub storage_limit: u64, // Default: 10GB
|
|
pub wifi_only: bool, // Default: true
|
|
}
|
|
```
|
|
|
|
**Caching Strategies**:
|
|
|
|
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
|
|
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
|
|
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
|
|
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
|
|
|
|
Play --> TrackHistory["Track Play History"]
|
|
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
|
|
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
|
|
|
|
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
|
|
CacheAlbum --> CheckStorage
|
|
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
|
|
CheckStorage -->|"OK"| Download["Queue Download"]
|
|
```
|
|
|
|
## Download Commands
|
|
|
|
**Location**: `src-tauri/src/commands/download.rs`
|
|
|
|
| Command | Parameters | Description |
|
|
|---------|------------|-------------|
|
|
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
|
|
| `download_album` | `album_id, user_id` | Queue all tracks in album |
|
|
| `get_downloads` | `user_id, status_filter` | Get download list |
|
|
| `pause_download` | `download_id` | Pause active download |
|
|
| `resume_download` | `download_id` | Resume paused download |
|
|
| `cancel_download` | `download_id` | Cancel and delete partial |
|
|
| `delete_download` | `download_id` | Delete completed download |
|
|
|
|
## Offline Commands
|
|
|
|
**Location**: `src-tauri/src/commands/offline.rs`
|
|
|
|
| Command | Parameters | Description |
|
|
|---------|------------|-------------|
|
|
| `offline_is_available` | `item_id` | Check if item downloaded |
|
|
| `offline_get_items` | `user_id` | Get all offline items |
|
|
| `offline_search` | `user_id, query` | Search downloaded items |
|
|
|
|
## Player Integration
|
|
|
|
**Location**: `src-tauri/src/commands/player.rs` (modified)
|
|
|
|
The player checks for local downloads before streaming:
|
|
|
|
```rust
|
|
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
|
|
let local_path = db.and_then(|db_wrapper| {
|
|
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
|
|
});
|
|
|
|
let source = if let Some(path) = local_path {
|
|
MediaSource::Local {
|
|
file_path: PathBuf::from(path),
|
|
jellyfin_item_id: Some(jellyfin_id.clone())
|
|
}
|
|
} else {
|
|
MediaSource::Remote {
|
|
stream_url: req.stream_url,
|
|
jellyfin_item_id: jellyfin_id.clone()
|
|
}
|
|
};
|
|
|
|
MediaItem { source, /* ... */ }
|
|
}
|
|
```
|
|
|
|
## Frontend Downloads Store
|
|
|
|
**Location**: `src/lib/stores/downloads.ts`
|
|
|
|
```typescript
|
|
interface DownloadsState {
|
|
downloads: Record<number, DownloadInfo>;
|
|
activeCount: number;
|
|
queuedCount: number;
|
|
}
|
|
|
|
const downloads = createDownloadsStore();
|
|
|
|
// Actions
|
|
downloads.downloadItem(itemId, userId, filePath)
|
|
downloads.downloadAlbum(albumId, userId)
|
|
downloads.pause(downloadId)
|
|
downloads.resume(downloadId)
|
|
downloads.cancel(downloadId)
|
|
downloads.delete(downloadId)
|
|
downloads.refresh(userId, statusFilter)
|
|
|
|
// Derived stores
|
|
export const activeDownloads = derived(downloads, ($d) =>
|
|
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
|
);
|
|
```
|
|
|
|
**Event Handling**:
|
|
|
|
The store listens to Tauri events for real-time updates:
|
|
|
|
```typescript
|
|
listen<DownloadEvent>('download-event', (event) => {
|
|
const payload = event.payload;
|
|
|
|
switch (payload.type) {
|
|
case 'started':
|
|
// Update status to 'downloading'
|
|
case 'progress':
|
|
// Update progress and bytes_downloaded
|
|
case 'completed':
|
|
// Update status to 'completed', progress to 1.0
|
|
case 'failed':
|
|
// Update status to 'failed', store error message
|
|
}
|
|
});
|
|
```
|
|
|
|
## Download UI Components
|
|
|
|
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
|
|
- Multiple states: available, downloading, completed, failed, paused
|
|
- Circular progress ring during download
|
|
- Size variants: sm, md, lg
|
|
- Integrated into TrackList with `showDownload={true}` prop
|
|
|
|
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
|
|
- Individual download list item with progress bar
|
|
- Action buttons: pause, resume, cancel, delete
|
|
- Status indicators with color coding
|
|
|
|
**Downloads Page** (`src/routes/downloads/+page.svelte`):
|
|
- Active/Completed tabs
|
|
- Bulk actions: Pause All, Resume All, Clear Completed
|
|
- Empty states with helpful instructions
|
|
|
|
## Database Schema
|
|
|
|
**downloads table**:
|
|
|
|
```sql
|
|
CREATE TABLE downloads (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
item_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
file_path TEXT,
|
|
file_size INTEGER,
|
|
mime_type TEXT,
|
|
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
|
progress REAL DEFAULT 0.0,
|
|
bytes_downloaded INTEGER DEFAULT 0,
|
|
priority INTEGER DEFAULT 0,
|
|
error_message TEXT,
|
|
retry_count INTEGER DEFAULT 0,
|
|
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
started_at TEXT,
|
|
completed_at TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_downloads_queue
|
|
ON downloads(status, priority DESC, queued_at ASC)
|
|
WHERE status IN ('pending', 'downloading');
|
|
```
|