// 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 export type SearchScope = "all" | "music" | "movies" | "tv"; export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"]; export const SCOPE_LABELS: Record = { all: "All", music: "Music", movies: "Movies", tv: "TV", }; /** * Jellyfin item types requested for each scope. * * `all` is deliberately absent: sending no `includeItemTypes` is *not* the same * as sending the union of the lists below — types nobody enumerated here * (Person, folders, …) would be filtered out by an explicit list. */ const SCOPE_ITEM_TYPES: Record, string[]> = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], movies: ["Movie"], tv: ["Series", "Episode"], }; /** * Item types to send with a scoped search, or `undefined` for the `all` scope * so the caller omits the key entirely. * * TRACES: UR-049 | DR-063 */ export function scopeItemTypes(scope: SearchScope): string[] | undefined { if (scope === "all") return undefined; return [...SCOPE_ITEM_TYPES[scope]]; } /** * 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"; } // --------------------------------------------------------------------------- // Result groups // --------------------------------------------------------------------------- export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows"; /** Shipped default order, per the spec. */ export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [ "songs", "albums", "artists", "movies", "tvShows", ]; export const GROUP_LABELS: Record = { songs: "Songs", albums: "Albums", artists: "Artists", movies: "Movies", tvShows: "TV Shows", }; /** Which scopes each group belongs to (`all` always includes everything). */ const GROUP_SCOPE: Record> = { songs: "music", albums: "music", artists: "music", movies: "movies", tvShows: "tv", }; /** Item types that fall into each group. */ const GROUP_ITEM_TYPES: Record = { songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"], movies: ["Movie"], tvShows: ["Series", "Episode"], }; export function groupItemTypes(group: SearchGroupId): string[] { return [...GROUP_ITEM_TYPES[group]]; } /** * 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" || !known.has(id)) continue; const groupId = id as SearchGroupId; 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[] { 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; }