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.
705 lines
26 KiB
Markdown
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.
|