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

8.2 KiB

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.

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:

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:

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:

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
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:

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

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:

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:

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