Add specs for the account menu, downloads-as-offline-library, offline downloaded-only filter, and scoped search (+ boundary revision). Add the new UR/DR entries to requirements.md, update ux-flows, and regenerate the traceability matrix. TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
8.9 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.
Status: Implemented (boundary revision pending — see banner above) 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.