diff --git a/README.md b/README.md index 152cc53..d552854 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript. | UR-011 | Download media on demand | Medium | Done | | UR-012 | Login info shall be stored securely and persistently | High | Done | | UR-013 | View and manage downloaded media | Medium | Done | -| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Planned | +| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done | | UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done | | UR-016 | Change system settings while playing (brightness, volume) | Low | Planned | | UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done | @@ -113,8 +113,8 @@ API endpoints and data contracts required for Jellyfin integration. | JA-016 | Get recently added items | Items | UR-024 | Done | | JA-017 | Mark item as favorite | UserData | UR-017 | Done | | JA-018 | Remove item from favorites | UserData | UR-017 | Done | -| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned | -| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned | +| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done | +| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done | | JA-021 | Get active sessions list | Sessions | UR-010 | Done | | JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done | | JA-023 | Send seek command to remote session | Sessions | UR-010 | Done | @@ -146,12 +146,12 @@ Internal architecture, components, and application logic. | DR-011 | Search bar with cross-library search | UI | UR-008 | Done | | DR-012 | Local database for media metadata cache | Storage | UR-002 | Done | | DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done | -| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Planned | +| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done | | DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done | | DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done | | DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done | | DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done | -| DR-019 | Playlist creation and editing UI | UI | UR-014 | Planned | +| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done | | DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done | | DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done | | DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done | @@ -205,7 +205,7 @@ Internal architecture, components, and application logic. | UR-011 | IR-013 | DR-003, DR-015, DR-018 | | UR-012 | IR-009, IR-014 | - | | UR-013 | IR-013 | DR-017 | -| UR-014 | - | DR-014, DR-019 | +| UR-014 | IR-010 | DR-014, DR-019 | | UR-015 | - | DR-005, DR-020 | | UR-016 | - | - | | UR-017 | - | DR-014, DR-021 | @@ -288,6 +288,16 @@ Internal architecture, components, and application logic. | UT-046 | Smart cache album affinity tracking | DR-015 | Done | | UT-047 | Smart cache queue precache config | DR-015 | Done | | UT-048 | Smart cache storage limit check | DR-015 | Done | +| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done | +| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done | +| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done | +| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done | +| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done | +| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done | +| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done | +| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done | +| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done | +| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done | ### Integration Tests diff --git a/SoftwareArchitecture.md b/SoftwareArchitecture.md index 87a0d20..75c4a5d 100644 --- a/SoftwareArchitecture.md +++ b/SoftwareArchitecture.md @@ -2,7 +2,7 @@ This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust. -**Last Updated:** 2026-02-28 +**Last Updated:** 2026-03-01 ## Architecture Overview @@ -16,10 +16,6 @@ JellyTau uses a modern client-server architecture with a thin Svelte UI layer an - **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"] @@ -50,7 +46,7 @@ flowchart TB Frontend -->|"Tauri IPC (invoke)"| Backend subgraph Backend["Rust Backend (Business Logic)"] - subgraph Commands["Tauri Commands (80+)"] + subgraph Commands["Tauri Commands (90+)"] PlayerCmds["player.rs"] RepoCmds["repository.rs (27)"] PlaybackModeCmds["playback_mode.rs (5)"] @@ -86,3007 +82,57 @@ flowchart TB --- -## 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 - -**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>, - muted: bool, - sleep_timer: Arc>, - autoplay_settings: Arc>, - autoplay_episode_count: Arc>, // Session-based counter - repository: Arc>>>, - event_emitter: Arc>>>, - // ... other fields -} -``` - -**Key Methods:** -- `play_item(item)`: Load and play single item (resets autoplay counter) -- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter) -- `next()` / `previous()`: Queue navigation (resets autoplay counter) -- `toggle_shuffle()` / `cycle_repeat()`: Mode changes -- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control -- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue) - -### 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` | - | `()` | -| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` | -| `player_cancel_sleep_timer` | - | `()` | -| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` | -| `player_get_video_settings` | - | `VideoSettings` | -| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` | -| `player_get_autoplay_settings` | - | `AutoplaySettings` | -| `player_on_playback_ended` | - | `()` | +## Detailed Documentation + +Each major subsystem is documented in its own file under [docs/architecture/](docs/architecture/): + +| Document | Contents | +|----------|----------| +| [01 - Rust Backend](docs/architecture/01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands | +| [02 - Svelte Frontend](docs/architecture/02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI | +| [03 - Data Flow](docs/architecture/03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control | +| [04 - Type Sync & Threading](docs/architecture/04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns | +| [05 - Platform Backends](docs/architecture/05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization | +| [06 - Downloads & Offline](docs/architecture/06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components | +| [07 - Connectivity](docs/architecture/07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture | +| [08 - Database Design](docs/architecture/08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates | +| [09 - Security](docs/architecture/09-security.md) | Authentication token storage, secure storage module, network security, local data protection | --- -## 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:** - -```mermaid -graph TD - Music["/library/music
(Landing page with category cards)"] - Tracks["Tracks
(List view only)"] - Artists["Artists
(Grid view)"] - Albums["Albums
(Grid view)"] - Playlists["Playlists
(Grid view)"] - Genres["Genres
(Genre browser)"] - - Music --> Tracks - Music --> Artists - Music --> Albums - Music --> Playlists - Music --> Genres -``` - -**View Enforcement:** - -| Content Type | View Mode | Toggle Visible | Component Used | -|--------------|-----------|----------------|----------------| -| Tracks | List (forced) | No | `TrackList` | -| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | -| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | -| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | -| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` | -| Album Detail Tracks | List (forced) | No | `TrackList` | - -**TrackList Component:** - -The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format: - -- **No Thumbnails**: Track numbers only (transform to play button on hover) -- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration -- **Mobile Layout**: Compact rows with track number and metadata -- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility -- **Click Behavior**: Clicking a track plays it and queues all filtered tracks - -**Example Usage:** -```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) -- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet - -**Resume Playback Feature:** -- When loading media for playback, the app checks local database for saved progress -- If progress exists (>30 seconds watched and <90% complete), shows resume dialog -- User can choose to "Resume" from saved position or "Start from Beginning" -- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point -- For audio: Seeks to resume position after loading via MPV backend -- Implemented in [src/routes/player/[id]/+page.svelte](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 - -```mermaid -graph TD - subgraph Routes["Routes (src/routes/)"] - LoginPage["Login Page"] - LibLayout["Library Layout"] - LibDetail["Album/Series Detail"] - MusicCategory["Music Category Landing"] - Tracks["Tracks"] - Artists["Artists"] - Albums["Albums"] - Playlists["Playlists"] - Genres["Genres"] - Downloads["Downloads Page"] - Settings["Settings Page"] - PlayerPage["Player Page"] - end - - subgraph PlayerComps["Player Components"] - AudioPlayer["AudioPlayer"] - VideoPlayer["VideoPlayer"] - MiniPlayer["MiniPlayer"] - Controls["Controls"] - Queue["Queue"] - SleepTimerModal["SleepTimerModal"] - SleepTimerIndicator["SleepTimerIndicator"] - end - - subgraph SessionComps["Sessions Components"] - CastButton["CastButton"] - SessionModal["SessionPickerModal"] - SessionCard["SessionCard"] - SessionsList["SessionsList"] - RemoteControls["RemoteControls"] - end - - subgraph LibraryComps["Library Components"] - LibGrid["LibraryGrid"] - LibListView["LibraryListView"] - TrackList["TrackList"] - DownloadBtn["DownloadButton"] - MediaCard["MediaCard"] - end - - subgraph CommonComps["Common Components"] - ScrollPicker["ScrollPicker"] - end - - subgraph OtherComps["Other Components"] - Search["Search"] - FavoriteBtn["FavoriteButton"] - DownloadItem["DownloadItem"] - end - - LibLayout --> PlayerComps - LibLayout --> LibDetail - MusicCategory --> Tracks - MusicCategory --> Artists - MusicCategory --> Albums - MusicCategory --> Playlists - MusicCategory --> Genres - LibDetail --> LibraryComps - Downloads --> DownloadItem - PlayerPage --> PlayerComps - - MiniPlayer --> CastButton - CastButton --> SessionModal - SleepTimerModal --> ScrollPicker - PlayerComps --> LibraryComps -``` - -### 3.9 MiniPlayer Behavior - -**Location**: `src/lib/components/player/MiniPlayer.svelte` - -The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls. - -**Touch Gesture Handling:** - -The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page): - -```typescript -function handleTouchStart(e: TouchEvent) { - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; - touchEndX = touchStartX; // Initialize to start position - touchEndY = touchStartY; // Prevents taps being treated as swipes - isSwiping = true; -} -``` - -**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page. - -**Skip Button State:** - -The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend. - -### 3.10 Sleep Timer Architecture - -**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs` - -**TRACES**: UR-026 | DR-029 - -The sleep timer supports three modes for stopping playback: - -```rust -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum SleepTimerMode { - Off, - Time { end_time: i64 }, // Unix timestamp in milliseconds - EndOfTrack, // Stop after current track/episode - Episodes { remaining: u32 }, // Stop after N more episodes -} -``` - -**Timer Modes:** - -| Mode | Trigger | How It Stops | -|------|---------|-------------| -| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` | -| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` | -| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 | - -**Time-Based Timer Flow:** - -```mermaid -sequenceDiagram - participant UI as SleepTimerModal - participant Store as sleepTimer store - participant Rust as PlayerController - participant Thread as Timer Thread - participant Backend as PlayerBackend - - UI->>Store: setTimeTimer(30) - Store->>Rust: invoke("player_set_sleep_timer", {mode}) - Rust->>Rust: Set SleepTimerMode::Time { end_time } - Rust->>UI: Emit SleepTimerChanged event - - loop Every 1 second - Thread->>Thread: update_remaining_seconds() - Thread->>UI: Emit SleepTimerChanged (countdown) - alt remaining_seconds == 0 - Thread->>Backend: stop() - Thread->>UI: Emit SleepTimerChanged (Off) - end - end -``` - -**Frontend Components:** - -- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection. -- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections — time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue). -- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown. -- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active. - -**Key Design Decisions:** - -1. **All logic in Rust**: Frontend only displays state and invokes commands -2. **Background timer thread**: Handles time-based countdown independently of track boundaries -3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary -4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display - -### 3.11 Auto-Play Episode Limit - -**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs` - -**TRACES**: UR-023 | DR-049 - -Limits how many episodes auto-play consecutively before requiring manual intervention. - -**Settings:** - -```rust -// In AutoplaySettings (runtime, in PlayerController) -pub struct AutoplaySettings { - pub enabled: bool, - pub countdown_seconds: u32, - pub max_episodes: u32, // 0 = unlimited -} - -// In VideoSettings (persisted, settings page) -pub struct VideoSettings { - pub auto_play_next_episode: bool, - pub auto_play_countdown_seconds: u32, - pub auto_play_max_episodes: u32, // 0 = unlimited -} -``` - -**Session-Based Counter:** - -The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes: - -- **Incremented**: In `on_playback_ended()` when auto-playing next episode -- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`) -- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` — user must manually click "Play Now" to continue - -```mermaid -flowchart TB - PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video
episode?"} - CheckEpisode -->|"No"| AudioFlow["Audio queue logic"] - CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"] - FetchNext --> IncrementCount["increment_autoplay_count()"] - IncrementCount --> CheckLimit{"max_episodes > 0
AND count >= max?"} - CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup
auto_advance: true"] - CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup
auto_advance: false"] - ShowPopupManual --> UserClick["User clicks 'Play Now'"] - UserClick --> PlayItem["play_item() → resets counter"] -``` - -**Settings Sync:** - -`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`. - -**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`. - -**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled. - -### 3.12 Player Page Navigation Guard - -**Location**: `src/routes/player/[id]/+page.svelte` - -When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback: - -```typescript -const alreadyPlayingMedia = get(storeCurrentMedia); -if (alreadyPlayingMedia?.id === id && !startPosition) { - // Track already playing — show UI without restarting playback - // Fetch queue status for hasNext/hasPrevious - return; -} -``` - -**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track. - ---- - -## 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 - 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; - // ... -} -``` - -### 5.3 Tauri v2 IPC Parameter Naming Convention - -**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters. - -**Rule**: Rust `fn cmd(repository_handle: String)` → Frontend sends `{ repositoryHandle: "..." }` - -```typescript -// ✅ CORRECT - Tauri v2 auto-converts snake_case → camelCase -await invoke("player_play_tracks", { - repositoryHandle: "handle-123", // Rust: repository_handle - request: { trackIds: ["id1"], startIndex: 0 } -}); - -await invoke("remote_send_command", { - sessionId: "session-123", // Rust: session_id - command: "PlayPause" -}); - -await invoke("pin_item", { - itemId: "item-123" // Rust: item_id -}); - -// ❌ WRONG - snake_case causes "invalid args request" error on Android -await invoke("player_play_tracks", { - repository_handle: "handle-123", // Will fail! -}); -``` - -**Parameter Name Mapping (Rust → Frontend)**: - -| Rust Parameter | Frontend Parameter | Used By | -|----------------|-------------------|---------| -| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` | -| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` | -| `item_id` | `itemId` | `pin_item`, `unpin_item` | -| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` | -| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` | -| `item_ids` | `itemIds` | `remote_play_on_session` | -| `start_index` | `startIndex` | `remote_play_on_session` | - -**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately — this is serde deserialization, not the command macro. Both layers convert independently. - -**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names. - ---- - -## 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` -- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` — this enables MiniPlayer skip button state - -**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command. - -### 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.3.2 Android Album Art Caching - -**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt` - -Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management. - -**Architecture:** - -```mermaid -flowchart TB - subgraph JellyTauPlayer["JellyTauPlayer.kt"] - LoadMedia["loadWithMetadata()
- Store artworkUrl
- Launch async download"] - AsyncDownload["Coroutine
- Non-blocking
- Dispatchers.IO"] - end - - subgraph Cache["AlbumArtCache.kt"] - MemoryCache["LruCache<String, Bitmap>
- 1/8 of heap
- ~12-16MB typical
- 50-100 albums capacity"] - Download["Download & Scale
- 512x512 max
- Exponential backoff"] - ErrorHandle["Error Handling
- Graceful fallback
- Auto-retry"] - end - - subgraph Service["JellyTauPlaybackService.kt"] - UpdateMeta["updateMediaMetadata()
- Accept Bitmap parameter
- Add METADATA_KEY_ALBUM_ART"] - Notification["Notification
- setLargeIcon()
- Lock screen display"] - end - - LoadMedia --> AsyncDownload - AsyncDownload --> MemoryCache - MemoryCache --> Download - Download --> ErrorHandle - AsyncDownload --> UpdateMeta - UpdateMeta --> Notification -``` - -**AlbumArtCache Singleton:** - -```kotlin -class AlbumArtCache(context: Context) { - // Memory-efficient cache using 1/8 of available heap - private val memoryCache = object : LruCache(cacheSize) { - override fun sizeOf(key: String, bitmap: Bitmap): Int { - return bitmap.byteCount / 1024 // Size in KB - } - } - - // Get artwork - checks cache first, downloads if needed - suspend fun getArtwork(url: String): Bitmap? { - memoryCache.get(url)?.let { return it } - return downloadAndCache(url) - } - - // Download and cache with automatic scaling - private suspend fun downloadAndCache(url: String): Bitmap? = - withContext(Dispatchers.IO) { - // HTTP download with 5s timeout - // Scale to 512x512 max - // Auto-evict LRU if needed - } -} -``` - -**Integration Flow:** - -1. **Track Load** (`loadWithMetadata()`): - - Store artwork URL in `currentArtworkUrl` - - Reset bitmap to null - - Start playback immediately (non-blocking) - -2. **Async Download** (Background Coroutine): - - Check cache: instant hit if available - - Network miss: download, scale, cache - - Auto-retry on network failure with exponential backoff - - Graceful fallback if artwork unavailable - -3. **Notification Update**: - - Pass bitmap to `updatePlaybackServiceNotification()` - - Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART` - - Display as large icon in notification - - Show on lock screen - -**Memory Management:** - -| Metric | Value | -|--------|-------| -| Cache Size | 1/8 of heap (12-16MB typical) | -| Max Resolution | 512x512 pixels | -| Capacity | ~50-100 album arts | -| Eviction Policy | LRU (Least Recently Used) | -| Lifetime | In-memory only (app session) | -| Network Timeout | 5 seconds per download | - -**File Locations:** - -- **Kotlin Cache Class**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt` -- **JellyTauPlayer Integration**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt` - - Instance variables: lines 146-147 - - Store URL: line 553 - - Async download: lines 670-682 - - Pass to service: line 994 - -- **JellyTauPlaybackService Integration**: `src-tauri/gen/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt` - - Bitmap import: line 8 - - Function signature: line 267 - - Add to metadata: lines 282-284 - - Large icon in notification: line 321 - -**Performance Characteristics:** - -- **Cache Hit**: ~1ms (in-memory retrieval) -- **Cache Miss**: ~200-500ms (download + scale) -- **Playback Impact**: Zero (async downloads) -- **Memory Overhead**: Max 16MB (auto-eviction) -- **Error Recovery**: Automatic with exponential backoff - -### 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**: - -```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. Connectivity & Network Architecture - -### 9.1 HTTP Client with Retry Logic - -**Location**: `src-tauri/src/jellyfin/http_client.rs` - -The HTTP client provides automatic retry with exponential backoff for network resilience: - -```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 -} -``` - -### 9.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); -}); -``` - -### 9.3 Network Resilience Architecture - -The connectivity system provides resilience through multiple layers: - -1. **HTTP Client Layer**: Automatic retry with exponential backoff -2. **Connectivity Monitoring**: Background reachability checks -3. **Frontend Integration**: Offline mode detection and UI updates -4. **Sync Queue**: Offline mutations queued for later (see Section 8) - -**Design Principles:** -- **Fail Fast**: Don't retry 4xx errors (client errors, authentication) -- **Fail Slow**: Retry network and 5xx errors with increasing delays -- **Adaptive Polling**: Reduce polling frequency when online, increase when offline -- **Event-Driven**: Frontend reacts to connectivity changes via events - ---- - -## 10. Offline Database Design - -### 12.1 Entity Relationship Diagram - -```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 -``` - ---- - -## 11. File Structure Summary +## File Structure Summary ``` src-tauri/src/ ├── lib.rs # Tauri app setup, state initialization -├── commands/ # Tauri command handlers (80+ commands) +├── commands/ # Tauri command handlers (90+ 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) +│ ├── repository.rs # 27 repository commands +│ ├── playlist.rs # 7 playlist commands +│ ├── playback_mode.rs # 5 playback mode commands +│ ├── connectivity.rs # 7 connectivity commands │ ├── 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) +├── repository/ # Repository pattern implementation │ ├── 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) +├── playback_mode/ # Playback mode manager │ └── mod.rs # PlaybackMode enum, transfer logic -├── connectivity/ # Connectivity monitoring (NEW) +├── connectivity/ # Connectivity monitoring │ └── mod.rs # ConnectivityMonitor, adaptive polling -├── jellyfin/ # Jellyfin API client (NEW) +├── jellyfin/ # Jellyfin API client │ ├── 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) +│ ├── db_service.rs # DatabaseService trait (async wrapper) │ ├── schema.rs # Table definitions │ └── queries/ # Query modules ├── download/ # Download manager module @@ -3096,7 +142,7 @@ src-tauri/src/ │ └── cache.rs # SmartCache, CacheConfig, LRU eviction └── player/ # Player subsystem ├── mod.rs # PlayerController - ├── session.rs # MediaSessionManager, MediaSessionType (NEW) + ├── session.rs # MediaSessionManager, MediaSessionType ├── state.rs # PlayerState, PlayerEvent ├── media.rs # MediaItem, MediaSource, MediaType ├── queue.rs # QueueManager, RepeatMode @@ -3108,51 +154,41 @@ src-tauri/src/ └── 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) +│ ├── repository-client.ts # RepositoryClient wrapper (~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) +│ └── playbackReporting.ts # Thin wrapper (~50 lines) ├── 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) +│ ├── playbackMode.ts # Playback mode store (~150 lines) +│ ├── connectivity.ts # Connectivity store (~250 lines) │ └── downloads.ts # Downloads store with event listeners └── components/ ├── Search.svelte ├── player/ # Player UI components + ├── playlist/ # Playlist modals (Create, AddTo) ├── 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 + └── library/ # Library UI components + PlaylistDetailView ``` -### Key Architecture Changes +## 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 +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 **TypeScript Layer (now ~800 lines, down from ~3,300):** - Svelte stores (reactive wrappers) @@ -3161,76 +197,4 @@ src/lib/ - Tauri command invocation - Event listeners for Rust events -**Total Commands:** 80+ Tauri commands across 8 command modules - ---- - -## 12. Security - -### 12.1 Authentication Token Storage - -Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage: - -```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 - -### 12.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 -``` - -### 12.3 Network Security - -| Aspect | Implementation | -|--------|----------------| -| Transport | HTTPS required for all Jellyfin API calls | -| Certificate Validation | System CA store (configurable for self-signed) | -| Token Transmission | Bearer token in `Authorization` header only | -| Token Refresh | Handled by Jellyfin server (long-lived tokens) | - -### 12.4 Local Data Protection - -| Data Type | Protection | -|-----------|------------| -| Access Tokens | System keyring or encrypted file | -| Database (SQLite) | Plaintext (metadata only, no secrets) | -| Downloaded Media | Filesystem permissions only | -| Cached Thumbnails | Filesystem permissions only | - -### 12.5 Security Considerations - -1. **No Secrets in SQLite**: The database contains only non-sensitive metadata -2. **Token Isolation**: Each user/server combination has a separate token entry -3. **Logout Cleanup**: Token deletion from secure storage on logout -4. **No Token Logging**: Tokens are never written to logs or debug output -5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution +**Total Commands:** 90+ Tauri commands across 14 command modules diff --git a/docs/architecture/01-rust-backend.md b/docs/architecture/01-rust-backend.md new file mode 100644 index 0000000..9044af4 --- /dev/null +++ b/docs/architecture/01-rust-backend.md @@ -0,0 +1,571 @@ +# Rust Backend Architecture + +**Location**: `src-tauri/src/` + +## Media Session State Machine + +**Location**: `src-tauri/src/player/session.rs` + +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 + +## 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), +} +``` + +## 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 + +## 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) + +## 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 +``` + +## Favorites System + +**Location**: +- Service: `src/lib/services/favorites.ts` +- Component: `src/lib/components/FavoriteButton.svelte` +- Backend: `src-tauri/src/commands/storage.rs` + +The favorites system implements optimistic updates with server synchronization: + +```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 + +**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 + +## 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 [05-platform-backends.md](05-platform-backends.md)) +- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md)) + +## Player Controller + +**Location**: `src-tauri/src/player/mod.rs` + +The `PlayerController` orchestrates playback: + +```rust +pub struct PlayerController { + backend: Arc>>, + queue: Arc>, + muted: bool, + sleep_timer: Arc>, + autoplay_settings: Arc>, + autoplay_episode_count: Arc>, // Session-based counter + repository: Arc>>>, + event_emitter: Arc>>>, + // ... other fields +} +``` + +**Key Methods:** +- `play_item(item)`: Load and play single item (resets autoplay counter) +- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter) +- `next()` / `previous()`: Queue navigation (resets autoplay counter) +- `toggle_shuffle()` / `cycle_repeat()`: Mode changes +- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control +- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue) + +## Playlist System + +**Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/` + +**TRACES**: UR-014 | JA-019 | JA-020 + +The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern. + +**Types:** + +```rust +/// A media item within a playlist, with its distinct playlist entry ID +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistEntry { + /// Jellyfin's PlaylistItemId (distinct from the media item ID) + pub playlist_item_id: String, + #[serde(flatten)] + pub item: MediaItem, +} + +/// Result of creating a new playlist +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistCreatedResult { + pub id: String, +} +``` + +**Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times. + +**MediaRepository Trait Methods:** +```rust +async fn create_playlist(&self, name: &str, item_ids: Option>) -> Result; +async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>; +async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>; +async fn get_playlist_items(&self, playlist_id: &str) -> Result, RepoError>; +async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec) -> Result<(), RepoError>; +async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec) -> Result<(), RepoError>; +async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>; +``` + +**Cache Strategy:** +- **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository +- **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback) +- Background cache update after server fetch via `save_playlist_items_to_cache()` + +**Playlist Tauri Commands:** + +| Command | Parameters | Returns | +|---------|------------|---------| +| `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` | +| `playlist_delete` | `handle, playlist_id` | `()` | +| `playlist_rename` | `handle, playlist_id, name` | `()` | +| `playlist_get_items` | `handle, playlist_id` | `Vec` | +| `playlist_add_items` | `handle, playlist_id, item_ids` | `()` | +| `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` | +| `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` | + +## Tauri Commands (Player) + +**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` | - | `()` | +| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` | +| `player_cancel_sleep_timer` | - | `()` | +| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` | +| `player_get_video_settings` | - | `VideoSettings` | +| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` | +| `player_get_autoplay_settings` | - | `AutoplaySettings` | +| `player_on_playback_ended` | - | `()` | diff --git a/docs/architecture/02-svelte-frontend.md b/docs/architecture/02-svelte-frontend.md new file mode 100644 index 0000000..d685a57 --- /dev/null +++ b/docs/architecture/02-svelte-frontend.md @@ -0,0 +1,644 @@ +# Svelte Frontend Architecture + +## 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 +``` + +## 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:** + +```mermaid +graph TD + Music["/library/music
(Landing page with category cards)"] + Tracks["Tracks
(List view only)"] + Artists["Artists
(Grid view)"] + Albums["Albums
(Grid view)"] + Playlists["Playlists
(Grid view)"] + Genres["Genres
(Genre browser)"] + + Music --> Tracks + Music --> Artists + Music --> Albums + Music --> Playlists + Music --> Genres +``` + +**View Enforcement:** + +| Content Type | View Mode | Toggle Visible | Component Used | +|--------------|-----------|----------------|----------------| +| Tracks | List (forced) | No | `TrackList` | +| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | +| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | +| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | +| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` | +| Album Detail Tracks | List (forced) | No | `TrackList` | + +**TrackList Component:** + +The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format: + +- **No Thumbnails**: Track numbers only (transform to play button on hover) +- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration +- **Mobile Layout**: Compact rows with track number and metadata +- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility +- **Click Behavior**: Clicking a track plays it and queues all filtered tracks + +**Example Usage:** +```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. + +## 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) +- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet + +**Resume Playback Feature:** +- When loading media for playback, the app checks local database for saved progress +- If progress exists (>30 seconds watched and <90% complete), shows resume dialog +- User can choose to "Resume" from saved position or "Start from Beginning" +- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point +- For audio: Seeks to resume position after loading via MPV backend +- Implemented in `src/routes/player/[id]/+page.svelte` + +## 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) + +create_playlist(name, item_ids) + +delete_playlist(playlist_id) + +rename_playlist(playlist_id, name) + +get_playlist_items(playlist_id) + +add_to_playlist(playlist_id, item_ids) + +remove_from_playlist(playlist_id, entry_ids) + +move_playlist_item(playlist_id, item_id, new_index) + } + + 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) + +## 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 + +## 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 +``` + +## Component Hierarchy + +```mermaid +graph TD + subgraph Routes["Routes (src/routes/)"] + LoginPage["Login Page"] + LibLayout["Library Layout"] + LibDetail["Album/Series Detail"] + MusicCategory["Music Category Landing"] + Tracks["Tracks"] + Artists["Artists"] + Albums["Albums"] + Playlists["Playlists"] + Genres["Genres"] + Downloads["Downloads Page"] + Settings["Settings Page"] + PlayerPage["Player Page"] + end + + subgraph PlayerComps["Player Components"] + AudioPlayer["AudioPlayer"] + VideoPlayer["VideoPlayer"] + MiniPlayer["MiniPlayer"] + Controls["Controls"] + Queue["Queue"] + SleepTimerModal["SleepTimerModal"] + SleepTimerIndicator["SleepTimerIndicator"] + end + + subgraph SessionComps["Sessions Components"] + CastButton["CastButton"] + SessionModal["SessionPickerModal"] + SessionCard["SessionCard"] + SessionsList["SessionsList"] + RemoteControls["RemoteControls"] + end + + subgraph LibraryComps["Library Components"] + LibGrid["LibraryGrid"] + LibListView["LibraryListView"] + TrackList["TrackList"] + PlaylistDetail["PlaylistDetailView"] + DownloadBtn["DownloadButton"] + MediaCard["MediaCard"] + end + + subgraph PlaylistComps["Playlist Components"] + CreatePlaylistModal["CreatePlaylistModal"] + AddToPlaylistModal["AddToPlaylistModal"] + end + + subgraph CommonComps["Common Components"] + ScrollPicker["ScrollPicker"] + end + + subgraph OtherComps["Other Components"] + Search["Search"] + FavoriteBtn["FavoriteButton"] + DownloadItem["DownloadItem"] + end + + LibLayout --> PlayerComps + LibLayout --> LibDetail + MusicCategory --> Tracks + MusicCategory --> Artists + MusicCategory --> Albums + MusicCategory --> Playlists + MusicCategory --> Genres + LibDetail --> LibraryComps + Playlists --> PlaylistComps + Playlists --> PlaylistDetail + Downloads --> DownloadItem + PlayerPage --> PlayerComps + + MiniPlayer --> CastButton + CastButton --> SessionModal + SleepTimerModal --> ScrollPicker + PlayerComps --> LibraryComps +``` + +## MiniPlayer Behavior + +**Location**: `src/lib/components/player/MiniPlayer.svelte` + +The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls. + +**Touch Gesture Handling:** + +The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page): + +```typescript +function handleTouchStart(e: TouchEvent) { + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + touchEndX = touchStartX; // Initialize to start position + touchEndY = touchStartY; // Prevents taps being treated as swipes + isSwiping = true; +} +``` + +**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page. + +**Skip Button State:** + +The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend. + +## Sleep Timer Architecture + +**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs` + +**TRACES**: UR-026 | DR-029 + +The sleep timer supports three modes for stopping playback: + +```rust +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SleepTimerMode { + Off, + Time { end_time: i64 }, // Unix timestamp in milliseconds + EndOfTrack, // Stop after current track/episode + Episodes { remaining: u32 }, // Stop after N more episodes +} +``` + +**Timer Modes:** + +| Mode | Trigger | How It Stops | +|------|---------|-------------| +| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` | +| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` | +| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 | + +**Time-Based Timer Flow:** + +```mermaid +sequenceDiagram + participant UI as SleepTimerModal + participant Store as sleepTimer store + participant Rust as PlayerController + participant Thread as Timer Thread + participant Backend as PlayerBackend + + UI->>Store: setTimeTimer(30) + Store->>Rust: invoke("player_set_sleep_timer", {mode}) + Rust->>Rust: Set SleepTimerMode::Time { end_time } + Rust->>UI: Emit SleepTimerChanged event + + loop Every 1 second + Thread->>Thread: update_remaining_seconds() + Thread->>UI: Emit SleepTimerChanged (countdown) + alt remaining_seconds == 0 + Thread->>Backend: stop() + Thread->>UI: Emit SleepTimerChanged (Off) + end + end +``` + +**Frontend Components:** + +- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection. +- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue). +- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown. +- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active. + +**Key Design Decisions:** + +1. **All logic in Rust**: Frontend only displays state and invokes commands +2. **Background timer thread**: Handles time-based countdown independently of track boundaries +3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary +4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display + +## Auto-Play Episode Limit + +**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs` + +**TRACES**: UR-023 | DR-049 + +Limits how many episodes auto-play consecutively before requiring manual intervention. + +**Settings:** + +```rust +// In AutoplaySettings (runtime, in PlayerController) +pub struct AutoplaySettings { + pub enabled: bool, + pub countdown_seconds: u32, + pub max_episodes: u32, // 0 = unlimited +} + +// In VideoSettings (persisted, settings page) +pub struct VideoSettings { + pub auto_play_next_episode: bool, + pub auto_play_countdown_seconds: u32, + pub auto_play_max_episodes: u32, // 0 = unlimited +} +``` + +**Session-Based Counter:** + +The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes: + +- **Incremented**: In `on_playback_ended()` when auto-playing next episode +- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`) +- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue + +```mermaid +flowchart TB + PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video
episode?"} + CheckEpisode -->|"No"| AudioFlow["Audio queue logic"] + CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"] + FetchNext --> IncrementCount["increment_autoplay_count()"] + IncrementCount --> CheckLimit{"max_episodes > 0
AND count >= max?"} + CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup
auto_advance: true"] + CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup
auto_advance: false"] + ShowPopupManual --> UserClick["User clicks 'Play Now'"] + UserClick --> PlayItem["play_item() -> resets counter"] +``` + +**Settings Sync:** + +`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`. + +**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`. + +**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled. + +## Player Page Navigation Guard + +**Location**: `src/routes/player/[id]/+page.svelte` + +When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback: + +```typescript +const alreadyPlayingMedia = get(storeCurrentMedia); +if (alreadyPlayingMedia?.id === id && !startPosition) { + // Track already playing - show UI without restarting playback + // Fetch queue status for hasNext/hasPrevious + return; +} +``` + +**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track. + +## Playlist Management UI + +**TRACES**: UR-014 | JA-019 | JA-020 + +**Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte` + +The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support. + +**Components:** + +- **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`): + - Modal for creating new playlists with a name input + - Accepts optional `initialItemIds` to pre-populate with tracks + - Keyboard support: Enter to create, Escape to close + - Navigates to new playlist detail page on creation + +- **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`): + - Modal listing all existing playlists to add tracks to + - "New Playlist" button for inline creation flow + - Shows playlist artwork via CachedImage + - Loading state with skeleton placeholders + +- **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`): + - Full playlist detail page with artwork, name, track count, total duration + - Click-to-rename with inline editing + - Play all / shuffle play buttons + - Delete with confirmation dialog + - Per-track removal buttons + - Uses `TrackList` component for track display + - Passes `{ type: "playlist", playlistId, playlistName }` context to player + +- **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`): + - Grid view using `GenericMediaListPage` + - Floating action button (FAB) to create new playlists + - Search by playlist name + +**Frontend API Methods** (`src/lib/api/repository-client.ts`): +- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult` +- `deletePlaylist(playlistId)` +- `renamePlaylist(playlistId, name)` +- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]` +- `addToPlaylist(playlistId, itemIds)` +- `removeFromPlaylist(playlistId, entryIds)` +- `movePlaylistItem(playlistId, itemId, newIndex)` + +**Offline Sync** (`src/lib/services/syncService.ts`): +All playlist mutations are queued for offline sync: +- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename` +- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem` diff --git a/docs/architecture/03-data-flow.md b/docs/architecture/03-data-flow.md new file mode 100644 index 0000000..f44ab76 --- /dev/null +++ b/docs/architecture/03-data-flow.md @@ -0,0 +1,154 @@ +# Data Flow + +## 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 + 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) + +## 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 +``` + +## 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 +``` + +## 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"] +``` + +## 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 diff --git a/docs/architecture/04-type-sync-and-threading.md b/docs/architecture/04-type-sync-and-threading.md new file mode 100644 index 0000000..8247141 --- /dev/null +++ b/docs/architecture/04-type-sync-and-threading.md @@ -0,0 +1,132 @@ +# Type Synchronization & Thread Safety + +## 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 }; +``` + +## 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; + // ... +} +``` + +## Tauri v2 IPC Parameter Naming Convention + +**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters. + +**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }` + +```typescript +// CORRECT - Tauri v2 auto-converts snake_case -> camelCase +await invoke("player_play_tracks", { + repositoryHandle: "handle-123", // Rust: repository_handle + request: { trackIds: ["id1"], startIndex: 0 } +}); + +await invoke("remote_send_command", { + sessionId: "session-123", // Rust: session_id + command: "PlayPause" +}); + +await invoke("pin_item", { + itemId: "item-123" // Rust: item_id +}); + +// WRONG - snake_case causes "invalid args request" error on Android +await invoke("player_play_tracks", { + repository_handle: "handle-123", // Will fail! +}); +``` + +**Parameter Name Mapping (Rust -> Frontend)**: + +| Rust Parameter | Frontend Parameter | Used By | +|----------------|-------------------|----| +| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` | +| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` | +| `item_id` | `itemId` | `pin_item`, `unpin_item` | +| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` | +| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` | +| `item_ids` | `itemIds` | `remote_play_on_session` | +| `start_index` | `startIndex` | `remote_play_on_session` | + +**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently. + +**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names. + +## Rust Backend Thread Safety + +```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 +} +``` + +## 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 } + })); +} +``` diff --git a/docs/architecture/05-platform-backends.md b/docs/architecture/05-platform-backends.md new file mode 100644 index 0000000..7211dae --- /dev/null +++ b/docs/architecture/05-platform-backends.md @@ -0,0 +1,485 @@ +# Platform-Specific Player Backends + +## 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` +- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state + +**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command. + +## 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>"| 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` + +## 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 +} +``` + +## 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) { + 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 + } + AudioManager.ADJUST_LOWER -> { + remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0) + nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel) + currentVolume = remoteVolumeLevel + } + } + } + } + + mediaSessionCompat.setPlaybackToRemote(volumeProvider) +} +``` + +**Disabling Remote Volume** (`disableRemoteVolume()`): +```kotlin +fun disableRemoteVolume() { + 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> { + start_playback_service()?; + let service_instance = get_playback_service_instance()?; + env.call_method(&service_instance, "enableRemoteVolume", "(I)V", + &[JValue::Int(initial_volume)])?; + Ok(()) +} +``` + +**Dependencies** (`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); + } +} +``` + +## Android Album Art Caching + +**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt` + +Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management. + +```mermaid +flowchart TB + subgraph JellyTauPlayer["JellyTauPlayer.kt"] + LoadMedia["loadWithMetadata()
- Store artworkUrl
- Launch async download"] + AsyncDownload["Coroutine
- Non-blocking
- Dispatchers.IO"] + end + + subgraph Cache["AlbumArtCache.kt"] + MemoryCache["LruCache
- 1/8 of heap
- ~12-16MB typical
- 50-100 albums capacity"] + Download["Download & Scale
- 512x512 max
- Exponential backoff"] + ErrorHandle["Error Handling
- Graceful fallback
- Auto-retry"] + end + + subgraph Service["JellyTauPlaybackService.kt"] + UpdateMeta["updateMediaMetadata()
- Accept Bitmap parameter
- Add METADATA_KEY_ALBUM_ART"] + Notification["Notification
- setLargeIcon()
- Lock screen display"] + end + + LoadMedia --> AsyncDownload + AsyncDownload --> MemoryCache + MemoryCache --> Download + Download --> ErrorHandle + AsyncDownload --> UpdateMeta + UpdateMeta --> Notification +``` + +**AlbumArtCache Singleton:** + +```kotlin +class AlbumArtCache(context: Context) { + private val memoryCache = object : LruCache(cacheSize) { + override fun sizeOf(key: String, bitmap: Bitmap): Int { + return bitmap.byteCount / 1024 // Size in KB + } + } + + suspend fun getArtwork(url: String): Bitmap? { + memoryCache.get(url)?.let { return it } + return downloadAndCache(url) + } + + private suspend fun downloadAndCache(url: String): Bitmap? = + withContext(Dispatchers.IO) { + // HTTP download with 5s timeout + // Scale to 512x512 max + // Auto-evict LRU if needed + } +} +``` + +**Integration Flow:** + +1. **Track Load** (`loadWithMetadata()`): + - Store artwork URL in `currentArtworkUrl` + - Reset bitmap to null + - Start playback immediately (non-blocking) + +2. **Async Download** (Background Coroutine): + - Check cache: instant hit if available + - Network miss: download, scale, cache + - Auto-retry on network failure with exponential backoff + - Graceful fallback if artwork unavailable + +3. **Notification Update**: + - Pass bitmap to `updatePlaybackServiceNotification()` + - Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART` + - Display as large icon in notification + - Show on lock screen + +**Memory Management:** + +| Metric | Value | +|--------|-------| +| Cache Size | 1/8 of heap (12-16MB typical) | +| Max Resolution | 512x512 pixels | +| Capacity | ~50-100 album arts | +| Eviction Policy | LRU (Least Recently Used) | +| Lifetime | In-memory only (app session) | +| Network Timeout | 5 seconds per download | + +**Performance Characteristics:** + +- **Cache Hit**: ~1ms (in-memory retrieval) +- **Cache Miss**: ~200-500ms (download + scale) +- **Playback Impact**: Zero (async downloads) +- **Memory Overhead**: Max 16MB (auto-eviction) +- **Error Recovery**: Automatic with exponential backoff + +## 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()) +} +``` diff --git a/docs/architecture/06-downloads-and-offline.md b/docs/architecture/06-downloads-and-offline.md new file mode 100644 index 0000000..27ea141 --- /dev/null +++ b/docs/architecture/06-downloads-and-offline.md @@ -0,0 +1,287 @@ +# Download Manager & Offline Architecture + +## Overview + +**Location**: `src-tauri/src/download/` + +The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching. + +```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 +``` + +## 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 +``` + +## 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"] +``` + +## Download Commands + +**Location**: `src-tauri/src/commands/download.rs` + +| Command | Parameters | Description | +|---------|------------|-------------| +| `download_item` | `item_id, user_id, file_path` | Queue single item download | +| `download_album` | `album_id, user_id` | Queue all tracks in album | +| `get_downloads` | `user_id, status_filter` | Get download list | +| `pause_download` | `download_id` | Pause active download | +| `resume_download` | `download_id` | Resume paused download | +| `cancel_download` | `download_id` | Cancel and delete partial | +| `delete_download` | `download_id` | Delete completed download | + +## Offline Commands + +**Location**: `src-tauri/src/commands/offline.rs` + +| Command | Parameters | Description | +|---------|------------|-------------| +| `offline_is_available` | `item_id` | Check if item downloaded | +| `offline_get_items` | `user_id` | Get all offline items | +| `offline_search` | `user_id, query` | Search downloaded items | + +## Player Integration + +**Location**: `src-tauri/src/commands/player.rs` (modified) + +The player checks for local downloads before streaming: + +```rust +fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem { + let local_path = db.and_then(|db_wrapper| { + check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten() + }); + + let source = if let Some(path) = local_path { + MediaSource::Local { + file_path: PathBuf::from(path), + jellyfin_item_id: Some(jellyfin_id.clone()) + } + } else { + MediaSource::Remote { + stream_url: req.stream_url, + jellyfin_item_id: jellyfin_id.clone() + } + }; + + MediaItem { source, /* ... */ } +} +``` + +## Frontend Downloads Store + +**Location**: `src/lib/stores/downloads.ts` + +```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 + } +}); +``` + +## Download UI Components + +**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`): +- Multiple states: available, downloading, completed, failed, paused +- Circular progress ring during download +- Size variants: sm, md, lg +- Integrated into TrackList with `showDownload={true}` prop + +**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`): +- Individual download list item with progress bar +- Action buttons: pause, resume, cancel, delete +- Status indicators with color coding + +**Downloads Page** (`src/routes/downloads/+page.svelte`): +- Active/Completed tabs +- Bulk actions: Pause All, Resume All, Clear Completed +- Empty states with helpful instructions + +## Database Schema + +**downloads table**: + +```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'); +``` diff --git a/docs/architecture/07-connectivity.md b/docs/architecture/07-connectivity.md new file mode 100644 index 0000000..fef2999 --- /dev/null +++ b/docs/architecture/07-connectivity.md @@ -0,0 +1,92 @@ +# Connectivity & Network Architecture + +## 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 +} +``` + +## 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); +}); +``` + +## 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 [06-downloads-and-offline.md](06-downloads-and-offline.md)) + +**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 diff --git a/docs/architecture/08-database-design.md b/docs/architecture/08-database-design.md new file mode 100644 index 0000000..cf793ea --- /dev/null +++ b/docs/architecture/08-database-design.md @@ -0,0 +1,614 @@ +# Offline Database Design + +## 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 + } +``` + +## Table Definitions + +### servers +Stores connected Jellyfin server information. + +```sql +CREATE TABLE servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + jellyfin_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + url TEXT NOT NULL, + version TEXT, + last_sync DATETIME, + 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 [09-security.md](09-security.md)). + +```sql +CREATE TABLE users ( + 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, + is_active BOOLEAN DEFAULT 0, + 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, + 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, + name TEXT NOT NULL, + sort_name TEXT, + overview TEXT, + + -- Media info + production_year INTEGER, + community_rating REAL, + official_rating TEXT, + runtime_ticks INTEGER, + + -- Images + primary_image_tag TEXT, + backdrop_image_tag TEXT, + + -- Audio-specific + album_id TEXT, + album_name TEXT, + album_artist TEXT, + artists TEXT, -- JSON array + + -- Series/Season-specific + index_number INTEGER, + parent_index_number INTEGER, + series_id TEXT, + series_name TEXT, + season_id TEXT, + + -- Additional + genres TEXT, -- JSON array + premiere_date TEXT, + metadata_json TEXT, + + -- 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, + type TEXT NOT NULL, + codec TEXT, + language TEXT, + display_title TEXT, + is_default BOOLEAN DEFAULT 0, + is_forced BOOLEAN DEFAULT 0, + is_external BOOLEAN DEFAULT 0, + path TEXT, + 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, + play_count INTEGER DEFAULT 0, + played BOOLEAN DEFAULT 0, + last_played DATETIME, + + -- User preferences + is_favorite BOOLEAN DEFAULT 0, + user_rating REAL, + + -- Sync tracking + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + synced_at DATETIME, + needs_sync BOOLEAN DEFAULT 0, + + 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_path TEXT, + file_size INTEGER, + file_hash TEXT, + + status TEXT NOT NULL DEFAULT 'pending', + progress REAL DEFAULT 0, + bytes_downloaded INTEGER DEFAULT 0, + + transcode_profile TEXT, + + priority INTEGER DEFAULT 0, + error_message TEXT, + retry_count INTEGER DEFAULT 0, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + completed_at DATETIME, + expires_at DATETIME, + + 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 TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + payload TEXT, + + status TEXT DEFAULT 'pending', + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 5, + last_attempt DATETIME, + error_message TEXT, + + 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, + image_tag TEXT, + 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, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + is_local_only BOOLEAN DEFAULT 0, + 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); +``` + +## 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 +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 +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 = ?; +``` + +## Data Flow + +### 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 +``` + +## 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 | + +## 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 +``` diff --git a/docs/architecture/09-security.md b/docs/architecture/09-security.md new file mode 100644 index 0000000..b54309c --- /dev/null +++ b/docs/architecture/09-security.md @@ -0,0 +1,69 @@ +# Security + +## 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 + +## 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 +``` + +## 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) | + +## 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 | + +## 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 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 535fb76..86524b9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod offline; pub mod playback_mode; pub mod playback_reporting; pub mod player; +pub mod playlist; pub mod repository; pub mod sessions; pub mod storage; @@ -25,6 +26,7 @@ pub use playback_mode::*; #[allow(unused_imports)] // Used when playback_reporting is fully integrated pub use playback_reporting::*; pub use player::*; +pub use playlist::*; pub use repository::{*, RepositoryManager, RepositoryManagerWrapper}; pub use sessions::*; pub use storage::*; diff --git a/src-tauri/src/commands/player.rs b/src-tauri/src/commands/player.rs index e0dbe26..0267146 100644 --- a/src-tauri/src/commands/player.rs +++ b/src-tauri/src/commands/player.rs @@ -2396,11 +2396,15 @@ pub async fn player_play_next_episode( /// Handle playback ended event - triggers autoplay decision logic /// This is called from: -/// - Frontend when HTML5 video ends (Linux/desktop) +/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video +/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed /// - Android JNI callback also triggers this logic directly #[tauri::command] pub async fn player_on_playback_ended( player: State<'_, PlayerStateWrapper>, + repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, + item_id: Option, + repository_handle: Option, ) -> Result<(), String> { use crate::player::autoplay::AutoplayDecision; use crate::player::PlayerStatusEvent; @@ -2408,9 +2412,24 @@ pub async fn player_on_playback_ended( let controller_arc = player.0.clone(); // Run autoplay decision logic + // If item_id is provided (HTML5 video case), use the video-specific path + // that bypasses the backend queue and stale end_reason let decision = { let controller = controller_arc.lock().await; - controller.on_playback_ended().await? + if let Some(ref id) = item_id { + // Video path: need repository to look up episode info + let repo = repository_handle + .as_ref() + .and_then(|handle| repository_manager.0.get(handle)); + if let Some(repo) = repo { + controller.on_video_playback_ended(id, repo).await? + } else { + log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id); + AutoplayDecision::Stop + } + } else { + controller.on_playback_ended().await? + } }; // Handle the decision @@ -2420,12 +2439,12 @@ pub async fn player_on_playback_ended( let controller = controller_arc.lock().await; if let Some(emitter) = controller.event_emitter() { // Emit StateChanged to idle to clear the current media from mini player + // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop + // (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...) emitter.emit(PlayerStatusEvent::StateChanged { state: "idle".to_string(), media_id: None, }); - // Also emit PlaybackEnded event - emitter.emit(PlayerStatusEvent::PlaybackEnded); } } AutoplayDecision::AdvanceToNext => { diff --git a/src-tauri/src/commands/playlist.rs b/src-tauri/src/commands/playlist.rs new file mode 100644 index 0000000..84fac05 --- /dev/null +++ b/src-tauri/src/commands/playlist.rs @@ -0,0 +1,115 @@ +//! Tauri commands for playlist management +//! Uses handle-based system: UUID -> Arc +//! +//! TRACES: UR-014 | JA-019, JA-020 + +use log::debug; +use tauri::State; + +use crate::repository::{MediaRepository, types::*}; +use super::repository::RepositoryManagerWrapper; + +/// Create a new playlist +#[tauri::command] +pub async fn playlist_create( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + name: String, + item_ids: Option>, +) -> Result { + debug!("[PLAYLIST] create called: name={}", name); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + let ids = item_ids.unwrap_or_default(); + repo.as_ref().create_playlist(&name, &ids) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Delete a playlist +#[tauri::command] +pub async fn playlist_delete( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + playlist_id: String, +) -> Result<(), String> { + debug!("[PLAYLIST] delete called: id={}", playlist_id); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().delete_playlist(&playlist_id) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Rename a playlist +#[tauri::command] +pub async fn playlist_rename( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + playlist_id: String, + name: String, +) -> Result<(), String> { + debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().rename_playlist(&playlist_id, &name) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Get playlist items with PlaylistItemId +#[tauri::command] +pub async fn playlist_get_items( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + playlist_id: String, +) -> Result, String> { + debug!("[PLAYLIST] get_items called: id={}", playlist_id); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().get_playlist_items(&playlist_id) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Add items to a playlist +#[tauri::command] +pub async fn playlist_add_items( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + playlist_id: String, + item_ids: Vec, +) -> Result<(), String> { + debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len()); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().add_to_playlist(&playlist_id, &item_ids) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs) +#[tauri::command] +pub async fn playlist_remove_items( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + playlist_id: String, + entry_ids: Vec, +) -> Result<(), String> { + debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len()); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids) + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Move a playlist item to a new position +#[tauri::command] +pub async fn playlist_move_item( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + playlist_id: String, + item_id: String, + new_index: u32, +) -> Result<(), String> { + debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index); + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index) + .await + .map_err(|e| format!("{:?}", e)) +} diff --git a/src-tauri/src/jellyfin/client.rs b/src-tauri/src/jellyfin/client.rs index 4b2d115..7786e69 100644 --- a/src-tauri/src/jellyfin/client.rs +++ b/src-tauri/src/jellyfin/client.rs @@ -224,20 +224,20 @@ impl JellyfinClient { debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}", session_id, item_ids.len(), start_index); - // Build URL with query parameters (Jellyfin expects query params, not JSON body!) + // Build URL with query parameters (Jellyfin expects PascalCase query params) let mut url = format!( - "{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}", + "{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}", self.config.server_url, session_id, start_index ); // Add item IDs as repeated query parameters for item_id in &item_ids { - url.push_str(&format!("&itemIds={}", item_id)); + url.push_str(&format!("&ItemIds={}", item_id)); } // Add start position if provided if let Some(ticks) = start_position_ticks { - url.push_str(&format!("&startPositionTicks={}", ticks)); + url.push_str(&format!("&StartPositionTicks={}", ticks)); log::info!("[JellyfinClient] Starting at position: {} ticks", ticks); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f016e5..0bf2c95 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -100,6 +100,9 @@ use commands::{ repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_person, repository_get_items_by_person, repository_get_similar_items, + // Playlist commands + playlist_create, playlist_delete, playlist_rename, playlist_get_items, + playlist_add_items, playlist_remove_items, playlist_move_item, // Conversion commands format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds, calc_progress, convert_percent_to_volume, @@ -769,6 +772,14 @@ pub fn run() { repository_get_person, repository_get_items_by_person, repository_get_similar_items, + // Playlist commands + playlist_create, + playlist_delete, + playlist_rename, + playlist_get_items, + playlist_add_items, + playlist_remove_items, + playlist_move_item, // Conversion commands format_time_seconds, format_time_seconds_long, diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 5df6fd3..42f8690 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -770,8 +770,18 @@ impl PlayerController { } // For video episodes, fetch next episode and show popup + // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended). + // It's here for the Android ExoPlayer path where video items may be in the backend queue. if current.media_type == MediaType::Video && self.is_episode_item(¤t).await { - if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? { + let repo = self.repository.lock().unwrap().clone(); + let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id); + let next_ep_result = if let Some(repo) = &repo { + self.fetch_next_episode_for_item(jellyfin_id, repo).await? + } else { + debug!("[PlayerController] No repository available for audio-path episode lookup"); + None + }; + if let Some(next_ep) = next_ep_result { let settings = self.autoplay_settings.lock().unwrap().clone(); // Check if auto-play episode limit is reached @@ -806,6 +816,78 @@ impl PlayerController { } } + /// Handle video playback ended from HTML5 video element. + /// + /// HTML5 video plays independently of the Rust backend, so the backend + /// queue has no knowledge of the video item. This method bypasses the + /// queue lookup and end_reason check, using the provided Jellyfin item ID + /// to look up the item and check for next episodes. + pub async fn on_video_playback_ended( + &self, + item_id: &str, + repo: Arc, + ) -> Result { + // Clear any stale end_reason (e.g., UserStop from stopping audio before video) + let stale_reason = self.take_end_reason(); + if stale_reason.is_some() { + debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason); + } + + debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id); + + // Check sleep timer state + let timer_mode = { + let timer = self.sleep_timer.lock().unwrap(); + timer.mode.clone() + }; + + match &timer_mode { + SleepTimerMode::Time { end_time } => { + let now = chrono::Utc::now().timestamp_millis(); + if now >= *end_time { + debug!("[PlayerController] Time-based sleep timer expired at video end"); + self.sleep_timer.lock().unwrap().cancel(); + self.emit_sleep_timer_changed(); + return Ok(AutoplayDecision::Stop); + } + } + SleepTimerMode::EndOfTrack => { + self.sleep_timer.lock().unwrap().cancel(); + self.emit_sleep_timer_changed(); + return Ok(AutoplayDecision::Stop); + } + SleepTimerMode::Episodes { .. } => { + let should_stop = self.sleep_timer.lock().unwrap().decrement_episode(); + self.emit_sleep_timer_changed(); + if should_stop { + return Ok(AutoplayDecision::Stop); + } + } + _ => {} + } + + // Fetch next episode for the video that just ended + if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? { + let settings = self.autoplay_settings.lock().unwrap().clone(); + + let limit_reached = self.increment_autoplay_count(); + if limit_reached { + debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes); + } + + return Ok(AutoplayDecision::ShowNextEpisodePopup { + current_episode: next_ep.0, + next_episode: next_ep.1, + countdown_seconds: settings.countdown_seconds, + auto_advance: settings.enabled && !limit_reached, + }); + } + + // No next episode found + debug!("[PlayerController] No next episode found for {}", item_id); + Ok(AutoplayDecision::Stop) + } + /// Check if a media item is an episode (has Jellyfin ID to query) async fn is_episode_item(&self, item: &MediaItem) -> bool { // For now, assume video items are episodes @@ -813,34 +895,63 @@ impl PlayerController { item.media_type == MediaType::Video } - /// Fetch next episode for a series (using Repository) - async fn fetch_next_episode_for_item(&self, current: &MediaItem) -> Result, String> { - let repo = self.repository.lock().unwrap().clone(); - let Some(repo) = repo else { - return Ok(None); - }; + /// Fetch next episode for a series by looking up the season's episodes + /// sorted by index number and picking the one after the current episode. + /// + /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API + /// (which relies on watch history that may not be updated yet due to + /// the async nature of playback progress reporting). + async fn fetch_next_episode_for_item( + &self, + item_id: &str, + repo: &Arc, + ) -> Result, String> { + use crate::repository::types::GetItemsOptions; - let jellyfin_id = current.jellyfin_id() - .ok_or_else(|| "No Jellyfin ID for current item".to_string())?; - - // First, get the current item details from repository - let current_repo_item = repo.get_item(jellyfin_id) + // Get the current item details from repository + let current_repo_item = repo.get_item(item_id) .await .map_err(|e| format!("Failed to get current item: {}", e))?; - let series_id = current_repo_item.series_id.clone() - .ok_or_else(|| "Current item is not an episode".to_string())?; - - // Fetch next up episodes for this series - let next_episodes = repo.get_next_up_episodes(Some(&series_id), Some(1)) - .await - .map_err(|e| format!("Failed to fetch next episodes: {}", e))?; - - if let Some(next) = next_episodes.first() { - // Verify it's not the same episode - if next.id != current_repo_item.id { - return Ok(Some((current_repo_item, next.clone()))); + // Need season_id to fetch sibling episodes + let season_id = match ¤t_repo_item.season_id { + Some(sid) => sid.clone(), + None => { + debug!("[PlayerController] Current item has no season_id, cannot find next episode"); + return Ok(None); } + }; + + // Fetch all episodes in the season sorted by episode number + let options = GetItemsOptions { + sort_by: Some("IndexNumber".to_string()), + sort_order: Some("Ascending".to_string()), + limit: Some(500), + include_item_types: Some(vec!["Episode".to_string()]), + ..Default::default() + }; + + let result = repo.get_items(&season_id, Some(options)) + .await + .map_err(|e| format!("Failed to fetch season episodes: {}", e))?; + + // Sort client-side by index_number to ensure correct ordering + // (offline repo ignores sort_by and sorts by sort_name instead) + let mut episodes = result.items; + episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX)); + debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id); + + // Find the current episode by ID and return the next one + if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) { + if current_idx + 1 < episodes.len() { + let next = &episodes[current_idx + 1]; + debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1); + return Ok(Some((current_repo_item, next.clone()))); + } else { + debug!("[PlayerController] Current episode is the last in the season"); + } + } else { + debug!("[PlayerController] Current episode not found in season episodes"); } Ok(None) diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index 4f15569..bffaea7 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -414,6 +414,107 @@ impl MediaRepository for HybridRepository { self.parallel_race(cache_future, server_future).await } + + // ===== Playlist Methods ===== + + async fn create_playlist( + &self, + name: &str, + item_ids: &[String], + ) -> Result { + // Write operation - delegate directly to server + self.online.create_playlist(name, item_ids).await + } + + async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> { + // Write operation - delegate directly to server + self.online.delete_playlist(playlist_id).await + } + + async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> { + // Write operation - delegate directly to server + self.online.rename_playlist(playlist_id, name).await + } + + async fn get_playlist_items( + &self, + playlist_id: &str, + ) -> Result, RepoError> { + let offline = Arc::clone(&self.offline); + let offline_for_save = Arc::clone(&self.offline); + let online = Arc::clone(&self.online); + let playlist_id = playlist_id.to_string(); + let playlist_id_clone = playlist_id.clone(); + let playlist_id_for_save = playlist_id.clone(); + + let cache_future = self.cache_with_timeout(async move { + offline.get_playlist_items(&playlist_id).await + }); + + let server_future = async move { + online.get_playlist_items(&playlist_id_clone).await + }; + + let (cache_result, server_result) = tokio::join!(cache_future, server_future); + + let cache_had_content = cache_result.as_ref() + .map(|data| data.has_content()) + .unwrap_or(false); + + if cache_had_content { + // If server also succeeded, update cache in background + if let Ok(server_entries) = server_result { + tokio::spawn(async move { + if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await { + warn!("[HybridRepo] Failed to update playlist cache: {:?}", e); + } + }); + } + return cache_result; + } + + // Cache miss - use server result + match server_result { + Ok(entries) => { + let entries_clone = entries.clone(); + tokio::spawn(async move { + if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await { + warn!("[HybridRepo] Failed to save playlist items to cache: {:?}", e); + } + }); + Ok(entries) + } + Err(e) => cache_result.or(Err(e)), + } + } + + async fn add_to_playlist( + &self, + playlist_id: &str, + item_ids: &[String], + ) -> Result<(), RepoError> { + // Write operation - delegate directly to server + self.online.add_to_playlist(playlist_id, item_ids).await + } + + async fn remove_from_playlist( + &self, + playlist_id: &str, + entry_ids: &[String], + ) -> Result<(), RepoError> { + // Write operation - delegate directly to server + self.online.remove_from_playlist(playlist_id, entry_ids).await + } + + async fn move_playlist_item( + &self, + playlist_id: &str, + item_id: &str, + new_index: u32, + ) -> Result<(), RepoError> { + // Write operation - delegate directly to server + self.online.move_playlist_item(playlist_id, item_id, new_index).await + } } #[cfg(test)] @@ -562,6 +663,34 @@ mod tests { async fn get_similar_items(&self, _item_id: &str, _limit: Option) -> Result { unimplemented!() } + + async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result { + unimplemented!() + } + + async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> { + unimplemented!() + } + + async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> { + unimplemented!() + } + + async fn get_playlist_items(&self, _playlist_id: &str) -> Result, RepoError> { + unimplemented!() + } + + async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> { + unimplemented!() + } + + async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> { + unimplemented!() + } + + async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> { + unimplemented!() + } } /// Mock online repository that returns predefined items @@ -691,6 +820,34 @@ mod tests { async fn get_similar_items(&self, _item_id: &str, _limit: Option) -> Result { unimplemented!() } + + async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result { + unimplemented!() + } + + async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> { + unimplemented!() + } + + async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> { + unimplemented!() + } + + async fn get_playlist_items(&self, _playlist_id: &str) -> Result, RepoError> { + unimplemented!() + } + + async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> { + unimplemented!() + } + + async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> { + unimplemented!() + } + + async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> { + unimplemented!() + } } fn create_test_item(id: &str, name: &str) -> MediaItem { diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index 560f5ce..82e47e3 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -191,4 +191,68 @@ pub trait MediaRepository: Send + Sync { item_id: &str, limit: Option, ) -> Result; + + // ===== Playlist Methods ===== + + /// Create a new playlist on the server + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-019 - Get/create/update playlists + async fn create_playlist( + &self, + name: &str, + item_ids: &[String], + ) -> Result; + + /// Delete a playlist + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-019 - Get/create/update playlists + async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>; + + /// Rename a playlist + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-019 - Get/create/update playlists + async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>; + + /// Get playlist items with PlaylistItemId (needed for remove/reorder) + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-019 - Get/create/update playlists + async fn get_playlist_items( + &self, + playlist_id: &str, + ) -> Result, RepoError>; + + /// Add items to a playlist + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-020 - Add/remove items from playlist + async fn add_to_playlist( + &self, + playlist_id: &str, + item_ids: &[String], + ) -> Result<(), RepoError>; + + /// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs) + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-020 - Add/remove items from playlist + async fn remove_from_playlist( + &self, + playlist_id: &str, + entry_ids: &[String], + ) -> Result<(), RepoError>; + + /// Move a playlist item to a new position + /// + /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin + /// @req: JA-020 - Add/remove items from playlist + async fn move_playlist_item( + &self, + playlist_id: &str, + item_id: &str, + new_index: u32, + ) -> Result<(), RepoError>; } diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 381701d..88bddfe 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -346,6 +346,56 @@ impl OfflineRepository { Ok(count) } + + /// Cache playlist items from server into local database + /// Called by HybridRepository after fetching from online + pub async fn save_playlist_items_to_cache( + &self, + playlist_id: &str, + entries: &[PlaylistEntry], + ) -> Result<(), RepoError> { + let playlist_id = playlist_id.to_string(); + let user_id = self.user_id.clone(); + let entries: Vec<(String, String, usize)> = entries + .iter() + .enumerate() + .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i)) + .collect(); + + self.db_service + .transaction(move |tx| { + use crate::storage::db_service::{Query, QueryParam}; + + // Ensure playlist record exists + tx.execute(Query::with_params( + "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)", + vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)], + ))?; + + // Clear existing entries and re-insert + tx.execute(Query::with_params( + "DELETE FROM playlist_items WHERE playlist_id = ?", + vec![QueryParam::String(playlist_id.clone())], + ))?; + + for (_, item_id, sort_order) in &entries { + tx.execute(Query::with_params( + "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)", + vec![ + QueryParam::String(playlist_id.clone()), + QueryParam::String(item_id.clone()), + QueryParam::Int(*sort_order as i32), + ], + ))?; + } + + Ok(()) + }) + .await + .map_err(|e| RepoError::Database { + message: format!("Failed to cache playlist items: {}", e), + }) + } } #[async_trait] @@ -1088,6 +1138,254 @@ impl MediaRepository for OfflineRepository { // Similar items require server-side computation and are not available offline Err(RepoError::Offline) } + + // ===== Playlist Methods ===== + + async fn create_playlist( + &self, + name: &str, + item_ids: &[String], + ) -> Result { + let playlist_id = uuid::Uuid::new_v4().to_string(); + let user_id = self.user_id.clone(); + let name = name.to_string(); + let item_ids = item_ids.to_vec(); + let pid = playlist_id.clone(); + + self.db_service + .transaction(move |tx| { + use crate::storage::db_service::{Query, QueryParam}; + + tx.execute(Query::with_params( + "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)", + vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)], + ))?; + + for (i, item_id) in item_ids.iter().enumerate() { + tx.execute(Query::with_params( + "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)", + vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)], + ))?; + } + + Ok(()) + }) + .await + .map_err(|e| RepoError::Database { + message: format!("Failed to create playlist: {}", e), + })?; + + Ok(PlaylistCreatedResult { id: playlist_id }) + } + + async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> { + let query = Query::with_params( + "DELETE FROM playlists WHERE id = ?", + vec![QueryParam::String(playlist_id.to_string())], + ); + self.db_service.execute(query).await.map_err(|e| RepoError::Database { + message: format!("Failed to delete playlist: {}", e), + })?; + Ok(()) + } + + async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> { + let query = Query::with_params( + "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + vec![ + QueryParam::String(name.to_string()), + QueryParam::String(playlist_id.to_string()), + ], + ); + self.db_service.execute(query).await.map_err(|e| RepoError::Database { + message: format!("Failed to rename playlist: {}", e), + })?; + Ok(()) + } + + async fn get_playlist_items( + &self, + playlist_id: &str, + ) -> Result, RepoError> { + let query = Query::with_params( + "SELECT pi.id, \ + i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \ + i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ + i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \ + i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \ + i.parent_index_number \ + FROM playlist_items pi \ + JOIN items i ON pi.item_id = i.id \ + WHERE pi.playlist_id = ? \ + ORDER BY pi.sort_order ASC", + vec![QueryParam::String(playlist_id.to_string())], + ); + + let items = self.db_service + .query_many(query, |row| { + let entry_id: i64 = row.get(0)?; + // Columns offset by 1 because first column is pi.id + let cached = CachedItem { + id: row.get(1)?, + name: row.get(2)?, + item_type: row.get(3)?, + server_id: row.get(4)?, + parent_id: row.get(5)?, + library_id: row.get(6)?, + overview: row.get(7)?, + genres: row.get(8)?, + runtime_ticks: row.get(9)?, + production_year: row.get(10)?, + community_rating: row.get(11)?, + official_rating: row.get(12)?, + primary_image_tag: row.get(13)?, + backdrop_image_tags: None, + parent_backdrop_image_tags: None, + album_id: row.get(14)?, + album_name: row.get(15)?, + album_artist: row.get(16)?, + artists: row.get(17)?, + index_number: row.get(18)?, + series_id: row.get(19)?, + series_name: row.get(20)?, + season_id: row.get(21)?, + season_name: row.get(22)?, + parent_index_number: row.get(23)?, + }; + Ok((entry_id.to_string(), cached)) + }) + .await + .map_err(|e| RepoError::Database { + message: format!("Failed to get playlist items: {}", e), + })?; + + Ok(items + .into_iter() + .map(|(entry_id, cached)| PlaylistEntry { + playlist_item_id: entry_id, + item: Self::cached_item_to_media_item(cached, None), + }) + .collect()) + } + + async fn add_to_playlist( + &self, + playlist_id: &str, + item_ids: &[String], + ) -> Result<(), RepoError> { + // Get current max sort_order + let max_query = Query::with_params( + "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?", + vec![QueryParam::String(playlist_id.to_string())], + ); + let max_order: i32 = self.db_service + .query_one(max_query, |row| row.get(0)) + .await + .unwrap_or(-1); + + let playlist_id = playlist_id.to_string(); + let item_ids = item_ids.to_vec(); + + self.db_service + .transaction(move |tx| { + use crate::storage::db_service::{Query, QueryParam}; + + for (i, item_id) in item_ids.iter().enumerate() { + tx.execute(Query::with_params( + "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)", + vec![ + QueryParam::String(playlist_id.clone()), + QueryParam::String(item_id.clone()), + QueryParam::Int(max_order + 1 + i as i32), + ], + ))?; + } + Ok(()) + }) + .await + .map_err(|e| RepoError::Database { + message: format!("Failed to add items to playlist: {}", e), + })?; + + Ok(()) + } + + async fn remove_from_playlist( + &self, + playlist_id: &str, + entry_ids: &[String], + ) -> Result<(), RepoError> { + let playlist_id = playlist_id.to_string(); + let entry_ids = entry_ids.to_vec(); + + self.db_service + .transaction(move |tx| { + use crate::storage::db_service::{Query, QueryParam}; + + for entry_id in &entry_ids { + tx.execute(Query::with_params( + "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?", + vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())], + ))?; + } + Ok(()) + }) + .await + .map_err(|e| RepoError::Database { + message: format!("Failed to remove items from playlist: {}", e), + })?; + + Ok(()) + } + + async fn move_playlist_item( + &self, + playlist_id: &str, + item_id: &str, + new_index: u32, + ) -> Result<(), RepoError> { + let playlist_id = playlist_id.to_string(); + let item_id = item_id.to_string(); + + self.db_service + .transaction(move |tx| { + use crate::storage::db_service::{Query, QueryParam}; + + // Get all items ordered by sort_order + let items: Vec<(i64, String)> = tx.query_many( + Query::with_params( + "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order", + vec![QueryParam::String(playlist_id)], + ), + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + + // Find the item to move + let old_idx = items.iter().position(|(_, iid)| iid == &item_id); + if let Some(old_pos) = old_idx { + let mut ids = items; + let entry = ids.remove(old_pos); + let insert_at = (new_index as usize).min(ids.len()); + ids.insert(insert_at, entry); + + // Renumber all sort_orders + for (i, (entry_id, _)) in ids.iter().enumerate() { + tx.execute(Query::with_params( + "UPDATE playlist_items SET sort_order = ? WHERE id = ?", + vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)], + ))?; + } + } + + Ok(()) + }) + .await + .map_err(|e| RepoError::Database { + message: format!("Failed to move playlist item: {}", e), + })?; + + Ok(()) + } } #[cfg(test)] @@ -1153,6 +1451,27 @@ mod tests { playback_context_id TEXT, PRIMARY KEY (user_id, item_id) ); + + CREATE TABLE playlists ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + is_local INTEGER DEFAULT 0, + jellyfin_id TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT + ); + + CREATE TABLE playlist_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL, + added_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(playlist_id, item_id) + ); + + CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order); "#).unwrap(); // Insert a test server @@ -1330,4 +1649,244 @@ mod tests { assert!(result.is_ok(), "Simple case should work: {:?}", result); assert_eq!(result.unwrap(), 3); } + + // ===== Playlist Tests ===== + + /// Helper to seed items into the DB for playlist tests + async fn seed_items(repo: &OfflineRepository, ids: &[&str]) { + let items: Vec = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect(); + repo.save_to_cache("library-1", &items).await.unwrap(); + } + + #[tokio::test] + async fn test_playlist_create_empty() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + + let result = repo.create_playlist("My Playlist", &[]).await; + assert!(result.is_ok()); + let created = result.unwrap(); + assert!(!created.id.is_empty(), "Should return a non-empty playlist ID"); + + // Verify playlist exists in DB + let name: String = db_service + .query_one( + Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]), + |row| row.get(0), + ) + .await + .unwrap(); + assert_eq!(name, "My Playlist"); + } + + #[tokio::test] + async fn test_playlist_create_with_items() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["t1", "t2", "t3"]).await; + + let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + assert_eq!(items.len(), 3); + assert_eq!(items[0].item.id, "t1"); + assert_eq!(items[1].item.id, "t2"); + assert_eq!(items[2].item.id, "t3"); + } + + #[tokio::test] + async fn test_playlist_delete() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["t1"]).await; + + let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap(); + + // Delete it + repo.delete_playlist(&created.id).await.unwrap(); + + // Verify playlist is gone + let count: i32 = db_service + .query_one( + Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]), + |row| row.get(0), + ) + .await + .unwrap(); + assert_eq!(count, 0); + + // Verify cascade deleted playlist_items + let item_count: i32 = db_service + .query_one( + Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]), + |row| row.get(0), + ) + .await + .unwrap(); + assert_eq!(item_count, 0); + } + + #[tokio::test] + async fn test_playlist_rename() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + + let created = repo.create_playlist("Original Name", &[]).await.unwrap(); + repo.rename_playlist(&created.id, "New Name").await.unwrap(); + + let name: String = db_service + .query_one( + Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]), + |row| row.get(0), + ) + .await + .unwrap(); + assert_eq!(name, "New Name"); + } + + #[tokio::test] + async fn test_playlist_get_items_preserves_order() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["a", "b", "c"]).await; + + let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap(); + let items = repo.get_playlist_items(&created.id).await.unwrap(); + + assert_eq!(items.len(), 3); + // Order should match insertion order: c, a, b + assert_eq!(items[0].item.id, "c"); + assert_eq!(items[1].item.id, "a"); + assert_eq!(items[2].item.id, "b"); + // Each entry should have a unique playlist_item_id + assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id); + assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id); + } + + #[tokio::test] + async fn test_playlist_get_items_empty_playlist() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + + let created = repo.create_playlist("Empty", &[]).await.unwrap(); + let items = repo.get_playlist_items(&created.id).await.unwrap(); + assert!(items.is_empty()); + } + + #[tokio::test] + async fn test_playlist_add_items() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["t1", "t2", "t3"]).await; + + let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap(); + + // Add two more tracks + repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + assert_eq!(items.len(), 3); + assert_eq!(items[0].item.id, "t1"); + assert_eq!(items[1].item.id, "t2"); + assert_eq!(items[2].item.id, "t3"); + } + + #[tokio::test] + async fn test_playlist_add_duplicate_items_ignored() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["t1"]).await; + + let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap(); + + // Try to add the same item again + repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)"); + } + + #[tokio::test] + async fn test_playlist_remove_items() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["t1", "t2", "t3"]).await; + + let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap(); + let items = repo.get_playlist_items(&created.id).await.unwrap(); + assert_eq!(items.len(), 3); + + // Remove the middle track by its entry ID + let entry_id_to_remove = items[1].playlist_item_id.clone(); + repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap(); + + let items_after = repo.get_playlist_items(&created.id).await.unwrap(); + assert_eq!(items_after.len(), 2); + assert_eq!(items_after[0].item.id, "t1"); + assert_eq!(items_after[1].item.id, "t3"); + } + + #[tokio::test] + async fn test_playlist_move_item_forward() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["a", "b", "c", "d"]).await; + + let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap(); + + // Move 'a' (index 0) to index 2: expect b, c, a, d + repo.move_playlist_item(&created.id, "a", 2).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); + assert_eq!(ids, vec!["b", "c", "a", "d"]); + } + + #[tokio::test] + async fn test_playlist_move_item_backward() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["a", "b", "c", "d"]).await; + + let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap(); + + // Move 'd' (index 3) to index 0: expect d, a, b, c + repo.move_playlist_item(&created.id, "d", 0).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); + assert_eq!(ids, vec!["d", "a", "b", "c"]); + } + + #[tokio::test] + async fn test_playlist_move_item_to_end() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["a", "b", "c"]).await; + + let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap(); + + // Move 'a' to index 99 (beyond end, should clamp): expect b, c, a + repo.move_playlist_item(&created.id, "a", 99).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); + assert_eq!(ids, vec!["b", "c", "a"]); + } + + #[tokio::test] + async fn test_playlist_move_nonexistent_item_is_noop() { + let db_service = create_test_db(); + let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); + seed_items(&repo, &["a", "b"]).await; + + let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap(); + + // Move a nonexistent item - should not error, just no-op + repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap(); + + let items = repo.get_playlist_items(&created.id).await.unwrap(); + let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"]); + } } diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 7546240..6c8503b 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -257,6 +257,31 @@ struct ItemsResponse { total_record_count: usize, } +/// Jellyfin playlist creation response +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct CreatePlaylistResponse { + id: String, +} + +/// Jellyfin playlist items response — items include PlaylistItemId +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +#[allow(dead_code)] +struct PlaylistItemsResponse { + items: Vec, + total_record_count: usize, +} + +/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct JellyfinPlaylistItem { + playlist_item_id: String, + #[serde(flatten)] + item: JellyfinItem, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct JellyfinItem { @@ -1192,6 +1217,146 @@ impl MediaRepository for OnlineRepository { total_record_count: response.total_record_count, }) } + + // ===== Playlist Methods ===== + + async fn create_playlist( + &self, + name: &str, + item_ids: &[String], + ) -> Result { + info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len()); + let body = serde_json::json!({ + "Name": name, + "Ids": item_ids, + "MediaType": "Audio", + "UserId": self.user_id, + }); + let response: CreatePlaylistResponse = + self.post_json_response("/Playlists", &body).await?; + Ok(PlaylistCreatedResult { id: response.id }) + } + + async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> { + info!("[OnlineRepo] Deleting playlist {}", playlist_id); + let endpoint = format!("/Items/{}", playlist_id); + let url = format!("{}{}", self.server_url, endpoint); + + let request = self.http_client.client.delete(&url) + .header("X-Emby-Authorization", self.auth_header()) + .build() + .map_err(|e| RepoError::Network { + message: format!("Failed to build request: {}", e), + })?; + + let response = self.http_client.request_with_retry(request).await + .map_err(|e| RepoError::Network { message: e.to_string() })?; + + if !response.status().is_success() { + return Err(RepoError::Server { + message: format!("HTTP {}", response.status()), + }); + } + + Ok(()) + } + + async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> { + info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name); + let endpoint = format!("/Items/{}", playlist_id); + self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await + } + + async fn get_playlist_items( + &self, + playlist_id: &str, + ) -> Result, RepoError> { + let endpoint = format!( + "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000", + playlist_id, self.user_id + ); + + let response: PlaylistItemsResponse = self.get_json(&endpoint).await?; + debug!( + "[OnlineRepo] Got {} playlist items for {}", + response.items.len(), + playlist_id + ); + + Ok(response + .items + .into_iter() + .map(|pi| PlaylistEntry { + playlist_item_id: pi.playlist_item_id, + item: pi.item.to_media_item(self.user_id.clone()), + }) + .collect()) + } + + async fn add_to_playlist( + &self, + playlist_id: &str, + item_ids: &[String], + ) -> Result<(), RepoError> { + info!( + "[OnlineRepo] Adding {} items to playlist {}", + item_ids.len(), + playlist_id + ); + let ids_param = item_ids.join(","); + let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param); + self.post_json(&endpoint, &serde_json::json!({})).await + } + + async fn remove_from_playlist( + &self, + playlist_id: &str, + entry_ids: &[String], + ) -> Result<(), RepoError> { + info!( + "[OnlineRepo] Removing {} entries from playlist {}", + entry_ids.len(), + playlist_id + ); + let ids_param = entry_ids.join(","); + let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param); + let url = format!("{}{}", self.server_url, endpoint); + + let request = self.http_client.client.delete(&url) + .header("X-Emby-Authorization", self.auth_header()) + .build() + .map_err(|e| RepoError::Network { + message: format!("Failed to build request: {}", e), + })?; + + let response = self.http_client.request_with_retry(request).await + .map_err(|e| RepoError::Network { message: e.to_string() })?; + + if !response.status().is_success() { + return Err(RepoError::Server { + message: format!("HTTP {}", response.status()), + }); + } + + Ok(()) + } + + async fn move_playlist_item( + &self, + playlist_id: &str, + item_id: &str, + new_index: u32, + ) -> Result<(), RepoError> { + info!( + "[OnlineRepo] Moving item {} in playlist {} to index {}", + item_id, playlist_id, new_index + ); + let endpoint = format!( + "/Playlists/{}/Items/{}/Move/{}", + playlist_id, item_id, new_index + ); + self.post_json(&endpoint, &serde_json::json!({})).await + } } #[cfg(test)] diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 0cb7e28..5317c26 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -331,6 +331,41 @@ impl MeaningfulContent for PlaybackInfo { } } +/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId +/// needed for remove/reorder operations (distinct from the media item's ID) +/// +/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistEntry { + /// The playlist-scoped entry ID (Jellyfin's PlaylistItemId) + pub playlist_item_id: String, + /// The underlying media item + #[serde(flatten)] + pub item: MediaItem, +} + +/// Result of creating a playlist +/// +/// @req: JA-019 - Get/create/update playlists +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistCreatedResult { + pub id: String, +} + +impl MeaningfulContent for Vec { + fn has_content(&self) -> bool { + !self.is_empty() + } +} + +impl MeaningfulContent for PlaylistCreatedResult { + fn has_content(&self) -> bool { + !self.id.is_empty() + } +} + #[cfg(test)] mod tests { use super::*; @@ -563,4 +598,103 @@ mod tests { assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field"); assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor"); } + + #[test] + fn test_playlist_entry_serialization() { + let entry = PlaylistEntry { + playlist_item_id: "entry-abc-123".to_string(), + item: MediaItem { + id: "track1".to_string(), + name: "Test Track".to_string(), + item_type: "Audio".to_string(), + server_id: "server1".to_string(), + parent_id: None, + library_id: None, + overview: None, + genres: None, + production_year: None, + community_rating: None, + official_rating: None, + runtime_ticks: None, + primary_image_tag: None, + backdrop_image_tags: None, + parent_backdrop_image_tags: None, + album_id: None, + album_name: None, + album_artist: None, + artists: Some(vec!["Artist One".to_string()]), + artist_items: None, + index_number: None, + parent_index_number: None, + series_id: None, + series_name: None, + season_id: None, + season_name: None, + user_data: None, + media_streams: None, + media_sources: None, + people: None, + }, + }; + + let json = serde_json::to_string(&entry).expect("Failed to serialize"); + // playlistItemId is camelCase + assert!(json.contains(r#""playlistItemId":"entry-abc-123""#)); + // Flattened MediaItem fields appear at top level + assert!(json.contains(r#""id":"track1""#)); + assert!(json.contains(r#""name":"Test Track""#)); + assert!(json.contains(r#""type":"Audio""#)); + } + + #[test] + fn test_playlist_created_result_serialization() { + let result = PlaylistCreatedResult { + id: "playlist-new-123".to_string(), + }; + let json = serde_json::to_string(&result).expect("Failed to serialize"); + assert!(json.contains(r#""id":"playlist-new-123""#)); + } + + #[test] + fn test_playlist_entry_meaningful_content() { + let empty: Vec = vec![]; + assert!(!empty.has_content()); + + let non_empty = vec![PlaylistEntry { + playlist_item_id: "e1".to_string(), + item: MediaItem { + id: "1".to_string(), + name: "Track".to_string(), + item_type: "Audio".to_string(), + server_id: "s1".to_string(), + parent_id: None, + library_id: None, + overview: None, + genres: None, + production_year: None, + community_rating: None, + official_rating: None, + runtime_ticks: None, + primary_image_tag: None, + backdrop_image_tags: None, + parent_backdrop_image_tags: None, + album_id: None, + album_name: None, + album_artist: None, + artists: None, + artist_items: None, + index_number: None, + parent_index_number: None, + series_id: None, + series_name: None, + season_id: None, + season_name: None, + user_data: None, + media_streams: None, + media_sources: None, + people: None, + }, + }]; + assert!(non_empty.has_content()); + } } diff --git a/src-tauri/src/storage/db_service.rs b/src-tauri/src/storage/db_service.rs index d9443b4..cfeb381 100644 --- a/src-tauri/src/storage/db_service.rs +++ b/src-tauri/src/storage/db_service.rs @@ -100,6 +100,13 @@ impl<'a> Transaction<'a> { pub fn execute(&mut self, query: Query) -> DbResult { execute_query(self.conn, query) } + + pub fn query_many(&self, query: Query, mapper: F) -> DbResult> + where + F: Fn(&Row) -> SqliteResult, + { + query_many(self.conn, query, mapper) + } } /// Rusqlite-based database service implementation diff --git a/src/lib/api/repository-client.test.ts b/src/lib/api/repository-client.test.ts index c90bd3a..4fd3bf6 100644 --- a/src/lib/api/repository-client.test.ts +++ b/src/lib/api/repository-client.test.ts @@ -402,6 +402,123 @@ describe("RepositoryClient", () => { }); }); + describe("Playlist Methods", () => { + beforeEach(async () => { + (invoke as any).mockResolvedValueOnce("test-handle-123"); + await client.create("https://server.com", "user1", "token123", "server1"); + }); + + it("should create a playlist", async () => { + const mockResult = { id: "playlist-001" }; + (invoke as any).mockResolvedValueOnce(mockResult); + + const result = await client.createPlaylist("My Playlist", ["track1", "track2"]); + + expect(result).toEqual(mockResult); + expect(invoke).toHaveBeenCalledWith("playlist_create", { + handle: "test-handle-123", + name: "My Playlist", + itemIds: ["track1", "track2"], + }); + }); + + it("should create a playlist without initial items", async () => { + const mockResult = { id: "playlist-002" }; + (invoke as any).mockResolvedValueOnce(mockResult); + + await client.createPlaylist("Empty Playlist"); + + expect(invoke).toHaveBeenCalledWith("playlist_create", { + handle: "test-handle-123", + name: "Empty Playlist", + itemIds: null, + }); + }); + + it("should delete a playlist", async () => { + (invoke as any).mockResolvedValueOnce(undefined); + + await client.deletePlaylist("playlist-001"); + + expect(invoke).toHaveBeenCalledWith("playlist_delete", { + handle: "test-handle-123", + playlistId: "playlist-001", + }); + }); + + it("should rename a playlist", async () => { + (invoke as any).mockResolvedValueOnce(undefined); + + await client.renamePlaylist("playlist-001", "New Name"); + + expect(invoke).toHaveBeenCalledWith("playlist_rename", { + handle: "test-handle-123", + playlistId: "playlist-001", + name: "New Name", + }); + }); + + it("should get playlist items", async () => { + const mockItems = [ + { playlistItemId: "entry1", id: "track1", name: "Track 1", type: "Audio" }, + { playlistItemId: "entry2", id: "track2", name: "Track 2", type: "Audio" }, + ]; + (invoke as any).mockResolvedValueOnce(mockItems); + + const items = await client.getPlaylistItems("playlist-001"); + + expect(items).toEqual(mockItems); + expect(invoke).toHaveBeenCalledWith("playlist_get_items", { + handle: "test-handle-123", + playlistId: "playlist-001", + }); + }); + + it("should add items to a playlist", async () => { + (invoke as any).mockResolvedValueOnce(undefined); + + await client.addToPlaylist("playlist-001", ["track3", "track4"]); + + expect(invoke).toHaveBeenCalledWith("playlist_add_items", { + handle: "test-handle-123", + playlistId: "playlist-001", + itemIds: ["track3", "track4"], + }); + }); + + it("should remove items from a playlist using entry IDs", async () => { + (invoke as any).mockResolvedValueOnce(undefined); + + await client.removeFromPlaylist("playlist-001", ["entry1", "entry2"]); + + expect(invoke).toHaveBeenCalledWith("playlist_remove_items", { + handle: "test-handle-123", + playlistId: "playlist-001", + entryIds: ["entry1", "entry2"], + }); + }); + + it("should move a playlist item", async () => { + (invoke as any).mockResolvedValueOnce(undefined); + + await client.movePlaylistItem("playlist-001", "track1", 3); + + expect(invoke).toHaveBeenCalledWith("playlist_move_item", { + handle: "test-handle-123", + playlistId: "playlist-001", + itemId: "track1", + newIndex: 3, + }); + }); + + it("should throw error if not initialized before playlist operations", async () => { + const newClient = new RepositoryClient(); + await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized"); + await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized"); + await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized"); + }); + }); + describe("Error Handling", () => { it("should throw error if invoke fails", async () => { (invoke as any).mockRejectedValueOnce(new Error("Network error")); diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index f80d572..75dc568 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -15,6 +15,8 @@ import type { ImageType, ImageOptions, Genre, + PlaylistEntry, + PlaylistCreatedResult, } from "./types"; /** @@ -305,6 +307,63 @@ export class RepositoryClient { }); } + // ===== Playlist Methods (via Rust) ===== + + async createPlaylist(name: string, itemIds?: string[]): Promise { + return invoke("playlist_create", { + handle: this.ensureHandle(), + name, + itemIds: itemIds ?? null, + }); + } + + async deletePlaylist(playlistId: string): Promise { + return invoke("playlist_delete", { + handle: this.ensureHandle(), + playlistId, + }); + } + + async renamePlaylist(playlistId: string, name: string): Promise { + return invoke("playlist_rename", { + handle: this.ensureHandle(), + playlistId, + name, + }); + } + + async getPlaylistItems(playlistId: string): Promise { + return invoke("playlist_get_items", { + handle: this.ensureHandle(), + playlistId, + }); + } + + async addToPlaylist(playlistId: string, itemIds: string[]): Promise { + return invoke("playlist_add_items", { + handle: this.ensureHandle(), + playlistId, + itemIds, + }); + } + + async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise { + return invoke("playlist_remove_items", { + handle: this.ensureHandle(), + playlistId, + entryIds, + }); + } + + async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise { + return invoke("playlist_move_item", { + handle: this.ensureHandle(), + playlistId, + itemId, + newIndex, + }); + } + // ===== Getters ===== get serverUrl(): string { diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 4615e08..1c3afaa 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -240,6 +240,15 @@ export interface Genre { name: string; } +// Playlist types +export interface PlaylistEntry extends MediaItem { + playlistItemId: string; +} + +export interface PlaylistCreatedResult { + id: string; +} + export interface GetItemsOptions { startIndex?: number; limit?: number; diff --git a/src/lib/components/home/HeroBanner.svelte b/src/lib/components/home/HeroBanner.svelte index a3f26a4..4d86509 100644 --- a/src/lib/components/home/HeroBanner.svelte +++ b/src/lib/components/home/HeroBanner.svelte @@ -77,6 +77,7 @@ // Touch/swipe handlers function handleTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX; + touchEndX = e.touches[0].clientX; isSwiping = true; } diff --git a/src/lib/components/library/PlaylistDetailView.svelte b/src/lib/components/library/PlaylistDetailView.svelte new file mode 100644 index 0000000..37c204f --- /dev/null +++ b/src/lib/components/library/PlaylistDetailView.svelte @@ -0,0 +1,312 @@ + + +
+ +
+ +
+ {#if playlist.primaryImageTag} + + {:else} +
+ + + +
+ {/if} +
+ + +
+
+ {#if editingName} + + {:else} + + {/if} +

+ {entries.length} track{entries.length !== 1 ? "s" : ""} + {#if totalDuration > 0} + · {formatDuration(totalDuration)} + {/if} +

+
+ + +
+ + + +
+ + {#if playlist.overview} +

{playlist.overview}

+ {/if} +
+
+ + +
+

Tracks

+ + + {#if !loading && entries.length > 0} +
+ {#each entries as entry, i (entry.playlistItemId)} +
+ +
+ {/each} +
+ {/if} +
+ + + {#if showDeleteConfirm} + +
showDeleteConfirm = false} + onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }} + role="dialog" + aria-modal="true" + tabindex="-1" + > +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="presentation" + > +

Delete Playlist?

+

+ Are you sure you want to delete "{playlist.name}"? This action cannot be undone. +

+
+ + +
+
+
+ {/if} +
diff --git a/src/lib/components/library/TrackList.svelte b/src/lib/components/library/TrackList.svelte index f005fda..b7a2a20 100644 --- a/src/lib/components/library/TrackList.svelte +++ b/src/lib/components/library/TrackList.svelte @@ -8,6 +8,7 @@ import type { MediaItem } from "$lib/api/types"; import DownloadButton from "./DownloadButton.svelte"; import Portal from "$lib/components/Portal.svelte"; + import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte"; import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition"; import { formatDuration } from "$lib/utils/duration"; @@ -41,6 +42,7 @@ let isPlayingTrack = $state(null); let openMenuId = $state(null); let menuPosition = $state(null); + let addToPlaylistTrackId = $state(null); // Track which track is currently playing (from player store) const currentlyPlayingId = $derived($currentMedia?.id ?? null); @@ -494,10 +496,31 @@ Add to Queue + {/if} {/if} + + addToPlaylistTrackId = null} + trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []} +/> + diff --git a/src/lib/components/library/TrackList.test.ts b/src/lib/components/library/TrackList.test.ts index 80958ca..55e1f4c 100644 --- a/src/lib/components/library/TrackList.test.ts +++ b/src/lib/components/library/TrackList.test.ts @@ -34,6 +34,19 @@ vi.mock("./DownloadButton.svelte", () => ({ default: vi.fn(() => ({ $$: {}, $set: vi.fn(), $on: vi.fn(), $destroy: vi.fn() })), })); +vi.mock("$lib/stores/library", () => ({ + library: { + loadLibraries: vi.fn(), + loadItems: vi.fn(), + loadItem: vi.fn(), + setCurrentLibrary: vi.fn(), + }, + libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) }, + libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) }, + currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) }, + isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) }, +})); + // Now import the modules after mocks are set up import { render, fireEvent, waitFor } from "@testing-library/svelte"; import { invoke } from "@tauri-apps/api/core"; diff --git a/src/lib/components/player/Controls.svelte b/src/lib/components/player/Controls.svelte index c9da593..84857c3 100644 --- a/src/lib/components/player/Controls.svelte +++ b/src/lib/components/player/Controls.svelte @@ -75,26 +75,7 @@
- - - - + {#if !$sleepTimerActive} + - {/if} - - - - - - - - - - - - - -
- - - - {#if showOverflowMenu} - {/if} + + + + + +
+ + + + {#if showOverflowMenu} + + {/if} +
+
+ + +
+
+ + + +
+ + + + + +
diff --git a/src/lib/components/player/SleepTimerModal.svelte b/src/lib/components/player/SleepTimerModal.svelte index d26815c..54ff0af 100644 --- a/src/lib/components/player/SleepTimerModal.svelte +++ b/src/lib/components/player/SleepTimerModal.svelte @@ -17,13 +17,27 @@ let { isOpen = false, onClose, mediaType }: Props = $props(); const timePickerItems = [ + // 5 min increments to 30 + { value: 5, label: "5 min" }, + { value: 10, label: "10 min" }, { value: 15, label: "15 min" }, + { value: 20, label: "20 min" }, + { value: 25, label: "25 min" }, { value: 30, label: "30 min" }, + // 15 min increments to 2 hrs { value: 45, label: "45 min" }, - { value: 60, label: "60 min" }, + { value: 60, label: "1 hr" }, + { value: 75, label: "1 hr 15 min" }, + { value: 90, label: "1 hr 30 min" }, + { value: 105, label: "1 hr 45 min" }, + { value: 120, label: "2 hr" }, + // 1 hr increments after + { value: 180, label: "3 hr" }, + { value: 240, label: "4 hr" }, + { value: 300, label: "5 hr" }, ]; - let selectedMinutes = $state(30); + let selectedMinutes = $state(15); const episodePresets = [1, 2, 3]; diff --git a/src/lib/components/player/VolumeControl.svelte b/src/lib/components/player/VolumeControl.svelte index 6abea6a..e9fad16 100644 --- a/src/lib/components/player/VolumeControl.svelte +++ b/src/lib/components/player/VolumeControl.svelte @@ -1,6 +1,8 @@ + +{#if isOpen} + +
{ if (e.key === "Escape") onClose?.(); }} + role="dialog" + aria-modal="true" + tabindex="-1" + > +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="presentation" + > +

Add to Playlist

+ + + + + +
+ {#if loading} + {#each Array(3) as _} +
+
+
+
+ {/each} + {:else if playlists.length === 0} +

No playlists yet

+ {:else} + {#each playlists as playlist (playlist.id)} + + {/each} + {/if} +
+ + +
+
+{/if} + + diff --git a/src/lib/components/playlist/CreatePlaylistModal.svelte b/src/lib/components/playlist/CreatePlaylistModal.svelte new file mode 100644 index 0000000..90fe4c9 --- /dev/null +++ b/src/lib/components/playlist/CreatePlaylistModal.svelte @@ -0,0 +1,92 @@ + + +{#if isOpen} + +
{ if (e.key === "Escape") onClose?.(); }} + role="dialog" + aria-modal="true" + tabindex="-1" + > +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="presentation" + > +

Create Playlist

+ + + + {#if initialItemIds.length > 0} +

+ {initialItemIds.length} track{initialItemIds.length !== 1 ? "s" : ""} will be added. +

+ {/if} + +
+ + +
+
+
+{/if} diff --git a/src/lib/services/nextEpisodeService.ts b/src/lib/services/nextEpisodeService.ts index 66c2731..f6f9d77 100644 --- a/src/lib/services/nextEpisodeService.ts +++ b/src/lib/services/nextEpisodeService.ts @@ -4,27 +4,27 @@ * Handles user interactions with the next episode popup. * Backend manages countdown logic and autoplay decisions. * + * Navigation uses goto() directly to load the next episode. + * The player page's $effect detects the URL param change and + * calls loadAndPlay for the new episode. + * * TRACES: UR-023 | DR-047, DR-048 */ -import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay"; +import { goto } from "$app/navigation"; +import { cancelAutoplayCountdown } from "$lib/api/autoplay"; import { nextEpisode } from "$lib/stores/nextEpisode"; +import type { MediaItem } from "$lib/api/types"; + +/** Guard against double-navigation */ +let isNavigating = false; /** * Cleanup next episode state (called on unmount/destroy) */ export function cleanup() { nextEpisode.reset(); -} - -/** - * Handle episode ended event - * Backend now handles autoplay decisions via on_playback_ended() - * This function is kept for backwards compatibility but does nothing - */ -export async function handleEpisodeEnded(media: any) { - // Backend now handles this - no action needed - // The backend will emit ShowNextEpisodePopup event + isNavigating = false; } /** @@ -36,13 +36,36 @@ export async function cancelAutoPlay() { nextEpisode.hidePopup(); } +/** + * Navigate to the next episode via goto(). + * Uses replaceState to prevent history buildup when auto-advancing. + */ +function navigateToEpisode(episode: MediaItem) { + if (isNavigating) { + console.warn("[NextEpisode] Already navigating, skipping duplicate navigation to", episode.id); + return; + } + isNavigating = true; + console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name); + nextEpisode.hidePopup(); + goto(`/player/${episode.id}`, { replaceState: true }).finally(() => { + isNavigating = false; + }); +} + /** * Manually play the next episode * Called when user clicks "Play Now" button on next episode popup - * - * @param nextEpisodeItem - The next episode to play */ -export async function watchNextManually(nextEpisodeItem: any) { - await playNextEpisode(nextEpisodeItem); - nextEpisode.hidePopup(); +export async function watchNextManually(nextEpisodeItem: MediaItem) { + await cancelAutoplayCountdown(); + navigateToEpisode(nextEpisodeItem); +} + +/** + * Auto-play the next episode when countdown reaches 0 + * Called by playerEvents when countdown_tick event has remaining_seconds: 0 + */ +export function autoPlayNext(nextEpisodeItem: MediaItem) { + navigateToEpisode(nextEpisodeItem); } diff --git a/src/lib/services/playerEvents.ts b/src/lib/services/playerEvents.ts index f201661..4f2da74 100644 --- a/src/lib/services/playerEvents.ts +++ b/src/lib/services/playerEvents.ts @@ -14,7 +14,8 @@ import { player, playbackPosition } from "$lib/stores/player"; import { queue, currentQueueItem } from "$lib/stores/queue"; import { playbackMode } from "$lib/stores/playbackMode"; import { sleepTimer } from "$lib/stores/sleepTimer"; -import { nextEpisode } from "$lib/stores/nextEpisode"; +import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode"; +import { autoPlayNext } from "$lib/services/nextEpisodeService"; import { preloadUpcomingTracks } from "$lib/services/preload"; import type { MediaItem } from "$lib/api/types"; import { get } from "svelte/store"; @@ -320,8 +321,17 @@ function handleShowNextEpisodePopup( /** * Handle countdown tick event. + * When countdown reaches 0, automatically trigger playback of the next episode. */ function handleCountdownTick(remainingSeconds: number): void { // Update next episode store with new countdown value nextEpisode.updateCountdown(remainingSeconds); + + // Auto-play when countdown reaches 0 + if (remainingSeconds === 0) { + const episode = get(nextEpisodeItemStore); + if (episode) { + autoPlayNext(episode); + } + } } diff --git a/src/lib/services/syncService.ts b/src/lib/services/syncService.ts index 607b84b..b3a295d 100644 --- a/src/lib/services/syncService.ts +++ b/src/lib/services/syncService.ts @@ -28,7 +28,13 @@ export type SyncOperation = | "unmark_favorite" | "update_progress" | "report_playback_start" - | "report_playback_stopped"; + | "report_playback_stopped" + | "playlist_create" + | "playlist_delete" + | "playlist_rename" + | "playlist_add_items" + | "playlist_remove_items" + | "playlist_reorder_item"; /** * Simplified sync service - handles offline mutation queueing @@ -164,6 +170,32 @@ class SyncService { return deleted; } + // ===== Playlist sync operations ===== + + async queuePlaylistCreate(playlistId: string, name: string, itemIds: string[]): Promise { + return this.queueMutation("playlist_create", playlistId, { name, itemIds }); + } + + async queuePlaylistDelete(playlistId: string): Promise { + return this.queueMutation("playlist_delete", playlistId); + } + + async queuePlaylistRename(playlistId: string, name: string): Promise { + return this.queueMutation("playlist_rename", playlistId, { name }); + } + + async queuePlaylistAddItems(playlistId: string, itemIds: string[]): Promise { + return this.queueMutation("playlist_add_items", playlistId, { itemIds }); + } + + async queuePlaylistRemoveItems(playlistId: string, entryIds: string[]): Promise { + return this.queueMutation("playlist_remove_items", playlistId, { entryIds }); + } + + async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise { + return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex }); + } + /** * Clear all sync operations for the current user (called during logout) * diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts index 5199f79..a9f4c8a 100644 --- a/src/lib/stores/playbackMode.ts +++ b/src/lib/stores/playbackMode.ts @@ -255,10 +255,14 @@ function createPlaybackModeStore() { } /** - * Monitor remote session for disconnection + * Monitor remote session for disconnection with grace period. + * Requires multiple consecutive misses before declaring disconnection + * to tolerate transient network hiccups. */ function initializeSessionMonitoring(): void { - // Subscribe to session changes + let consecutiveMisses = 0; + const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval + selectedSession.subscribe((session) => { const currentState = get({ subscribe }); @@ -266,14 +270,28 @@ function createPlaybackModeStore() { // Don't interfere during an active transfer (we intentionally clear the session) if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) { if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) { - console.warn("[PlaybackMode] Remote session lost or disconnected"); - update((s) => ({ - ...s, - mode: "idle", - remoteSessionId: null, - transferError: "Remote session disconnected", - })); + consecutiveMisses++; + console.warn(`[PlaybackMode] Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`); + + if (consecutiveMisses >= DISCONNECT_THRESHOLD) { + console.warn("[PlaybackMode] Remote session lost after sustained disconnection"); + consecutiveMisses = 0; + update((s) => ({ + ...s, + mode: "idle", + remoteSessionId: null, + transferError: "Remote session disconnected", + })); + } + } else { + // Session is healthy, reset counter + if (consecutiveMisses > 0) { + console.log("[PlaybackMode] Remote session recovered after", consecutiveMisses, "misses"); + } + consecutiveMisses = 0; } + } else { + consecutiveMisses = 0; } }); } diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index 331bd3e..e7252be 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -228,15 +228,20 @@ export const mergedVolume = derived( /** * Should show audio miniplayer - state machine gated * Only true when: - * 1. Player is in playing or paused state (not idle, loading, error) - * 2. Current media is audio (not video: Movie or Episode) + * 1. In remote mode with an active session playing media, OR + * 2. Player is in playing or paused state (not idle, loading, error) + * AND current media is audio (not video: Movie or Episode) */ export const shouldShowAudioMiniPlayer = derived( - [player, currentMedia], - ([$player, $media]) => { - const state = $player.state; + [player, currentMedia, isRemoteMode, selectedSession], + ([$player, $media, $isRemote, $session]) => { + // In remote mode, show if the remote session has a now-playing item + if ($isRemote && $session?.nowPlayingItem) { + return true; + } - // Only show when actively playing or paused + // Local mode: only show when actively playing or paused + const state = $player.state; if (state.kind !== "playing" && state.kind !== "paused") { return false; } diff --git a/src/lib/utils/tauriCommandParams.test.ts b/src/lib/utils/tauriCommandParams.test.ts index 8df4c8c..19d80c5 100644 --- a/src/lib/utils/tauriCommandParams.test.ts +++ b/src/lib/utils/tauriCommandParams.test.ts @@ -123,6 +123,65 @@ describe("Tauri Command Parameter Names - Critical Pattern Test", () => { expect(Object.keys(params)).toContain("repositoryHandle"); expect(Object.keys(params)).not.toContain("repository_handle"); }); + + it("playlist_create: handle, name, itemIds (NOT item_ids)", () => { + const params = { + handle: "handle-123", + name: "My Playlist", + itemIds: ["track1", "track2"], + }; + + expect(Object.keys(params)).toContain("itemIds"); + expect(Object.keys(params)).not.toContain("item_ids"); + }); + + it("playlist_get_items: handle, playlistId (NOT playlist_id)", () => { + const params = { + handle: "handle-123", + playlistId: "pl-123", + }; + + expect(Object.keys(params)).toContain("playlistId"); + expect(Object.keys(params)).not.toContain("playlist_id"); + }); + + it("playlist_add_items: playlistId, itemIds", () => { + const params = { + handle: "handle-123", + playlistId: "pl-123", + itemIds: ["t1", "t2"], + }; + + expect(Object.keys(params)).toContain("playlistId"); + expect(Object.keys(params)).toContain("itemIds"); + expect(Object.keys(params)).not.toContain("playlist_id"); + expect(Object.keys(params)).not.toContain("item_ids"); + }); + + it("playlist_remove_items: playlistId, entryIds (NOT entry_ids)", () => { + const params = { + handle: "handle-123", + playlistId: "pl-123", + entryIds: ["e1", "e2"], + }; + + expect(Object.keys(params)).toContain("entryIds"); + expect(Object.keys(params)).not.toContain("entry_ids"); + }); + + it("playlist_move_item: playlistId, itemId, newIndex (NOT new_index)", () => { + const params = { + handle: "handle-123", + playlistId: "pl-123", + itemId: "track1", + newIndex: 2, + }; + + expect(Object.keys(params)).toContain("newIndex"); + expect(Object.keys(params)).toContain("itemId"); + expect(Object.keys(params)).not.toContain("new_index"); + expect(Object.keys(params)).not.toContain("item_id"); + }); }); describe("Nested struct fields also use camelCase (via serde rename_all)", () => { diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 83fd06b..2219b71 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -19,6 +19,7 @@ import PersonDetailView from "$lib/components/library/PersonDetailView.svelte"; import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte"; import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte"; + import PlaylistDetailView from "$lib/components/library/PlaylistDetailView.svelte"; import CrewLinks from "$lib/components/library/CrewLinks.svelte"; import GenreTags from "$lib/components/library/GenreTags.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte"; @@ -545,6 +546,9 @@ {:else if item.type === "MusicArtist"} + {:else if item.type === "Playlist"} + + {:else} import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte"; + import CreatePlaylistModal from "$lib/components/playlist/CreatePlaylistModal.svelte"; /** * Playlist browser * @req: UR-007 - Navigate media in library * @req: UR-008 - Search media across libraries * @req: DR-007 - Library browsing screens + * @req: UR-014 - Make and edit playlists */ + let showCreateModal = $state(false); + const config = { itemType: "Playlist" as const, title: "Playlists", @@ -20,4 +24,22 @@ }; - +
+ + + + +
+ + showCreateModal = false} +/> diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index 7b4ba25..558222a 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -18,7 +18,7 @@ reportPlaybackProgress, reportPlaybackStopped, } from "$lib/services/playbackReporting"; - import { cleanup as cleanupNextEpisode, handleEpisodeEnded } from "$lib/services/nextEpisodeService"; + import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService"; const itemId = $derived($page.params.id); const queueParam = $derived($page.url.searchParams.get("queue")); @@ -62,25 +62,12 @@ let pollInterval: ReturnType | null = null; let loadedItemId: string | null = null; - // Handle next episode navigation event from popup - function handlePlayNextEpisode(event: Event) { - const customEvent = event as CustomEvent<{ episode: MediaItem }>; - const episode = customEvent.detail.episode; - if (episode) { - goto(`/player/${episode.id}`); - } - } - onMount(() => { // Start position polling (only for audio via MPV backend) pollInterval = setInterval(updateStatus, 1000); - // Listen for next episode navigation events - window.addEventListener("playNextEpisode", handlePlayNextEpisode); - return () => { if (pollInterval) clearInterval(pollInterval); - window.removeEventListener("playNextEpisode", handlePlayNextEpisode); }; }); @@ -92,6 +79,7 @@ $effect(() => { const id = itemId; if (id && id !== loadedItemId) { + console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ")"); loadAndPlay(id); } }); @@ -526,10 +514,20 @@ async function handleVideoEnded() { // Call backend to handle autoplay decision (works on both Android and Linux) + // Pass the item ID and repository handle so the backend can look up the item + // and check for next episodes. HTML5 video plays independently of the Rust + // backend queue, so the backend needs these to know what just finished. + const mediaId = currentMedia?.id ?? null; + console.log("[AutoPlay] Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId); try { - await invoke("player_on_playback_ended"); + const repo = auth.getRepository(); + const repoHandle = repo.getHandle(); + await invoke("player_on_playback_ended", { + itemId: mediaId, + repositoryHandle: repoHandle, + }); } catch (e) { - console.error("[VideoPlayer] Failed to handle playback ended:", e); + console.error("[AutoPlay] Failed to handle playback ended:", e); } }