jellytau/docs/architecture/06-downloads-and-offline.md
Duncan Tourolle 09780103a7
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
2026-03-01 19:47:46 +01:00

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');
```