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
203 lines
8.9 KiB
Markdown
203 lines
8.9 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.
|
|
|
|
**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](../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.
|