jellytau/SoftwareArchitecture.md
2026-01-26 22:31:37 +01:00

91 KiB

JellyTau Software Architecture

This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.

Last Updated: 2026-01-26

Architecture Overview

JellyTau uses a modern client-server architecture with a thin Svelte UI layer and comprehensive Rust backend:

Architecture Principles

  • Thin UI Layer: TypeScript reduced from ~3,300 to ~800 lines
  • Business Logic in Rust: Performance, reliability, type safety
  • Event-Driven: Rust emits events, TypeScript listens and updates UI
  • Handle-Based Resources: UUID handles for stateful Rust objects
  • Cache-First: Parallel queries with intelligent fallback

1. High-Level Overview

flowchart TB
    subgraph Frontend["Svelte Frontend"]
        subgraph Stores["Stores (Thin Wrappers)"]
            auth["auth"]
            player["player"]
            queue["queue"]
            library["library"]
            connectivity["connectivity"]
            playbackMode["playbackMode"]
        end
        subgraph Components
            playerComp["player/"]
            libraryComp["library/"]
            Search["Search"]
        end
        subgraph Routes
            routeLibrary["/library"]
            routePlayer["/player"]
            routeRoot["/"]
        end
        subgraph API["API Layer (Thin Client)"]
            RepositoryClient["RepositoryClient<br/>(Handle-based)"]
            JellyfinClient["JellyfinClient<br/>(Helper)"]
        end
    end

    Frontend -->|"Tauri IPC (invoke)"| Backend

    subgraph Backend["Rust Backend (Business Logic)"]
        subgraph Commands["Tauri Commands (80+)"]
            PlayerCmds["player.rs"]
            RepoCmds["repository.rs (27)"]
            PlaybackModeCmds["playback_mode.rs (5)"]
            StorageCmds["storage.rs"]
            ConnectivityCmds["connectivity.rs (7)"]
        end

        subgraph Core["Core Modules"]
            MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]

            PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]

            Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]

            PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]

            ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]

            HttpClient["HttpClient<br/>(Exponential backoff retry)"]
        end

        subgraph Storage["Storage Layer"]
            DatabaseService["DatabaseService<br/>(Async trait)"]
            SQLite["SQLite Database<br/>(13 tables)"]
        end

        Commands --> Core
        Core --> Storage
        Repository --> HttpClient
        Repository --> DatabaseService
    end

2. Rust Backend Architecture

2.1 Media Session State Machine

Location: src-tauri/src/player/session.rs IMPLEMENTED

The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.

Architecture Note: The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.

stateDiagram-v2
    [*] --> Idle

    Idle --> AudioActive : play_queue(audio)
    Idle --> MovieActive : play_item(movie)
    Idle --> TvShowActive : play_item(episode)

    state "Audio Session" as AudioSession {
        [*] --> AudioActive
        AudioActive --> AudioInactive : playback_ended
        AudioInactive --> AudioActive : resume/play
        AudioActive --> AudioActive : next/previous
    }

    state "Movie Session" as MovieSession {
        [*] --> MovieActive
        MovieActive --> MovieInactive : playback_ended
        MovieInactive --> MovieActive : resume
    }

    state "TV Show Session" as TvShowSession {
        [*] --> TvShowActive
        TvShowActive --> TvShowInactive : playback_ended
        TvShowInactive --> TvShowActive : next_episode/resume
    }

    AudioSession --> Idle : dismiss/clear_queue
    AudioSession --> MovieSession : play_item(movie)
    AudioSession --> TvShowSession : play_item(episode)

    MovieSession --> Idle : dismiss/playback_complete
    MovieSession --> AudioSession : play_queue(audio)

    TvShowSession --> Idle : dismiss/series_complete
    TvShowSession --> AudioSession : play_queue(audio)

    note right of Idle
        No active media session
        Queue may exist but not playing
        No miniplayer/video player shown
    end note

    note right of AudioSession
        SHOW: Miniplayer (always visible)
        - Active: Play/pause/skip controls enabled
        - Inactive: Play button to resume queue
        Persists until explicit dismiss
    end note

    note right of MovieSession
        SHOW: Full video player
        - Active: Video playing/paused
        - Inactive: Resume dialog
        Auto-dismiss when playback ends
    end note

    note right of TvShowSession
        SHOW: Full video player + Next Episode UI
        - Active: Video playing/paused
        - Inactive: Next episode prompt
        Auto-dismiss when series ends
    end note

Session State Enum:

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MediaSessionType {
    /// No active session - browsing library
    Idle,

    /// Audio playback session (music, audiobooks, podcasts)
    /// Persists until explicitly dismissed
    Audio {
        /// Last/current track being played
        last_item: Option<MediaItem>,
        /// True = playing/paused, False = stopped/ended
        is_active: bool,
    },

    /// Movie playback (single video, auto-dismiss on end)
    Movie {
        item: MediaItem,
        is_active: bool,  // true = playing/paused, false = ended
    },

    /// TV show playback (supports next episode auto-advance)
    TvShow {
        item: MediaItem,
        series_id: String,
        is_active: bool,  // true = playing/paused, false = ended
    },
}

State Transitions & Rules:

From State Event To State UI Behavior Notes
Idle play_queue(audio) Audio (active) Show miniplayer Creates audio session
Idle play_item(movie) Movie (active) Show video player Creates movie session
Idle play_item(episode) TvShow (active) Show video player Creates TV session
Audio (active) playback_ended Audio (inactive) Miniplayer stays visible Queue preserved
Audio (inactive) play/resume Audio (active) Miniplayer enabled Resume from queue
Audio (active/inactive) dismiss Idle Hide miniplayer Clear session
Audio (active/inactive) play_item(movie) Movie (active) Switch to video player Replace session
Movie (active) playback_ended Idle Hide video player Auto-dismiss
Movie (active) dismiss Idle Hide video player User dismiss
TvShow (active) playback_ended TvShow (inactive) Show next episode UI Wait for user choice
TvShow (inactive) next_episode TvShow (active) Play next episode Stay in session
TvShow (inactive) series_complete Idle Hide video player No more episodes

Key Design Decisions:

  1. Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
  2. Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
  3. Single Active Session: Playing new content type replaces current session
  4. Explicit Dismiss for Audio: User must click close button to clear audio session
  5. Session ≠ PlayerState: Session is higher-level, PlayerState tracks playing/paused/seeking

Edge Cases Handled:

  • Album finishes: Session goes inactive, miniplayer shows last track with play disabled
  • User wants to dismiss: Close button clears session → Idle
  • Switch content types: New session replaces old (audio → movie)
  • Paused for extended time: Session persists indefinitely
  • Playback errors: Session stays inactive, allows retry
  • Queue operations while idle: Queue exists but no session created until play

2.2 Player State Machine (Low-Level Playback)

Location: src-tauri/src/player/state.rs

The player uses a deterministic state machine with 6 states (operates within a media session):

stateDiagram-v2
    [*] --> Idle
    Idle --> Loading : Load
    Loading --> Playing : MediaLoaded
    Playing --> Paused : Pause
    Paused --> Playing : Play
    Paused --> Seeking : Seek
    Seeking --> Playing : PositionUpdate
    Playing --> Idle : Stop
    Paused --> Idle : Stop
    Idle --> Error : Error
    Loading --> Error : Error
    Playing --> Error : Error
    Paused --> Error : Error
    Seeking --> Error : Error

    state Playing {
        [*] : position, duration
    }
    state Paused {
        [*] : position, duration
    }
    state Seeking {
        [*] : target
    }
    state Error {
        [*] : error message
    }

State Enum:

pub enum PlayerState {
    Idle,
    Loading { media: MediaItem },
    Playing { media: MediaItem, position: f64, duration: f64 },
    Paused { media: MediaItem, position: f64, duration: f64 },
    Seeking { media: MediaItem, target: f64 },
    Error { media: Option<MediaItem>, error: String },
}

Event Enum:

pub enum PlayerEvent {
    Load(MediaItem),
    Play,
    Pause,
    Stop,
    Seek(f64),
    Next,
    Previous,
    MediaLoaded(f64),      // duration
    PositionUpdate(f64),   // position
    PlaybackEnded,
    Error(String),
}

2.3 Playback Mode State Machine

Location: src-tauri/src/playback_mode/mod.rs

The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):

stateDiagram-v2
    [*] --> Idle

    Idle --> Local : play_queue()
    Idle --> Remote : transfer_to_remote(session_id)

    Local --> Remote : transfer_to_remote(session_id)
    Local --> Idle : stop()

    Remote --> Local : transfer_to_local()
    Remote --> Idle : session_disconnected()
    Remote --> Idle : stop()

    state Local {
        [*] : Playing on device
        [*] : ExoPlayer active
        [*] : Volume buttons → device
    }

    state Remote {
        [*] : Controlling session
        [*] : session_id
        [*] : Volume buttons → remote
        [*] : Android: VolumeProvider active
    }

    state Idle {
        [*] : No active playback
    }

State Enum:

pub enum PlaybackMode {
    Local,                           // Playing on local device
    Remote { session_id: String },   // Controlling remote Jellyfin session
    Idle,                            // No active playback
}

State Transitions:

From Event To Side Effects
Idle play_queue() Local Start local playback
Idle transfer_to_remote(session_id) Remote Send queue to remote session
Local transfer_to_remote(session_id) Remote Stop local, send queue to remote, enable remote volume (Android)
Local stop() Idle Stop local playback
Remote transfer_to_local() Local Get remote state, stop remote, start local at same position, disable remote volume
Remote stop() Idle Stop remote playback, disable remote volume
Remote session_disconnected() Idle Session lost, disable remote volume

Integration with Player State Machine:

  • When PlaybackMode = Local: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
  • When PlaybackMode = Remote: Player state is typically Idle (remote session controls playback)
  • When PlaybackMode = Idle: Player state is Idle

Android Volume Control Integration:

When transitioning to Remote mode on Android:

  1. Call enable_remote_volume(initial_volume)
  2. VolumeProviderCompat intercepts hardware volume buttons
  3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
  4. Volume commands routed to remote session via Jellyfin API

When transitioning away from Remote mode:

  1. Call disable_remote_volume()
  2. Volume buttons return to controlling device volume
  3. PlaybackStateCompat set to STATE_NONE
  4. VolumeProviderCompat is cleared

2.4 Media Item & Source

Location: src-tauri/src/player/media.rs

pub struct MediaItem {
    pub id: String,
    pub title: String,
    pub artist: Option<String>,
    pub album: Option<String>,
    pub duration: Option<f64>,
    pub artwork_url: Option<String>,
    pub media_type: MediaType,
    pub source: MediaSource,
}

pub enum MediaType {
    Audio,
    Video,
}

pub enum MediaSource {
    Remote {
        stream_url: String,
        jellyfin_item_id: String,
    },
    Local {
        file_path: PathBuf,
        jellyfin_item_id: Option<String>,
    },
    DirectUrl {
        url: String,
    },
}

The MediaSource enum enables:

  • Remote: Streaming from Jellyfin server
  • Local: Downloaded/cached files (future offline support)
  • DirectUrl: Direct URLs (channel plugins, external sources)

2.5 Queue Manager

Location: src-tauri/src/player/queue.rs

pub struct QueueManager {
    items: Vec<MediaItem>,
    current_index: Option<usize>,
    shuffle: bool,
    repeat: RepeatMode,
    shuffle_order: Vec<usize>,  // Fisher-Yates permutation
    history: Vec<usize>,        // For back navigation in shuffle
}

pub enum RepeatMode {
    Off,
    All,
    One,
}

Queue Navigation Logic:

flowchart TB
    QM[QueueManager]
    QM --> Shuffle
    QM --> Repeat
    QM --> History

    subgraph Shuffle["Shuffle Mode"]
        ShuffleOff["OFF<br/>next() returns index + 1"]
        ShuffleOn["ON<br/>next() follows shuffle_order[]"]
    end

    subgraph Repeat["Repeat Mode"]
        RepeatOff["OFF<br/>next() at end: → None"]
        RepeatAll["ALL<br/>next() at end: → wrap to index 0"]
        RepeatOne["ONE<br/>next() returns same item"]
    end

    subgraph History["History"]
        HistoryDesc["Used for previous()<br/>in shuffle mode"]
    end

2.6 Favorites System

Location:

  • Service: src/lib/services/favorites.ts
  • Component: src/lib/components/FavoriteButton.svelte
  • Backend: src-tauri/src/commands/storage.rs

Architecture:

The favorites system implements optimistic updates with server synchronization to provide instant UI feedback while maintaining data consistency with the Jellyfin server.

flowchart TB
    UI[FavoriteButton] -->|Click| Service[toggleFavorite]
    Service -->|1. Optimistic| LocalDB[(SQLite user_data)]
    Service -->|2. Sync| JellyfinAPI[Jellyfin API]
    Service -->|3. Mark Synced| LocalDB

    JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"]
    JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"]

    LocalDB -->|is_favorite<br/>pending_sync| UserData[user_data table]

Flow:

  1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
  2. toggleFavorite() service function handles the logic:
    • Updates local SQLite database immediately (optimistic update)
    • Attempts to sync with Jellyfin server
    • Marks as synced if successful, otherwise leaves pending_sync = 1
  3. UI reflects the change immediately without waiting for server response

Components:

  • FavoriteButton.svelte: Reusable heart button component

    • Configurable size (sm/md/lg)
    • Red when favorited, gray when not
    • Loading state during toggle
    • Bindable isFavorite prop for two-way binding
  • Integration Points:

    • MiniPlayer: Shows favorite button for audio tracks (hidden on small screens)
    • Full AudioPlayer: Shows favorite button (planned)
    • Album/Artist detail pages: Shows favorite button (planned)

Database Schema:

  • user_data.is_favorite: Boolean flag (stored as INTEGER 0/1)
  • user_data.pending_sync: Indicates if local changes need syncing

Tauri Commands:

  • storage_toggle_favorite: Updates favorite status in local database
  • storage_mark_synced: Clears pending_sync flag after successful sync

API Methods:

  • LibraryApi.markFavorite(itemId): POST to Jellyfin
  • LibraryApi.unmarkFavorite(itemId): DELETE from Jellyfin

2.7 Player Backend Trait

Location: src-tauri/src/player/backend.rs

pub trait PlayerBackend: Send + Sync {
    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
    fn play(&mut self) -> Result<(), PlayerError>;
    fn pause(&mut self) -> Result<(), PlayerError>;
    fn stop(&mut self) -> Result<(), PlayerError>;
    fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
    fn position(&self) -> f64;
    fn duration(&self) -> Option<f64>;
    fn state(&self) -> PlayerState;
    fn is_loaded(&self) -> bool;
    fn volume(&self) -> f32;
}

Implementations:

  • NullBackend - Mock backend for testing
  • MpvBackend - Linux playback via libmpv (see Section 7.1)
  • ExoPlayerBackend - Android playback via ExoPlayer/Media3 (see Section 7.2)

2.8 Player Controller

Location: src-tauri/src/player/mod.rs

The PlayerController orchestrates playback:

pub struct PlayerController {
    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
    queue: Arc<Mutex<QueueManager>>,
    volume: f32,
    muted: bool,
}

Key Methods:

  • play_item(item): Load and play single item
  • play_queue(items, start_index): Load queue and start playback
  • next() / previous(): Queue navigation
  • toggle_shuffle() / cycle_repeat(): Mode changes

2.9 Tauri Commands

Location: src-tauri/src/commands/player.rs

Command Parameters Returns
player_play_item PlayItemRequest PlayerStatus
player_play_queue items, start_index, shuffle PlayerStatus
player_play - PlayerStatus
player_pause - PlayerStatus
player_toggle - PlayerStatus
player_stop - PlayerStatus
player_next - PlayerStatus
player_previous - PlayerStatus
player_seek position: f64 PlayerStatus
player_set_volume volume: f32 PlayerStatus
player_toggle_shuffle - QueueStatus
player_cycle_repeat - QueueStatus
player_get_status - PlayerStatus
player_get_queue - QueueStatus
player_get_session - MediaSessionType
player_dismiss_session - ()

3. Svelte Frontend Architecture

3.1 Store Structure

Location: src/lib/stores/

flowchart TB
    subgraph Stores
        subgraph auth["auth.ts"]
            AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
        end
        subgraph playerStore["player.ts"]
            PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
        end
        subgraph queueStore["queue.ts"]
            QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
        end
        subgraph libraryStore["library.ts"]
            LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
        end
        subgraph Derived["Derived Stores"]
            DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
        end
    end

3.2 Music Library Architecture

Category-Based Navigation:

JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.

Route Structure:

graph TD
    Music["/library/music<br/>(Landing page with category cards)"]
    Tracks["Tracks<br/>(List view only)"]
    Artists["Artists<br/>(Grid view)"]
    Albums["Albums<br/>(Grid view)"]
    Playlists["Playlists<br/>(Grid view)"]
    Genres["Genres<br/>(Genre browser)"]

    Music --> Tracks
    Music --> Artists
    Music --> Albums
    Music --> Playlists
    Music --> Genres

View Enforcement:

Content Type View Mode Toggle Visible Component Used
Tracks List (forced) No TrackList
Artists Grid (forced) No LibraryGrid with forceGrid={true}
Albums Grid (forced) No LibraryGrid with forceGrid={true}
Playlists Grid (forced) No LibraryGrid with forceGrid={true}
Genres Grid (both levels) No LibraryGrid with forceGrid={true}
Album Detail Tracks List (forced) No TrackList

TrackList Component:

The TrackList component (src/lib/components/library/TrackList.svelte) is a dedicated component for displaying songs in list format:

  • No Thumbnails: Track numbers only (transform to play button on hover)
  • Desktop Layout: Table with columns: #, Title, Artist, Album, Duration
  • Mobile Layout: Compact rows with track number and metadata
  • Configurable Columns: showArtist and showAlbum props control column visibility
  • Click Behavior: Clicking a track plays it and queues all filtered tracks

Example Usage:

<TrackList
  tracks={filteredTracks}
  loading={loading}
  showArtist={true}
  showAlbum={true}
/>

LibraryGrid forceGrid Prop:

The forceGrid prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.

3.3 Playback Reporting Service

Location: src/lib/services/playbackReporting.ts

The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:

  • Offline "Continue Watching" functionality
  • Sync queue for when network is unavailable
  • Consistent progress across app restarts
sequenceDiagram
    participant VideoPlayer
    participant PlaybackService as playbackReporting.ts
    participant LocalDB as Local SQLite<br/>(Tauri Commands)
    participant Jellyfin as Jellyfin Server

    VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)

    par Local Storage (always works)
        PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
        LocalDB-->>PlaybackService: Ok (pending_sync = true)
    and Server Sync (if online)
        PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
        Jellyfin-->>PlaybackService: Ok
        PlaybackService->>LocalDB: invoke("storage_mark_synced")
    end

Service Functions:

  • reportPlaybackStart(itemId, positionSeconds) - Called when playback begins
  • reportPlaybackProgress(itemId, positionSeconds, isPaused) - Called periodically (every 10s)
  • reportPlaybackStopped(itemId, positionSeconds) - Called when player closes or video ends

Tauri Commands:

Command Description
storage_update_playback_progress Update position in local DB (marks pending_sync = true)
storage_mark_played Mark item as played, increment play count
storage_get_playback_progress Get stored progress for an item
storage_mark_synced Clear pending_sync flag after successful server sync

Database Schema Notes:

  • The user_data table stores playback progress using Jellyfin IDs directly (as TEXT)
  • Playback progress can be tracked even when the full item metadata hasn't been downloaded yet

Resume Playback Feature:

  • When loading media for playback, the app checks local database for saved progress
  • If progress exists (>30 seconds watched and <90% complete), shows resume dialog
  • User can choose to "Resume" from saved position or "Start from Beginning"
  • For video: Uses startTimeSeconds parameter in stream URL to begin transcoding from resume point
  • For audio: Seeks to resume position after loading via MPV backend
  • Implemented in src/routes/player/[id]/+page.svelte

3.4 Repository Architecture (Rust-Based)

Location: src-tauri/src/repository/

classDiagram
    class MediaRepository {
        <<trait>>
        +get_libraries()
        +get_items(parent_id, options)
        +get_item(item_id)
        +search(query, options)
        +get_latest_items(parent_id, limit)
        +get_resume_items(parent_id, limit)
        +get_next_up_episodes(series_id, limit)
        +get_genres(parent_id)
        +get_playback_info(item_id)
        +report_playback_start(item_id, position_ticks)
        +report_playback_progress(item_id, position_ticks, is_paused)
        +report_playback_stopped(item_id, position_ticks)
        +mark_favorite(item_id)
        +unmark_favorite(item_id)
        +get_person(person_id)
        +get_items_by_person(person_id, options)
        +get_image_url(item_id, image_type, options)
    }

    class OnlineRepository {
        -http_client: Arc~HttpClient~
        -server_url: String
        -user_id: String
        -access_token: String
        +new()
    }

    class OfflineRepository {
        -db_service: Arc~DatabaseService~
        -server_id: String
        -user_id: String
        +new()
        +cache_library()
        +cache_items()
        +cache_item()
    }

    class HybridRepository {
        -online: Arc~OnlineRepository~
        -offline: Arc~OfflineRepository~
        -connectivity: Arc~ConnectivityMonitor~
        +new()
        -parallel_query()
        -has_meaningful_content()
    }

    MediaRepository <|.. OnlineRepository
    MediaRepository <|.. OfflineRepository
    MediaRepository <|.. HybridRepository

    HybridRepository --> OnlineRepository
    HybridRepository --> OfflineRepository

Key Implementation Details:

  1. Cache-First Racing Strategy (hybrid.rs):

    • Runs cache (SQLite) and server (HTTP) queries in parallel
    • Cache has 100ms timeout
    • Returns cache result if it has meaningful content
    • Falls back to server result otherwise
    • Background cache updates planned
  2. Handle-Based Resource Management (repository.rs commands):

    // Frontend creates repository with UUID handle
    repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
    
    // All operations use handle for identification
    repository_get_libraries(handle: String) -> Vec<Library>
    repository_get_items(handle: String, ...) -> SearchResult
    
    // Cleanup when done
    repository_destroy(handle: String)
    
    • Enables multiple concurrent repository instances
    • Thread-safe with Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>
    • No global state conflicts
  3. Frontend API Layer (src/lib/api/repository-client.ts):

    • Thin TypeScript wrapper over Rust commands
    • Maintains handle throughout session
    • All methods: invoke<T>("repository_operation", { handle, ...args })
    • ~100 lines (down from 1061 lines)

3.5 Playback Mode System

Location: src-tauri/src/playback_mode/mod.rs

The playback mode system manages transitions between local device playback and remote Jellyfin session control:

pub enum PlaybackMode {
    Local,                     // Playing on local device
    Remote { session_id: String }, // Controlling remote session
    Idle,                      // Not playing
}

pub struct PlaybackModeManager {
    current_mode: PlaybackMode,
    player_controller: Arc<Mutex<PlayerController>>,
    jellyfin_client: Arc<JellyfinClient>,
}

Key Operations:

  1. Transfer to Remote (transfer_to_remote(session_id)):

    sequenceDiagram
        participant UI
        participant Manager as PlaybackModeManager
        participant Player as PlayerController
        participant Jellyfin as Jellyfin API
    
        UI->>Manager: transfer_to_remote(session_id)
        Manager->>Player: Extract queue items
        Manager->>Manager: Get Jellyfin IDs from queue
        Manager->>Jellyfin: POST /Sessions/{id}/Playing
        Note over Jellyfin: Start playback with queue
        Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
        Note over Jellyfin: Seek to current position
        Manager->>Player: Stop local playback
        Manager->>Manager: Set mode to Remote
    
  2. Transfer to Local (transfer_to_local(item_id, position_ticks)):

    • Stops remote session playback
    • Prepares local player to resume
    • Sets mode to Local

Tauri Commands (playback_mode.rs):

  • playback_mode_get_current() → Returns current PlaybackMode
  • playback_mode_transfer_to_remote(session_id) → Async transfer
  • playback_mode_transfer_to_local(item_id, position_ticks) → Async transfer back
  • playback_mode_is_transferring() → Check transfer state
  • playback_mode_set(mode) → Direct mode setting

Frontend Store (src/lib/stores/playbackMode.ts):

  • Thin wrapper calling Rust commands
  • Maintains UI state (isTransferring, transferError)
  • Listens to mode change events from Rust

3.6 Database Service Abstraction

Location: src-tauri/src/storage/db_service.rs

Async database interface wrapping synchronous rusqlite to prevent blocking the Tokio runtime:

#[async_trait]
pub trait DatabaseService: Send + Sync {
    async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
    async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
    async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
        where F: FnOnce(&Row) -> Result<T> + Send + 'static;
    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
        where F: FnOnce(&Row) -> Result<T> + Send + 'static;
    async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
        where F: Fn(&Row) -> Result<T> + Send + 'static;
    async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
        where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
}

pub struct RusqliteService {
    connection: Arc<Mutex<Connection>>,
}

impl DatabaseService for RusqliteService {
    async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
        let conn = self.connection.clone();
        tokio::task::spawn_blocking(move || {
            // Execute query on blocking thread pool
        }).await?
    }
    // ... other methods use spawn_blocking
}

Key Benefits:

  • No Freezing: All blocking DB ops run in thread pool via spawn_blocking
  • Type Safety: QueryParam enum prevents SQL injection
  • Future Proof: Easy to swap to native async DB (tokio-rusqlite)
  • Testable: Can mock DatabaseService for tests

Usage Pattern:

// Before (blocking - causes UI freeze)
let conn = database.connection();
let conn = conn.lock().unwrap();  // ❌ BLOCKS
conn.query_row(...)  // ❌ BLOCKS

// After (async - no freezing)
let db_service = database.service();
let query = Query::with_params("SELECT ...", vec![...]);
db_service.query_one(query, |row| {...}).await  // ✅ spawn_blocking internally

3.7 Connectivity & HTTP Client

Location: src-tauri/src/connectivity/ and src-tauri/src/jellyfin/http_client.rs

HTTP Client with Retry Logic

pub struct HttpClient {
    client: reqwest::Client,
    config: HttpConfig,
}

pub struct HttpConfig {
    pub base_url: String,
    pub timeout: Duration,        // Default: 10s
    pub max_retries: u32,         // Default: 3
}

Retry Strategy:

  • Retry delays: 1s, 2s, 4s (exponential backoff)
  • Retries on: Network errors, 5xx server errors
  • No retry on: 4xx client errors, 401/403 authentication errors

Connectivity Monitor

pub struct ConnectivityMonitor {
    state: Arc<RwLock<ConnectivityState>>,
    app_handle: AppHandle,
    monitoring_task: Arc<Mutex<Option<JoinHandle<()>>>>,
}

Features:

  • Adaptive Polling: 30s when online, 5s when offline
  • Event Emission: connectivity:changed, connectivity:reconnected
  • Manual Marking: Mark reachable/unreachable based on API results
  • Thread-Safe: Arc<RwLock<>> for shared state

Frontend Integration (src/lib/stores/connectivity.ts):

  • Thin wrapper (~250 lines, down from 301)
  • Listens to Rust events
  • Calls Rust commands for operations

3.8 Component Hierarchy

graph TD
    subgraph Routes["Routes (src/routes/)"]
        LoginPage["Login Page"]
        LibLayout["Library Layout"]
        LibDetail["Album/Series Detail"]
        MusicCategory["Music Category Landing"]
        Tracks["Tracks"]
        Artists["Artists"]
        Albums["Albums"]
        Playlists["Playlists"]
        Genres["Genres"]
        Downloads["Downloads Page"]
        Settings["Settings Page"]
        PlayerPage["Player Page"]
    end

    subgraph PlayerComps["Player Components"]
        AudioPlayer["AudioPlayer"]
        MiniPlayer["MiniPlayer"]
        Controls["Controls"]
        Queue["Queue"]
    end

    subgraph SessionComps["Sessions Components"]
        CastButton["CastButton"]
        SessionModal["SessionPickerModal"]
        SessionCard["SessionCard"]
        SessionsList["SessionsList"]
        RemoteControls["RemoteControls"]
    end

    subgraph LibraryComps["Library Components"]
        LibGrid["LibraryGrid"]
        LibListView["LibraryListView"]
        TrackList["TrackList"]
        DownloadBtn["DownloadButton"]
        MediaCard["MediaCard"]
    end

    subgraph OtherComps["Other Components"]
        Search["Search"]
        FavoriteBtn["FavoriteButton"]
        DownloadItem["DownloadItem"]
    end

    LibLayout --> PlayerComps
    LibLayout --> LibDetail
    MusicCategory --> Tracks
    MusicCategory --> Artists
    MusicCategory --> Albums
    MusicCategory --> Playlists
    MusicCategory --> Genres
    LibDetail --> LibraryComps
    Downloads --> DownloadItem
    PlayerPage --> PlayerComps

    MiniPlayer --> CastButton
    CastButton --> SessionModal
    PlayerComps --> LibraryComps

4. Data Flow

4.1 Repository Query Flow (Cache-First)

sequenceDiagram
    participant UI as Svelte Component
    participant Client as RepositoryClient (TS)
    participant Rust as Tauri Command
    participant Hybrid as HybridRepository
    participant Cache as OfflineRepository (SQLite)
    participant Server as OnlineRepository (HTTP)

    UI->>Client: getItems(parentId)
    Client->>Rust: invoke("repository_get_items", {handle, parentId})
    Rust->>Hybrid: get_items()

    par Parallel Racing
        Hybrid->>Cache: get_items() with 100ms timeout
        Hybrid->>Server: get_items() (no timeout)
    end

    alt Cache returns with content
        Cache-->>Hybrid: Result with items
        Hybrid-->>Rust: Return cache result
    else Cache timeout or empty
        Server-->>Hybrid: Fresh result
        Hybrid-->>Rust: Return server result
    end

    Rust-->>Client: SearchResult
    Client-->>UI: items[]
    Note over UI: Reactive update

Key Points:

  • Cache queries have 100ms timeout for responsiveness
  • Server queries always run for fresh data
  • Cache wins if it has meaningful content
  • Automatic fallback to server if cache is empty/stale
  • Background cache updates (planned)

4.2 Playback Initiation Flow

sequenceDiagram
    participant User
    participant AudioPlayer
    participant Tauri as Tauri IPC
    participant Command as player_play_item()
    participant Controller as PlayerController
    participant Backend as PlayerBackend
    participant Store as Frontend Store

    User->>AudioPlayer: clicks play
    AudioPlayer->>Tauri: invoke("player_play_item", {item})
    Tauri->>Command: player_play_item()
    Command->>Command: Convert PlayItemRequest → MediaItem
    Command->>Controller: play_item(item)
    Controller->>Backend: load(item)
    Note over Backend: State → Loading
    Controller->>Backend: play()
    Note over Backend: State → Playing
    Controller-->>Command: Ok(())
    Command-->>Tauri: PlayerStatus {state, position, duration, volume}
    Tauri-->>AudioPlayer: status
    AudioPlayer->>Store: player.setPlaying(media, position, duration)
    Note over Store: UI updates reactively

4.3 Playback Mode Transfer Flow

sequenceDiagram
    participant UI as Cast Button
    participant Store as playbackMode store
    participant Rust as Tauri Command
    participant Manager as PlaybackModeManager
    participant Player as PlayerController
    participant Jellyfin as Jellyfin API

    UI->>Store: transferToRemote(sessionId)
    Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
    Rust->>Manager: transfer_to_remote()

    Manager->>Player: Get current queue
    Player-->>Manager: Vec<MediaItem>
    Manager->>Manager: Extract Jellyfin IDs

    Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
    Jellyfin-->>Manager: 200 OK

    Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
    Jellyfin-->>Manager: 200 OK

    Manager->>Player: stop()
    Manager->>Manager: mode = Remote {sessionId}

    Manager-->>Rust: Ok(())
    Rust-->>Store: PlaybackMode
    Store->>UI: Update cast icon

4.4 Queue Navigation Flow

flowchart TB
    User["User clicks Next"] --> Invoke["invoke('player_next')"]
    Invoke --> ControllerNext["controller.next()"]
    ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]

    QueueNext --> None["None<br/>(at end)"]
    QueueNext --> Some["Some(next)"]
    QueueNext --> Same["Same<br/>(repeat one)"]

    Some --> PlayItem["play_item(next)<br/>Returns new status"]

4.5 Volume Control Flow

sequenceDiagram
    participant User
    participant Slider as Volume Slider
    participant Handler as handleVolumeChange()
    participant Tauri as Tauri IPC
    participant Command as player_set_volume
    participant Controller as PlayerController
    participant Backend as MpvBackend/NullBackend
    participant Events as playerEvents.ts
    participant Store as Player Store
    participant UI

    User->>Slider: adjusts (0-100)
    Slider->>Handler: oninput event
    Handler->>Handler: Convert 0-100 → 0.0-1.0
    Handler->>Tauri: invoke("player_set_volume", {volume})
    Tauri->>Command: player_set_volume
    Command->>Controller: set_volume(volume)
    Controller->>Backend: set_volume(volume)
    Backend->>Backend: Clamp to 0.0-1.0
    Note over Backend: MpvBackend: Send to MPV loop
    Backend-->>Tauri: emit "player-event"
    Tauri-->>Events: VolumeChanged event
    Events->>Store: player.setVolume(volume)
    Store-->>UI: Reactive update
    Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync

Key Implementation Details:

  • Volume is stored in the backend (NullBackend/MpvBackend)
  • PlayerController.volume() delegates to backend
  • get_player_status() returns controller.volume() (not hardcoded)
  • Frontend uses normalized 0.0-1.0 scale, UI shows 0-100

5. Type Synchronization

5.1 PlayerState (Rust ↔ TypeScript)

Rust:

pub enum PlayerState {
    Idle,
    Loading { media: MediaItem },
    Playing { media: MediaItem, position: f64, duration: f64 },
    Paused { media: MediaItem, position: f64, duration: f64 },
    Seeking { media: MediaItem, target: f64 },
    Error { media: Option<MediaItem>, error: String },
}

TypeScript:

type PlayerState =
  | { kind: "idle" }
  | { kind: "loading"; media: MediaItem }
  | { kind: "playing"; media: MediaItem; position: number; duration: number }
  | { kind: "paused"; media: MediaItem; position: number; duration: number }
  | { kind: "seeking"; media: MediaItem; target: number }
  | { kind: "error"; media: MediaItem | null; error: string };

5.2 MediaItem Serialization

// Rust (serde serialization)
#[derive(Serialize, Deserialize)]
pub struct MediaItem {
    pub id: String,
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub artist: Option<String>,
    // ...
}
// TypeScript
interface MediaItem {
  id: string;
  title: string;
  artist?: string;
  // ...
}

6. Thread Safety

6.1 Rust Backend

// Shared state wrapped in Arc<Mutex<>>
pub struct PlayerController {
    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
    queue: Arc<Mutex<QueueManager>>,
    // ...
}

// Tauri state wrapper
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);

// Command handler pattern
#[tauri::command]
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
    let mut controller = state.0.lock().unwrap();  // Acquire lock
    controller.play()?;                             // Operate
    Ok(get_player_status(&controller))              // Lock released
}

6.2 Frontend Stores

Svelte stores are inherently reactive and thread-safe for UI updates:

const { subscribe, update } = writable<PlayerStore>(initialState);

// Atomic updates
function setPlaying(media: MediaItem, position: number, duration: number) {
  update(state => ({
    ...state,
    state: { kind: "playing", media, position, duration }
  }));
}

7. Platform-Specific Player Backends

7.1 Player Events System

Location: src-tauri/src/player/events.rs

The player uses a push-based event system to notify the frontend of state changes:

pub enum PlayerStatusEvent {
    /// Playback position updated (emitted periodically during playback)
    PositionUpdate { position: f64, duration: f64 },

    /// Player state changed
    StateChanged { state: String, media_id: Option<String> },

    /// Media has finished loading and is ready to play
    MediaLoaded { duration: f64 },

    /// Playback has ended naturally
    PlaybackEnded,

    /// Buffering state changed
    Buffering { percent: u8 },

    /// An error occurred during playback
    Error { message: String, recoverable: bool },

    /// Volume changed
    VolumeChanged { volume: f32, muted: bool },

    /// Sleep timer state changed
    SleepTimerChanged {
        mode: SleepTimerMode,
        remaining_seconds: u32,
    },

    /// Show next episode popup with countdown
    ShowNextEpisodePopup {
        current_episode: MediaItem,
        next_episode: MediaItem,
        countdown_seconds: u32,
        auto_advance: bool,
    },

    /// Countdown tick (emitted every second during autoplay countdown)
    CountdownTick { remaining_seconds: u32 },

    /// Queue changed (items added, removed, reordered, or playback mode changed)
    QueueChanged {
        items: Vec<MediaItem>,
        current_index: Option<usize>,
        shuffle: bool,
        repeat: RepeatMode,
        has_next: bool,
        has_previous: bool,
    },

    /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
    SessionChanged { session: MediaSessionType },
}

Events are emitted via Tauri's event system:

flowchart LR
    subgraph Backend["Player Backend"]
        MPV["MPV/ExoPlayer"]
    end

    subgraph EventSystem["Event System"]
        Emitter["TauriEventEmitter<br/>emit()"]
        Bus["Tauri Event Bus<br/>'player-event'"]
    end

    subgraph Frontend["Frontend"]
        Listener["playerEvents.ts<br/>Frontend Listener"]
        Store["Player Store Update<br/>(position, state, etc)"]
    end

    MPV --> Emitter --> Bus --> Listener --> Store

Frontend Listener (src/lib/services/playerEvents.ts):

  • Listens for player-event Tauri events
  • Updates player/queue stores based on event type
  • Auto-advances to next track on PlaybackEnded

7.2 MpvBackend (Linux)

Location: src-tauri/src/player/mpv/

The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not Send, all operations occur on a dedicated thread.

flowchart TB
    subgraph MainThread["Main Thread"]
        MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
        Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
    end

    subgraph EventLoopThread["MPV Event Loop Thread"]
        EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
        TauriEmitter["TauriEventEmitter"]
    end

    MpvBackend -->|"MpvCommand"| EventLoop
    MpvBackend <-->|"Arc&lt;Mutex&lt;&gt;&gt;"| EventLoop
    EventLoop -->|"Events"| TauriEmitter
    TauriEmitter --> FrontendStore["Frontend Store"]

Key Components:

// Command enum sent to event loop thread
pub enum MpvCommand {
    Load { url: String, media: MediaItem },
    Play,
    Pause,
    Stop,
    Seek(f64),
    SetVolume(f32),
    Quit,
}

// Shared state between main thread and event loop
pub struct MpvSharedState {
    pub state: PlayerState,
    pub position: f64,
    pub duration: Option<f64>,
    pub volume: f32,
    pub is_loaded: bool,
    pub current_media: Option<MediaItem>,
}

Event Loop (event_loop.rs):

  • Initializes MPV with audio-only config (vo=null, video=false)
  • Observes properties: time-pos, duration, pause, volume
  • Emits position updates every 250ms during playback
  • Processes commands from channel (non-blocking)
  • Handles MPV events: FileLoaded, EndFile, PropertyChange

7.3 ExoPlayerBackend (Android)

Location: src-tauri/src/player/android/ and Kotlin sources

The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.

flowchart TB
    subgraph RustNative["Rust (Native)"]
        ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
        NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
        TauriEmitter2["TauriEventEmitter"]
    end

    subgraph KotlinJVM["Kotlin (JVM)"]
        JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
    end

    ExoBackend -->|"JNI Calls"| JellyTauPlayer
    JellyTauPlayer -->|"Callbacks"| NativeFuncs
    NativeFuncs --> TauriEmitter2
    TauriEmitter2 --> FrontendStore2["Frontend Store"]

Kotlin Player (JellyTauPlayer.kt):

class JellyTauPlayer(context: Context) {
    private val exoPlayer: ExoPlayer
    private var positionUpdateJob: Job?

    // Methods callable from Rust via JNI
    fun load(url: String, mediaId: String)
    fun play()
    fun pause()
    fun stop()
    fun seek(positionSeconds: Double)
    fun setVolume(volume: Float)

    // Native callbacks to Rust
    private external fun nativeOnPositionUpdate(position: Double, duration: Double)
    private external fun nativeOnStateChanged(state: String, mediaId: String?)
    private external fun nativeOnMediaLoaded(duration: Double)
    private external fun nativeOnPlaybackEnded()
}

JNI Callbacks (Rust):

#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
    _env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
) {
    // Update shared state
    // Emit PlayerStatusEvent::PositionUpdate
}

7.3.1 Android MediaSession & Remote Volume Control

Location: JellyTauPlaybackService.kt

JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:

flowchart TB
    subgraph Service["JellyTauPlaybackService"]
        MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]

        MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]

        VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]

        MediaSessionCompat --> VolumeProvider
    end

    subgraph Hardware["System"]
        VolumeButtons["Hardware Volume Buttons"]
        Lockscreen["Lockscreen Controls"]
        Notification["Media Notification"]
    end

    subgraph Rust["Rust Backend"]
        JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
        PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
        JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
    end

    VolumeButtons --> VolumeProvider
    Lockscreen --> MediaSession
    Notification --> MediaSession

    VolumeProvider --> JNI
    JNI --> PlaybackMode
    PlaybackMode --> JellyfinAPI

Architecture Rationale:

JellyTau maintains both MediaSession types because they serve different purposes:

  1. Media3 MediaSession: Handles lockscreen/notification playback controls (play/pause/next/previous)
  2. MediaSessionCompat: Intercepts hardware volume button presses for remote playback control

When in remote playback mode (controlling a Jellyfin session on another device):

  • Volume buttons are routed through VolumeProviderCompat
  • Volume changes are sent to the remote session via Jellyfin API
  • System volume UI shows the remote session's volume level

Remote Volume Flow:

sequenceDiagram
    participant User
    participant VolumeButton as Hardware Volume Button
    participant VolumeProvider as VolumeProviderCompat
    participant JNI as nativeOnRemoteVolumeChange
    participant PlaybackMode as PlaybackModeManager
    participant Jellyfin as Jellyfin Server
    participant RemoteSession as Remote Session (TV/Browser)

    User->>VolumeButton: Press Volume Up
    VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
    VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
    VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
    VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
    JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
    PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
    Jellyfin->>RemoteSession: Set volume to new level
    RemoteSession-->>User: Volume changes on TV/Browser

Key Implementation Details:

Enabling Remote Volume (enableRemoteVolume()):

fun enableRemoteVolume(initialVolume: Int) {
    // Create VolumeProviderCompat with callbacks
    volumeProvider = object : VolumeProviderCompat(
        VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
        100,  // Max volume
        initialVolume
    ) {
        override fun onSetVolumeTo(volume: Int) {
            remoteVolumeLevel = volume.coerceIn(0, 100)
            nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
        }

        override fun onAdjustVolume(direction: Int) {
            when (direction) {
                AudioManager.ADJUST_RAISE -> {
                    remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
                    nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
                    currentVolume = remoteVolumeLevel  // Update slider
                }
                AudioManager.ADJUST_LOWER -> {
                    remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
                    nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
                    currentVolume = remoteVolumeLevel
                }
            }
        }
    }

    // Route volume buttons through the provider
    mediaSessionCompat.setPlaybackToRemote(volumeProvider)
}

Disabling Remote Volume (disableRemoteVolume()):

fun disableRemoteVolume() {
    // Return control to system media volume (ExoPlayer)
    mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
    volumeProvider = null
}

Rust Integration (src-tauri/src/player/android/mod.rs):

/// Enable remote volume control on Android
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
    // Ensure JellyTauPlaybackService is running
    start_playback_service()?;

    // Call enableRemoteVolume() via JNI using ClassLoader
    // (ClassLoader required because we're on a native thread)
    let service_instance = get_playback_service_instance()?;
    env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
        &[JValue::Int(initial_volume)])?;

    Ok(())
}

Dependencies (build.gradle.kts):

dependencies {
    implementation("androidx.media3:media3-session:1.5.1")  // Media3 MediaSession
    implementation("androidx.media:media:1.7.0")            // MediaSessionCompat
}

Integration with Playback Mode:

Remote volume is automatically enabled/disabled during playback mode transfers:

// In PlaybackModeManager::transfer_to_remote()
#[cfg(target_os = "android")]
{
    if let Err(e) = crate::player::enable_remote_volume(50) {
        log::warn!("Failed to enable remote volume: {}", e);
    }
}

// In PlaybackModeManager::transfer_to_local()
#[cfg(target_os = "android")]
{
    if let Err(e) = crate::player::disable_remote_volume() {
        log::warn!("Failed to disable remote volume: {}", e);
    }
}

7.4 Backend Initialization

Location: src-tauri/src/lib.rs

Backend selection is platform-specific:

fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
    let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));

    #[cfg(target_os = "linux")]
    {
        match MpvBackend::new(event_emitter.clone()) {
            Ok(backend) => return Box::new(backend),
            Err(e) => eprintln!("MPV init failed: {}", e),
        }
    }

    #[cfg(target_os = "android")]
    {
        // ExoPlayer requires Activity context, initialized separately
    }

    // Fallback
    Box::new(NullBackend::new())
}

8. Download Manager & Offline Architecture

8.1 Download Manager 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

8.2 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

8.3 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"]

8.4 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

8.5 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

8.6 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 {
    // Check if downloaded locally
    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, /* ... */ }
}

8.7 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
  }
});

8.8 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

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

9. Connectivity & Network Architecture

9.1 HTTP Client with Retry Logic

Location: src-tauri/src/jellyfin/http_client.rs

The HTTP client provides automatic retry with exponential backoff for network resilience:

pub struct HttpClient {
    client: reqwest::Client,
    config: HttpConfig,
}

pub struct HttpConfig {
    pub base_url: String,
    pub timeout: Duration,        // Default: 10s
    pub max_retries: u32,         // Default: 3
}

Retry Strategy:

  • Retry delays: 1s, 2s, 4s (exponential backoff)
  • Retries on: Network errors, 5xx server errors
  • No retry on: 4xx client errors, 401/403 authentication errors

Error Classification:

pub enum ErrorKind {
    Network,        // Connection failures, timeouts, DNS errors
    Authentication, // 401/403 responses
    Server,         // 5xx server errors
    Client,         // Other 4xx errors
}

9.2 Connectivity Monitor

Location: src-tauri/src/connectivity/mod.rs

The connectivity monitor tracks server reachability with adaptive polling:

flowchart TB
    Monitor["ConnectivityMonitor"] --> Poller["Background Task"]
    Poller --> Check{"Server<br/>Reachable?"}
    Check -->|"Yes"| Online["30s Interval"]
    Check -->|"No"| Offline["5s Interval"]
    Online --> Emit["Emit Events"]
    Offline --> Emit
    Emit --> Frontend["Frontend Store"]

Features:

  • Adaptive Polling: 30s when online, 5s when offline (for quick reconnection detection)
  • Event Emission: Emits connectivity:changed and connectivity:reconnected events
  • Manual Marking: Can mark reachable/unreachable based on API call results
  • Thread-Safe: Uses Arc<RwLock<>> for shared state

Tauri Commands:

Command Description
connectivity_check_server Manual reachability check
connectivity_set_server_url Update monitored server URL
connectivity_get_status Get current connectivity status
connectivity_start_monitoring Start background monitoring
connectivity_stop_monitoring Stop monitoring
connectivity_mark_reachable Mark server as reachable (after successful API call)
connectivity_mark_unreachable Mark server as unreachable (after failed API call)

Frontend Integration:

// TypeScript store listens to Rust events
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
  updateConnectivityState(event.payload.isReachable);
});

9.3 Network Resilience Architecture

The connectivity system provides resilience through multiple layers:

  1. HTTP Client Layer: Automatic retry with exponential backoff
  2. Connectivity Monitoring: Background reachability checks
  3. Frontend Integration: Offline mode detection and UI updates
  4. Sync Queue: Offline mutations queued for later (see Section 8)

Design Principles:

  • Fail Fast: Don't retry 4xx errors (client errors, authentication)
  • Fail Slow: Retry network and 5xx errors with increasing delays
  • Adaptive Polling: Reduce polling frequency when online, increase when offline
  • Event-Driven: Frontend reacts to connectivity changes via events

10. Offline Database Design

12.1 Entity Relationship Diagram

erDiagram
    servers ||--o{ users : "has"
    servers ||--o{ libraries : "has"
    libraries ||--o{ items : "contains"
    items ||--o{ items : "parent_of"
    items ||--o{ user_data : "has"
    items ||--o{ downloads : "has"
    items ||--o{ media_streams : "has"
    items ||--o{ thumbnails : "has"
    users ||--o{ user_data : "owns"
    users ||--o{ downloads : "owns"
    users ||--o{ sync_queue : "owns"

    servers {
        int id PK
        string jellyfin_id UK
        string name
        string url
        string version
        datetime last_sync
    }

    users {
        int id PK
        string jellyfin_id
        int server_id FK
        string name
        boolean is_active
    }

    libraries {
        int id PK
        string jellyfin_id
        int server_id FK
        string name
        string collection_type
        string image_tag
    }

    items {
        int id PK
        string jellyfin_id
        int server_id FK
        int library_id FK
        int parent_id FK
        string type
        string name
        string sort_name
        string overview
        int production_year
        float community_rating
        string official_rating
        int runtime_ticks
        string primary_image_tag
        string backdrop_image_tag
        string album_id
        string album_name
        string album_artist
        json artists
        json genres
        int index_number
        int parent_index_number
        string premiere_date
        json metadata_json
        datetime created_at
        datetime updated_at
        datetime last_sync
    }

    user_data {
        int id PK
        int item_id FK
        int user_id FK
        int position_ticks
        int play_count
        boolean is_favorite
        boolean played
        datetime last_played
        datetime updated_at
        datetime synced_at
    }

    downloads {
        int id PK
        int item_id FK
        int user_id FK
        string file_path
        int file_size
        string status
        float progress
        int priority
        string error_message
        datetime created_at
        datetime completed_at
    }

    media_streams {
        int id PK
        int item_id FK
        int stream_index
        string type
        string codec
        string language
        string display_title
        boolean is_default
        boolean is_forced
        boolean is_external
    }

    sync_queue {
        int id PK
        int user_id FK
        string operation
        string entity_type
        string entity_id
        json payload
        datetime created_at
        int attempts
        datetime last_attempt
        string status
    }

    thumbnails {
        int id PK
        int item_id FK
        string image_type
        string image_tag
        string file_path
        int width
        int height
        datetime cached_at
    }

12.2 Table Definitions

servers

Stores connected Jellyfin server information.

CREATE TABLE servers (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    jellyfin_id     TEXT NOT NULL UNIQUE,      -- Server's unique ID from Jellyfin
    name            TEXT NOT NULL,              -- Display name
    url             TEXT NOT NULL,              -- Base URL
    version         TEXT,                       -- Jellyfin version
    last_sync       DATETIME,                   -- Last full metadata sync
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP
);

users

Stores user accounts per server. Access tokens are stored separately in secure storage (see Section 10).

CREATE TABLE users (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    jellyfin_id     TEXT NOT NULL,              -- User ID from Jellyfin
    server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
    name            TEXT NOT NULL,
    is_active       BOOLEAN DEFAULT 0,          -- Currently logged in user
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(jellyfin_id, server_id)
);

libraries

Stores library/collection metadata.

CREATE TABLE libraries (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    jellyfin_id     TEXT NOT NULL,
    server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
    name            TEXT NOT NULL,
    collection_type TEXT,                       -- 'movies', 'tvshows', 'music', etc.
    image_tag       TEXT,
    sort_order      INTEGER DEFAULT 0,
    last_sync       DATETIME,
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(jellyfin_id, server_id)
);

CREATE INDEX idx_libraries_server ON libraries(server_id);

items

Main table for all media items (movies, episodes, albums, songs, etc.).

CREATE TABLE items (
    id                   INTEGER PRIMARY KEY AUTOINCREMENT,
    jellyfin_id          TEXT NOT NULL,
    server_id            INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
    library_id           INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
    parent_id            INTEGER REFERENCES items(id) ON DELETE CASCADE,

    -- Basic metadata
    type                 TEXT NOT NULL,          -- 'Movie', 'Episode', 'Audio', 'MusicAlbum', etc.
    name                 TEXT NOT NULL,
    sort_name            TEXT,
    overview             TEXT,

    -- Media info
    production_year      INTEGER,
    community_rating     REAL,
    official_rating      TEXT,                   -- 'PG-13', 'R', etc.
    runtime_ticks        INTEGER,                -- Duration in ticks (10M ticks = 1 second)

    -- Images
    primary_image_tag    TEXT,
    backdrop_image_tag   TEXT,

    -- Audio-specific
    album_id             TEXT,                   -- Jellyfin album ID
    album_name           TEXT,
    album_artist         TEXT,
    artists              TEXT,                   -- JSON array

    -- Series/Season-specific
    index_number         INTEGER,                -- Episode/track number
    parent_index_number  INTEGER,                -- Season number
    series_id            TEXT,
    series_name          TEXT,
    season_id            TEXT,

    -- Additional
    genres               TEXT,                   -- JSON array
    premiere_date        TEXT,
    metadata_json        TEXT,                   -- Full metadata blob for fields not indexed

    -- Sync tracking
    created_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
    last_sync            DATETIME,

    UNIQUE(jellyfin_id, server_id)
);

-- Performance indexes
CREATE INDEX idx_items_server ON items(server_id);
CREATE INDEX idx_items_library ON items(library_id);
CREATE INDEX idx_items_parent ON items(parent_id);
CREATE INDEX idx_items_type ON items(type);
CREATE INDEX idx_items_album ON items(album_id);
CREATE INDEX idx_items_series ON items(series_id);
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);

-- Full-text search
CREATE VIRTUAL TABLE items_fts USING fts5(
    name,
    overview,
    artists,
    album_name,
    album_artist,
    content='items',
    content_rowid='id'
);

-- Triggers to keep FTS in sync
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
    INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
    VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
END;

CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
    INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
    VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
END;

CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
    INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
    VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
    INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
    VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
END;

media_streams

Stores subtitle and audio track information for items.

CREATE TABLE media_streams (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    stream_index    INTEGER NOT NULL,           -- Index in the media file
    type            TEXT NOT NULL,              -- 'Audio', 'Video', 'Subtitle'
    codec           TEXT,
    language        TEXT,
    display_title   TEXT,
    is_default      BOOLEAN DEFAULT 0,
    is_forced       BOOLEAN DEFAULT 0,
    is_external     BOOLEAN DEFAULT 0,          -- External subtitle file
    path            TEXT,                       -- Path for external subs
    UNIQUE(item_id, stream_index)
);

CREATE INDEX idx_media_streams_item ON media_streams(item_id);

user_data

Stores per-user data for items (favorites, progress, play count).

CREATE TABLE user_data (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- Playback state
    position_ticks  INTEGER DEFAULT 0,          -- Resume position
    play_count      INTEGER DEFAULT 0,
    played          BOOLEAN DEFAULT 0,          -- Marked as played
    last_played     DATETIME,

    -- User preferences
    is_favorite     BOOLEAN DEFAULT 0,
    user_rating     REAL,                       -- User's personal rating

    -- Sync tracking
    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    synced_at       DATETIME,                   -- Last sync with server
    needs_sync      BOOLEAN DEFAULT 0,          -- Dirty flag for sync

    UNIQUE(item_id, user_id)
);

CREATE INDEX idx_user_data_item ON user_data(item_id);
CREATE INDEX idx_user_data_user ON user_data(user_id);
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
    WHERE position_ticks > 0 AND played = 0;

downloads

Tracks downloaded media files.

CREATE TABLE downloads (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- File info
    file_path       TEXT,                       -- Local file path
    file_size       INTEGER,                    -- Size in bytes
    file_hash       TEXT,                       -- For integrity verification

    -- Download state
    status          TEXT NOT NULL DEFAULT 'pending',  -- pending, downloading, paused, completed, failed
    progress        REAL DEFAULT 0,             -- 0.0 to 1.0
    bytes_downloaded INTEGER DEFAULT 0,

    -- Transcode settings (if applicable)
    transcode_profile TEXT,                     -- JSON: bitrate, codec, etc.

    -- Queue management
    priority        INTEGER DEFAULT 0,          -- Higher = download first
    error_message   TEXT,
    retry_count     INTEGER DEFAULT 0,

    -- Timestamps
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    started_at      DATETIME,
    completed_at    DATETIME,
    expires_at      DATETIME,                   -- Optional expiration

    UNIQUE(item_id, user_id)
);

CREATE INDEX idx_downloads_status ON downloads(status);
CREATE INDEX idx_downloads_user ON downloads(user_id);
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
    WHERE status IN ('pending', 'downloading');

sync_queue

Stores mutations to sync back to server when online.

CREATE TABLE sync_queue (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    -- Operation details
    operation       TEXT NOT NULL,              -- 'playback_progress', 'playback_start',
                                                -- 'playback_stop', 'favorite', 'unfavorite',
                                                -- 'mark_played', 'mark_unplayed'
    entity_type     TEXT NOT NULL,              -- 'item', 'playlist', etc.
    entity_id       TEXT NOT NULL,              -- Jellyfin ID of the entity
    payload         TEXT,                       -- JSON payload for the operation

    -- Sync state
    status          TEXT DEFAULT 'pending',     -- pending, in_progress, completed, failed
    attempts        INTEGER DEFAULT 0,
    max_attempts    INTEGER DEFAULT 5,
    last_attempt    DATETIME,
    error_message   TEXT,

    -- Timestamps
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    completed_at    DATETIME
);

CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
    WHERE status = 'pending';
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);

thumbnails

Caches downloaded artwork.

CREATE TABLE thumbnails (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    image_type      TEXT NOT NULL,              -- 'Primary', 'Backdrop', 'Banner', etc.
    image_tag       TEXT,                       -- For cache invalidation
    file_path       TEXT NOT NULL,
    width           INTEGER,
    height          INTEGER,
    file_size       INTEGER,
    cached_at       DATETIME DEFAULT CURRENT_TIMESTAMP,
    last_accessed   DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(item_id, image_type, width)
);

CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);

playlists (for local/synced playlists)

CREATE TABLE playlists (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    jellyfin_id     TEXT,                       -- NULL for local-only playlists
    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name            TEXT NOT NULL,
    description     TEXT,
    is_local_only   BOOLEAN DEFAULT 0,          -- Not synced to server
    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
    synced_at       DATETIME,
    needs_sync      BOOLEAN DEFAULT 0
);

CREATE TABLE playlist_items (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    playlist_id     INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    sort_order      INTEGER NOT NULL,
    added_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(playlist_id, item_id)
);

CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);

12.3 Key Queries

Get items for offline library browsing

-- Get all albums in a music library
SELECT * FROM items
WHERE library_id = ? AND type = 'MusicAlbum'
ORDER BY sort_name;

-- Get tracks for an album
SELECT * FROM items
WHERE album_id = ? AND type = 'Audio'
ORDER BY parent_index_number, index_number;

Resume / Continue Watching

-- Get in-progress items for a user
SELECT i.*, ud.position_ticks, ud.last_played
FROM items i
JOIN user_data ud ON ud.item_id = i.id
WHERE ud.user_id = ?
  AND ud.position_ticks > 0
  AND ud.played = 0
ORDER BY ud.last_played DESC
LIMIT 20;
-- Full-text search
SELECT i.* FROM items i
JOIN items_fts fts ON fts.rowid = i.id
WHERE items_fts MATCH ?
ORDER BY rank;

Download queue management

-- Get next item to download
SELECT d.*, i.name, i.type
FROM downloads d
JOIN items i ON i.id = d.item_id
WHERE d.status = 'pending'
ORDER BY d.priority DESC, d.created_at ASC
LIMIT 1;

-- Get download progress for UI
SELECT
    d.status,
    COUNT(*) as count,
    SUM(d.file_size) as total_size,
    SUM(d.bytes_downloaded) as downloaded
FROM downloads d
WHERE d.user_id = ?
GROUP BY d.status;

Sync queue processing

-- Get pending sync operations (oldest first)
SELECT * FROM sync_queue
WHERE status = 'pending'
  AND attempts < max_attempts
ORDER BY created_at ASC
LIMIT 10;

-- Mark operation complete
UPDATE sync_queue
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
WHERE id = ?;

12.4 Data Flow Diagram

Online Mode

flowchart TB
    subgraph OnlineMode["Online Mode"]
        JellyfinServer["Jellyfin Server"]
        OnlineRepo["OnlineRepo"]
        SQLite["SQLite"]
        HybridRepo["HybridRepository"]
        UI["UI / Stores"]

        JellyfinServer -->|"API Response"| OnlineRepo
        OnlineRepo -->|"Cache"| SQLite
        SQLite -->|"Sync"| JellyfinServer
        OnlineRepo -->|"Response"| HybridRepo
        SQLite -->|"Fallback"| HybridRepo
        HybridRepo --> UI
    end

Offline Mode

flowchart TB
    subgraph OfflineMode["Offline Mode"]
        OfflineRepo["OfflineRepo"]
        SQLite2["SQLite"]
        SyncQueue["sync_queue<br/>(Queued for later)"]
        HybridRepo2["HybridRepository"]
        UI2["UI / Stores"]

        OfflineRepo <-->|"Query"| SQLite2
        SQLite2 -->|"Mutations"| SyncQueue
        OfflineRepo --> HybridRepo2
        HybridRepo2 --> UI2
    end

Sync on Reconnect

flowchart LR
    NetworkRestored["Network restored"]
    SyncService["SyncService"]
    SyncQueue2["sync_queue"]
    JellyfinAPI["Jellyfin API"]
    MarkSynced["Mark synced"]

    NetworkRestored --> SyncService
    SyncService -->|"Read"| SyncQueue2
    SyncQueue2 -->|"Send"| JellyfinAPI
    JellyfinAPI -->|"Success"| MarkSynced
    MarkSynced --> SyncService

12.5 Storage Estimates

Content Type Metadata Size Thumbnail Size Media Size
Song ~2 KB ~50 KB (300px) 5-15 MB
Album (12 tracks) ~30 KB ~100 KB 60-180 MB
Movie ~5 KB ~200 KB 1-8 GB
Episode ~3 KB ~100 KB 300 MB - 2 GB
Full music library (5000 songs) ~10 MB ~250 MB 25-75 GB

12.6 Rust Module Structure

src-tauri/src/storage/
├── mod.rs              # Module exports, Database struct
├── schema.rs           # Table definitions, migrations
├── models.rs           # Rust structs matching tables
├── queries/
│   ├── mod.rs
│   ├── items.rs        # Item CRUD operations
│   ├── user_data.rs    # User data operations
│   ├── downloads.rs    # Download queue operations
│   └── sync.rs         # Sync queue operations
└── sync/
    ├── mod.rs          # SyncService
    ├── manager.rs      # Background sync manager
    └── operations.rs   # Individual sync operation handlers

11. File Structure Summary

src-tauri/src/
├── lib.rs                    # Tauri app setup, state initialization
├── commands/                 # Tauri command handlers (80+ commands)
│   ├── mod.rs               # Command exports
│   ├── player.rs            # 16 player commands
│   ├── repository.rs        # 27 repository commands (NEW)
│   ├── playback_mode.rs     # 5 playback mode commands (NEW)
│   ├── connectivity.rs      # 7 connectivity commands (NEW)
│   ├── storage.rs           # Storage & database commands
│   ├── download.rs          # 7 download commands
│   ├── offline.rs           # 3 offline commands
│   └── sync.rs              # Sync queue commands
├── repository/              # Repository pattern implementation (NEW)
│   ├── mod.rs               # MediaRepository trait, handle management
│   ├── types.rs             # RepoError, Library, MediaItem, etc.
│   ├── hybrid.rs            # HybridRepository with cache-first racing
│   ├── online.rs            # OnlineRepository (HTTP API)
│   └── offline.rs           # OfflineRepository (SQLite queries)
├── playback_mode/           # Playback mode manager (NEW)
│   └── mod.rs               # PlaybackMode enum, transfer logic
├── connectivity/            # Connectivity monitoring (NEW)
│   └── mod.rs               # ConnectivityMonitor, adaptive polling
├── jellyfin/                # Jellyfin API client (NEW)
│   ├── mod.rs               # Module exports
│   ├── http_client.rs       # HTTP client with retry logic
│   └── client.rs            # JellyfinClient for API calls
├── storage/                 # Database layer
│   ├── mod.rs               # Database struct, migrations
│   ├── db_service.rs        # DatabaseService trait (async wrapper) (NEW)
│   ├── schema.rs            # Table definitions
│   └── queries/             # Query modules
├── download/                # Download manager module
│   ├── mod.rs               # DownloadManager, DownloadInfo, DownloadTask
│   ├── worker.rs            # DownloadWorker, HTTP streaming, retry logic
│   ├── events.rs            # DownloadEvent enum
│   └── cache.rs             # SmartCache, CacheConfig, LRU eviction
└── player/                  # Player subsystem
    ├── mod.rs               # PlayerController
    ├── session.rs           # MediaSessionManager, MediaSessionType (NEW)
    ├── state.rs             # PlayerState, PlayerEvent
    ├── media.rs             # MediaItem, MediaSource, MediaType
    ├── queue.rs             # QueueManager, RepeatMode
    ├── backend.rs           # PlayerBackend trait, NullBackend
    ├── events.rs            # PlayerStatusEvent, TauriEventEmitter
    ├── mpv/                 # Linux MPV backend
    │   ├── mod.rs           # MpvBackend implementation
    │   └── event_loop.rs    # Dedicated thread for MPV operations
    └── android/             # Android ExoPlayer backend
        └── mod.rs           # ExoPlayerBackend + JNI bindings

src-tauri/gen/android/app/src/main/java/com/dtourolle/jellytau/
└── player/
    └── JellyTauPlayer.kt    # Kotlin ExoPlayer wrapper

src/lib/
├── api/                     # Thin API layer (~200 lines total)
│   ├── types.ts             # TypeScript type definitions
│   ├── repository-client.ts # RepositoryClient wrapper (NEW - ~100 lines)
│   ├── client.ts            # JellyfinClient (helper for streaming)
│   └── sessions.ts          # SessionsApi (remote session control)
├── services/
│   ├── playerEvents.ts      # Tauri event listener for player events
│   └── playbackReporting.ts # Thin wrapper (was 220 lines, now ~50)
├── stores/                  # Thin reactive wrappers over Rust commands
│   ├── index.ts             # Re-exports
│   ├── auth.ts              # Auth store (calls Rust commands)
│   ├── player.ts            # Player store
│   ├── queue.ts             # Queue store
│   ├── library.ts           # Library store
│   ├── playbackMode.ts      # Playback mode store (NEW - ~150 lines)
│   ├── connectivity.ts      # Connectivity store (~250 lines, simplified)
│   └── downloads.ts         # Downloads store with event listeners
└── components/
    ├── Search.svelte
    ├── player/              # Player UI components
    ├── sessions/            # Remote session control UI
    │   ├── CastButton.svelte
    │   ├── SessionPickerModal.svelte
    │   └── RemoteControls.svelte
    ├── downloads/           # Download UI components
    │   └── DownloadItem.svelte
    └── library/             # Library UI components
        ├── DownloadButton.svelte
        └── TrackList.svelte

Key Architecture Changes

What moved to Rust (~3,500 lines of business logic):

  1. HTTP Client (338 lines) - Retry logic with exponential backoff
  2. Connectivity Monitor (301 lines) - Adaptive polling, event emission
  3. Repository Pattern (1061 lines) - Cache-first hybrid with parallel racing
  4. Database Service - Async wrapper preventing UI freezing
  5. Playback Mode (303 lines) - Local/remote transfer coordination
  6. 🔄 Auth & Session (in progress) - Session management, verification

TypeScript Layer (now ~800 lines, down from ~3,300):

  • Svelte stores (reactive wrappers)
  • Type definitions
  • UI event handling
  • Tauri command invocation
  • Event listeners for Rust events

Total Commands: 80+ Tauri commands across 8 command modules


12. Security

12.1 Authentication Token Storage

Access tokens are not stored in the SQLite database. Instead, they are stored using platform-native secure storage:

flowchart TB
    LoginSuccess["Login Success"]
    KeyringCheck{"System Keyring<br/>Available?"}
    OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
    EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]

    LoginSuccess --> KeyringCheck
    KeyringCheck -->|"Yes"| OSCredential
    KeyringCheck -->|"No"| EncryptedFallback

Key Format:

jellytau::{server_id}::{user_id}::access_token

Rationale:

  • Tokens in SQLite would be readable if the database file is accessed
  • System keyrings provide OS-level encryption and access control
  • Fallback ensures functionality on minimal systems without a keyring daemon

12.2 Secure Storage Module

Location: src-tauri/src/secure_storage/ (planned)

pub trait SecureStorage: Send + Sync {
    fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
    fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
    fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
}

// Platform implementations
pub struct KeyringStorage;      // Uses keyring crate
pub struct EncryptedFileStorage; // AES-256-GCM fallback

12.3 Network Security

Aspect Implementation
Transport HTTPS required for all Jellyfin API calls
Certificate Validation System CA store (configurable for self-signed)
Token Transmission Bearer token in Authorization header only
Token Refresh Handled by Jellyfin server (long-lived tokens)

12.4 Local Data Protection

Data Type Protection
Access Tokens System keyring or encrypted file
Database (SQLite) Plaintext (metadata only, no secrets)
Downloaded Media Filesystem permissions only
Cached Thumbnails Filesystem permissions only

12.5 Security Considerations

  1. No Secrets in SQLite: The database contains only non-sensitive metadata
  2. Token Isolation: Each user/server combination has a separate token entry
  3. Logout Cleanup: Token deletion from secure storage on logout
  4. No Token Logging: Tokens are never written to logs or debug output
  5. IPC Security: Tauri's IPC uses structured commands, not arbitrary code execution