jellytau/docs/architecture/01-rust-backend.md
Duncan Tourolle 09780103a7
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
2026-03-01 19:47:46 +01:00

572 lines
19 KiB
Markdown

# 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<MediaItem>,
/// True = playing/paused, False = stopped/ended
is_active: bool,
},
/// Movie playback (single video, auto-dismiss on end)
Movie {
item: MediaItem,
is_active: bool, // true = playing/paused, false = ended
},
/// TV show playback (supports next episode auto-advance)
TvShow {
item: MediaItem,
series_id: String,
is_active: bool, // true = playing/paused, false = ended
},
}
```
**State Transitions & Rules:**
| From State | Event | To State | UI Behavior | Notes |
|------------|-------|----------|-------------|-------|
| Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session |
| Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session |
| Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session |
| Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved |
| Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue |
| Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session |
| Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session |
| Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss |
| Movie (active) | `dismiss` | Idle | Hide video player | User dismiss |
| TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice |
| TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session |
| TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes |
**Key Design Decisions:**
1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume
2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused)
3. **Single Active Session**: Playing new content type replaces current session
4. **Explicit Dismiss for Audio**: User must click close button to clear audio session
5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking
**Edge Cases Handled:**
- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
- User wants to dismiss: Close button clears session -> Idle
- Switch content types: New session replaces old (audio -> movie)
- Paused for extended time: Session persists indefinitely
- Playback errors: Session stays inactive, allows retry
- Queue operations while idle: Queue exists but no session created until play
## Player State Machine (Low-Level Playback)
**Location**: `src-tauri/src/player/state.rs`
The player uses a deterministic state machine with 6 states (operates within a media session):
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Loading : Load
Loading --> Playing : MediaLoaded
Playing --> Paused : Pause
Paused --> Playing : Play
Paused --> Seeking : Seek
Seeking --> Playing : PositionUpdate
Playing --> Idle : Stop
Paused --> Idle : Stop
Idle --> Error : Error
Loading --> Error : Error
Playing --> Error : Error
Paused --> Error : Error
Seeking --> Error : Error
state Playing {
[*] : position, duration
}
state Paused {
[*] : position, duration
}
state Seeking {
[*] : target
}
state Error {
[*] : error message
}
```
**State Enum:**
```rust
pub enum PlayerState {
Idle,
Loading { media: MediaItem },
Playing { media: MediaItem, position: f64, duration: f64 },
Paused { media: MediaItem, position: f64, duration: f64 },
Seeking { media: MediaItem, target: f64 },
Error { media: Option<MediaItem>, error: String },
}
```
**Event Enum:**
```rust
pub enum PlayerEvent {
Load(MediaItem),
Play,
Pause,
Stop,
Seek(f64),
Next,
Previous,
MediaLoaded(f64), // duration
PositionUpdate(f64), // position
PlaybackEnded,
Error(String),
}
```
## 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<String>,
pub album: Option<String>,
pub duration: Option<f64>,
pub artwork_url: Option<String>,
pub media_type: MediaType,
pub source: MediaSource,
}
pub enum MediaType {
Audio,
Video,
}
pub enum MediaSource {
Remote {
stream_url: String,
jellyfin_item_id: String,
},
Local {
file_path: PathBuf,
jellyfin_item_id: Option<String>,
},
DirectUrl {
url: String,
},
}
```
The `MediaSource` enum enables:
- **Remote**: Streaming from Jellyfin server
- **Local**: Downloaded/cached files (future offline support)
- **DirectUrl**: Direct URLs (channel plugins, external sources)
## Queue Manager
**Location**: `src-tauri/src/player/queue.rs`
```rust
pub struct QueueManager {
items: Vec<MediaItem>,
current_index: Option<usize>,
shuffle: bool,
repeat: RepeatMode,
shuffle_order: Vec<usize>, // Fisher-Yates permutation
history: Vec<usize>, // For back navigation in shuffle
}
pub enum RepeatMode {
Off,
All,
One,
}
```
**Queue Navigation Logic:**
```mermaid
flowchart TB
QM[QueueManager]
QM --> Shuffle
QM --> Repeat
QM --> History
subgraph Shuffle["Shuffle Mode"]
ShuffleOff["OFF<br/>next() returns index + 1"]
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
end
subgraph Repeat["Repeat Mode"]
RepeatOff["OFF<br/>next() at end: -> None"]
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
RepeatOne["ONE<br/>next() returns same item"]
end
subgraph History["History"]
HistoryDesc["Used for previous()<br/>in shuffle mode"]
end
```
## 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<br/>pending_sync| UserData[user_data table]
```
**Flow**:
1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
2. `toggleFavorite()` service function handles the logic:
- Updates local SQLite database immediately (optimistic update)
- Attempts to sync with Jellyfin server
- Marks as synced if successful, otherwise leaves `pending_sync = 1`
3. UI reflects the change immediately without waiting for server response
**Components**:
- **FavoriteButton.svelte**: Reusable heart button component
- Configurable size (sm/md/lg)
- Red when favorited, gray when not
- Loading state during toggle
- Bindable `isFavorite` prop for two-way binding
- **Integration Points**:
- MiniPlayer: Shows favorite button for audio tracks (hidden on small screens)
- Full AudioPlayer: Shows favorite button (planned)
- Album/Artist detail pages: Shows favorite button (planned)
**Database Schema**:
- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1)
- `user_data.pending_sync`: Indicates if local changes need syncing
**Tauri Commands**:
- `storage_toggle_favorite`: Updates favorite status in local database
- `storage_mark_synced`: Clears pending_sync flag after successful sync
**API Methods**:
- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin
- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin
## Player Backend Trait
**Location**: `src-tauri/src/player/backend.rs`
```rust
pub trait PlayerBackend: Send + Sync {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
fn stop(&mut self) -> Result<(), PlayerError>;
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
fn position(&self) -> f64;
fn duration(&self) -> Option<f64>;
fn state(&self) -> PlayerState;
fn is_loaded(&self) -> bool;
fn volume(&self) -> f32;
}
```
**Implementations:**
- `NullBackend` - Mock backend for testing
- `MpvBackend` - Linux playback via libmpv (see [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<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
muted: bool,
sleep_timer: Arc<Mutex<SleepTimerState>>,
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
// ... 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<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
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<Vec<PlaylistEntry>, RepoError>;
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> 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<PlaylistEntry>` |
| `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` | - | `()` |