Files
jellytau/docs/specs/scoped-search-boundary.md
T
dtourolle 8b028b6b60 docs: specs, requirements, ux-flows and traceability for new features
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
2026-07-23 20:04:35 +02:00

13 KiB

Spec: Move search scope taxonomy behind the Rust boundary

Status: Proposed Scope: Rust + Frontend. Revises a decision in 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). UX spec: unchanged — ux-flows.md §6. This is a pure architecture/boundary change with no user-visible behaviour difference.

Why this spec exists

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 and architecture/02-svelte-frontend.md).

The offending knowledge lives in searchScope.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:

// 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 §Background 2).

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:

#[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 search() and 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 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 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:

// 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 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 consumes SearchGroup[] from the store instead of a flat MediaItem[] + client-side composeSearchGroups(results, …). The store now holds grouped results.
  • 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.

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 AllNone.
  • 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 SearchGroupIds; 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, 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 §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): #[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.