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
274 lines
13 KiB
Markdown
274 lines
13 KiB
Markdown
# Spec: Move search scope taxonomy behind the Rust boundary
|
|
|
|
**Status:** Proposed
|
|
**Scope:** Rust + Frontend. **Revises a decision in
|
|
[scoped-search.md](scoped-search.md).**
|
|
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
|
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
|
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
|
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
|
architecture/boundary change with **no user-visible behaviour difference**.
|
|
|
|
## Why this spec exists
|
|
|
|
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
|
Rust changes." That was the smallest wiring change, and it worked — but it left
|
|
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
|
violates the project's core boundary rule ("Svelte frontend — presentation
|
|
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
|
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
|
|
|
The offending knowledge lives in
|
|
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
|
|
|
```ts
|
|
const SCOPE_ITEM_TYPES = {
|
|
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
|
movies: ["Movie"],
|
|
tv: ["Series", "Episode"],
|
|
};
|
|
const GROUP_ITEM_TYPES = {
|
|
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
|
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
|
};
|
|
```
|
|
|
|
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
|
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
|
creates: the day the backend starts returning a type the frontend never
|
|
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
|
drops it from both the query filter and the result buckets, and nothing in the
|
|
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
|
sources of truth that will drift.
|
|
|
|
**This must be fixed while the feature is uncommitted**, before the leak ships
|
|
baked into a released wire contract.
|
|
|
|
### What is *not* a leak (leave it alone)
|
|
|
|
Single concrete-type list pages are **not** business logic and stay as-is:
|
|
|
|
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
|
`tv.ts` → `["Series"]`
|
|
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
|
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
|
`PersonDetailView`
|
|
|
|
"This page shows albums" is a legitimate presentation choice expressed through a
|
|
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
|
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
|
the line. Do **not** invent a backend enum for every list page — that is
|
|
over-abstraction, not cleaner separation.
|
|
|
|
## The boundary rule after this change
|
|
|
|
> The frontend never names a Jellyfin item type **in connection with search.**
|
|
> It sends an opaque `scope`, and receives results already sorted into labelled
|
|
> groups. The frontend owns only **group order** (presentation) and
|
|
> **rendering**.
|
|
|
|
## Design
|
|
|
|
### Rust owns scope → item-types (query side)
|
|
|
|
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
|
|
|
```rust
|
|
// repository/types.rs
|
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum SearchScope { All, Music, Movies, Tv }
|
|
|
|
impl SearchScope {
|
|
/// The Jellyfin item types this scope requests, or None for `All`
|
|
/// (which must send NO includeItemTypes — see below).
|
|
pub fn item_types(self) -> Option<Vec<String>> {
|
|
match self {
|
|
SearchScope::All => None,
|
|
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
|
.into_iter().map(String::from).collect()),
|
|
SearchScope::Movies => Some(vec!["Movie".into()]),
|
|
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`SearchOptions` gains `scope` and the search command resolves it into the
|
|
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
|
online/offline paths (which already honour `include_item_types` — do not touch
|
|
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
|
|
|
```rust
|
|
pub struct SearchOptions {
|
|
pub limit: Option<usize>,
|
|
pub search_term: Option<String>,
|
|
pub scope: Option<SearchScope>, // NEW
|
|
// include_item_types stays for the single-type list-page callers,
|
|
// but the SEARCH command derives it from `scope` when scope is set.
|
|
}
|
|
```
|
|
|
|
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
|
non-search `getItems` callers. Document this so a future reader does not send
|
|
both.
|
|
|
|
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
|
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
|
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
|
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
|
|
|
### Rust owns result bucketing (result side)
|
|
|
|
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
|
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
|
|
|
```rust
|
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
|
|
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
|
|
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
|
```
|
|
|
|
Rust emits **every** non-empty group it can classify, in a stable canonical
|
|
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
|
those are presentation and stay frontend-side (see below). Items whose type maps
|
|
to no group are omitted from grouped output (same as today's frontend filter).
|
|
|
|
### 🔴 The `search-event` wrinkle — both payloads must change
|
|
|
|
Search returns results **twice**: the command resolves with instant local-cache
|
|
results, then the merged cache+server union arrives later via the `search-event`
|
|
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
|
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
|
command return value **and** the `search-event` payload must carry
|
|
`GroupedSearchResult`. If only one is converted, the instant results group and
|
|
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
|
is the single largest part of the change and the easiest to half-do.
|
|
|
|
### What the frontend keeps (all pure presentation)
|
|
|
|
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
|
|
|
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
|
Rust enum (delete the hand-written union).
|
|
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
|
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
|
unit-tested. **Stays exactly as-is.**
|
|
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
|
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
|
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
|
presentation.
|
|
|
|
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
|
|
|
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
|
- `scopeItemTypes()`, `groupItemTypes()`.
|
|
- The `.type`-inspecting body of `composeSearchGroups()`.
|
|
|
|
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
|
groups** — no `.type` inspection anywhere:
|
|
|
|
```ts
|
|
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
|
// attach labels, omit empties. No Jellyfin type vocabulary.
|
|
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
|
```
|
|
|
|
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
|
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
|
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
|
drop out via the empty-omit rule — so the frontend does not strictly need
|
|
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
|
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
|
it as a display hint, not authority.
|
|
|
|
### Frontend call-site changes
|
|
|
|
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
|
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
|
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
|
event merge) is preserved.
|
|
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
|
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
|
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
|
results.
|
|
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
|
behaviour; only the type it passes to `SearchResults` changes.
|
|
|
|
## Out of scope
|
|
|
|
- Any change to online/offline `include_item_types` **filtering** — it already
|
|
works; only the *source* of the type list moves.
|
|
- Single concrete-type list pages (see "What is not a leak").
|
|
- Ranking within or across groups.
|
|
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
|
[scoped-search.md](scoped-search.md).
|
|
|
|
## Acceptance criteria
|
|
|
|
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
|
in `searchScope.ts` or any search call path. Verify:
|
|
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
|
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
|
`bindings.ts`, not hand-written unions.
|
|
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
|
groups, same order, same empty/out-of-scope omission, offline included.
|
|
- [ ] Both the command return and the `search-event` payload carry the grouped
|
|
shape; no shape flicker between instant and merged results.
|
|
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
|
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
|
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
|
committed.
|
|
|
|
## Testing
|
|
|
|
**Rust** (`src-tauri`, `cargo test`):
|
|
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
|
- Search command: `scope: Music` resolves to the four music types on the query;
|
|
`scope: All` sends no `include_item_types`.
|
|
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
|
unknown types are dropped; groups come out in canonical order.
|
|
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
|
|
|
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
|
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
|
outgoing options — **rewrite** to assert `scope` is sent instead.
|
|
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
|
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
|
compose-over-groups (order + empty-omit, no type inspection).
|
|
- `searchGroupOrder.test.ts` — unchanged.
|
|
|
|
## TRACES
|
|
|
|
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
|
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
|
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
|
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
|
the wire shape.
|
|
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
|
|
|
## Notes for the implementer
|
|
|
|
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
|
§Design "Scope model / Threading scope through the store," which asserted no
|
|
Rust change. Update that spec's status to note the boundary was moved, or add a
|
|
banner pointing here — do not leave the two specs contradicting silently.
|
|
- The IPC camelCase rule applies to the new enums and structs
|
|
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
|
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
|
`tauriIntegration`-style test if a new command is introduced.
|
|
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
|
types; do not hand-edit it.
|
|
- **Another Claude session may be active in these same files** (per project
|
|
memory). `git diff` before repairing anything unexpected; these search files
|
|
are exactly the ones a parallel session touched.
|