# 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
(Handle-based)"] JellyfinClient["JellyfinClient
(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
(Audio/Movie/TvShow/Idle)"] PlayerController["PlayerController
+ PlayerBackend
+ QueueManager"] Repository["Repository Layer
HybridRepository (cache-first)
OnlineRepository (HTTP)
OfflineRepository (SQLite)"] PlaybackModeManager["PlaybackModeManager
(Local/Remote/Idle)"] ConnectivityMonitor["ConnectivityMonitor
(Adaptive polling)"] HttpClient["HttpClient
(Exponential backoff retry)"] end subgraph Storage["Storage Layer"] DatabaseService["DatabaseService
(Async trait)"] SQLite["SQLite Database
(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, /// 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, 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, pub album: Option, pub duration: Option, pub artwork_url: Option, 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, }, 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, current_index: Option, shuffle: bool, repeat: RepeatMode, shuffle_order: Vec, // Fisher-Yates permutation history: Vec, // 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
next() returns index + 1"] ShuffleOn["ON
next() follows shuffle_order[]"] end subgraph Repeat["Repeat Mode"] RepeatOff["OFF
next() at end: → None"] RepeatAll["ALL
next() at end: → wrap to index 0"] RepeatOne["ONE
next() returns same item"] end subgraph History["History"] HistoryDesc["Used for previous()
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
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; 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>>, queue: Arc>, 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
- user
- serverUrl
- token
- isLoading"] end subgraph playerStore["player.ts"] PlayerStoreState["PlayerState
- kind
- media
- position
- duration"] end subgraph queueStore["queue.ts"] QueueState["QueueState
- items
- index
- shuffle
- repeat"] end subgraph libraryStore["library.ts"] LibraryState["LibraryState
- libraries
- items
- loading"] end subgraph Derived["Derived Stores"] DerivedList["isAuthenticated, currentUser
isPlaying, isPaused, currentMedia
hasNext, hasPrevious, isShuffle
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 ``` **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
(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 { <> +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 repository_get_items(handle: String, ...) -> SearchResult // Cleanup when done repository_destroy(handle: String) ``` - Enables multiple concurrent repository instances - Thread-safe with `Arc>>>` - 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("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>, jellyfin_client: Arc, } ``` **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; async fn execute_batch(&self, queries: Vec) -> Result<(), DatabaseError>; async fn query_one(&self, query: Query, mapper: F) -> Result where F: FnOnce(&Row) -> Result + Send + 'static; async fn query_optional(&self, query: Query, mapper: F) -> Result, DatabaseError> where F: FnOnce(&Row) -> Result + Send + 'static; async fn query_many(&self, query: Query, mapper: F) -> Result, DatabaseError> where F: Fn(&Row) -> Result + Send + 'static; async fn transaction(&self, f: F) -> Result where F: FnOnce(Transaction) -> Result + Send + 'static; } pub struct RusqliteService { connection: Arc>, } impl DatabaseService for RusqliteService { async fn execute(&self, query: Query) -> Result { 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>, app_handle: AppHandle, monitoring_task: Arc>>>, } ``` **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> 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 Manager->>Manager: Extract Jellyfin IDs Manager->>Jellyfin: POST /Sessions/{id}/Playing
{itemIds, startIndex} Jellyfin-->>Manager: 200 OK Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
{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()
- Check repeat mode
- Check shuffle
- Update history"] QueueNext --> None["None
(at end)"] QueueNext --> Some["Some(next)"] QueueNext --> Same["Same
(repeat one)"] Some --> PlayItem["play_item(next)
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
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, 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, // ... } ``` ```typescript // TypeScript interface MediaItem { id: string; title: string; artist?: string; // ... } ``` --- ## 6. Thread Safety ### 6.1 Rust Backend ```rust // Shared state wrapped in Arc> pub struct PlayerController { backend: Arc>>, queue: Arc>, // ... } // Tauri state wrapper pub struct PlayerStateWrapper(pub Mutex); // Command handler pattern #[tauri::command] pub fn player_play(state: State) -> Result { 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(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 }, /// 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, current_index: Option, 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
emit()"] Bus["Tauri Event Bus
'player-event'"] end subgraph Frontend["Frontend"] Listener["playerEvents.ts
Frontend Listener"] Store["Player Store Update
(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
- command_tx
- shared_state
- shutdown"] Commands["Commands:
Load, Play, Pause
Stop, Seek, SetVolume"] end subgraph EventLoopThread["MPV Event Loop Thread"] EventLoop["event_loop.rs
- MPV Handle
- command_rx
- 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, pub volume: f32, pub is_loaded: bool, pub current_media: Option, } ``` **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
- player_ref
- shared_state"] NativeFuncs["JNI Callbacks
nativeOnPosition...
nativeOnState...
nativeOnMediaLoaded
nativeOnPlaybackEnd"] TauriEmitter2["TauriEventEmitter"] end subgraph KotlinJVM["Kotlin (JVM)"] JellyTauPlayer["JellyTauPlayer
- ExoPlayer
- 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
- Lockscreen controls
- Media notifications
- Play/Pause/Next/Previous"] MediaSessionCompat["MediaSessionCompat
- Remote volume control
- Hardware button interception"] VolumeProvider["VolumeProviderCompat
- onSetVolumeTo()
- 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
nativeOnRemoteVolumeChange()"] PlaybackMode["PlaybackModeManager
send_remote_volume_command()"] JellyfinAPI["Jellyfin API
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 { 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, pub expected_size: Option, } ``` **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>, album_play_history: Arc>>>, } 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
Pre-cache?"} CheckQueue -->|"Yes"| CacheNext5["Download
Next 5 Tracks"] Play --> TrackHistory["Track Play History"] TrackHistory --> CheckAlbum{"3+ Tracks
from Album?"} CheckAlbum -->|"Yes"| CacheAlbum["Download
Full Album"] CacheNext5 --> CheckStorage{"Storage
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; 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('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
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> 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
- sessions[]
- activeId"] RemoteControl["RemoteControl
- play/pause
- seek
- volume
- playItem"] SessionsStore -->|"Select"| RemoteControl end subgraph JellyfinServer["Jellyfin Server"] GetSessions["GET /Sessions"] ActiveSessions["Active Sessions
- Jellyfin Web (Chrome)
- Jellyfin Android TV
- 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 { return this.client.get('/Sessions', { params: { controllableByUserId: this.client.userId } }); } async playOnSession(sessionId: string, itemIds: string[], startIndex = 0): Promise { await this.client.post(`/Sessions/${sessionId}/Playing`, { itemIds, startIndex, playCommand: 'PlayNow' }); } async sendCommand(sessionId: string, command: SessionCommand): Promise { await this.client.post(`/Sessions/${sessionId}/Playing/${command}`); } async setVolume(sessionId: string, volume: number): Promise { await this.client.post(`/Sessions/${sessionId}/Command/SetVolume`, { Arguments: { Volume: Math.round(volume) } }); } async seek(sessionId: string, positionTicks: number): Promise { 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({ sessions: [], activeSessionId: null, isPolling: false, lastUpdated: null }); let pollInterval: ReturnType | 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
appears showing
remote session name"] MediaRouter["MediaRouter.Callback
onRouteVolumeChanged()"] SessionsApi["SessionsApi.setVolume()
POST /Sessions/{id}/
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) { 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>, } 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
(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
Available?"} OSCredential["Store in OS Credential Manager
- Linux: libsecret/GNOME Keyring
- macOS: Keychain
- Windows: Credential Manager
- Android: EncryptedSharedPrefs"] EncryptedFallback["Encrypted File Fallback
(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, 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