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.
209 lines
9.3 KiB
Markdown
209 lines
9.3 KiB
Markdown
# Spec: Context-scoped search with filter chips and configurable group order
|
|
|
|
> ⚠️ **Superseded in part by
|
|
> [scoped-search-boundary.md](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](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](../requirements.md)).
|
|
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
|
§6.3 group order, §6.4 current deviations.
|
|
|
|
## Summary
|
|
|
|
Two related changes to search:
|
|
|
|
1. **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.
|
|
2. **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.
|
|
|
|
1. **`SearchOptions` already carries the filter.**
|
|
[bindings.ts](../../src/lib/api/bindings.ts) —
|
|
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
|
|
|
2. **Rust already honours `include_item_types` on both paths** — online
|
|
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
|
options mapping) and offline
|
|
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
|
type filter from it). **Do not add Rust code for filtering.**
|
|
|
|
3. **Per-page list search already does this correctly.**
|
|
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
|
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
|
the reference for the call shape, including the `requestId` handling.
|
|
|
|
4. **The gap is exactly one function.**
|
|
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
|
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
|
any scope. Both callers
|
|
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
|
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
|
it.
|
|
|
|
5. **Group order is hardcoded in markup.**
|
|
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
|
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
|
renders three fixed sections in source order.
|
|
|
|
6. **Frontend preferences persist via `localStorage`**, per the existing
|
|
`viewMode` precedent in [library.ts](../../src/lib/stores/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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
1. drop groups outside the active scope,
|
|
2. sort the remainder by the user's saved order,
|
|
3. 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](../ux-flows.md) 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 own `itemType`.
|
|
- 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 check` and `bun run test` pass.
|
|
|
|
## Testing
|
|
|
|
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
|
|
|
- `resolveSearchScope` — pure unit tests over the route table, including the
|
|
`/library/shows/genres` case and unknown routes falling back to `all`.
|
|
- Scope → `includeItemTypes` mapping, asserting `all` omits 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 `includeItemTypes` to the repository, and
|
|
the existing stale-`requestId` guard still discards superseded responses.
|
|
|
|
New requirement-implementing code needs `TRACES:` comments — see
|
|
[CLAUDE.md](../../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](../ux-flows.md) 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](../../CLAUDE.md)) — though this change should not add commands.
|
|
- Another session may be active in this repo. Check `git diff` before
|
|
"repairing" unexpected changes.
|