Files
jellytau/specs/scoped-search-boundary.html
T

497 lines
35 KiB
HTML

<!DOCTYPE HTML>
<html lang="en" class="navy" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>Scoped Search Boundary - 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"><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" class="active"><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/specs/scoped-search-boundary.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-move-search-scope-taxonomy-behind-the-rust-boundary"><a class="header" href="#spec-move-search-scope-taxonomy-behind-the-rust-boundary">Spec: Move search scope taxonomy behind the Rust boundary</a></h1>
<p><strong>Status:</strong> Design authority — <strong>Stage 1 implemented</strong>, Stage 2 outstanding.
The scope→item-type mapping now lives in Rust (<code>SearchScope::item_types()</code> in
<code>repository/types.rs</code>, DR-063 … DR-067). The <em>result-side</em> grouping table
(<code>GROUP_ITEM_TYPES</code> in <code>src/lib/utils/searchScope.ts</code>) is still in the
frontend, and <code>check:boundary</code> does not match its shape. Delivery status and
the remaining work live in
<a href="scoped-search-boundary-implementation.html">scoped-search-boundary-implementation.md</a>;
this spec remains the design authority.
<strong>Scope:</strong> Rust + Frontend. <strong>Revises a decision in
<a href="scoped-search.html">scoped-search.md</a>.</strong>
<strong>Requirements:</strong> UR-049, UR-050 (existing) → new DRs for the boundary move
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
new DR for the grouped result shape — see <a href="../requirements.html">requirements.md</a>).
<strong>UX spec:</strong> unchanged — <a href="../ux-flows.html">ux-flows.md §6</a>. This is a pure
architecture/boundary change with <strong>no user-visible behaviour difference</strong>.</p>
<h2 id="why-this-spec-exists"><a class="header" href="#why-this-spec-exists">Why this spec exists</a></h2>
<p><a href="scoped-search.html">scoped-search.md</a> shipped scoped search as "frontend only, no
Rust changes." That was the smallest wiring change, and it worked — but it left
<strong>Jellyfin's item-type taxonomy encoded in the presentation layer</strong>, which
violates the project's core boundary rule ("Svelte frontend — presentation
only"; all business logic in Rust — see <a href="../../CLAUDE.html">CLAUDE.md</a> and
<a href="../architecture/02-svelte-frontend.html">architecture/02-svelte-frontend.md</a>).</p>
<p>The offending knowledge lives in
<a href="../../src/lib/utils/searchScope.ts">searchScope.ts</a>:</p>
<pre><code class="language-ts">const SCOPE_ITEM_TYPES = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
const GROUP_ITEM_TYPES = {
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
movies: ["Movie"], tvShows: ["Series", "Episode"],
};
</code></pre>
<p>This is a <strong>domain definition</strong> — "what the category <em>Music</em> means in Jellyfin's
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
creates: the day the backend starts returning a type the frontend never
enumerated (e.g. <code>MusicVideo</code>, or Jellyfin renaming a kind), search silently
drops it from both the query filter and the result buckets, and nothing in the
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
sources of truth that will drift.</p>
<p><strong>This must be fixed while the feature is uncommitted</strong>, before the leak ships
baked into a released wire contract.</p>
<h3 id="what-is-not-a-leak-leave-it-alone"><a class="header" href="#what-is-not-a-leak-leave-it-alone">What is <em>not</em> a leak (leave it alone)</a></h3>
<p>Single concrete-type list pages are <strong>not</strong> business logic and stay as-is:</p>
<ul>
<li><code>music.ts</code><code>["MusicAlbum"]</code> / <code>["Playlist"]</code>, <code>movies.ts</code><code>["Movie"]</code>,
<code>tv.ts</code><code>["Series"]</code></li>
<li><code>GenericMediaListPage.svelte</code><code>[config.itemType]</code></li>
<li><code>ArtistDetailView</code>, <code>RelatedItemsSection</code>, <code>AddToPlaylistModal</code>,
<code>PersonDetailView</code></li>
</ul>
<p>"This page shows albums" is a legitimate presentation choice expressed through a
generic <code>getItems(parentId, { includeItemTypes })</code> API. Only the <strong>search scope
taxonomy</strong> (a semantic category → many types, defined once and reused) crosses
the line. Do <strong>not</strong> invent a backend enum for every list page — that is
over-abstraction, not cleaner separation.</p>
<h2 id="the-boundary-rule-after-this-change"><a class="header" href="#the-boundary-rule-after-this-change">The boundary rule after this change</a></h2>
<blockquote>
<p>The frontend never names a Jellyfin item type <strong>in connection with search.</strong>
It sends an opaque <code>scope</code>, and receives results already sorted into labelled
groups. The frontend owns only <strong>group order</strong> (presentation) and
<strong>rendering</strong>.</p>
</blockquote>
<h2 id="design"><a class="header" href="#design">Design</a></h2>
<h3 id="rust-owns-scope--item-types-query-side"><a class="header" href="#rust-owns-scope--item-types-query-side">Rust owns scope → item-types (query side)</a></h3>
<p>Add an opaque enum that crosses IPC, and move the expansion table into Rust:</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>// repository/types.rs
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope {
/// The Jellyfin item types this scope requests, or None for `All`
/// (which must send NO includeItemTypes — see below).
pub fn item_types(self) -&gt; Option&lt;Vec&lt;String&gt;&gt; {
match self {
SearchScope::All =&gt; None,
SearchScope::Music =&gt; Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
.into_iter().map(String::from).collect()),
SearchScope::Movies =&gt; Some(vec!["Movie".into()]),
SearchScope::Tv =&gt; Some(vec!["Series".into(), "Episode".into()]),
}
}
}
<span class="boring">}</span></code></pre></pre>
<p><code>SearchOptions</code> gains <code>scope</code> and the search command resolves it into the
existing <code>include_item_types</code> filter <strong>inside Rust</strong>, before dispatching to the
online/offline paths (which already honour <code>include_item_types</code> — do not touch
their filtering, per <a href="scoped-search.html">scoped-search.md</a> §Background 2).</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>pub struct SearchOptions {
pub limit: Option&lt;usize&gt;,
pub search_term: Option&lt;String&gt;,
pub scope: Option&lt;SearchScope&gt;, // NEW
// include_item_types stays for the single-type list-page callers,
// but the SEARCH command derives it from `scope` when scope is set.
}
<span class="boring">}</span></code></pre></pre>
<p><strong>Precedence:</strong> if <code>scope</code> is set it wins; <code>include_item_types</code> remains for the
non-search <code>getItems</code> callers. Document this so a future reader does not send
both.</p>
<p><strong><code>All</code> sends no filter.</strong> Preserve the existing invariant: <code>All</code> must omit
<code>includeItemTypes</code> entirely, not send the union of every enumerated type — types
nobody listed (Person, folders) would otherwise be filtered out. This is why
<code>item_types()</code> returns <code>Option</code>, and the command must skip the filter on <code>None</code>.</p>
<h3 id="rust-owns-result-bucketing-result-side"><a class="header" href="#rust-owns-result-bucketing-result-side">Rust owns result bucketing (result side)</a></h3>
<p>Results arrive <strong>pre-grouped</strong>. Rust classifies each returned <code>MediaItem</code> into a
group by its type — the <code>GROUP_ITEM_TYPES</code> knowledge, moved to the authority:</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec&lt;MediaItem&gt; }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GroupedSearchResult { pub groups: Vec&lt;SearchGroup&gt; }
<span class="boring">}</span></code></pre></pre>
<p>Rust emits <strong>every</strong> non-empty group it can classify, in a stable canonical
order. It does <strong>not</strong> apply the user's ordering or drop out-of-scope groups —
those are presentation and stay frontend-side (see below). Items whose type maps
to no group are omitted from grouped output (same as today's frontend filter).</p>
<h3 id="-the-search-event-wrinkle--both-payloads-must-change"><a class="header" href="#-the-search-event-wrinkle--both-payloads-must-change">🔴 The <code>search-event</code> wrinkle — both payloads must change</a></h3>
<p>Search returns results <strong>twice</strong>: the command resolves with instant local-cache
results, then the merged cache+server union arrives later via the <code>search-event</code>
listener (see <a href="../../src/lib/stores/library.ts">library.ts</a> <code>search()</code> and
<a href="../architecture/03-data-flow.html">architecture/03-data-flow.md</a>). <strong>Both</strong> the
command return value <strong>and</strong> the <code>search-event</code> payload must carry
<code>GroupedSearchResult</code>. If only one is converted, the instant results group and
the merged ones do not (or vice versa), and the UI flickers between shapes. This
is the single largest part of the change and the easiest to half-do.</p>
<h3 id="what-the-frontend-keeps-all-pure-presentation"><a class="header" href="#what-the-frontend-keeps-all-pure-presentation">What the frontend keeps (all pure presentation)</a></h3>
<p><a href="../../src/lib/utils/searchScope.ts">searchScope.ts</a> <strong>retains</strong>:</p>
<ul>
<li><code>SearchScope</code> type — now sourced from the generated bindings, mirroring the
Rust enum (delete the hand-written union).</li>
<li><code>SCOPE_LABELS</code>, <code>SEARCH_SCOPES</code> (chip labels / order).</li>
<li><code>resolveSearchScope(pathname)</code> — route → initial scope. Pure, DOM-free,
unit-tested. <strong>Stays exactly as-is.</strong></li>
<li><code>SearchGroupId</code> (from bindings), <code>GROUP_LABELS</code>.</li>
<li><code>normalizeGroupOrder</code>, <code>groupsForScope</code>, <code>moveGroup</code>, <code>reorderGroups</code>,
<code>DEFAULT_GROUP_ORDER</code> — group-order persistence and reordering, all
presentation.</li>
</ul>
<p><a href="../../src/lib/utils/searchScope.ts">searchScope.ts</a> <strong>loses</strong>:</p>
<ul>
<li><code>SCOPE_ITEM_TYPES</code>, <code>GROUP_ITEM_TYPES</code> (moved to Rust).</li>
<li><code>scopeItemTypes()</code>, <code>groupItemTypes()</code>.</li>
<li>The <code>.type</code>-inspecting body of <code>composeSearchGroups()</code>.</li>
</ul>
<p><code>composeSearchGroups()</code> shrinks to a <strong>presentation composition over Rust's
groups</strong> — no <code>.type</code> inspection anywhere:</p>
<pre><code class="language-ts">// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
// attach labels, omit empties. No Jellyfin type vocabulary.
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
</code></pre>
<p><code>GROUP_SCOPE</code> (which group belongs to which scope) is a borderline case: it is
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
filtered the query by scope, out-of-scope groups will simply be <strong>empty</strong> and
drop out via the empty-omit rule — so the frontend does not strictly need
<code>GROUP_SCOPE</code> for correctness once Rust filters. <strong>Recommendation:</strong> delete
<code>GROUP_SCOPE</code> and rely on empty-omission; if kept for belt-and-suspenders, treat
it as a display hint, not authority.</p>
<h3 id="frontend-call-site-changes"><a class="header" href="#frontend-call-site-changes">Frontend call-site changes</a></h3>
<ul>
<li><a href="../../src/lib/stores/library.ts">library.ts</a> <code>search(query, scope)</code> sends
<code>{ scope }</code> in <code>SearchOptions</code> instead of computing <code>includeItemTypes</code>.
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
event merge) is preserved.</li>
<li><a href="../../src/lib/components/search/SearchResults.svelte">SearchResults.svelte</a>
consumes <code>SearchGroup[]</code> from the store instead of a flat <code>MediaItem[]</code> +
client-side <code>composeSearchGroups(results, …)</code>. The store now holds grouped
results.</li>
<li><a href="../../src/routes/search/+page.svelte">search/+page.svelte</a> is unchanged in
behaviour; only the type it passes to <code>SearchResults</code> changes.</li>
</ul>
<h2 id="out-of-scope"><a class="header" href="#out-of-scope">Out of scope</a></h2>
<ul>
<li>Any change to online/offline <code>include_item_types</code> <strong>filtering</strong> — it already
works; only the <em>source</em> of the type list moves.</li>
<li>Single concrete-type list pages (see "What is not a leak").</li>
<li>Ranking within or across groups.</li>
<li>The UX / chip behaviour / persistence mechanism — all unchanged from
<a href="scoped-search.html">scoped-search.md</a>.</li>
</ul>
<h2 id="acceptance-criteria"><a class="header" href="#acceptance-criteria">Acceptance criteria</a></h2>
<ul>
<li><input disabled="" type="checkbox"/>
No Jellyfin item-type string literal (<code>"MusicAlbum"</code>, <code>"Audio"</code>, …) remains
in <code>searchScope.ts</code> or any search call path. Verify:
<code>grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts</code> returns nothing.</li>
<li><input disabled="" type="checkbox"/>
<code>SearchScope</code> and <code>SearchGroupId</code> in the frontend come from the generated
<code>bindings.ts</code>, not hand-written unions.</li>
<li><input disabled="" type="checkbox"/>
Search behaviour is <strong>identical</strong> to today for the user: same scoping, same
groups, same order, same empty/out-of-scope omission, offline included.</li>
<li><input disabled="" type="checkbox"/>
Both the command return and the <code>search-event</code> payload carry the grouped
shape; no shape flicker between instant and merged results.</li>
<li><input disabled="" type="checkbox"/>
<code>All</code> scope still sends no <code>includeItemTypes</code> (assert in a Rust test).</li>
<li><input disabled="" type="checkbox"/>
Adding a hypothetical new type to a scope requires editing <strong>only</strong> Rust.</li>
<li><input disabled="" type="checkbox"/>
<code>cargo fmt</code> clean, <code>cargo clippy</code> clean, <code>bun run test:rust</code> passes.</li>
<li><input disabled="" type="checkbox"/>
<code>bun run check</code> and <code>bun run test</code> pass; <code>bindings.ts</code> regenerated and
committed.</li>
</ul>
<h2 id="testing"><a class="header" href="#testing">Testing</a></h2>
<p><strong>Rust</strong> (<code>src-tauri</code>, <code>cargo test</code>):</p>
<ul>
<li><code>SearchScope::item_types()</code>: each scope's list, and <code>All</code><code>None</code>.</li>
<li>Search command: <code>scope: Music</code> resolves to the four music types on the query;
<code>scope: All</code> sends no <code>include_item_types</code>.</li>
<li>Bucketing: a mixed <code>Vec&lt;MediaItem&gt;</code> classifies into the right <code>SearchGroupId</code>s;
unknown types are dropped; groups come out in canonical order.</li>
<li>The <code>search-event</code> payload is the grouped shape (guard the wrinkle).</li>
</ul>
<p><strong>Frontend</strong> (vitest, <code>src/lib/**/*.test.ts</code>) — update existing tests:</p>
<ul>
<li><code>librarySearchScope.test.ts</code> currently asserts <code>includeItemTypes</code> on the
outgoing options — <strong>rewrite</strong> to assert <code>scope</code> is sent instead.</li>
<li><code>searchScope.test.ts</code> — drop <code>scopeItemTypes</code>/<code>groupItemTypes</code> cases; keep and
extend <code>resolveSearchScope</code>, order normalize/move/reorder, and the new
compose-over-groups (order + empty-omit, no type inspection).</li>
<li><code>searchGroupOrder.test.ts</code> — unchanged.</li>
</ul>
<h2 id="traces"><a class="header" href="#traces">TRACES</a></h2>
<p>Per <a href="../../CLAUDE.html">CLAUDE.md</a>, tag requirement-implementing code:</p>
<ul>
<li><code>SearchScope</code> enum + <code>item_types()</code> + search command scope resolution:
<code>UR-049 | DR-063</code> (revised — resolution now Rust-side).</li>
<li>Grouped result shape + bucketing: <code>UR-050 | DR-067</code> (revised) + a new DR for
the wire shape.</li>
<li><code>library.ts</code> store change: <code>UR-049 | DR-065</code> (revised — sends scope not types).</li>
</ul>
<h2 id="notes-for-the-implementer"><a class="header" href="#notes-for-the-implementer">Notes for the implementer</a></h2>
<ul>
<li>This spec <strong>revises</strong> <a href="scoped-search.html">scoped-search.md</a> §Background 2 and
§Design "Scope model / Threading scope through the store," which asserted no
Rust change. Update that spec's status to note the boundary was moved, or add a
banner pointing here — do not leave the two specs contradicting silently.</li>
<li>The IPC camelCase rule applies to the new enums and structs
(<a href="../../CLAUDE.html">CLAUDE.md</a>): <code>#[serde(rename_all = "camelCase")]</code> on structs;
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
<code>tauriIntegration</code>-style test if a new command is introduced.</li>
<li>Regenerate <code>bindings.ts</code> via the tauri-specta build step after changing Rust
types; do not hand-edit it.</li>
<li><strong>Another Claude session may be active in these same files</strong> (per project
memory). <code>git diff</code> before repairing anything unexpected; these search files
are exactly the ones a parallel session touched.</li>
</ul>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../specs/scoped-search.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/scoped-search-boundary-implementation.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/scoped-search.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/scoped-search-boundary-implementation.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>