jellytau/SoftwareArchitecture.md

3358 lines
108 KiB
Markdown

# 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-18
**Architecture Status:** Phase 2-3 of TypeScript to Rust migration complete
## Major Architectural Changes (Recent)
JellyTau has undergone a significant architectural transformation, migrating ~3,500 lines of business logic from TypeScript to Rust:
### ✅ Completed Migrations
1. **HTTP Client & Connectivity** (Phase 1)
- Exponential backoff retry logic moved to Rust
- Adaptive connectivity monitoring (30s online, 5s offline)
- Event-driven architecture for network state changes
2. **Repository Pattern** (Phase 2)
- Complete MediaRepository trait implementation in Rust
- Cache-first parallel racing (100ms cache timeout)
- Handle-based resource management (UUID handles)
- 27 new Tauri commands for data access
- Eliminated 1,061 lines of TypeScript
3. **Database Service Abstraction** (Phase 2.5)
- Async wrapper over synchronous rusqlite
- All DB operations use `spawn_blocking` to prevent UI freezing
- ~1-2ms overhead per query (acceptable tradeoff)
4. **Playback Mode Management** (Phase 3)
- Local/Remote/Idle mode tracking
- Seamless queue transfer to remote Jellyfin sessions
- Position synchronization during transfers
### 🔄 In Progress
- **Authentication & Session Management** (Phase 4)
- Session restoration and credential management
- Re-authentication flow
### 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
```mermaid
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.
```mermaid
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:**
```rust
#[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):
```mermaid
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:**
```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 },
}
```
**Event Enum:**
```rust
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.):
```mermaid
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:**
```rust
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`
```rust
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`
```rust
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:**
```mermaid
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.
```mermaid
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
4. Future: Sync queue will retry failed syncs when online
**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`
```rust
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:
```rust
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/`
```mermaid
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:**
```
/library/music # Landing page with category cards
├── /tracks # All songs (ALWAYS list view)
├── /artists # Artist grid (ALWAYS grid view)
├── /albums # Album grid (ALWAYS grid view)
├── /playlists # Playlist grid (ALWAYS grid view)
└── /genres # Genre browser (two-level)
```
**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:**
```svelte
<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
```mermaid
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)
- Foreign key constraint on `item_id` was removed in migration 003 to allow tracking progress for items not yet synced to local database
- This enables playback tracking 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](src/routes/player/[id]/+page.svelte:54-167)
### 3.4 Repository Architecture (Rust-Based)
**Location**: `src-tauri/src/repository/`
```mermaid
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):
```rust
// 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:
```rust
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)`):
```mermaid
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:
```rust
#[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:**
```rust
// 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
```rust
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
```rust
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
```
src/routes/
├── +page.svelte # Login page
├── library/
│ ├── +layout.svelte # Main layout with MiniPlayer
│ ├── +page.svelte # Library browser (library selector)
│ ├── [id]/+page.svelte # Album/series detail
│ └── music/ # Music library structure
│ ├── +page.svelte # Music category landing page
│ ├── tracks/+page.svelte # All tracks (list view only)
│ ├── artists/+page.svelte # Artists grid
│ ├── albums/+page.svelte # Albums grid
│ ├── playlists/+page.svelte # Playlists grid
│ └── genres/+page.svelte # Genre browser
├── downloads/
│ └── +page.svelte # Manage downloads (Active/Completed tabs)
├── settings/
│ └── +page.svelte # Settings (includes download settings)
└── player/
└── [id]/+page.svelte # Full player page
src/lib/components/
├── Search.svelte
├── player/
│ ├── AudioPlayer.svelte # Full screen audio player
│ ├── MiniPlayer.svelte # Bottom bar mini player (auto-hides for video, includes cast button)
│ ├── Controls.svelte # Play/pause/skip controls
│ └── Queue.svelte # Queue list view
├── sessions/
│ ├── CastButton.svelte # Cast button with session picker (integrated in MiniPlayer)
│ ├── SessionPickerModal.svelte # Modal for selecting remote session
│ ├── SessionCard.svelte # Individual session display card
│ ├── SessionsList.svelte # List of all controllable sessions
│ └── RemoteControls.svelte # Full remote playback control UI
├── downloads/
│ └── DownloadItem.svelte # Download list item with progress/actions
├── FavoriteButton.svelte # Reusable heart/like button
└── library/
├── LibraryGrid.svelte # Grid of media items (supports forceGrid)
├── LibraryListView.svelte # List view for albums/artists
├── TrackList.svelte # Dedicated track list (now with showDownload prop)
├── DownloadButton.svelte # Download button with progress ring
├── MediaCard.svelte # Individual item card
└── AlbumView.svelte # Album detail with tracks
```
---
## 4. Data Flow
### 4.1 Repository Query Flow (Cache-First)
```mermaid
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
Note over Hybrid: TODO: Update cache in background
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
```mermaid
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
```mermaid
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
```mermaid
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
```mermaid
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:**
```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:**
```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
// 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
// TypeScript
interface MediaItem {
id: string;
title: string;
artist?: string;
// ...
}
```
---
## 6. Thread Safety
### 6.1 Rust Backend
```rust
// 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:
```typescript
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:
```rust
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:
```mermaid
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.
```mermaid
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:**
```rust
// 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.
```mermaid
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`):
```kotlin
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):
```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:
```mermaid
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:**
```mermaid
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()`):
```kotlin
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()`):
```kotlin
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`):
```rust
/// 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](src-tauri/android/build.gradle.kts)):
```kotlin
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:
```rust
// 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:
```rust
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.
```mermaid
flowchart TB
subgraph Frontend["Frontend"]
DownloadButton["DownloadButton.svelte"]
DownloadsPage["/downloads"]
DownloadsStore["downloads.ts store"]
end
subgraph Backend["Rust Backend"]
Commands["Download Commands"]
DownloadManager["DownloadManager"]
DownloadWorker["DownloadWorker"]
SmartCache["SmartCache Engine"]
end
subgraph Storage["Storage"]
SQLite[("SQLite DB")]
MediaFiles[("Downloaded Files")]
end
DownloadButton -->|"invoke('download_item')"| Commands
DownloadsPage -->|"invoke('get_downloads')"| Commands
Commands --> DownloadManager
DownloadManager --> DownloadWorker
DownloadManager --> SmartCache
DownloadWorker -->|"HTTP Stream"| MediaFiles
DownloadWorker -->|"Events"| DownloadsStore
Commands <--> SQLite
SmartCache <--> SQLite
```
### 8.2 Download Worker
**Location**: `src-tauri/src/download/worker.rs`
The download worker handles HTTP streaming with retry logic and resume support:
```rust
pub struct DownloadWorker {
client: reqwest::Client,
max_retries: u32,
}
pub struct DownloadTask {
pub id: i64,
pub item_id: String,
pub user_id: String,
pub priority: i32,
pub url: String,
pub target_path: PathBuf,
pub mime_type: Option<String>,
pub expected_size: Option<i64>,
}
```
**Retry Strategy**:
- Exponential backoff: 5s, 15s, 45s
- Maximum 3 retry attempts
- HTTP Range requests for resume support
- Progress events emitted every 1MB
**Download Flow**:
```mermaid
sequenceDiagram
participant UI
participant Command as download_item
participant DB as SQLite
participant Worker as DownloadWorker
participant Jellyfin as Jellyfin Server
participant Store as downloads store
UI->>Command: download_item(itemId, userId)
Command->>DB: INSERT INTO downloads
Command->>Worker: Start download task
Worker->>Jellyfin: GET /Items/{id}/Download
loop Progress Updates
Jellyfin->>Worker: Stream chunks
Worker->>Worker: Write to .part file
Worker->>Store: Emit progress event
Store->>UI: Update progress bar
end
Worker->>Worker: Rename .part to final
Worker->>DB: UPDATE status='completed'
Worker->>Store: Emit completed event
Store->>UI: Show completed
```
### 8.3 Smart Caching Engine
**Location**: `src-tauri/src/download/cache.rs`
The smart caching system provides predictive downloads based on listening patterns:
```rust
pub struct SmartCache {
config: Arc<Mutex<CacheConfig>>,
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
}
pub struct CacheConfig {
pub queue_precache_enabled: bool,
pub queue_precache_count: usize, // Default: 5
pub album_affinity_enabled: bool,
pub album_affinity_threshold: usize, // Default: 3
pub storage_limit: u64, // Default: 10GB
pub wifi_only: bool, // Default: true
}
```
**Caching Strategies**:
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
```mermaid
flowchart TB
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
Play --> TrackHistory["Track Play History"]
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
CacheAlbum --> CheckStorage
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
CheckStorage -->|"OK"| Download["Queue Download"]
```
### 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:
```rust
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`
```typescript
interface DownloadsState {
downloads: Record<number, DownloadInfo>;
activeCount: number;
queuedCount: number;
}
const downloads = createDownloadsStore();
// Actions
downloads.downloadItem(itemId, userId, filePath)
downloads.downloadAlbum(albumId, userId)
downloads.pause(downloadId)
downloads.resume(downloadId)
downloads.cancel(downloadId)
downloads.delete(downloadId)
downloads.refresh(userId, statusFilter)
// Derived stores
export const activeDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'downloading')
);
```
**Event Handling**:
The store listens to Tauri events for real-time updates:
```typescript
listen<DownloadEvent>('download-event', (event) => {
const payload = event.payload;
switch (payload.type) {
case 'started':
// Update status to 'downloading'
case 'progress':
// Update progress and bytes_downloaded
case 'completed':
// Update status to 'completed', progress to 1.0
case 'failed':
// Update status to 'failed', store error message
}
});
```
### 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** (enhanced in migration 004):
```sql
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
user_id TEXT NOT NULL,
file_path TEXT,
file_size INTEGER,
mime_type TEXT,
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
progress REAL DEFAULT 0.0,
bytes_downloaded INTEGER DEFAULT 0,
priority INTEGER DEFAULT 0,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT
);
CREATE INDEX idx_downloads_queue
ON downloads(status, priority DESC, queued_at ASC)
WHERE status IN ('pending', 'downloading');
```
---
## 9. TypeScript to Rust Migration Status
### 9.1 Migration Overview
JellyTau has undergone a phased migration from TypeScript to Rust, moving ~3,500 lines of business logic to Rust while simplifying the TypeScript layer to thin UI wrappers.
**Approach**: Incremental migration with direct replacement
- Complete one phase at a time with full testing
- Delete TypeScript implementations after Rust validation
- Each phase is independently deployable
- No parallel implementations maintained
### 9.2 Completed Phases
#### ✅ Phase 1: HTTP Client & Connectivity Foundation (Complete)
**Created:**
- `src-tauri/src/jellyfin/http_client.rs` (289 lines)
- HTTP client with exponential backoff retry (1s, 2s, 4s)
- Error classification (Network, Authentication, Server, Client)
- Automatic retry on network/5xx errors
- `src-tauri/src/connectivity/mod.rs` (351 lines)
- Background monitoring with adaptive polling (30s online, 5s offline)
- Event emission to frontend
- Manual reachability marking
**Simplified:**
- `src/lib/stores/connectivity.ts`: 301→249 lines (-17%)
- Removed polling logic
- Now listens to Rust events
- Thin wrapper over Rust commands
**Commands Added:** 7 connectivity commands
**Impact:** Eliminated TypeScript polling/retry logic, improved battery efficiency
#### ✅ Phase 2: Repository Pattern & Data Layer (Complete)
**Created:**
- `src-tauri/src/repository/` (complete module)
- `mod.rs`: MediaRepository trait + handle-based management
- `types.rs`: Type definitions (RepoError, Library, MediaItem, etc.)
- `hybrid.rs`: Cache-first parallel racing (100ms cache timeout)
- `online.rs`: OnlineRepository (HTTP API calls)
- `offline.rs`: OfflineRepository (SQLite queries with caching)
**Replaced:**
- Deleted `src/lib/api/repository.ts` (1061 lines)
- Created `src/lib/api/repository-client.ts` (~100 lines)
- Thin wrapper with handle-based resource management
- All methods delegate to Rust commands
**Commands Added:** 27 repository commands
**Key Features:**
- Handle-based resource management (UUID handles)
- Cache-first racing: parallel cache (100ms timeout) vs server queries
- Returns cache if meaningful content, else server result
- Supports multiple concurrent repository instances
#### ✅ Phase 2.5: Database Service Abstraction (Complete)
**Created:**
- `src-tauri/src/storage/db_service.rs`
- `DatabaseService` trait with async methods
- `RusqliteService` implementation using `spawn_blocking`
- Prevents blocking Tokio async runtime
**Impact:**
- Eliminated UI freezing from blocking database operations
- All DB queries now use `spawn_blocking` thread pool
- ~1-2ms overhead per query (acceptable tradeoff)
**Migration Status:**
- ✅ Phase 1: Foundation (Complete)
- ✅ Phase 2: OfflineRepository (18 methods migrated)
- 🔄 Phase 3: Command layer (97 operations across 5 files - in progress)
#### ✅ Phase 3: Playback Mode System (Complete)
**Created:**
- `src-tauri/src/playback_mode/mod.rs`
- `PlaybackMode` enum (Local, Remote, Idle)
- `PlaybackModeManager` for mode transfers
- Transfer queue between local device and remote sessions
**Simplified:**
- `src/lib/stores/playbackMode.ts`: 303→150 lines (-50%)
- Thin wrapper calling Rust commands
- Maintains UI state only
**Commands Added:** 5 playback mode commands
**Features:**
- Seamless transfer of playback queue to remote sessions
- Position synchronization during transfer
- Automatic local player stop when transferring to remote
### 9.3 In Progress
#### 🔄 Phase 4: Authentication & Session Management
**Status:** Partially complete
- Session restoration logic migrated
- Credential storage using secure keyring
- Re-authentication flow in progress
**Target:** Simplify `src/lib/stores/auth.ts` from 616→150 lines
### 9.4 Architecture Summary
**Before Migration:**
- TypeScript: ~3,300 lines of business logic
- Rust: ~4,000 lines (player, storage, downloads)
- Total Commands: 73
**After Migration (Current):**
- TypeScript: ~800 lines (thin wrappers, UI state)
- Rust: ~8,000 lines (business logic, HTTP, repository, etc.)
- Total Commands: 80+
**Lines Eliminated:** ~2,500 lines of TypeScript business logic
**Benefits:**
- Improved performance (zero-cost abstractions)
- Better reliability (type safety, memory safety)
- Reduced battery drain (efficient async I/O)
- Easier maintenance (centralized business logic)
- No UI freezing (async database operations)
## 10. Connectivity & Network Architecture
### 10.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:
```rust
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:**
```rust
pub enum ErrorKind {
Network, // Connection failures, timeouts, DNS errors
Authentication, // 401/403 responses
Server, // 5xx server errors
Client, // Other 4xx errors
}
```
### 10.2 Connectivity Monitor
**Location**: `src-tauri/src/connectivity/mod.rs`
The connectivity monitor tracks server reachability with adaptive polling:
```mermaid
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
// TypeScript store listens to Rust events
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
updateConnectivityState(event.payload.isReachable);
});
```
### 10.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
## 11. Architecture Extensions
### 11.1 Native Async Database (Future)
**Future Enhancement**: Migrate from `rusqlite` + `spawn_blocking` to native async database:
```rust
// Current: spawn_blocking overhead (~1-2ms per query)
tokio::task::spawn_blocking(move || {
let conn = connection.lock().unwrap();
conn.query_row(...)
}).await?
// Future: Native async with tokio-rusqlite (zero overhead)
use tokio_rusqlite::Connection;
let conn = Connection::open(path).await?;
conn.call(|conn| {
conn.query_row(...)
}).await?
```
**Benefits:**
- Eliminate spawn_blocking overhead
- Better integration with Tokio runtime
- Improved throughput for high-frequency queries
**Migration Path:**
- DatabaseService trait already abstracts implementation
- Swap RusqliteService for TokioRusqliteService
- No changes to command layer needed
### 11.2 Remote Session Control (Existing Feature - Documented)
Remote session control allows JellyTau to discover and control playback on other Jellyfin clients (TVs, web browsers, etc.). This enables cast-like functionality where mobile devices become remote controls.
#### 11.2.1 Architecture Overview
```mermaid
flowchart TB
subgraph JellyTauApp["JellyTau App"]
SessionsStore["SessionsStore<br/>- sessions[]<br/>- activeId"]
RemoteControl["RemoteControl<br/>- play/pause<br/>- seek<br/>- volume<br/>- playItem"]
SessionsStore -->|"Select"| RemoteControl
end
subgraph JellyfinServer["Jellyfin Server"]
GetSessions["GET /Sessions"]
ActiveSessions["Active Sessions<br/>- Jellyfin Web (Chrome)<br/>- Jellyfin Android TV<br/>- Jellyfin for Roku"]
GetSessions --> ActiveSessions
end
SessionsStore <-->|"Poll (5-10s)"| GetSessions
RemoteControl -->|"Commands"| ActiveSessions
```
#### 11.2.2 Jellyfin Sessions API
**Endpoints:**
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/Sessions` | List all active sessions |
| POST | `/Sessions/{id}/Playing` | Start playback of item(s) |
| POST | `/Sessions/{id}/Playing/PlayPause` | Toggle play/pause |
| POST | `/Sessions/{id}/Playing/Stop` | Stop playback |
| POST | `/Sessions/{id}/Playing/Seek` | Seek to position |
| POST | `/Sessions/{id}/Command/SetVolume` | Set volume (0-100) |
| POST | `/Sessions/{id}/Command/Mute` | Mute |
| POST | `/Sessions/{id}/Command/Unmute` | Unmute |
| POST | `/Sessions/{id}/Command/VolumeUp` | Volume up |
| POST | `/Sessions/{id}/Command/VolumeDown` | Volume down |
**Session Response Schema:**
```typescript
interface Session {
id: string;
userId: string;
userName: string;
client: string; // "Jellyfin Web", "Jellyfin Android TV", etc.
deviceName: string; // "Living Room TV", "Chrome - Windows"
deviceId: string;
applicationVersion: string;
isActive: boolean;
supportsMediaControl: boolean;
supportsRemoteControl: boolean;
playState: {
positionTicks: number;
canSeek: boolean;
isPaused: boolean;
isMuted: boolean;
volumeLevel: number; // 0-100
repeatMode: string;
} | null;
nowPlayingItem: MediaItem | null;
playableMediaTypes: string[]; // ["Audio", "Video"]
supportedCommands: string[]; // ["PlayPause", "Seek", "SetVolume", ...]
}
```
#### 11.2.3 API Layer
**Location**: `src/lib/api/sessions.ts`
```typescript
export class SessionsApi {
constructor(private client: JellyfinClient) {}
async getSessions(): Promise<Session[]> {
return this.client.get<Session[]>('/Sessions', {
params: { controllableByUserId: this.client.userId }
});
}
async playOnSession(sessionId: string, itemIds: string[], startIndex = 0): Promise<void> {
await this.client.post(`/Sessions/${sessionId}/Playing`, {
itemIds,
startIndex,
playCommand: 'PlayNow'
});
}
async sendCommand(sessionId: string, command: SessionCommand): Promise<void> {
await this.client.post(`/Sessions/${sessionId}/Playing/${command}`);
}
async setVolume(sessionId: string, volume: number): Promise<void> {
await this.client.post(`/Sessions/${sessionId}/Command/SetVolume`, {
Arguments: { Volume: Math.round(volume) }
});
}
async seek(sessionId: string, positionTicks: number): Promise<void> {
await this.client.post(`/Sessions/${sessionId}/Playing/Seek`, {
seekPositionTicks: positionTicks
});
}
}
type SessionCommand = 'PlayPause' | 'Stop' | 'Pause' | 'Unpause' |
'NextTrack' | 'PreviousTrack' | 'Mute' | 'Unmute';
```
#### 11.2.4 Sessions Store
**Location**: `src/lib/stores/sessions.ts`
```typescript
interface SessionsState {
sessions: Session[];
activeSessionId: string | null;
isPolling: boolean;
lastUpdated: Date | null;
}
function createSessionsStore() {
const { subscribe, update } = writable<SessionsState>({
sessions: [],
activeSessionId: null,
isPolling: false,
lastUpdated: null
});
let pollInterval: ReturnType<typeof setInterval> | null = null;
return {
subscribe,
startPolling(api: SessionsApi, intervalMs = 5000) {
this.stopPolling();
pollInterval = setInterval(() => this.refresh(api), intervalMs);
this.refresh(api); // Immediate first fetch
},
stopPolling() {
if (pollInterval) clearInterval(pollInterval);
},
async refresh(api: SessionsApi) {
const sessions = await api.getSessions();
update(s => ({
...s,
sessions: sessions.filter(s => s.supportsRemoteControl),
lastUpdated: new Date()
}));
},
selectSession(sessionId: string | null) {
update(s => ({ ...s, activeSessionId: sessionId }));
}
};
}
```
#### 11.2.5 Android MediaRouter Integration (IR-021)
On Android, when controlling a remote session's volume, JellyTau integrates with the system audio control panel via MediaRouter API:
```mermaid
flowchart TB
User["User presses volume button"]
VolumePanel["System Volume Panel<br/>appears showing<br/>remote session name"]
MediaRouter["MediaRouter.Callback<br/>onRouteVolumeChanged()"]
SessionsApi["SessionsApi.setVolume()<br/>POST /Sessions/{id}/<br/>Command/SetVolume"]
User --> VolumePanel
VolumePanel --> MediaRouter
MediaRouter --> SessionsApi
```
**Kotlin Implementation** (`JellyTauMediaRouterCallback.kt`):
```kotlin
class JellyTauMediaRouterCallback(
private val sessionsApi: SessionsApi
) : MediaRouter.Callback() {
private var selectedRoute: MediaRouter.RouteInfo? = null
override fun onRouteSelected(router: MediaRouter, route: RouteInfo) {
selectedRoute = route
// Update UI to show remote session controls
}
override fun onRouteVolumeChanged(router: MediaRouter, route: RouteInfo) {
selectedRoute?.let { selected ->
if (route == selected) {
val volume = route.volume
val sessionId = route.extras?.getString("sessionId")
sessionId?.let {
// Send volume to Jellyfin session
coroutineScope.launch {
sessionsApi.setVolume(it, volume)
}
}
}
}
}
}
```
**MediaRouteProvider** - Exposes Jellyfin sessions as cast-like routes:
```kotlin
class JellyfinMediaRouteProvider(context: Context) : MediaRouteProvider(context) {
fun updateSessionRoutes(sessions: List<Session>) {
val routes = sessions
.filter { it.supportsRemoteControl }
.map { session ->
MediaRouteDescriptor.Builder(session.id, session.deviceName)
.setDescription(session.client)
.setPlaybackType(MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE)
.setVolumeHandling(MediaRouter.RouteInfo.PLAYBACK_VOLUME_VARIABLE)
.setVolumeMax(100)
.setVolume(session.playState?.volumeLevel ?: 100)
.addControlFilter(IntentFilter(MediaControlIntent.ACTION_PLAY))
.setExtras(Bundle().apply {
putString("sessionId", session.id)
})
.build()
}
setDescriptor(MediaRouteProviderDescriptor.Builder()
.addRoutes(routes)
.build())
}
}
```
#### 11.2.6 UI Components
**CastButton** (`src/lib/components/sessions/CastButton.svelte`) - Cast button for MiniPlayer:
- Integrated into MiniPlayer component (visible on all screen sizes)
- Shows cast icon that changes when connected to remote session
- Displays badge with number of available sessions
- Auto-polls for sessions every 15 seconds
- Opens SessionPickerModal when clicked
- Visual indicators:
- Gray when disconnected, purple when connected
- Badge shows count of available devices
- Green dot indicator when actively casting
**SessionPickerModal** (`src/lib/components/sessions/SessionPickerModal.svelte`) - Modal for selecting cast device:
- Lists controllable sessions with device name and client type
- Shows currently playing item (if any) for each session
- Highlights currently selected/connected session
- Device type icons (TV, Web, Mobile, Generic)
- "Disconnect" option when already connected
- Empty state with refresh button
- Responsive: slides up on mobile, centered on desktop
**SessionCard** (`src/lib/components/sessions/SessionCard.svelte`) - Individual session display:
- Device name and client information
- Now playing preview with artwork
- Play/pause state indicator
- Position and volume display
**SessionsList** (`src/lib/components/sessions/SessionsList.svelte`) - List of all sessions:
- Filters to show only controllable sessions
- Refresh button for manual updates
- Loading and error states
- Empty state messaging
**RemoteControls** (`src/lib/components/sessions/RemoteControls.svelte`) - Full remote playback control:
- Uses polling data for play state (position, volume, etc.)
- Sends commands via SessionsApi
- Shows "Controlling: {deviceName}" header
- Full playback controls: play/pause, next/previous, stop
- Seek bar with position display (if supported)
- Volume slider
- Empty state when no media playing on remote session
### 11.3 MPRIS Integration (Linux - Future)
```rust
// Future: D-Bus media controls
pub struct MprisController {
connection: Connection,
player: Arc<Mutex<PlayerController>>,
}
impl MprisController {
fn register_handlers(&self) {
// Handle PlayPause, Next, Previous from system
}
}
```
---
## 12. Offline Database Design
### 12.1 Entity Relationship Diagram
```mermaid
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.
```sql
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).
```sql
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.
```sql
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.).
```sql
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.
```sql
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).
```sql
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.
```sql
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.
```sql
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.
```sql
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)
```sql
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
```sql
-- 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
```sql
-- 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;
```
#### Offline search
```sql
-- 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
```sql
-- 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
```sql
-- 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
```mermaid
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
```mermaid
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
```mermaid
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
```
---
## 13. 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
---
## 14. Security
### 14.1 Authentication Token Storage
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
```mermaid
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
### 14.2 Secure Storage Module
**Location**: `src-tauri/src/secure_storage/` (planned)
```rust
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
```
### 14.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) |
### 14.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 |
### 14.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