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 StateEventTo StateUI BehaviorNotes
Idleplay_queue(audio)Audio (active)Show miniplayerCreates audio session
Idleplay_item(movie)Movie (active)Show video playerCreates movie session
Idleplay_item(episode)TvShow (active)Show video playerCreates TV session
Audio (active)playback_endedAudio (inactive)Miniplayer stays visibleQueue preserved
Audio (inactive)play/resumeAudio (active)Miniplayer enabledResume from queue
Audio (active/inactive)dismissIdleHide miniplayerClear session
Audio (active/inactive)play_item(movie)Movie (active)Switch to video playerReplace session
Movie (active)playback_endedIdleHide video playerAuto-dismiss
Movie (active)dismissIdleHide video playerUser dismiss
TvShow (active)playback_endedTvShow (inactive)Show next episode UIWait for user choice
TvShow (inactive)next_episodeTvShow (active)Play next episodeStay in session
TvShow (inactive)series_completeIdleHide video playerNo 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:

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

FromEventToSide Effects
Idleplay_queue()LocalStart local playback
Idletransfer_to_remote(session_id)RemoteSend queue to remote session
Localtransfer_to_remote(session_id)RemoteStop local, send queue to remote, enable remote volume (Android)
Localstop()IdleStop local playback
Remotetransfer_to_local()LocalGet remote state, stop remote, start local at same position, disable remote volume
Remotestop()IdleStop remote playback, disable remote volume
Remotesession_disconnected()IdleSession 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

#![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/ (local user_data writes)
  • Repository: get_favorites on the trait, implemented by online.rs, offline.rs and hybrid.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
  1. The local row is updated immediately, so the heart fills without a round trip.
  2. The repository is asked to mark or unmark on the server.
  3. On success pending_sync is cleared; on failure the row stays pending.
  4. A background drain (spawn_favorites_drain, started in lib.rs setup) 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:

MethodUsed for
get_favorites_cache_onlyThe instant leg — the local user_data join
get_favorites_server_onlyThe reconciliation leg
get_favoritesCache-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:

CommandDescription
repository_get_favoritesCross-library favourites for a scope
repository_mark_favorite / repository_unmark_favoriteToggle on the server, through the repository
storage_toggle_favoriteLocal optimistic write (is_favorite, pending_sync)
storage_mark_syncedClear 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:

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

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

CommandParametersReturns
playlist_createhandle, name, item_ids?PlaylistCreatedResult
playlist_deletehandle, playlist_id()
playlist_renamehandle, playlist_id, name()
playlist_get_itemshandle, playlist_idVec<PlaylistEntry>
playlist_add_itemshandle, playlist_id, item_ids()
playlist_remove_itemshandle, playlist_id, entry_ids()
playlist_move_itemhandle, playlist_id, item_id, new_index()

Tauri Commands (Player)

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

CommandParametersReturns
player_play_itemPlayItemRequestPlayerStatus
player_play_queueitems, start_index, shufflePlayerStatus
player_play-PlayerStatus
player_pause-PlayerStatus
player_toggle-PlayerStatus
player_stop-PlayerStatus
player_next-PlayerStatus
player_previous-PlayerStatus
player_seekposition: f64PlayerStatus
player_set_volumevolume: f32PlayerStatus
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_timermode: SleepTimerMode()
player_cancel_sleep_timer-()
player_set_video_settingssettings: VideoSettingsVideoSettings
player_get_video_settings-VideoSettings
player_set_autoplay_settingssettings: AutoplaySettingsAutoplaySettings
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:

  • All returns None, not the union of every listed type. An explicit includeItemTypes list 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 on None.
  • for_collection_type maps a Jellyfin CollectionType to 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.

MethodAnswers
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.

Two levels of ceiling

Location: src-tauri/src/repository/online.rs (TRACES: UR-074, UR-079 | DR-226)

There are two, and they are not the same thing:

Set byLives untilRead via
Device defaultSettings (player_set_video_settings)Persisted; restored at startupstreaming_quality()
Per-playback overrideThe in-player picker (player_set_stream_quality)The next item starts playingplayback_quality_override()

effective_streaming_quality() resolves the pair — override first, else default — and is the only thing stream construction may read. Every URL builder and the PlaybackInfo negotiation go through it, for the reason the process-wide static existed in the first place: if the negotiation and the URL builder disagree, the cap leaks — the negotiation authorises a direct play the builder then never gets to constrain, or the reverse.

The override exists because a single global cannot express "this 4K remux needs a ceiling, that podcast does not". The picker had documented itself as a "this film, this connection" control since it was written, but was implemented by writing the default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, with Settings still showing the old value. It is cleared on every player_play_item / player_play_queue / player_play_tracks, which is what stops it surviving into an autoplayed next episode where nobody would reopen the picker.

Stream selection

Location: src-tauri/src/repository/stream_selection.rs, OnlineRepository::get_stream_selection (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)

Rust decides what stream. The player decides how to deliver it. That line is the whole design. A backend with genuine adaptive selection (ExoPlayer over a multi-variant playlist) is left to do it; Rust chooses what to request and never paces bytes.

get_stream_selection returns one self-describing StreamSelection in place of the bare URL get_video_stream_url used to hand out:

FieldCarries
urlWhat to open
transportHls / Progressive / LocalFile — how to fetch it
playback_kindDirectPlay / DirectStream / Transcode — what the server is doing to the source
renditionThe negotiated ceiling and codecs; None for a direct play, which is the source
availableThe quality ladder as it applies to this media source (DR-227)
needs_transcodingDerived from playback_kind, so the rule is answered once

Both enums are serde-tagged ({"type":"hls"}) so the frontend matches a discriminant rather than comparing text.

Why transport exists. VideoPlayer.svelte chose its loader with url.includes(".m3u8"), in two places. Rust built that URL and knows exactly what it is; re-deriving it downstream by substring match is a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions: a progressive file served from a path containing the substring gets an HLS loader, and a playlist served from a path without it does not.

The paths that never negotiate get the same shape from Rust rather than letting a caller assemble one — media_local_selection for a downloaded file, LiveStreamInfo.transport for a live channel — so there is no second place where a transport is decided.

The playback-kind decision

decide_playback_kind is a free function and pure, so every branch is testable from PlaybackInfo fixtures without a server. Order matters — the two client-side overrides come first, because each describes a case where the server's answer is right about the file and wrong about what this app will do with it:

  1. Undecodable audio → Transcode. Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence. A silent direct play is worse than a transcode.
  2. A pinned audio track → Transcode. Not a defect in the server's answer, a different question: the file has one default track and the viewer asked for another.
  3. Otherwise supports_direct_playDirectPlay, else supports_direct_streamDirectStream, else Transcode.

A direct stream is a remux — codecs copied, container repackaged. It is cheap and is deliberately not counted as transcoding; conflating the two would report a free passthrough as a server-side re-encode.

What this is worth, measured. Against the development server (Jellyfin 10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation per profile:

ProfileDirect play
Linux / WebKitGTK (h264 only, 2ch)3/40 — 7%
Android / ExoPlayer (h264,hevc,vp8,vp9,av1,mpeg4 + ac3,eac3, 6ch)34/40 — 85%

The library is ~80% hevc (hevc+eac3 alone is a third of it), which is why the two diverge so hard.

Read that 85% as a ceiling, not a result. It was measured with a profile containing ac3,eac3. The Android device this was later run on reports neither in its MediaCodecList — no Dolby licence, which is normal for a tablet — so eac3 content, about a third of the sampled library, correctly transcodes there. What any given device achieves depends on its own codec list, and on the profile being derived from the renderer at all (DR-234), which it was not when the figure was taken.

The payoff is still overwhelmingly Android, because that is where a real decoder is already doing the work. Linux stays near 7% until libmpv decodes the picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau choice, and is what linux-native-video-spike.md exists to remove. A reviewer should not expect this code to fix Linux on its own.

The quality ladder per source

quality_options_for_source(source_bitrate) returns every rung, each marked with exceeds_source: true when that rung's ceiling is at or above what the source itself carries, so selecting it produces the same bytes as Original. The frontend draws the list and drops the redundant rungs; it does not decide which they are.

  • Original is never marked — it is the source.
  • An unreported source bitrate (some containers have none; the sampled library has avi files with no bitrate at all) marks nothing redundant, keeping every rung offered. That is the safe direction: the viewer keeps every choice.

No adaptive ladder to preserve

TRACES: UR-079 | DR-229 (Won't Do)

Mid-playback re-negotiation on throughput was scoped and dropped on measurement. A master playlist from this server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. So there is no adaptation for hls.js to be preserving and none that mpv would lose — the claim that there was is recorded in playback-backend-unification.md and does not hold. "Adapt mid-stream" collapses into "pick well at open", which is what the two levels of ceiling and the per-source ladder already are.

Kept here because it is a measurement, not an opinion: a server that does publish a ladder would change the answer, and the re-negotiation path below is the hook that work would build on.

Re-negotiation

One mechanism, not two. player_seek_video, player_switch_audio_track and player_set_stream_quality all return a tagged strategy saying who reloads — the backend handles a native backend itself and hands the webview a StreamSelection for reloadSource. Note the wire wart: tauri-specta keeps these response fields snake_case (seek_offset), while the strategy tag itself is camelCase.

The frontend names a variant and nothing else; the labels the picker shows are served over IPC — from available on the selection, or player_get_streaming_qualities for the Settings list.

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.

WorkerLocationResponsibility
spawn_catalog_indexercommands/catalog.rsKeeps the local FTS5 catalog fresh (DR-109, IR-030)
spawn_favorites_draincommands/favorites.rsRetries favourite toggles made while offline (DR-120)
spawn_sync_queue_draincommands/sync_drain.rsDrains 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.