Files
jellytau/docs/architecture/01-rust-backend.md
T
dtourolle 83dc8c7028 feat(playback): let Rust decide what stream to play, and say so
Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.

One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.

Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:

  Linux / WebKitGTK (h264 only, 2ch)          3/40 —  7% direct play
  Android / ExoPlayer (hevc, ac3/eac3, 6ch)  34/40 — 85% direct play

The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.

DR-219  StreamSelection: url + tagged Transport (hls/progressive/localFile)
        + PlaybackKind (directPlay/directStream/transcode) + the negotiated
        rendition + this source's ladder + a needs_transcoding flag derived
        in Rust so the rule is answered once. Both enums are serde-tagged
        so the frontend matches a discriminant, not a substring. The paths
        that never negotiate get the same shape from Rust rather than
        assembling 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.

DR-220  The ceiling becomes two levels: a durable device default (Settings,
        persisted) and a per-playback override the in-player picker sets.
        The picker had called itself a "this film, this connection" control
        since it was written but wrote the process-wide 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. The override is cleared whenever playback moves to a
        new item, which stops it surviving into an autoplayed next episode.
        effective_streaming_quality() is the single resolution point.

DR-221  The quality picker is filled from what this media source can offer.
        Rust marks a rung exceeds_source when its ceiling is at or above the
        source's own bitrate — such a rung is another way to spell Original
        — and the frontend does not draw those. Original is never marked; a
        source whose bitrate the server does not report marks nothing, which
        keeps every rung offered.

DR-222  Direct play and direct stream are negotiated, with two client-side
        overrides on top because the server's answer is right about the file
        and wrong about what this app will do with it: undecodable audio
        (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) and a viewer-pinned
        audio track the file does not default to. A direct stream is a remux
        and is deliberately not counted as transcoding.

DR-223  Dropped on measurement, not deferred. 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 mpv would lose — the claim that there was, in
        playback-backend-unification.md, does not hold. Recorded rather than
        deleted because it is a measurement: a server that does publish a
        ladder would change the answer.

DR-224  Every backend consumes the same selection. The queue item carries
        the transport, so player_seek_video picks its seek strategy from the
        backend's decision instead of the last stream_url.contains(".m3u8")
        in the codebase. Items queued by a path that never negotiated carry
        None and fall back to needs_transcoding, which is exact rather than
        a guess because every transcode this app requests is HLS (DR-140).

The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.

Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.

The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.

Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
2026-08-21 22:44:13 +02:00

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

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

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

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

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:

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.

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.

Two levels of ceiling

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

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

Set by Lives until Read via
Device default Settings (player_set_video_settings) Persisted; restored at startup streaming_quality()
Per-playback override The in-player picker (player_set_stream_quality) The next item starts playing playback_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-224, DR-226, DR-227)

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:

Field Carries
url What to open
transport Hls / Progressive / LocalFile — how to fetch it
playback_kind DirectPlay / DirectStream / Transcode — what the server is doing to the source
rendition The negotiated ceiling and codecs; None for a direct play, which is the source
available The quality ladder as it applies to this media source (DR-226)
needs_transcoding Derived 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:

Profile Direct 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. The payoff is overwhelmingly Android, where 85% of plays previously burned a transcode nobody needed. 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-228 (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.

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.