# 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. ```mermaid 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:** ```rust #[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, /// 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): ```mermaid 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:** ```rust 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, error: String }, } ``` **Event Enum:** ```rust 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.): ```mermaid 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:** ```rust 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` ```rust pub struct MediaItem { pub id: String, pub title: String, pub artist: Option, pub album: Option, pub duration: Option, pub artwork_url: Option, 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, }, 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` ```rust pub struct QueueManager { items: Vec, current_index: Option, shuffle: bool, repeat: RepeatMode, shuffle_order: Vec, // Fisher-Yates permutation history: Vec, // For back navigation in shuffle } pub enum RepeatMode { Off, All, One, } ``` **Queue Navigation Logic:** ```mermaid flowchart TB QM[QueueManager] QM --> Shuffle QM --> Repeat QM --> History subgraph Shuffle["Shuffle Mode"] ShuffleOff["OFF
next() returns index + 1"] ShuffleOn["ON
next() follows shuffle_order[]"] end subgraph Repeat["Repeat Mode"] RepeatOff["OFF
next() at end: -> None"] RepeatAll["ALL
next() at end: -> wrap to index 0"] RepeatOne["ONE
next() returns same item"] end subgraph History["History"] HistoryDesc["Used for previous()
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: ```mermaid flowchart TB UI[FavoriteButton] -->|Click| Service[toggleFavorite] Service -->|"1. Optimistic"| LocalDB[("SQLite user_data
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
(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](#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`](#search-scope-and-the-taxonomy-boundary) 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` ```rust 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; 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](05-platform-backends.md)) - `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md)) ## Player Controller **Location**: `src-tauri/src/player/mod.rs` The `PlayerController` orchestrates playback: ```rust pub struct PlayerController { backend: Arc>>, queue: Arc>, muted: bool, sleep_timer: Arc>, autoplay_settings: Arc>, autoplay_episode_count: Arc>, // Session-based counter repository: Arc>>>, event_emitter: Arc>>>, // ... 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:** ```rust /// 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:** ```rust async fn create_playlist(&self, name: &str, item_ids: Option>) -> Result; 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, RepoError>; async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec) -> Result<(), RepoError>; async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec) -> 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` | | `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](../../CLAUDE.md) and [scoped-search-boundary.md](../specs/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: ```rust 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> { … } /// The scope a library of this Jellyfin `CollectionType` belongs to. pub fn for_collection_type(collection_type: &str) -> Option { … } } ``` 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](../specs/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>` 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_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-233), 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-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.