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

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:

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`

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

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 testing
- `MpvBackend` - 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 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:

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:

- `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-226)

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

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-227)
`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_play` → `DirectPlay`, else `supports_direct_stream` → `DirectStream`, 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.

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

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.