Stage 1 of scoped-search-boundary-implementation.md — the query side.
scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.
Rust now owns the taxonomy:
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }
- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
over include_item_types, which stays for the non-search get_items
callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
paths diverge, so online and offline filter identically — the failure
mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
an explicit includeItemTypes list would silently drop People, folders,
and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.
8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.
The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.
Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.
Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
9.3 KiB
Spec: Context-scoped search with filter chips and configurable group order
⚠️ Superseded in part by scoped-search-boundary.md. The "frontend only, no Rust changes" decision below (§Background 2, §Design "Scope model" and "Threading scope through the store") left Jellyfin's item-type taxonomy in the presentation layer, which violates the backend/frontend boundary. The taxonomy is being moved into Rust. The user-facing behaviour and UX in this spec are unchanged; only where the scope→item-type mapping and result bucketing live changes. Read the boundary spec before touching search code.
Progress: the scope→item-type mapping now lives in Rust (
SearchScope::item_types()); the frontend sends an opaque scope. Result-side bucketing (GROUP_ITEM_TYPES) is still frontend-side — see scoped-search-boundary-implementation.md §Stage 2.
Status: Implemented (boundary revision: query side done, result side pending) Scope: Frontend only. No Rust changes required. (Revised — see banner.) Requirements: UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067 (see requirements.md). UX spec: ux-flows.md §6 — §6.1 scope, §6.2 layout, §6.3 group order, §6.4 current deviations.
Summary
Two related changes to search:
- Scope — a search started inside a library searches that library.
Started from Home,
/library, or the search tab, it searches everything. The active scope shows as a chip row under the search bar, preselected from context and freely changeable without retyping. - Group order — the order result groups appear in (Songs, Albums, Artists, Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
Motivation
Searching "office" while browsing TV currently returns music albums, because both search entry points call the same unscoped query. The user has already told us what they're looking at; ignoring that makes search feel indiscriminate and pushes the relevant result below unrelated media.
Background: what already exists
Verified in code — most of the plumbing is already there. This is substantially a wiring task, not new infrastructure.
-
SearchOptionsalready carries the filter. bindings.ts —SearchOptions = { limit?, includeItemTypes?, searchTerm? }. -
Rust already honours
include_item_typeson both paths — online (online.rs, in theget_itemsoptions mapping) and offline (offline.rs, which builds a SQL type filter from it). Do not add Rust code for filtering. -
Per-page list search already does this correctly. GenericMediaListPage.svelte passes
includeItemTypes: [config.itemType]torepo.search(...). Use it as the reference for the call shape, including therequestIdhandling. -
The gap is exactly one function. library.ts —
search(query)takes only a query and callsrepo.search(query, { limit: 10000 }, requestId), dropping any scope. Both callers (search/+page.svelte and library/+layout.svelte) go through it. -
Group order is hardcoded in markup. SearchResults.svelte categorizes into
music{tracks,albums,artists} / movies / tvShowsand renders three fixed sections in source order. -
Frontend preferences persist via
localStorage, per the existingviewModeprecedent in library.ts (jellytau-view-mode). Follow that pattern — do not add a Rust settings command for this.
Design
Scope model
One SearchScope type, defined once and shared:
| Scope | includeItemTypes |
Chip label |
|---|---|---|
all |
unset | All |
music |
MusicAlbum, MusicArtist, Audio, Playlist |
Music |
movies |
Movie |
Movies |
tv |
Series, Episode |
TV |
all must send no includeItemTypes key rather than a list of every type —
the two are not equivalent for item types not enumerated here (Person, folders).
Route → scope resolution (DR-063)
A pure function, unit-testable without a DOM:
resolveSearchScope(pathname: string): SearchScope
/library/music*→music/library/movies*→movies/library/tv*→tv/,/library,/search, anything else →all
Note /library/shows/genres exists as a route; treat shows as tv. Check the
current route list before finalising — do not assume this table is exhaustive.
Scope is a starting point, not a lock (DR-064)
The resolved scope sets the initial chip only. Once the user taps a chip, their choice governs until they leave the search surface. Concretely: derive the initial value from the route, hold it in component state, and do not re-derive it on every navigation — otherwise a user who widens to All snaps back to TV.
Changing a chip re-runs the current query at the new scope. Changing the query keeps the current scope.
Threading scope through the store (DR-065)
Extend the store's search signature to accept an optional scope and pass
includeItemTypes down to repo.search. Preserve the existing behaviour
exactly: the requestId bump, the stale-response guard, the search-event
listener merge, the 10s timeout, and the empty-query clear path. This is an
additive parameter — no caller should break.
Group order (DR-066, DR-067)
Persist an ordered array of group ids:
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
Rendering composes scope and order as two independent axes, in this order:
- drop groups outside the active scope,
- sort the remainder by the user's saved order,
- omit groups that came back empty.
Scope never rewrites the saved order — narrowing to Music and back to All must restore the user's full arrangement. See ux-flows.md §6.3 for the worked example.
Settings gets a reorderable list. Dragging alone is not sufficient: provide keyboard-operable move up/down controls with proper labels, or the setting is unusable with a screen reader and on any pointerless input.
Unknown or missing ids in the stored array must not crash rendering — treat the stored order as a hint, append any group it doesn't mention, and ignore ids that no longer exist. A user upgrading from a build with fewer groups must not lose the new ones.
Out of scope
- Ranking within a group. Order is presentation-only.
- Server-side search ranking or the Jellyfin query itself.
- Scope chips on the per-page list search in
GenericMediaListPage— that page is already implicitly scoped by its ownitemType. - Any Rust change.
Acceptance criteria
- Searching from inside Music returns no movies or TV; from inside TV, no music.
- Searching from Home,
/library, or the search tab returns all types. - The chip row renders under the search bar on both the search page and the in-library header search, with the context-derived chip preselected.
- Tapping a chip re-runs the search with the query preserved; editing the query preserves the selected chip.
- Tapping "All" from a context-scoped search widens results without retyping.
- Result groups render in the user's configured order, with out-of-scope and empty groups omitted and relative order preserved.
- Group order is reorderable by drag and by keyboard, persists across restarts, and ships with the documented default.
- Offline search respects scope (the offline path already filters — verify, don't reimplement).
bun run checkandbun run testpass.
Testing
Follow the existing frontend test conventions (vitest, src/lib/**/*.test.ts).
resolveSearchScope— pure unit tests over the route table, including the/library/shows/genrescase and unknown routes falling back toall.- Scope →
includeItemTypesmapping, assertingallomits the key entirely. - The compose step: scope filter + user order + empty-group omission, including the "narrow then widen restores order" case and a stored order containing an unknown id.
- Store-level: scoped search forwards
includeItemTypesto the repository, and the existing stale-requestIdguard still discards superseded responses.
New requirement-implementing code needs TRACES: comments — see
CLAUDE.md. Suggested tags: the scope resolver and chip row
UR-049 | DR-063, DR-064, the store change UR-049 | DR-065, the settings list
and ordered rendering UR-050 | DR-066, DR-067.
Notes for the implementer
- Read ux-flows.md §6 first — it is the behavioural spec; this document is the implementation plan.
- The IPC camelCase rule applies to anything new that crosses the boundary (CLAUDE.md) — though this change should not add commands.
- Another session may be active in this repo. Check
git diffbefore "repairing" unexpected changes.