Four bugs, one cause. "What can this device decode" was answered in five
places, four of which assumed the webview was decoding:
- the device profile's direct-play codecs (cfg per platform, inline)
- the transcoding targets (hardcoded "h264,hevc")
- the direct-play audio narrowing (webview list, all platforms)
- the client-side audio override (webview list, all platforms)
- get_video_stream_url's VideoCodec (hardcoded "h264")
On Android the decoder is ExoPlayer, so four of those were simply wrong there,
and the costs were invisible without a device:
- dts is in the tablet's own codec list, gets stripped from the profile, and
is then forced to transcode by a rule about a renderer that is not playing
it.
- An hevc source whose *audio* is eac3 had its **picture fully re-encoded**.
The server's own transcoding URL got this right — VideoCodec=h264,hevc,
TranscodeReasons=AudioCodecNotSupported, video copied — but the moment a
quality change or track switch re-opened the stream through our builder,
the hardcoded h264 turned a cheap audio remux into a full transcode. That
is a quality change silently making playback more expensive, on the exact
path a viewer uses when playback is already struggling.
`renderer_codecs()` and `renderer_can_decode_audio()` are now the single
source, and all five sites read them. On the webview path every value resolves
exactly as before, so desktop behaviour is unchanged by construction; on
Android the profile becomes the device's own.
The list is also what lets the server *copy* rather than re-encode: naming
every codec the renderer can decode is what turns a transcode into a
passthrough when the source is already playable. That is the whole of "use the
best format available".
Also corrects this branch's headline number where it is asserted — the
architecture doc, the desktop-native-video spec and the spike. The measured 85%
Android direct-play rate used a profile containing ac3/eac3; the device it was
later verified on reports neither, so eac3 content correctly transcodes there.
It is a ceiling for an ExoPlayer-appropriate profile, not what the app achieves,
and realising any of it depends on this change. Left in place with the caveat
rather than deleted, because the measurement is real — it just measures
something narrower than it was quoted as measuring.
Unverified: this changes what Android negotiates and has not been exercised on
the tablet yet. Desktop is unchanged by construction but also unre-tested.
858 lines
34 KiB
Markdown
858 lines
34 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.
|
|
|
|
#### 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.
|