Files
jellytau/docs/architecture/01-rust-backend.md
T
dtourolle 32043a2152 docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.

The shipped designs move into docs/architecture, which is the maintained
description of the build, and the spec files go. Git history keeps the
originals; what a future change still needs is carried across:

- 01-rust-backend: favourites rewritten (the old section named a file that no
  longer exists and called shipped buttons "planned"), domain vocabulary owned
  by Rust (SearchScope, exclusions, the bitrate ladder), background workers
- 02-svelte-frontend: app shell and chrome, library mosaic, series/episode
  navigation, downloaded browse, safe-area insets, native-video store, logging
- 03-data-flow: locally-indexed search
- 05-platform-backends: audio settings on ExoPlayer, the equalizer's band
  vocabulary, native video compositing, the background-audio handoff
- 06-downloads-and-offline: one storage model, offline catalog visibility
- 09-security: path confinement and input binding

docs/specs/README.md now says what the directory is for and where each shipped
design went. Deferred work the specs recorded is kept beside the code it
concerns rather than lost: season-bounded autoplay, the two dead search
commands, why indexing is a full crawl.

requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
2026-08-21 18:15:58 +02:00

705 lines
26 KiB
Markdown

# 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<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):
```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<MediaItem>, 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<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`
```rust
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:**
```mermaid
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:
```mermaid
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](#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<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](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<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:**
```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<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](../../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<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](../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<Vec<String>>` restored from the database at
startup, and applied by the repository layer to every music query (libraries,
artists, albums, genres, search, home rows).
The id is normalised (`trim`, strip `-`, lowercase) because Jellyfin writes the
same GUID both dashed and undashed depending on the endpoint. The predecessor was
a frontend filter matching the English string "Podcasts" — wrong in three ways at
once, and the reason this lives in the repository.
The set is process-wide rather than a field on a repository for the same reason
as `online::STREAMING_QUALITY`: it is a preference about *this user's browsing*,
not about a server session, so it must survive a repository being rebuilt on
re-login.
### Streaming quality ladder
**Location**: `src-tauri/src/settings.rs` (TRACES: UR-074 | DR-162)
`StreamingQuality` is a bandwidth ladder (`Original`, 20/10/8/4/2/1 Mbps,
720 kbps), not a resolution picker: it exists to fit a connection, and the
resolution cap is chosen *from* the bitrate so the encoder does not spend a small
budget on pixels it cannot afford.
| Method | Answers |
|--------|---------|
| `max_bitrate()` | Total bits/s (video + audio), `None` for `Original` |
| `audio_bitrate()` | The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom |
| `video_bitrate()` | Total minus audio, so the two together honour the ceiling |
| `max_height()` | Resolution ceiling that suits the bitrate |
The ceiling goes to `PlaybackInfo` as `MaxStreamingBitrate` **and** into the
device profile. Sending it there — not just on the transcode URL — is what makes
the cap real: a stream the server decides to *direct play* is served at the
source file's own bitrate, and no URL parameter afterwards can reduce it.
The frontend names a variant and nothing else; the labels the picker shows are
served over IPC by `player_get_streaming_qualities`.
## Background workers
Three long-lived tasks are spawned from the Tauri `setup` hook in `lib.rs`. All
three exist because *when* something happens is a backend policy, not something
a page load should decide.
| Worker | Location | Responsibility |
|--------|----------|----------------|
| `spawn_catalog_indexer` | `commands/catalog.rs` | Keeps the local FTS5 catalog fresh (DR-109, IR-030) |
| `spawn_favorites_drain` | `commands/favorites.rs` | Retries favourite toggles made while offline (DR-120) |
| `spawn_sync_queue_drain` | `commands/sync_drain.rs` | Drains the offline mutation queue (DR-131) |
### Catalog indexer
Replaces the frontend's startup-only `syncCatalog()` call. It ticks on
`CATALOG_INDEX_TICK` and runs a pass when three things hold: a repository exists,
the server is reachable, and the index is due per `index_is_due`. A tick is
nearly free — one indexed `app_settings` lookup — which is what makes it
responsive to events it cannot subscribe to, such as signing in: a fresh install
would otherwise sit unindexed until the next scheduled pass.
`index_is_due` treats both "never indexed" and an unparseable stored timestamp as
due; a corrupt timestamp should trigger a re-index, not silently freeze the
catalog. A failed pass is never fatal — it leaves the existing index in place and
warns. Progress is emitted on `CATALOG_INDEX_EVENT` for the staleness hint in the
UI.