3358 lines
108 KiB
Markdown
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<Mutex<>>"| 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
|