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

19 KiB

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.

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:

#[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):

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:

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:

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.):

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:

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

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

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:

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:

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

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:

Player Controller

Location: src-tauri/src/player/mod.rs

The PlayerController orchestrates playback:

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:

/// 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:

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 - ()