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:
#![allow(unused)] fn main() { #[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:
- Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
- Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
- Single Active Session: Playing new content type replaces current session
- Explicit Dismiss for Audio: User must click close button to clear audio session
- 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
#![allow(unused)] fn main() { 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:
- Call
enable_remote_volume(initial_volume) - VolumeProviderCompat intercepts hardware volume buttons
- PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
- Volume commands routed to remote session via Jellyfin API
When transitioning away from Remote mode:
- Call
disable_remote_volume() - Volume buttons return to controlling device volume
- PlaybackStateCompat set to STATE_NONE
- VolumeProviderCompat is cleared
Media Item & Source
Location: src-tauri/src/player/media.rs
#![allow(unused)] fn main() { 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
#![allow(unused)] fn main() { 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:
- Commands:
src-tauri/src/commands/favorites.rs(offline drain),src-tauri/src/commands/repository.rs(query + toggle),src-tauri/src/commands/storage/(localuser_datawrites) - Repository:
get_favoriteson the trait, implemented byonline.rs,offline.rsandhybrid.rs - Frontend:
src/lib/services/favorites.ts,src/lib/components/FavoriteButton.svelte,/library/favorites
Favouriting has two halves that are easy to confuse: marking an item, which has existed since UR-017, and browsing what was marked, which arrived with UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.
Marking
Optimistic local write, then server sync:
flowchart TB
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
Service -->|"2. Sync"| Repo[Repository]
Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
Service -->|"3. Mark synced"| LocalDB
Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo
- The local row is updated immediately, so the heart fills without a round trip.
- The repository is asked to mark or unmark on the server.
- On success
pending_syncis cleared; on failure the row stays pending. - A background drain (
spawn_favorites_drain, started inlib.rssetup) retries pending rows, so a favourite marked offline still reaches the server (DR-120). This is the same pattern as the sync-queue drain — see Background workers.
Browsing
get_favorites(scope, options) answers "what did this user favourite", across
libraries, with the scope owned by Rust — the frontend sends a
SearchScope variant and never names
an item type. HybridRepository splits it the same way it splits every query:
| Method | Used for |
|---|---|
get_favorites_cache_only | The instant leg — the local user_data join |
get_favorites_server_only | The reconciliation leg |
get_favorites | Cache-first with server merge, per the repository's usual policy |
GetItemsOptions.favorites_only is the other entry point: it filters an
existing library listing rather than starting a cross-library query (DR-116),
which is what a library page's favourites filter uses.
Server favourite state is mirrored into the local user_data table on catalog
sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up
here — before this, MediaItem.user_data was left empty and no query anywhere
asked for favourites.
Tauri commands:
| Command | Description |
|---|---|
repository_get_favorites | Cross-library favourites for a scope |
repository_mark_favorite / repository_unmark_favorite | Toggle on the server, through the repository |
storage_toggle_favorite | Local optimistic write (is_favorite, pending_sync) |
storage_mark_synced | Clear pending_sync after a successful server write |
Frontend surfaces (DR-117 … DR-119): the /library/favorites page with a
scope selector, favourite rows on home (favoriteMovies / favoriteShows /
favoriteMusic in stores/home.ts), a favourites tile per category in the
library mosaic, and FavoriteButton mounted wherever a whole item is shown —
movie, series, episode, album, artist and playlist detail views as well as the
mini player.
Player Backend Trait
Location: src-tauri/src/player/backend.rs
#![allow(unused)] fn main() { 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 testingMpvBackend- Linux playback via libmpv (see 05-platform-backends.md)ExoPlayerBackend- Android playback via ExoPlayer/Media3 (see 05-platform-backends.md)
Player Controller
Location: src-tauri/src/player/mod.rs
The PlayerController orchestrates playback:
#![allow(unused)] fn main() { 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 changesset_sleep_timer(mode)/cancel_sleep_timer(): Sleep timer controlon_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:
#![allow(unused)] fn main() { /// 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:
#![allow(unused)] fn main() { 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 | - | () |
Domain Vocabulary Owned by Rust
The frontend is presentation-only and must not encode Jellyfin's taxonomy — the rule in CLAUDE.md and scoped-search-boundary.md. These are the places where that vocabulary actually lives.
Search scope and the taxonomy boundary
Location: src-tauri/src/repository/types.rs
SearchScope is the canonical example the boundary rule is taught from. The
frontend sends an opaque variant; Rust expands it into Jellyfin item types:
#![allow(unused)] fn main() { pub enum SearchScope { All, Music, Movies, Tv } impl SearchScope { /// The Jellyfin item types this scope requests, or `None` for `All`. pub fn item_types(self) -> Option<Vec<String>> { … } /// The scope a library of this Jellyfin `CollectionType` belongs to. pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { … } } }
Two details that are load-bearing:
AllreturnsNone, not the union of every listed type. An explicitincludeItemTypeslist filters out anything not named in it, so a union would silently drop People, folders, and any type nobody enumerated. Callers must omit the filter entirely onNone.for_collection_typemaps a JellyfinCollectionTypeto a favourites category (DR-175). It changes when Jellyfin renames a collection type, not when the library page is redesigned — which is the test for whether something belongs on this side of the boundary.
⚠️ The result side has not moved yet. GROUP_ITEM_TYPES in
src/lib/utils/searchScope.ts still maps result groups to item types in the
frontend, and check:boundary does not match its shape. Tracked as Stage 2 of
scoped-search-boundary-implementation.md.
Library exclusions
Location: src-tauri/src/repository/exclusions.rs (TRACES: UR-076 | DR-209)
Folders the user has chosen to keep out of music browsing — a "Podcasts" folder
inside a music library being the canonical case. Excluded by item id, not by
name, in a process-wide RwLock<Vec<String>> restored from the database at
startup, and applied by the repository layer to every music query (libraries,
artists, albums, genres, search, home rows).
The id is normalised (trim, strip -, lowercase) because Jellyfin writes the
same GUID both dashed and undashed depending on the endpoint. The predecessor was
a frontend filter matching the English string "Podcasts" — wrong in three ways at
once, and the reason this lives in the repository.
The set is process-wide rather than a field on a repository for the same reason
as online::STREAMING_QUALITY: it is a preference about this user's browsing,
not about a server session, so it must survive a repository being rebuilt on
re-login.
Streaming quality ladder
Location: src-tauri/src/settings.rs (TRACES: UR-074 | DR-162)
StreamingQuality is a bandwidth ladder (Original, 20/10/8/4/2/1 Mbps,
720 kbps), not a resolution picker: it exists to fit a connection, and the
resolution cap is chosen from the bitrate so the encoder does not spend a small
budget on pixels it cannot afford.
| Method | Answers |
|---|---|
max_bitrate() | Total bits/s (video + audio), None for Original |
audio_bitrate() | The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom |
video_bitrate() | Total minus audio, so the two together honour the ceiling |
max_height() | Resolution ceiling that suits the bitrate |
The ceiling goes to PlaybackInfo as MaxStreamingBitrate and into the
device profile. Sending it there — not just on the transcode URL — is what makes
the cap real: a stream the server decides to direct play is served at the
source file's own bitrate, and no URL parameter afterwards can reduce it.
The frontend names a variant and nothing else; the labels the picker shows are
served over IPC by player_get_streaming_qualities.
Background workers
Three long-lived tasks are spawned from the Tauri setup hook in lib.rs. All
three exist because when something happens is a backend policy, not something
a page load should decide.
| Worker | Location | Responsibility |
|---|---|---|
spawn_catalog_indexer | commands/catalog.rs | Keeps the local FTS5 catalog fresh (DR-109, IR-030) |
spawn_favorites_drain | commands/favorites.rs | Retries favourite toggles made while offline (DR-120) |
spawn_sync_queue_drain | commands/sync_drain.rs | Drains the offline mutation queue (DR-131) |
Catalog indexer
Replaces the frontend's startup-only syncCatalog() call. It ticks on
CATALOG_INDEX_TICK and runs a pass when three things hold: a repository exists,
the server is reachable, and the index is due per index_is_due. A tick is
nearly free — one indexed app_settings lookup — which is what makes it
responsive to events it cannot subscribe to, such as signing in: a fresh install
would otherwise sit unindexed until the next scheduled pass.
index_is_due treats both "never indexed" and an unparseable stored timestamp as
due; a corrupt timestamp should trigger a re-index, not silently freeze the
catalog. A failed pass is never fatal — it leaves the existing index in place and
warns. Progress is emitted on CATALOG_INDEX_EVENT for the staleness hint in the
UI.