Files
jellytau/docs/specs/scoped-search.md
T
dtourolle 105cc082ea fix(search): move scope→item-type taxonomy into Rust (UR-049, DR-063)
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.
2026-07-30 10:30:38 +02:00

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:

  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.tsSearchOptions = { limit?, includeItemTypes?, searchTerm? }.

  2. Rust already honours include_item_types on both paths — online (online.rs, in the get_items options mapping) and offline (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 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.tssearch(query) takes only a query and calls repo.search(query, { limit: 10000 }, requestId), dropping any scope. Both callers (search/+page.svelte and library/+layout.svelte) go through it.

  5. Group order is hardcoded in markup. 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 (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:

  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 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. 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 diff before "repairing" unexpected changes.