Files
jellytau/specs/backend-owned-stream-selection.html
T

450 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE HTML>
<html lang="en" class="navy" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>Backend-Owned Stream Selection - 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 "><a href="../architecture/01-rust-backend.html"><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" class="active"><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/specs/backend-owned-stream-selection.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="spec-backend-owned-stream-selection"><a class="header" href="#spec-backend-owned-stream-selection">Spec: Backend-owned stream selection</a></h1>
<p><strong>Status:</strong> Proposed
<strong>Requirements:</strong> UR-079 (new) → DR-219 … DR-224 (new); <strong>implements and extends
DR-121</strong>, currently allocated to
<a href="read-through-media-cache.html">read-through-media-cache.md</a> and not started.
Re-check <code>requirements.md</code> before allocating — the ids moved twice while this was
being written (<code>DR</code> max was 215, then 218).
<strong>UX spec:</strong> the quality selector in <code>VideoPlayer.svelte</code> already exists; this
changes what fills it, not how it looks.
<strong>Supersedes / revises:</strong> takes DR-121 out of
<a href="read-through-media-cache.html">read-through-media-cache.md</a>, which should keep
only its capture/eviction half. Unblocks
<a href="linux-native-video-spike.html">linux-native-video-spike.md</a>.</p>
<p><strong>Destination on completion:</strong>
<a href="../architecture/01-rust-backend.html">01-rust-backend.md</a> — extends the
"Streaming quality ladder" section; and
<a href="../architecture/03-data-flow.html">03-data-flow.md</a> — playback initiation. The
durable half is the layer line and the <code>StreamSelection</code> contract; phases and
acceptance criteria are disposable.</p>
<h2 id="summary"><a class="header" href="#summary">Summary</a></h2>
<p>Make Rust the single owner of <em>which stream to play</em> — direct play or transcode,
at what ceiling, over what transport — and hand every player backend a
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
<code>&lt;video&gt;</code>/hls.js path all become consumers of the same decision rather than three
places that re-derive it.</p>
<p>Nothing about how playback <em>looks</em> changes. What changes is that the frontend
stops inferring transport from a URL string, and that direct play becomes
possible at all.</p>
<h2 id="motivation"><a class="header" href="#motivation">Motivation</a></h2>
<p>Four concrete problems, all the same shape.</p>
<p><strong>1. The frontend sniffs transport out of the URL.</strong>
<a href="../../src/lib/components/player/VideoPlayer.svelte#L569">VideoPlayer.svelte:569</a>:</p>
<pre><code class="language-ts">const isHlsStream = currentStreamUrl.includes(".m3u8");
</code></pre>
<p>and again inline at line 2364. Rust <em>built</em> that URL and knows exactly what it
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
path, serve a progressive file, and this silently picks wrong. This is the
boundary rule in miniature — not item-type taxonomy, but the same error: a
domain fact reconstructed in the presentation layer because the wire shape did
not carry it.</p>
<p><strong>2. There is no direct-play path.</strong> <code>get_video_stream_url</code> always builds an HLS
transcode URL (<code>TranscodingProtocol=hls</code>, <code>VideoCodec=h264</code> first). Every video
play burns server CPU, even when the file would play untouched. This is the cost
the Linux native-video work exists to remove, and it cannot be removed without a
decision that does not currently exist anywhere in the codebase.</p>
<p><strong>3. Quality is a process-wide global.</strong> <code>streaming_quality()</code> /
<code>set_streaming_quality()</code> in <code>repository/online.rs</code> read and write a static.
It is not per-session or per-item, so it cannot express "this 4K remux needs a
ceiling, that podcast does not", and two concurrent playbacks would share one
setting.</p>
<p><strong>4. Rust cannot say what qualities <em>this</em> media source supports.</strong> The selector
is populated from a fixed enum rather than from what the source actually offers.
DR-121 already names this; it has not been built.</p>
<h3 id="the-prior-question"><a class="header" href="#the-prior-question">The prior question</a></h3>
<p>Finding 3 of <a href="playback-backend-unification.html">playback-backend-unification.md</a>
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
in this repo suggests <strong>there is no ABR today</strong>: a single rendition is requested,
no level-handling code exists anywhere in the frontend, and a quality switch is
implemented by re-opening the stream.</p>
<p><strong>Run this before sizing the adaptation work.</strong> It needs a live server:</p>
<pre><code>curl -s "https://&lt;server&gt;/Videos/&lt;itemId&gt;/master.m3u8?api_key=&lt;key&gt;&amp;…" \
| grep -c EXT-X-STREAM-INF
</code></pre>
<p><code>1</code> → there is no adaptation to preserve, and the adaptation half of this spec
collapses to "pick well at open". <code>&gt;1</code> → finding 3 stands and DR-223 applies.
<strong>Everything else in this spec is worth doing either way</strong> — the ownership
problems above are independent of the answer.</p>
<h2 id="layer-assignment"><a class="header" href="#layer-assignment">Layer assignment</a></h2>
<div class="table-wrapper"><table><thead><tr><th>Logic / responsibility</th><th>Layer</th><th>Why it belongs there</th></tr></thead><tbody>
<tr><td>Direct play vs direct stream vs transcode</td><td>Rust</td><td>Depends on Jellyfin's <code>PlaybackInfo</code>, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test.</td></tr>
<tr><td>Transport of the chosen stream (HLS / progressive / local file)</td><td>Rust</td><td>Rust constructs the URL; it is the only place that <em>knows</em> rather than infers. Today the frontend guesses from <code>.m3u8</code>.</td></tr>
<tr><td>Which qualities this media source can offer</td><td>Rust</td><td>Derived from the source's own streams and the quality→transcode-parameter mapping that <code>get_video_download_url</code> already holds. DR-121.</td></tr>
<tr><td>The quality ceiling in force, per playback session</td><td>Rust</td><td>Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static.</td></tr>
<tr><td>Deciding to re-negotiate mid-playback (if adaptation is needed)</td><td>Rust</td><td>It performs the HTTP and already derives reachability from real traffic via <code>ConnectivityMonitor</code>. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent.</td></tr>
<tr><td>Frame-level delivery <em>within</em> the selected stream, including a player's own ABR</td><td><strong>Player</strong></td><td>ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses <em>what to request</em>, never how a player paces bytes. See "The line".</td></tr>
<tr><td>Rendering the selector, showing the current quality, ordering the list</td><td>Frontend</td><td>Pure presentation over a backend-supplied list.</td></tr>
<tr><td>Poster, letterbox, controls, overlay z-order</td><td>Frontend</td><td>Unchanged.</td></tr>
</tbody></table>
</div>
<h3 id="the-line"><a class="header" href="#the-line">The line</a></h3>
<p><strong>Rust decides <em>what stream</em>. The player decides <em>how to deliver it</em>.</strong></p>
<p>This matters most for ExoPlayer, which already does real adaptive track selection
over HLS. This spec must not reimplement that or fight it — if a multi-variant
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
same restraint applies to any future backend that gains the capability. Rust only
steps in where the player has no such ability (mpv) <em>and</em> the server actually
offers a ladder.</p>
<p>Borderline row, with its tie-breaker: "which media source of a multi-source item"
looks like a user choice, and its <em>presentation</em> is. The default and the
constraint set are domain → <strong>Rust</strong>, per the borderline-defaults-to-Rust rule.</p>
<h2 id="design"><a class="header" href="#design">Design</a></h2>
<h3 id="the-contract"><a class="header" href="#the-contract">The contract</a></h3>
<p>One self-describing selection replaces the bare URL. Nested fields are
camelCase over the wire (<code>#[serde(rename_all = "camelCase")]</code>); the enums are
tagged so the frontend matches a tag instead of parsing a string.</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StreamSelection {
pub url: String,
pub transport: Transport,
pub playback_kind: PlaybackKind,
/// The negotiated rendition; None when direct-playing the source as-is.
pub rendition: Option&lt;Rendition&gt;,
/// What this media source can offer — fills the selector (DR-121).
pub available: Vec&lt;QualityOption&gt;,
}
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Transport { Hls, Progressive, LocalFile }
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
<span class="boring">}</span></code></pre></pre>
<p><code>Transport</code> is the field that deletes the <code>.m3u8</code> sniff. The frontend picks
hls.js on <code>Hls</code> and the element's own loader otherwise — a tag match, not a
substring search.</p>
<h3 id="re-negotiation"><a class="header" href="#re-negotiation">Re-negotiation</a></h3>
<p>Rust emits <code>stream-selection-changed</code> (kebab-case, per convention) carrying a new
<code>StreamSelection</code> plus the position to resume at. The existing
<code>playerSetStreamQuality</code> response already has exactly the right shape — a tagged
<code>strategy</code> that tells the caller who reloads, with the backend handling native
itself and handing HTML5 a URL for <code>reloadSource</code>
(<a href="../../src/lib/player/index.ts#L198">index.ts:198</a>). <strong>Extend that; do not
invent a second mechanism.</strong> It is the one piece of this that is already right.</p>
<p>Note the existing wart to preserve or fix deliberately, not accidentally:
tauri-specta keeps those response fields snake_case (<code>new_url</code>), and the facade
comments say so.</p>
<h3 id="phases"><a class="header" href="#phases">Phases</a></h3>
<ol>
<li><strong>DR-219</strong> <code>StreamSelection</code> + <code>Transport</code>; delete the <code>.m3u8</code> sniff. No
behaviour change — pure ownership move, and independently shippable.</li>
<li><strong>DR-220</strong> Per-session quality ceiling replacing the <code>online.rs</code> static.</li>
<li><strong>DR-221</strong> <code>available</code> populated from the media source (DR-121's substance).</li>
<li><strong>DR-222</strong> Direct-play/direct-stream negotiation via <code>PlaybackInfo</code>. This is
the phase that unlocks native video and removes the transcode.</li>
<li><strong>DR-223</strong> Adaptation, <strong>only if the playlist check says a ladder exists</strong>.
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
playlist is a last resort, not a starting point.</li>
<li><strong>DR-224</strong> ExoPlayer and mpv consume <code>StreamSelection</code> unchanged, proving the
contract is player-agnostic rather than HTML5-shaped.</li>
</ol>
<p>Phases 14 stand on their own merits with no dependency on the ladder question.</p>
<h2 id="out-of-scope"><a class="header" href="#out-of-scope">Out of scope</a></h2>
<ul>
<li>Rendering, compositing, and the Linux native-video work itself. This spec
unblocks <a href="linux-native-video-spike.html">linux-native-video-spike.md</a>; it does
not contain it.</li>
<li>Replacing hls.js. It stays as the HLS loader for the webview path.</li>
<li>Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".</li>
<li>The download/capture half of <a href="read-through-media-cache.html">read-through-media-cache.md</a>
(DR-122, DR-124, DR-125), which keeps its own spec.</li>
<li>Audio. The same argument applies, but video is where the transcode cost is.</li>
</ul>
<h2 id="acceptance-criteria"><a class="header" href="#acceptance-criteria">Acceptance criteria</a></h2>
<ul>
<li><input disabled="" type="checkbox"/>
The <code>.m3u8</code> substring check is gone from <code>VideoPlayer.svelte</code> (both sites)
and transport comes from the tagged enum.</li>
<li><input disabled="" type="checkbox"/>
<code>bun run check</code>, <code>bun run test</code>, <code>bun run format:check</code>, <code>bun run lint</code> pass.</li>
<li><input disabled="" type="checkbox"/>
<code>cargo fmt</code> clean, <code>cargo clippy -D warnings</code> clean, <code>bun run test:rust</code> passes.</li>
<li><input disabled="" type="checkbox"/>
<code>bun run check:boundary</code> passes — and the reviewer confirms by reading that
no transport/kind decision was reconstructed in <code>src/</code>, since the tripwire
only catches item-type array literals.</li>
<li><input disabled="" type="checkbox"/>
<code>bindings.ts</code> regenerated from Rust, not hand-edited.</li>
<li><input disabled="" type="checkbox"/>
New code carries <code>// TRACES:</code> comments; <code>bun run traces:validate</code> passes and
coverage stays ≥ the CI ratchet.</li>
<li><input disabled="" type="checkbox"/>
The <code>EXT-X-STREAM-INF</code> count is recorded in this spec before DR-223 is
started or dropped.</li>
<li><input disabled="" type="checkbox"/>
DR-121 is removed from <code>read-through-media-cache.md</code> with a pointer here.</li>
</ul>
<h2 id="testing"><a class="header" href="#testing">Testing</a></h2>
<ul>
<li>Rust: <code>PlaybackInfo</code> fixtures → expected <code>PlaybackKind</code>, one per branch
(supported container direct-plays; unsupported codec transcodes; a ceiling
below the source bitrate transcodes even when the codec is fine).</li>
<li>Rust: <code>Transport</code> round-trips through serde with the tag the frontend matches.</li>
<li>Frontend: adapter selection driven by <code>transport</code>, including the case a URL
ending <code>.m3u8</code> is served as <code>Progressive</code> — that test fails on today's code,
which is the point.</li>
<li>Extend <code>tauriIntegration.test.ts</code> for the new command params (camelCase rule).</li>
<li>No test asserts a URL substring.</li>
</ul>
<h2 id="traces"><a class="header" href="#traces">TRACES</a></h2>
<div class="table-wrapper"><table><thead><tr><th>Piece</th><th>Tag</th></tr></thead><tbody>
<tr><td><code>StreamSelection</code> / <code>Transport</code></td><td><code>UR-079 | DR-219</code></td></tr>
<tr><td>Per-session ceiling</td><td><code>UR-074 | DR-220</code></td></tr>
<tr><td><code>available</code> from media source</td><td><code>UR-079 | DR-221, DR-121</code></td></tr>
<tr><td>Direct-play negotiation</td><td><code>UR-079 | DR-222</code></td></tr>
<tr><td>Adaptation, if built</td><td><code>UR-079 | DR-223</code></td></tr>
<tr><td>ExoPlayer/mpv consumers</td><td><code>UR-003, UR-004 | DR-224</code></td></tr>
</tbody></table>
</div>
<h2 id="notes-for-the-implementer"><a class="header" href="#notes-for-the-implementer">Notes for the implementer</a></h2>
<ul>
<li><strong>Phase 1 is worth doing on its own</strong>, even if everything after it is dropped.
It removes a real leak and costs almost nothing.</li>
<li>Do not frame any phase as "no Rust changes required" — that framing is what
produced the leak <code>scoped-search-boundary.md</code> records.</li>
<li><code>ConnectivityMonitor</code> is the precedent for DR-223: derive network facts from
real traffic, never from a side-channel poller.</li>
<li>A parallel Claude session may be active in this repo — <code>git diff</code> before
"repairing" unexpected changes. Requirement ids in particular moved twice
during the writing of this spec.</li>
</ul>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../specs/linux-native-video-spike.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="../specs/player-facade-enforcement.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="../specs/linux-native-video-spike.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="../specs/player-facade-enforcement.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>