// Search scoping and result-group ordering. // // Two independent axes govern how search results are presented: // - *scope* narrows which item types are requested from the repository, // - *group order* decides the sequence the surviving groups render in. // Neither one rewrites the other: narrowing to Music and widening back to All // restores the user's saved arrangement untouched. // // TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067 // Sourced from Rust via the generated bindings — the backend owns what a scope // *means* (which Jellyfin item types it covers). Naming an opaque variant is // presentation; knowing its expansion is domain vocabulary and stays in Rust. export type { SearchScope } from "$lib/api/bindings"; import type { SearchScope } from "$lib/api/bindings"; export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"]; export const SCOPE_LABELS: Record = { all: "All", music: "Music", movies: "Movies", tv: "TV", }; // NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here. // It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in // repository/types.rs); the frontend sends the opaque scope and the backend // expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file // is the boundary leak documented in docs/specs/scoped-search-boundary.md. /** * Resolve the scope a search started from a given route should default to. * Pure — takes a pathname, touches no DOM, so it unit-tests directly. * * TRACES: UR-049 | DR-063 */ export function resolveSearchScope(pathname: string): SearchScope { // Tolerate query strings, hashes and trailing slashes. const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/"; if (path === "/library/music" || path.startsWith("/library/music/")) return "music"; if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies"; if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv"; // `/library/shows/genres` is the TV genre route despite the differing segment. if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv"; return "all"; } /** * The URL of the single search surface for a query + scope. * * `/search` is the *only* route that renders results, so every other search * affordance (the desktop header bar) is a navigator to this URL rather than a * second result renderer. The `all` scope is the page's own default, so it is * omitted to keep shared/back-navigated URLs clean. * * TRACES: UR-049 | DR-063 */ export function searchRouteUrl(query: string, scope: SearchScope): string { const trimmed = query.trim(); if (!trimmed) return "/search"; const params = new URLSearchParams({ q: trimmed }); if (scope !== "all") params.set("scope", scope); // URLSearchParams renders spaces as "+", valid in a query but noisier to // read; %20 is equally valid and matches how the app builds other links. return `/search?${params.toString().replace(/\+/g, "%20")}`; } /** * Whether a search typed on `pathname` must navigate to `/search` to be seen. * * True for every route except `/search` itself: no other page renders * `searchResults`, so a search performed there is invisible. Guarding on * `/search` keeps typing from pushing a history entry per keystroke. * * TRACES: UR-049 | DR-063 */ export function shouldNavigateToSearch(pathname: string, query: string): boolean { if (!query.trim()) return false; const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/"; return path !== "/search"; } // --------------------------------------------------------------------------- // Result groups // --------------------------------------------------------------------------- export type SearchGroupId = | "shows" | "episodes" | "movies" | "songs" | "albums" | "artists" | "people"; /** * Shipped default order. * * TRACES: UR-060 | DR-091 * * Containers lead the kinds they contain — a show above its episodes, an album * above nothing (songs are ranked separately) — which matches how people search: * you look for the show, not an arbitrary episode of it. `people` sits last as * a peripheral match; it exists so searching an actor's name reaches their bio * page rather than silently dropping the result. */ export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [ "shows", "episodes", "movies", "songs", "albums", "artists", "people", ]; export const GROUP_LABELS: Record = { shows: "TV Shows", episodes: "Episodes", movies: "Movies", songs: "Songs", albums: "Albums", artists: "Artists", people: "People", }; /** * Which scopes each group belongs to (`all` always includes everything). * * `people` maps to no narrow scope: cast/crew cut across music, film and TV, so * it surfaces only under All rather than being forced into one of them. */ const GROUP_SCOPE: Record | null> = { shows: "tv", episodes: "tv", movies: "movies", songs: "music", albums: "music", artists: "music", people: null, }; /** Item types that fall into each group. */ const GROUP_ITEM_TYPES: Record = { shows: ["Series"], episodes: ["Episode"], movies: ["Movie"], songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"], people: ["Person"], }; export function groupItemTypes(group: SearchGroupId): string[] { return [...GROUP_ITEM_TYPES[group]]; } /** * Stored group ids that no longer exist, mapped to the ids that replaced them. * * `tvShows` was one group holding both Series and Episode; it split so a show * can outrank its own episodes. Expanding in place preserves the position the * user chose for it. * * TRACES: UR-060 | DR-091 */ const RETIRED_GROUP_IDS: Record = { tvShows: ["shows", "episodes"], }; /** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */ function migrateGroupId(id: string, known: Set): SearchGroupId[] { if (known.has(id)) return [id as SearchGroupId]; return RETIRED_GROUP_IDS[id] ?? []; } /** * Normalise a stored order into a usable one. * * The stored array is a *hint*, not a contract: ids that no longer exist are * dropped, and groups it never mentions (a user upgrading from a build with * fewer groups) are appended in default order rather than lost. * * TRACES: UR-050 | DR-066 */ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] { const known = new Set(DEFAULT_GROUP_ORDER); const seen = new Set(); const order: SearchGroupId[] = []; if (Array.isArray(stored)) { for (const id of stored) { if (typeof id !== "string") continue; // Retired ids expand in place rather than being dropped, so a user who // dragged the old combined "TV Shows" group to the top keeps TV at the // top instead of having shows/episodes appended to the bottom. for (const groupId of migrateGroupId(id, known)) { if (seen.has(groupId)) continue; seen.add(groupId); order.push(groupId); } } } for (const id of DEFAULT_GROUP_ORDER) { if (!seen.has(id)) order.push(id); } return order; } /** Groups visible under a scope, in the user's configured order. */ export function groupsForScope( scope: SearchScope, order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER ): SearchGroupId[] { // A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives // only under `all` — the `=== scope` test already excludes it elsewhere. return normalizeGroupOrder(order as SearchGroupId[]).filter( (id) => scope === "all" || GROUP_SCOPE[id] === scope ); } export interface SearchGroup { id: SearchGroupId; label: string; items: T[]; } /** * Compose scope, saved order and the results into the sections to render: * drop out-of-scope groups, sort by the saved order, omit empty groups. * * TRACES: UR-050 | DR-067 */ export function composeSearchGroups( results: readonly T[], scope: SearchScope, order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER ): SearchGroup[] { return groupsForScope(scope, order) .map((id) => { const types = GROUP_ITEM_TYPES[id]; return { id, label: GROUP_LABELS[id], items: results.filter((item) => item.type != null && types.includes(item.type)), }; }) .filter((group) => group.items.length > 0); } /** Move a group one slot up (-1) or down (+1); out-of-range moves are no-ops. */ export function moveGroup( order: readonly SearchGroupId[], id: SearchGroupId, delta: number ): SearchGroupId[] { const next = [...order]; const from = next.indexOf(id); if (from === -1) return next; const to = from + delta; if (to < 0 || to >= next.length) return next; next.splice(to, 0, ...next.splice(from, 1)); return next; } /** Move a group from one index to another (drag-and-drop drop handler). */ export function reorderGroups( order: readonly SearchGroupId[], from: number, to: number ): SearchGroupId[] { const next = [...order]; if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next; next.splice(to, 0, ...next.splice(from, 1)); return next; }