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

1006 lines
64 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/desktop-native-video.html"><strong aria-hidden="true">21.</strong> Desktop Native Video</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">22.</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">23.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">24.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../native-player-verification.html"><strong aria-hidden="true">25.</strong> Native Player Verification</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">26.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">27.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">28.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">29.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">30.</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>
<h4 id="two-levels-of-ceiling"><a class="header" href="#two-levels-of-ceiling">Two levels of ceiling</a></h4>
<p><strong>Location</strong>: <code>src-tauri/src/repository/online.rs</code> (TRACES: UR-074, UR-079 | DR-226)</p>
<p>There are two, and they are not the same thing:</p>
<div class="table-wrapper"><table><thead><tr><th></th><th>Set by</th><th>Lives until</th><th>Read via</th></tr></thead><tbody>
<tr><td><strong>Device default</strong></td><td>Settings (<code>player_set_video_settings</code>)</td><td>Persisted; restored at startup</td><td><code>streaming_quality()</code></td></tr>
<tr><td><strong>Per-playback override</strong></td><td>The in-player picker (<code>player_set_stream_quality</code>)</td><td>The next item starts playing</td><td><code>playback_quality_override()</code></td></tr>
</tbody></table>
</div>
<p><code>effective_streaming_quality()</code> resolves the pair — override first, else default —
and <strong>is the only thing stream construction may read</strong>. Every URL builder and the
<code>PlaybackInfo</code> 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.</p>
<blockquote>
<p>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 <em>default</em> — 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 <code>player_play_item</code> /
<code>player_play_queue</code> / <code>player_play_tracks</code>, which is what stops it surviving
into an autoplayed next episode where nobody would reopen the picker.</p>
</blockquote>
<h3 id="stream-selection"><a class="header" href="#stream-selection">Stream selection</a></h3>
<p><strong>Location</strong>: <code>src-tauri/src/repository/stream_selection.rs</code>,
<code>OnlineRepository::get_stream_selection</code> (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)</p>
<p><strong>Rust decides <em>what stream</em>. The player decides <em>how to deliver it</em>.</strong> 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.</p>
<p><code>get_stream_selection</code> returns one self-describing <code>StreamSelection</code> in place of
the bare URL <code>get_video_stream_url</code> used to hand out:</p>
<div class="table-wrapper"><table><thead><tr><th>Field</th><th>Carries</th></tr></thead><tbody>
<tr><td><code>url</code></td><td>What to open</td></tr>
<tr><td><code>transport</code></td><td><code>Hls</code> / <code>Progressive</code> / <code>LocalFile</code> — how to fetch it</td></tr>
<tr><td><code>playback_kind</code></td><td><code>DirectPlay</code> / <code>DirectStream</code> / <code>Transcode</code> — what the server is doing to the source</td></tr>
<tr><td><code>rendition</code></td><td>The negotiated ceiling and codecs; <code>None</code> for a direct play, which <em>is</em> the source</td></tr>
<tr><td><code>available</code></td><td>The quality ladder as it applies to this media source (DR-227)</td></tr>
<tr><td><code>needs_transcoding</code></td><td>Derived from <code>playback_kind</code>, so the rule is answered once</td></tr>
</tbody></table>
</div>
<p>Both enums are serde-tagged (<code>{"type":"hls"}</code>) so the frontend matches a
discriminant rather than comparing text.</p>
<blockquote>
<p><strong>Why <code>transport</code> exists.</strong> <code>VideoPlayer.svelte</code> chose its loader with
<code>url.includes(".m3u8")</code>, in two places. Rust <em>built</em> 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 <strong>both</strong> 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.</p>
<p>The paths that never negotiate get the same shape from Rust rather than letting
a caller assemble one — <code>media_local_selection</code> for a downloaded file,
<code>LiveStreamInfo.transport</code> for a live channel — so there is no second place
where a transport is decided.</p>
</blockquote>
<h4 id="the-playback-kind-decision"><a class="header" href="#the-playback-kind-decision">The playback-kind decision</a></h4>
<p><code>decide_playback_kind</code> is a free function and pure, so every branch is testable
from <code>PlaybackInfo</code> 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 <em>file</em> and wrong about what this app will do
with it:</p>
<ol>
<li><strong>Undecodable audio → <code>Transcode</code>.</strong> Jellyfin 10.11.5 honours a
DirectPlayProfile's container and video codec but <em>ignores its audio codec</em>,
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.</li>
<li><strong>A pinned audio track → <code>Transcode</code>.</strong> Not a defect in the server's answer, a
different question: the file has one default track and the viewer asked for
another.</li>
<li>Otherwise <code>supports_direct_play</code><code>DirectPlay</code>, else <code>supports_direct_stream</code>
<code>DirectStream</code>, else <code>Transcode</code>.</li>
</ol>
<p>A direct <strong>stream</strong> is a remux — codecs copied, container repackaged. It is cheap
and is deliberately <em>not</em> counted as transcoding; conflating the two would report
a free passthrough as a server-side re-encode.</p>
<blockquote>
<p><strong>What this is worth, measured.</strong> Against the development server (Jellyfin
10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation
per profile:</p>
<div class="table-wrapper"><table><thead><tr><th>Profile</th><th>Direct play</th></tr></thead><tbody>
<tr><td>Linux / WebKitGTK (<code>h264</code> only, 2ch)</td><td>3/40 — <strong>7%</strong></td></tr>
<tr><td>Android / ExoPlayer (<code>h264,hevc,vp8,vp9,av1,mpeg4</code> + <code>ac3,eac3</code>, 6ch)</td><td>34/40 — <strong>85%</strong></td></tr>
</tbody></table>
</div>
<p>The library is ~80% hevc (<code>hevc+eac3</code> alone is a third of it), which is why the
two diverge so hard.</p>
<p><strong>Read that 85% as a ceiling, not a result.</strong> It was measured with a profile
containing <code>ac3,eac3</code>. The Android device this was later run on reports neither
in its <code>MediaCodecList</code> — 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-234), which it was not when
the figure was taken.</p>
<p><strong>The payoff is still overwhelmingly Android</strong>, 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 <code>linux-native-video-spike.md</code> exists to remove. A reviewer
should not expect this code to fix Linux on its own.</p>
</blockquote>
<h4 id="the-quality-ladder-per-source"><a class="header" href="#the-quality-ladder-per-source">The quality ladder per source</a></h4>
<p><code>quality_options_for_source(source_bitrate)</code> returns every rung, each marked with
<code>exceeds_source</code>: true when that rung's ceiling is at or above what the source
itself carries, so selecting it produces the same bytes as <code>Original</code>. The
frontend draws the list and drops the redundant rungs; it does not decide which
they are.</p>
<ul>
<li><code>Original</code> is never marked — it <em>is</em> the source.</li>
<li>An unreported source bitrate (some containers have none; the sampled library
has <code>avi</code> files with no bitrate at all) marks <strong>nothing</strong> redundant, keeping
every rung offered. That is the safe direction: the viewer keeps every choice.</li>
</ul>
<h4 id="no-adaptive-ladder-to-preserve"><a class="header" href="#no-adaptive-ladder-to-preserve">No adaptive ladder to preserve</a></h4>
<p><strong>TRACES: UR-079 | DR-229 (Won't Do)</strong></p>
<p>Mid-playback re-negotiation on throughput was scoped and dropped on measurement.
A master playlist from this server carries exactly <strong>one</strong> <code>EXT-X-STREAM-INF</code>:
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
<code>playback-backend-unification.md</code> 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.</p>
<p>Kept here because it is a measurement, not an opinion: a server that <em>does</em>
publish a ladder would change the answer, and the re-negotiation path below is
the hook that work would build on.</p>
<h4 id="re-negotiation"><a class="header" href="#re-negotiation">Re-negotiation</a></h4>
<p>One mechanism, not two. <code>player_seek_video</code>, <code>player_switch_audio_track</code> and
<code>player_set_stream_quality</code> all return a tagged <code>strategy</code> saying who reloads —
the backend handles a native backend itself and hands the webview a
<code>StreamSelection</code> for <code>reloadSource</code>. Note the wire wart: tauri-specta keeps
these response fields snake_case (<code>seek_offset</code>), while the <code>strategy</code> tag itself
is camelCase.</p>
<p>The frontend names a variant and nothing else; the labels the picker shows are
served over IPC — from <code>available</code> on the selection, or
<code>player_get_streaming_qualities</code> for the Settings list.</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>