A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning.
13 KiB
Spec: Move search scope taxonomy behind the Rust boundary
Status: Design authority — Stage 1 implemented, Stage 2 outstanding.
The scope→item-type mapping now lives in Rust (SearchScope::item_types() in
repository/types.rs, DR-063 … DR-067). The result-side grouping table
(GROUP_ITEM_TYPES in src/lib/utils/searchScope.ts) is still in the
frontend, and check:boundary does not match its shape. Delivery status and
the remaining work live in
scoped-search-boundary-implementation.md;
this spec remains the design authority.
Scope: Rust + Frontend. Revises a decision in
scoped-search.md.
Requirements: 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 requirements.md).
UX spec: unchanged — ux-flows.md §6. This is a pure
architecture/boundary change with no user-visible behaviour difference.
Why this spec exists
scoped-search.md shipped scoped search as "frontend only, no Rust changes." That was the smallest wiring change, and it worked — but it left Jellyfin's item-type taxonomy encoded in the presentation layer, which violates the project's core boundary rule ("Svelte frontend — presentation only"; all business logic in Rust — see CLAUDE.md and architecture/02-svelte-frontend.md).
The offending knowledge lives in searchScope.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"],
};
This is a domain definition — "what the category Music 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. MusicVideo, 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.
This must be fixed while the feature is uncommitted, before the leak ships baked into a released wire contract.
What is not a leak (leave it alone)
Single concrete-type list pages are not business logic and stay as-is:
music.ts→["MusicAlbum"]/["Playlist"],movies.ts→["Movie"],tv.ts→["Series"]GenericMediaListPage.svelte→[config.itemType]ArtistDetailView,RelatedItemsSection,AddToPlaylistModal,PersonDetailView
"This page shows albums" is a legitimate presentation choice expressed through a
generic getItems(parentId, { includeItemTypes }) API. Only the search scope
taxonomy (a semantic category → many types, defined once and reused) crosses
the line. Do not invent a backend enum for every list page — that is
over-abstraction, not cleaner separation.
The boundary rule after this change
The frontend never names a Jellyfin item type in connection with search. It sends an opaque
scope, and receives results already sorted into labelled groups. The frontend owns only group order (presentation) and rendering.
Design
Rust owns scope → item-types (query side)
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
// 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) -> Option<Vec<String>> {
match self {
SearchScope::All => None,
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
.into_iter().map(String::from).collect()),
SearchScope::Movies => Some(vec!["Movie".into()]),
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
}
}
}
SearchOptions gains scope and the search command resolves it into the
existing include_item_types filter inside Rust, before dispatching to the
online/offline paths (which already honour include_item_types — do not touch
their filtering, per scoped-search.md §Background 2).
pub struct SearchOptions {
pub limit: Option<usize>,
pub search_term: Option<String>,
pub scope: Option<SearchScope>, // NEW
// include_item_types stays for the single-type list-page callers,
// but the SEARCH command derives it from `scope` when scope is set.
}
Precedence: if scope is set it wins; include_item_types remains for the
non-search getItems callers. Document this so a future reader does not send
both.
All sends no filter. Preserve the existing invariant: All must omit
includeItemTypes entirely, not send the union of every enumerated type — types
nobody listed (Person, folders) would otherwise be filtered out. This is why
item_types() returns Option, and the command must skip the filter on None.
Rust owns result bucketing (result side)
Results arrive pre-grouped. Rust classifies each returned MediaItem into a
group by its type — the GROUP_ITEM_TYPES knowledge, moved to the authority:
#[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<MediaItem> }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
Rust emits every non-empty group it can classify, in a stable canonical order. It does not 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).
🔴 The search-event wrinkle — both payloads must change
Search returns results twice: the command resolves with instant local-cache
results, then the merged cache+server union arrives later via the search-event
listener (see library.ts search() and
architecture/03-data-flow.md). Both the
command return value and the search-event payload must carry
GroupedSearchResult. 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.
What the frontend keeps (all pure presentation)
searchScope.ts retains:
SearchScopetype — now sourced from the generated bindings, mirroring the Rust enum (delete the hand-written union).SCOPE_LABELS,SEARCH_SCOPES(chip labels / order).resolveSearchScope(pathname)— route → initial scope. Pure, DOM-free, unit-tested. Stays exactly as-is.SearchGroupId(from bindings),GROUP_LABELS.normalizeGroupOrder,groupsForScope,moveGroup,reorderGroups,DEFAULT_GROUP_ORDER— group-order persistence and reordering, all presentation.
searchScope.ts loses:
SCOPE_ITEM_TYPES,GROUP_ITEM_TYPES(moved to Rust).scopeItemTypes(),groupItemTypes().- The
.type-inspecting body ofcomposeSearchGroups().
composeSearchGroups() shrinks to a presentation composition over Rust's
groups — no .type inspection anywhere:
// 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[]
GROUP_SCOPE (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 empty and
drop out via the empty-omit rule — so the frontend does not strictly need
GROUP_SCOPE for correctness once Rust filters. Recommendation: delete
GROUP_SCOPE and rely on empty-omission; if kept for belt-and-suspenders, treat
it as a display hint, not authority.
Frontend call-site changes
- library.ts
search(query, scope)sends{ scope }inSearchOptionsinstead of computingincludeItemTypes. Everything else (requestId bump, stale guard, 10s timeout, empty-query clear, event merge) is preserved. - SearchResults.svelte
consumes
SearchGroup[]from the store instead of a flatMediaItem[]+ client-sidecomposeSearchGroups(results, …). The store now holds grouped results. - search/+page.svelte is unchanged in
behaviour; only the type it passes to
SearchResultschanges.
Out of scope
- Any change to online/offline
include_item_typesfiltering — it already works; only the source of the type list moves. - Single concrete-type list pages (see "What is not a leak").
- Ranking within or across groups.
- The UX / chip behaviour / persistence mechanism — all unchanged from scoped-search.md.
Acceptance criteria
- No Jellyfin item-type string literal (
"MusicAlbum","Audio", …) remains insearchScope.tsor any search call path. Verify:grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.tsreturns nothing. SearchScopeandSearchGroupIdin the frontend come from the generatedbindings.ts, not hand-written unions.- Search behaviour is identical to today for the user: same scoping, same groups, same order, same empty/out-of-scope omission, offline included.
- Both the command return and the
search-eventpayload carry the grouped shape; no shape flicker between instant and merged results. Allscope still sends noincludeItemTypes(assert in a Rust test).- Adding a hypothetical new type to a scope requires editing only Rust.
cargo fmtclean,cargo clippyclean,bun run test:rustpasses.bun run checkandbun run testpass;bindings.tsregenerated and committed.
Testing
Rust (src-tauri, cargo test):
SearchScope::item_types(): each scope's list, andAll→None.- Search command:
scope: Musicresolves to the four music types on the query;scope: Allsends noinclude_item_types. - Bucketing: a mixed
Vec<MediaItem>classifies into the rightSearchGroupIds; unknown types are dropped; groups come out in canonical order. - The
search-eventpayload is the grouped shape (guard the wrinkle).
Frontend (vitest, src/lib/**/*.test.ts) — update existing tests:
librarySearchScope.test.tscurrently assertsincludeItemTypeson the outgoing options — rewrite to assertscopeis sent instead.searchScope.test.ts— dropscopeItemTypes/groupItemTypescases; keep and extendresolveSearchScope, order normalize/move/reorder, and the new compose-over-groups (order + empty-omit, no type inspection).searchGroupOrder.test.ts— unchanged.
TRACES
Per CLAUDE.md, tag requirement-implementing code:
SearchScopeenum +item_types()+ search command scope resolution:UR-049 | DR-063(revised — resolution now Rust-side).- Grouped result shape + bucketing:
UR-050 | DR-067(revised) + a new DR for the wire shape. library.tsstore change:UR-049 | DR-065(revised — sends scope not types).
Notes for the implementer
- This spec revises scoped-search.md §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.
- The IPC camelCase rule applies to the new enums and structs
(CLAUDE.md):
#[serde(rename_all = "camelCase")]on structs; the tagged-enum tag convention if any enum becomes tagged. Add/extend atauriIntegration-style test if a new command is introduced. - Regenerate
bindings.tsvia the tauri-specta build step after changing Rust types; do not hand-edit it. - Another Claude session may be active in these same files (per project
memory).
git diffbefore repairing anything unexpected; these search files are exactly the ones a parallel session touched.