Files
jellytau/architecture/01-rust-backend.html
T

872 lines
53 KiB
HTML

<!DOCTYPE HTML>
<html lang="en" class="navy" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>Rust Backend - JellyTau Documentation</title>
<!-- Custom HTML head -->
<meta name="description" content="Requirements, traceability, and architecture for the JellyTau Jellyfin client.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="../favicon.svg">
<link rel="shortcut icon" href="../favicon.png">
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/general.css">
<link rel="stylesheet" href="../css/chrome.css">
<link rel="stylesheet" href="../css/print.css" media="print">
<!-- Fonts -->
<link rel="stylesheet" href="../FontAwesome/css/font-awesome.css">
<link rel="stylesheet" href="../fonts/fonts.css">
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" href="../highlight.css">
<link rel="stylesheet" href="../tomorrow-night.css">
<link rel="stylesheet" href="../ayu-highlight.css">
<!-- Custom theme stylesheets -->
</head>
<body class="sidebar-visible no-js">
<div id="body-container">
<!-- Provide site root to javascript -->
<script>
var path_to_root = "../";
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "navy" : "navy";
</script>
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
var theme = localStorage.getItem('mdbook-theme');
var sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
var html = document.querySelector('html');
html.classList.remove('navy')
html.classList.add(theme);
var body = document.querySelector('body');
body.classList.remove('no-js')
body.classList.add('js');
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
var body = document.querySelector('body');
var sidebar = null;
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
}
sidebar_toggle.checked = sidebar === 'visible';
body.classList.remove('sidebar-visible');
body.classList.add("sidebar-" + sidebar);
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<div class="sidebar-scrollbox">
<ol class="chapter"><li class="chapter-item expanded affix "><a href="../index.html">Introduction</a></li><li class="chapter-item expanded affix "><li class="part-title">Requirements & Traceability</li><li class="chapter-item expanded "><a href="../requirements.html"><strong aria-hidden="true">1.</strong> Requirements Specification</a></li><li class="chapter-item expanded "><a href="../traceability.html"><strong aria-hidden="true">2.</strong> Traceability Matrix</a></li><li class="chapter-item expanded "><a href="../traceability-ci.html"><strong aria-hidden="true">3.</strong> Traceability CI</a></li><li class="chapter-item expanded "><a href="../traces-quick-ref.html"><strong aria-hidden="true">4.</strong> Traces Quick Reference</a></li><li class="chapter-item expanded affix "><li class="part-title">Architecture</li><li class="chapter-item expanded "><a href="../architecture/index.html"><strong aria-hidden="true">5.</strong> Overview</a><a class="toggle"><div></div></a></li><li><ol class="section"><li class="chapter-item expanded "><a href="../architecture/01-rust-backend.html" class="active"><strong aria-hidden="true">5.1.</strong> Rust Backend</a></li><li class="chapter-item "><a href="../architecture/02-svelte-frontend.html"><strong aria-hidden="true">5.2.</strong> Svelte Frontend</a></li><li class="chapter-item "><a href="../architecture/03-data-flow.html"><strong aria-hidden="true">5.3.</strong> Data Flow</a></li><li class="chapter-item "><a href="../architecture/04-type-sync-and-threading.html"><strong aria-hidden="true">5.4.</strong> Type Sync & Threading</a></li><li class="chapter-item "><a href="../architecture/05-platform-backends.html"><strong aria-hidden="true">5.5.</strong> Platform Backends</a></li><li class="chapter-item "><a href="../architecture/06-downloads-and-offline.html"><strong aria-hidden="true">5.6.</strong> Downloads & Offline</a></li><li class="chapter-item "><a href="../architecture/07-connectivity.html"><strong aria-hidden="true">5.7.</strong> Connectivity</a></li><li class="chapter-item "><a href="../architecture/08-database-design.html"><strong aria-hidden="true">5.8.</strong> Database Design</a></li><li class="chapter-item "><a href="../architecture/09-security.html"><strong aria-hidden="true">5.9.</strong> Security</a></li></ol></li><li class="chapter-item expanded "><li class="part-title">UX</li><li class="chapter-item expanded "><a href="../ux-flows.html"><strong aria-hidden="true">6.</strong> UX Flows</a></li><li class="chapter-item expanded affix "><li class="part-title">Specs — Pending Work</li><li class="chapter-item expanded "><a href="../specs/index.html"><strong aria-hidden="true">7.</strong> Specs Index</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-TEMPLATE.html"><strong aria-hidden="true">8.</strong> Spec Template</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-REVIEW-CHECKLIST.html"><strong aria-hidden="true">9.</strong> Spec Review Checklist</a></li><li class="chapter-item expanded "><a href="../specs/playback-backend-unification.html"><strong aria-hidden="true">10.</strong> Playback Backend Unification</a></li><li class="chapter-item expanded "><a href="../specs/linux-native-video-spike.html"><strong aria-hidden="true">11.</strong> Linux Native Video Spike</a></li><li class="chapter-item expanded "><a href="../specs/backend-owned-stream-selection.html"><strong aria-hidden="true">12.</strong> Backend-Owned Stream Selection</a></li><li class="chapter-item expanded "><a href="../specs/player-facade-enforcement.html"><strong aria-hidden="true">13.</strong> Player Facade Enforcement</a></li><li class="chapter-item expanded "><a href="../specs/windows-native-audio-backend.html"><strong aria-hidden="true">14.</strong> Windows Native Audio Backend</a></li><li class="chapter-item expanded "><a href="../specs/libmpv2-migration.html"><strong aria-hidden="true">15.</strong> libmpv2 Migration</a></li><li class="chapter-item expanded "><a href="../specs/read-through-media-cache.html"><strong aria-hidden="true">16.</strong> Read-Through Media Cache</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search.html"><strong aria-hidden="true">17.</strong> Scoped Search</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary.html"><strong aria-hidden="true">18.</strong> Scoped Search Boundary</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary-implementation.html"><strong aria-hidden="true">19.</strong> Scoped Search Boundary — Implementation</a></li><li class="chapter-item expanded "><a href="../specs/frontend-domain-model.html"><strong aria-hidden="true">20.</strong> Frontend Domain Model</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">21.</strong> Build Provenance</a></li><li class="chapter-item expanded affix "><li class="part-title">Build & Release</li><li class="chapter-item expanded "><a href="../build/build-release.html"><strong aria-hidden="true">22.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">23.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">24.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">25.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">26.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">27.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">28.</strong> Builder Image</a></li><li class="chapter-item expanded affix "><li class="spacer"></li><li class="chapter-item expanded affix "><a href="../api-redirect.html">Rust API Reference (rustdoc)</a></li></ol>
</div>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<!-- Track and set sidebar scroll position -->
<script>
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
sidebarScrollbox.addEventListener('click', function(e) {
if (e.target.tagName === 'A') {
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
}
}, { passive: true });
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
sessionStorage.removeItem('sidebar-scroll');
if (sidebarScrollTop) {
// preserve sidebar scroll position when navigating via links within sidebar
sidebarScrollbox.scrollTop = sidebarScrollTop;
} else {
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
var activeSection = document.querySelector('#sidebar .active');
if (activeSection) {
activeSection.scrollIntoView({ block: 'center' });
}
}
</script>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
</div>
<h1 class="menu-title">JellyTau Documentation</h1>
<div class="right-buttons">
<a href="../print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
<a href="https://gitea.tourolle.paris/dtourolle/jellytau" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa fa-github"></i>
</a>
<a href="https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/../docs/architecture/01-rust-backend.md" title="Suggest an edit" aria-label="Suggest an edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
</div>
</div>
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
<h1 id="rust-backend-architecture"><a class="header" href="#rust-backend-architecture">Rust Backend Architecture</a></h1>
<p><strong>Location</strong>: <code>src-tauri/src/</code></p>
<h2 id="media-session-state-machine"><a class="header" href="#media-session-state-machine">Media Session State Machine</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/player/session.rs</code></p>
<p>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.</p>
<p><strong>Architecture Note:</strong> The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.</p>
<pre><code class="language-mermaid">stateDiagram-v2
[*] --&gt; Idle
Idle --&gt; AudioActive : play_queue(audio)
Idle --&gt; MovieActive : play_item(movie)
Idle --&gt; TvShowActive : play_item(episode)
state "Audio Session" as AudioSession {
[*] --&gt; AudioActive
AudioActive --&gt; AudioInactive : playback_ended
AudioInactive --&gt; AudioActive : resume/play
AudioActive --&gt; AudioActive : next/previous
}
state "Movie Session" as MovieSession {
[*] --&gt; MovieActive
MovieActive --&gt; MovieInactive : playback_ended
MovieInactive --&gt; MovieActive : resume
}
state "TV Show Session" as TvShowSession {
[*] --&gt; TvShowActive
TvShowActive --&gt; TvShowInactive : playback_ended
TvShowInactive --&gt; TvShowActive : next_episode/resume
}
AudioSession --&gt; Idle : dismiss/clear_queue
AudioSession --&gt; MovieSession : play_item(movie)
AudioSession --&gt; TvShowSession : play_item(episode)
MovieSession --&gt; Idle : dismiss/playback_complete
MovieSession --&gt; AudioSession : play_queue(audio)
TvShowSession --&gt; Idle : dismiss/series_complete
TvShowSession --&gt; 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
</code></pre>
<p><strong>Session State Enum:</strong></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>#[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&lt;MediaItem&gt;,
/// 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
},
}
<span class="boring">}</span></code></pre></pre>
<p><strong>State Transitions &amp; Rules:</strong></p>
<div class="table-wrapper"><table><thead><tr><th>From State</th><th>Event</th><th>To State</th><th>UI Behavior</th><th>Notes</th></tr></thead><tbody>
<tr><td>Idle</td><td><code>play_queue(audio)</code></td><td>Audio (active)</td><td>Show miniplayer</td><td>Creates audio session</td></tr>
<tr><td>Idle</td><td><code>play_item(movie)</code></td><td>Movie (active)</td><td>Show video player</td><td>Creates movie session</td></tr>
<tr><td>Idle</td><td><code>play_item(episode)</code></td><td>TvShow (active)</td><td>Show video player</td><td>Creates TV session</td></tr>
<tr><td>Audio (active)</td><td><code>playback_ended</code></td><td>Audio (inactive)</td><td>Miniplayer stays visible</td><td>Queue preserved</td></tr>
<tr><td>Audio (inactive)</td><td><code>play/resume</code></td><td>Audio (active)</td><td>Miniplayer enabled</td><td>Resume from queue</td></tr>
<tr><td>Audio (active/inactive)</td><td><code>dismiss</code></td><td>Idle</td><td>Hide miniplayer</td><td>Clear session</td></tr>
<tr><td>Audio (active/inactive)</td><td><code>play_item(movie)</code></td><td>Movie (active)</td><td>Switch to video player</td><td>Replace session</td></tr>
<tr><td>Movie (active)</td><td><code>playback_ended</code></td><td>Idle</td><td>Hide video player</td><td>Auto-dismiss</td></tr>
<tr><td>Movie (active)</td><td><code>dismiss</code></td><td>Idle</td><td>Hide video player</td><td>User dismiss</td></tr>
<tr><td>TvShow (active)</td><td><code>playback_ended</code></td><td>TvShow (inactive)</td><td>Show next episode UI</td><td>Wait for user choice</td></tr>
<tr><td>TvShow (inactive)</td><td><code>next_episode</code></td><td>TvShow (active)</td><td>Play next episode</td><td>Stay in session</td></tr>
<tr><td>TvShow (inactive)</td><td><code>series_complete</code></td><td>Idle</td><td>Hide video player</td><td>No more episodes</td></tr>
</tbody></table>
</div>
<p><strong>Key Design Decisions:</strong></p>
<ol>
<li><strong>Audio Sessions Persist</strong>: Miniplayer stays visible even when queue ends, allows easy resume</li>
<li><strong>Video Sessions Auto-Dismiss</strong>: Movies auto-close when finished (unless paused)</li>
<li><strong>Single Active Session</strong>: Playing new content type replaces current session</li>
<li><strong>Explicit Dismiss for Audio</strong>: User must click close button to clear audio session</li>
<li><strong>Session != PlayerState</strong>: Session is higher-level, PlayerState tracks playing/paused/seeking</li>
</ol>
<p><strong>Edge Cases Handled:</strong></p>
<ul>
<li>Album finishes: Session goes inactive, miniplayer shows last track with play disabled</li>
<li>User wants to dismiss: Close button clears session -&gt; Idle</li>
<li>Switch content types: New session replaces old (audio -&gt; movie)</li>
<li>Paused for extended time: Session persists indefinitely</li>
<li>Playback errors: Session stays inactive, allows retry</li>
<li>Queue operations while idle: Queue exists but no session created until play</li>
</ul>
<h2 id="player-state-machine-low-level-playback"><a class="header" href="#player-state-machine-low-level-playback">Player State Machine (Low-Level Playback)</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/player/state.rs</code></p>
<p>The player uses a deterministic state machine with 6 states (operates within a media session):</p>
<pre><code class="language-mermaid">stateDiagram-v2
[*] --&gt; Idle
Idle --&gt; Loading : Load
Loading --&gt; Playing : MediaLoaded
Playing --&gt; Paused : Pause
Paused --&gt; Playing : Play
Paused --&gt; Seeking : Seek
Seeking --&gt; Playing : PositionUpdate
Playing --&gt; Idle : Stop
Paused --&gt; Idle : Stop
Idle --&gt; Error : Error
Loading --&gt; Error : Error
Playing --&gt; Error : Error
Paused --&gt; Error : Error
Seeking --&gt; Error : Error
state Playing {
[*] : position, duration
}
state Paused {
[*] : position, duration
}
state Seeking {
[*] : target
}
state Error {
[*] : error message
}
</code></pre>
<p><strong>State Enum:</strong></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>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&lt;MediaItem&gt;, error: String },
}
<span class="boring">}</span></code></pre></pre>
<p><strong>Event Enum:</strong></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub enum PlayerEvent {
Load(MediaItem),
Play,
Pause,
Stop,
Seek(f64),
Next,
Previous,
MediaLoaded(f64), // duration
PositionUpdate(f64), // position
PlaybackEnded,
Error(String),
}
<span class="boring">}</span></code></pre></pre>
<h2 id="playback-mode-state-machine"><a class="header" href="#playback-mode-state-machine">Playback Mode State Machine</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/playback_mode/mod.rs</code></p>
<p>The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):</p>
<pre><code class="language-mermaid">stateDiagram-v2
[*] --&gt; Idle
Idle --&gt; Local : play_queue()
Idle --&gt; Remote : transfer_to_remote(session_id)
Local --&gt; Remote : transfer_to_remote(session_id)
Local --&gt; Idle : stop()
Remote --&gt; Local : transfer_to_local()
Remote --&gt; Idle : session_disconnected()
Remote --&gt; Idle : stop()
state Local {
[*] : Playing on device
[*] : ExoPlayer active
[*] : Volume buttons -&gt; device
}
state Remote {
[*] : Controlling session
[*] : session_id
[*] : Volume buttons -&gt; remote
[*] : Android: VolumeProvider active
}
state Idle {
[*] : No active playback
}
</code></pre>
<p><strong>State Enum:</strong></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub enum PlaybackMode {
Local, // Playing on local device
Remote { session_id: String }, // Controlling remote Jellyfin session
Idle, // No active playback
}
<span class="boring">}</span></code></pre></pre>
<p><strong>State Transitions:</strong></p>
<div class="table-wrapper"><table><thead><tr><th>From</th><th>Event</th><th>To</th><th>Side Effects</th></tr></thead><tbody>
<tr><td>Idle</td><td><code>play_queue()</code></td><td>Local</td><td>Start local playback</td></tr>
<tr><td>Idle</td><td><code>transfer_to_remote(session_id)</code></td><td>Remote</td><td>Send queue to remote session</td></tr>
<tr><td>Local</td><td><code>transfer_to_remote(session_id)</code></td><td>Remote</td><td>Stop local, send queue to remote, enable remote volume (Android)</td></tr>
<tr><td>Local</td><td><code>stop()</code></td><td>Idle</td><td>Stop local playback</td></tr>
<tr><td>Remote</td><td><code>transfer_to_local()</code></td><td>Local</td><td>Get remote state, stop remote, start local at same position, disable remote volume</td></tr>
<tr><td>Remote</td><td><code>stop()</code></td><td>Idle</td><td>Stop remote playback, disable remote volume</td></tr>
<tr><td>Remote</td><td><code>session_disconnected()</code></td><td>Idle</td><td>Session lost, disable remote volume</td></tr>
</tbody></table>
</div>
<p><strong>Integration with Player State Machine:</strong></p>
<ul>
<li>When <code>PlaybackMode = Local</code>: Player state machine is active (Idle/Loading/Playing/Paused/etc.)</li>
<li>When <code>PlaybackMode = Remote</code>: Player state is typically Idle (remote session controls playback)</li>
<li>When <code>PlaybackMode = Idle</code>: Player state is Idle</li>
</ul>
<p><strong>Android Volume Control Integration:</strong></p>
<p>When transitioning to <code>Remote</code> mode on Android:</p>
<ol>
<li>Call <code>enable_remote_volume(initial_volume)</code></li>
<li>VolumeProviderCompat intercepts hardware volume buttons</li>
<li>PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)</li>
<li>Volume commands routed to remote session via Jellyfin API</li>
</ol>
<p>When transitioning away from <code>Remote</code> mode:</p>
<ol>
<li>Call <code>disable_remote_volume()</code></li>
<li>Volume buttons return to controlling device volume</li>
<li>PlaybackStateCompat set to STATE_NONE</li>
<li>VolumeProviderCompat is cleared</li>
</ol>
<h2 id="media-item--source"><a class="header" href="#media-item--source">Media Item &amp; Source</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/player/media.rs</code></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub struct MediaItem {
pub id: String,
pub title: String,
pub artist: Option&lt;String&gt;,
pub album: Option&lt;String&gt;,
pub duration: Option&lt;f64&gt;,
pub artwork_url: Option&lt;String&gt;,
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&lt;String&gt;,
},
DirectUrl {
url: String,
},
}
<span class="boring">}</span></code></pre></pre>
<p>The <code>MediaSource</code> enum enables:</p>
<ul>
<li><strong>Remote</strong>: Streaming from Jellyfin server</li>
<li><strong>Local</strong>: Downloaded/cached files (future offline support)</li>
<li><strong>DirectUrl</strong>: Direct URLs (channel plugins, external sources)</li>
</ul>
<h2 id="queue-manager"><a class="header" href="#queue-manager">Queue Manager</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/player/queue.rs</code></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub struct QueueManager {
items: Vec&lt;MediaItem&gt;,
current_index: Option&lt;usize&gt;,
shuffle: bool,
repeat: RepeatMode,
shuffle_order: Vec&lt;usize&gt;, // Fisher-Yates permutation
history: Vec&lt;usize&gt;, // For back navigation in shuffle
}
pub enum RepeatMode {
Off,
All,
One,
}
<span class="boring">}</span></code></pre></pre>
<p><strong>Queue Navigation Logic:</strong></p>
<pre><code class="language-mermaid">flowchart TB
QM[QueueManager]
QM --&gt; Shuffle
QM --&gt; Repeat
QM --&gt; History
subgraph Shuffle["Shuffle Mode"]
ShuffleOff["OFF&lt;br/&gt;next() returns index + 1"]
ShuffleOn["ON&lt;br/&gt;next() follows shuffle_order[]"]
end
subgraph Repeat["Repeat Mode"]
RepeatOff["OFF&lt;br/&gt;next() at end: -&gt; None"]
RepeatAll["ALL&lt;br/&gt;next() at end: -&gt; wrap to index 0"]
RepeatOne["ONE&lt;br/&gt;next() returns same item"]
end
subgraph History["History"]
HistoryDesc["Used for previous()&lt;br/&gt;in shuffle mode"]
end
</code></pre>
<h2 id="favorites-system"><a class="header" href="#favorites-system">Favorites System</a></h2>
<p><strong>Location</strong>:</p>
<ul>
<li>Commands: <code>src-tauri/src/commands/favorites.rs</code> (offline drain),
<code>src-tauri/src/commands/repository.rs</code> (query + toggle),
<code>src-tauri/src/commands/storage/</code> (local <code>user_data</code> writes)</li>
<li>Repository: <code>get_favorites</code> on the trait, implemented by <code>online.rs</code>,
<code>offline.rs</code> and <code>hybrid.rs</code></li>
<li>Frontend: <code>src/lib/services/favorites.ts</code>,
<code>src/lib/components/FavoriteButton.svelte</code>, <code>/library/favorites</code></li>
</ul>
<p>Favouriting has two halves that are easy to confuse: <strong>marking</strong> an item, which
has existed since UR-017, and <strong>browsing</strong> what was marked, which arrived with
UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.</p>
<h3 id="marking"><a class="header" href="#marking">Marking</a></h3>
<p>Optimistic local write, then server sync:</p>
<pre><code class="language-mermaid">flowchart TB
UI[FavoriteButton] --&gt;|Click| Service[toggleFavorite]
Service --&gt;|"1. Optimistic"| LocalDB[("SQLite user_data&lt;br/&gt;is_favorite, pending_sync")]
Service --&gt;|"2. Sync"| Repo[Repository]
Repo --&gt;|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
Service --&gt;|"3. Mark synced"| LocalDB
Drain["spawn_favorites_drain&lt;br/&gt;(background task)"] --&gt;|"pending_sync = 1"| Repo
</code></pre>
<ol>
<li>The local row is updated immediately, so the heart fills without a round trip.</li>
<li>The repository is asked to mark or unmark on the server.</li>
<li>On success <code>pending_sync</code> is cleared; on failure the row stays pending.</li>
<li>A <strong>background drain</strong> (<code>spawn_favorites_drain</code>, started in <code>lib.rs</code> 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
<a href="#background-workers">Background workers</a>.</li>
</ol>
<h3 id="browsing"><a class="header" href="#browsing">Browsing</a></h3>
<p><code>get_favorites(scope, options)</code> answers "what did this user favourite", across
libraries, with the <strong>scope owned by Rust</strong> — the frontend sends a
<a href="#search-scope-and-the-taxonomy-boundary"><code>SearchScope</code></a> variant and never names
an item type. <code>HybridRepository</code> splits it the same way it splits every query:</p>
<div class="table-wrapper"><table><thead><tr><th>Method</th><th>Used for</th></tr></thead><tbody>
<tr><td><code>get_favorites_cache_only</code></td><td>The instant leg — the local <code>user_data</code> join</td></tr>
<tr><td><code>get_favorites_server_only</code></td><td>The reconciliation leg</td></tr>
<tr><td><code>get_favorites</code></td><td>Cache-first with server merge, per the repository's usual policy</td></tr>
</tbody></table>
</div>
<p><code>GetItemsOptions.favorites_only</code> is the other entry point: it filters an
<em>existing</em> library listing rather than starting a cross-library query (DR-116),
which is what a library page's favourites filter uses.</p>
<p>Server favourite state is mirrored into the local <code>user_data</code> table on catalog
sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up
here — before this, <code>MediaItem.user_data</code> was left empty and no query anywhere
asked for favourites.</p>
<p><strong>Tauri commands</strong>:</p>
<div class="table-wrapper"><table><thead><tr><th>Command</th><th>Description</th></tr></thead><tbody>
<tr><td><code>repository_get_favorites</code></td><td>Cross-library favourites for a scope</td></tr>
<tr><td><code>repository_mark_favorite</code> / <code>repository_unmark_favorite</code></td><td>Toggle on the server, through the repository</td></tr>
<tr><td><code>storage_toggle_favorite</code></td><td>Local optimistic write (<code>is_favorite</code>, <code>pending_sync</code>)</td></tr>
<tr><td><code>storage_mark_synced</code></td><td>Clear <code>pending_sync</code> after a successful server write</td></tr>
</tbody></table>
</div>
<p><strong>Frontend surfaces</strong> (DR-117 … DR-119): the <code>/library/favorites</code> page with a
scope selector, favourite rows on home (<code>favoriteMovies</code> / <code>favoriteShows</code> /
<code>favoriteMusic</code> in <code>stores/home.ts</code>), a favourites tile per category in the
library mosaic, and <code>FavoriteButton</code> mounted wherever a whole item is shown —
movie, series, episode, album, artist and playlist detail views as well as the
mini player.</p>
<h2 id="player-backend-trait"><a class="header" href="#player-backend-trait">Player Backend Trait</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/player/backend.rs</code></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub trait PlayerBackend: Send + Sync {
fn load(&amp;mut self, media: &amp;MediaItem) -&gt; Result&lt;(), PlayerError&gt;;
fn play(&amp;mut self) -&gt; Result&lt;(), PlayerError&gt;;
fn pause(&amp;mut self) -&gt; Result&lt;(), PlayerError&gt;;
fn stop(&amp;mut self) -&gt; Result&lt;(), PlayerError&gt;;
fn seek(&amp;mut self, position: f64) -&gt; Result&lt;(), PlayerError&gt;;
fn set_volume(&amp;mut self, volume: f32) -&gt; Result&lt;(), PlayerError&gt;;
fn position(&amp;self) -&gt; f64;
fn duration(&amp;self) -&gt; Option&lt;f64&gt;;
fn state(&amp;self) -&gt; PlayerState;
fn is_loaded(&amp;self) -&gt; bool;
fn volume(&amp;self) -&gt; f32;
}
<span class="boring">}</span></code></pre></pre>
<p><strong>Implementations:</strong></p>
<ul>
<li><code>NullBackend</code> - Mock backend for testing</li>
<li><code>MpvBackend</code> - Linux playback via libmpv (see <a href="05-platform-backends.html">05-platform-backends.md</a>)</li>
<li><code>ExoPlayerBackend</code> - Android playback via ExoPlayer/Media3 (see <a href="05-platform-backends.html">05-platform-backends.md</a>)</li>
</ul>
<h2 id="player-controller"><a class="header" href="#player-controller">Player Controller</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/player/mod.rs</code></p>
<p>The <code>PlayerController</code> orchestrates playback:</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub struct PlayerController {
backend: Arc&lt;Mutex&lt;Box&lt;dyn PlayerBackend&gt;&gt;&gt;,
queue: Arc&lt;Mutex&lt;QueueManager&gt;&gt;,
muted: bool,
sleep_timer: Arc&lt;Mutex&lt;SleepTimerState&gt;&gt;,
autoplay_settings: Arc&lt;Mutex&lt;AutoplaySettings&gt;&gt;,
autoplay_episode_count: Arc&lt;Mutex&lt;u32&gt;&gt;, // Session-based counter
repository: Arc&lt;Mutex&lt;Option&lt;Arc&lt;dyn MediaRepository&gt;&gt;&gt;&gt;,
event_emitter: Arc&lt;Mutex&lt;Option&lt;Arc&lt;dyn PlayerEventEmitter&gt;&gt;&gt;&gt;,
// ... other fields
}
<span class="boring">}</span></code></pre></pre>
<p><strong>Key Methods:</strong></p>
<ul>
<li><code>play_item(item)</code>: Load and play single item (resets autoplay counter)</li>
<li><code>play_queue(items, start_index)</code>: Load queue and start playback (resets autoplay counter)</li>
<li><code>next()</code> / <code>previous()</code>: Queue navigation (resets autoplay counter)</li>
<li><code>toggle_shuffle()</code> / <code>cycle_repeat()</code>: Mode changes</li>
<li><code>set_sleep_timer(mode)</code> / <code>cancel_sleep_timer()</code>: Sleep timer control</li>
<li><code>on_playback_ended()</code>: Autoplay decision making (checks sleep timer, episode limit, queue)</li>
</ul>
<h2 id="playlist-system"><a class="header" href="#playlist-system">Playlist System</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/commands/playlist.rs</code>, <code>src-tauri/src/repository/</code></p>
<p><strong>TRACES</strong>: UR-014 | JA-019 | JA-020</p>
<p>The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.</p>
<p><strong>Types:</strong></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>/// 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,
}
<span class="boring">}</span></code></pre></pre>
<p><strong>Key Design Decision</strong>: <code>PlaylistEntry</code> wraps a <code>MediaItem</code> with a distinct <code>playlist_item_id</code>. 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.</p>
<p><strong>MediaRepository Trait Methods:</strong></p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>async fn create_playlist(&amp;self, name: &amp;str, item_ids: Option&lt;Vec&lt;String&gt;&gt;) -&gt; Result&lt;PlaylistCreatedResult, RepoError&gt;;
async fn delete_playlist(&amp;self, playlist_id: &amp;str) -&gt; Result&lt;(), RepoError&gt;;
async fn rename_playlist(&amp;self, playlist_id: &amp;str, name: &amp;str) -&gt; Result&lt;(), RepoError&gt;;
async fn get_playlist_items(&amp;self, playlist_id: &amp;str) -&gt; Result&lt;Vec&lt;PlaylistEntry&gt;, RepoError&gt;;
async fn add_to_playlist(&amp;self, playlist_id: &amp;str, item_ids: Vec&lt;String&gt;) -&gt; Result&lt;(), RepoError&gt;;
async fn remove_from_playlist(&amp;self, playlist_id: &amp;str, entry_ids: Vec&lt;String&gt;) -&gt; Result&lt;(), RepoError&gt;;
async fn move_playlist_item(&amp;self, playlist_id: &amp;str, item_id: &amp;str, new_index: u32) -&gt; Result&lt;(), RepoError&gt;;
<span class="boring">}</span></code></pre></pre>
<p><strong>Cache Strategy:</strong></p>
<ul>
<li><strong>Write operations</strong> (create, delete, rename, add, remove, move): Delegate directly to online repository</li>
<li><strong>Read operation</strong> (<code>get_playlist_items</code>): Uses cache-first parallel racing (100ms cache timeout, server fallback)</li>
<li>Background cache update after server fetch via <code>save_playlist_items_to_cache()</code></li>
</ul>
<p><strong>Playlist Tauri Commands:</strong></p>
<div class="table-wrapper"><table><thead><tr><th>Command</th><th>Parameters</th><th>Returns</th></tr></thead><tbody>
<tr><td><code>playlist_create</code></td><td><code>handle, name, item_ids?</code></td><td><code>PlaylistCreatedResult</code></td></tr>
<tr><td><code>playlist_delete</code></td><td><code>handle, playlist_id</code></td><td><code>()</code></td></tr>
<tr><td><code>playlist_rename</code></td><td><code>handle, playlist_id, name</code></td><td><code>()</code></td></tr>
<tr><td><code>playlist_get_items</code></td><td><code>handle, playlist_id</code></td><td><code>Vec&lt;PlaylistEntry&gt;</code></td></tr>
<tr><td><code>playlist_add_items</code></td><td><code>handle, playlist_id, item_ids</code></td><td><code>()</code></td></tr>
<tr><td><code>playlist_remove_items</code></td><td><code>handle, playlist_id, entry_ids</code></td><td><code>()</code></td></tr>
<tr><td><code>playlist_move_item</code></td><td><code>handle, playlist_id, item_id, new_index</code></td><td><code>()</code></td></tr>
</tbody></table>
</div>
<h2 id="tauri-commands-player"><a class="header" href="#tauri-commands-player">Tauri Commands (Player)</a></h2>
<p><strong>Location</strong>: <code>src-tauri/src/commands/player.rs</code></p>
<div class="table-wrapper"><table><thead><tr><th>Command</th><th>Parameters</th><th>Returns</th></tr></thead><tbody>
<tr><td><code>player_play_item</code></td><td><code>PlayItemRequest</code></td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_play_queue</code></td><td><code>items, start_index, shuffle</code></td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_play</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_pause</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_toggle</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_stop</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_next</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_previous</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_seek</code></td><td><code>position: f64</code></td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_set_volume</code></td><td><code>volume: f32</code></td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_toggle_shuffle</code></td><td>-</td><td><code>QueueStatus</code></td></tr>
<tr><td><code>player_cycle_repeat</code></td><td>-</td><td><code>QueueStatus</code></td></tr>
<tr><td><code>player_get_status</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
<tr><td><code>player_get_queue</code></td><td>-</td><td><code>QueueStatus</code></td></tr>
<tr><td><code>player_get_session</code></td><td>-</td><td><code>MediaSessionType</code></td></tr>
<tr><td><code>player_dismiss_session</code></td><td>-</td><td><code>()</code></td></tr>
<tr><td><code>player_set_sleep_timer</code></td><td><code>mode: SleepTimerMode</code></td><td><code>()</code></td></tr>
<tr><td><code>player_cancel_sleep_timer</code></td><td>-</td><td><code>()</code></td></tr>
<tr><td><code>player_set_video_settings</code></td><td><code>settings: VideoSettings</code></td><td><code>VideoSettings</code></td></tr>
<tr><td><code>player_get_video_settings</code></td><td>-</td><td><code>VideoSettings</code></td></tr>
<tr><td><code>player_set_autoplay_settings</code></td><td><code>settings: AutoplaySettings</code></td><td><code>AutoplaySettings</code></td></tr>
<tr><td><code>player_get_autoplay_settings</code></td><td>-</td><td><code>AutoplaySettings</code></td></tr>
<tr><td><code>player_on_playback_ended</code></td><td>-</td><td><code>()</code></td></tr>
</tbody></table>
</div>
<h2 id="domain-vocabulary-owned-by-rust"><a class="header" href="#domain-vocabulary-owned-by-rust">Domain Vocabulary Owned by Rust</a></h2>
<p>The frontend is presentation-only and must not encode Jellyfin's <em>taxonomy</em> — the
rule in <a href="../../CLAUDE.html">CLAUDE.md</a> and
<a href="../specs/scoped-search-boundary.html">scoped-search-boundary.md</a>. These are the
places where that vocabulary actually lives.</p>
<h3 id="search-scope-and-the-taxonomy-boundary"><a class="header" href="#search-scope-and-the-taxonomy-boundary">Search scope and the taxonomy boundary</a></h3>
<p><strong>Location</strong>: <code>src-tauri/src/repository/types.rs</code></p>
<p><code>SearchScope</code> is the canonical example the boundary rule is taught from. The
frontend sends an opaque variant; Rust expands it into Jellyfin item types:</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>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) -&gt; Option&lt;Vec&lt;String&gt;&gt; { … }
/// The scope a library of this Jellyfin `CollectionType` belongs to.
pub fn for_collection_type(collection_type: &amp;str) -&gt; Option&lt;SearchScope&gt; { … }
}
<span class="boring">}</span></code></pre></pre>
<p>Two details that are load-bearing:</p>
<ul>
<li><code>All</code> returns <code>None</code>, <strong>not</strong> the union of every listed type. An explicit
<code>includeItemTypes</code> 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 <code>None</code>.</li>
<li><code>for_collection_type</code> maps a Jellyfin <code>CollectionType</code> to a favourites
category (DR-175). It changes when <em>Jellyfin</em> 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.</li>
</ul>
<p>⚠️ <strong>The result side has not moved yet.</strong> <code>GROUP_ITEM_TYPES</code> in
<code>src/lib/utils/searchScope.ts</code> still maps result groups to item types in the
frontend, and <code>check:boundary</code> does not match its shape. Tracked as Stage 2 of
<a href="../specs/scoped-search-boundary-implementation.html">scoped-search-boundary-implementation.md</a>.</p>
<h3 id="library-exclusions"><a class="header" href="#library-exclusions">Library exclusions</a></h3>
<p><strong>Location</strong>: <code>src-tauri/src/repository/exclusions.rs</code> (TRACES: UR-076 | DR-209)</p>
<p>Folders the user has chosen to keep out of music browsing — a "Podcasts" folder
inside a music library being the canonical case. Excluded <strong>by item id</strong>, not by
name, in a process-wide <code>RwLock&lt;Vec&lt;String&gt;&gt;</code> restored from the database at
startup, and applied by the repository layer to every music query (libraries,
artists, albums, genres, search, home rows).</p>
<p>The id is normalised (<code>trim</code>, strip <code>-</code>, 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.</p>
<p>The set is process-wide rather than a field on a repository for the same reason
as <code>online::STREAMING_QUALITY</code>: it is a preference about <em>this user's browsing</em>,
not about a server session, so it must survive a repository being rebuilt on
re-login.</p>
<h3 id="streaming-quality-ladder"><a class="header" href="#streaming-quality-ladder">Streaming quality ladder</a></h3>
<p><strong>Location</strong>: <code>src-tauri/src/settings.rs</code> (TRACES: UR-074 | DR-162)</p>
<p><code>StreamingQuality</code> is a bandwidth ladder (<code>Original</code>, 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 <em>from</em> the bitrate so the encoder does not spend a small
budget on pixels it cannot afford.</p>
<div class="table-wrapper"><table><thead><tr><th>Method</th><th>Answers</th></tr></thead><tbody>
<tr><td><code>max_bitrate()</code></td><td>Total bits/s (video + audio), <code>None</code> for <code>Original</code></td></tr>
<tr><td><code>audio_bitrate()</code></td><td>The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom</td></tr>
<tr><td><code>video_bitrate()</code></td><td>Total minus audio, so the two together honour the ceiling</td></tr>
<tr><td><code>max_height()</code></td><td>Resolution ceiling that suits the bitrate</td></tr>
</tbody></table>
</div>
<p>The ceiling goes to <code>PlaybackInfo</code> as <code>MaxStreamingBitrate</code> <strong>and</strong> 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 <em>direct play</em> is served at the
source file's own bitrate, and no URL parameter afterwards can reduce it.</p>
<p>The frontend names a variant and nothing else; the labels the picker shows are
served over IPC by <code>player_get_streaming_qualities</code>.</p>
<h2 id="background-workers"><a class="header" href="#background-workers">Background workers</a></h2>
<p>Three long-lived tasks are spawned from the Tauri <code>setup</code> hook in <code>lib.rs</code>. All
three exist because <em>when</em> something happens is a backend policy, not something
a page load should decide.</p>
<div class="table-wrapper"><table><thead><tr><th>Worker</th><th>Location</th><th>Responsibility</th></tr></thead><tbody>
<tr><td><code>spawn_catalog_indexer</code></td><td><code>commands/catalog.rs</code></td><td>Keeps the local FTS5 catalog fresh (DR-109, IR-030)</td></tr>
<tr><td><code>spawn_favorites_drain</code></td><td><code>commands/favorites.rs</code></td><td>Retries favourite toggles made while offline (DR-120)</td></tr>
<tr><td><code>spawn_sync_queue_drain</code></td><td><code>commands/sync_drain.rs</code></td><td>Drains the offline mutation queue (DR-131)</td></tr>
</tbody></table>
</div>
<h3 id="catalog-indexer"><a class="header" href="#catalog-indexer">Catalog indexer</a></h3>
<p>Replaces the frontend's startup-only <code>syncCatalog()</code> call. It ticks on
<code>CATALOG_INDEX_TICK</code> and runs a pass when three things hold: a repository exists,
the server is reachable, and the index is due per <code>index_is_due</code>. A tick is
nearly free — one indexed <code>app_settings</code> 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.</p>
<p><code>index_is_due</code> 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 <code>CATALOG_INDEX_EVENT</code> for the staleness hint in the
UI.</p>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../architecture/index.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<a rel="next prefetch" href="../architecture/02-svelte-frontend.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
<a rel="prev" href="../architecture/index.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<a rel="next prefetch" href="../architecture/02-svelte-frontend.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
</nav>
</div>
<script>
window.playground_copyable = true;
</script>
<script src="../elasticlunr.min.js"></script>
<script src="../mark.min.js"></script>
<script src="../searcher.js"></script>
<script src="../clipboard.min.js"></script>
<script src="../highlight.js"></script>
<script src="../book.js"></script>
<!-- Custom JS scripts -->
</div>
</body>
</html>