Files
jellytau/src/lib/utils/searchScope.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

329 lines
11 KiB
TypeScript

// 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<SearchScope, string> = {
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/*` is a legacy TV route that now redirects into
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
// before the redirect lands still scopes to TV.
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")}`;
}
/** Normalise a pathname for comparison: drop query, hash and trailing slashes. */
function normalizePath(pathname: string): string {
return pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
}
/** Whether `pathname` is the search surface itself. */
export function isSearchRoute(pathname: string): boolean {
return normalizePath(pathname) === "/search";
}
/**
* 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;
return !isSearchRoute(pathname);
}
/** Read a `?scope=` value, falling back when it is absent or unrecognised. */
export function parseSearchScope(
raw: string | null | undefined,
fallback: SearchScope = "all",
): SearchScope {
return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback;
}
/** The query + scope a `/search` URL asks the page to show. */
export interface SearchSeed {
query: string;
scope: SearchScope;
}
/**
* What a `/search` URL should seed the page with, or `null` if it asks for
* nothing new.
*
* The URL is *consumed once per value*, not continuously reconciled against the
* live input. `applied` is the seed the caller last took from the URL: while it
* still matches, the user's own typing and chip picks govern, and only a real
* navigation (the header search sending a new query) re-seeds the page.
*
* Reconciling instead of consuming was the bug this replaced — the old effect
* compared the URL against `library.searchQuery`, so every keystroke's search
* re-ran it and snapped the input back to the query the header had sent.
*
* TRACES: UR-049 | DR-063, DR-064
*/
export function seedFromSearchUrl(
params: URLSearchParams,
applied: SearchSeed | null,
): SearchSeed | null {
const seed: SearchSeed = {
query: params.get("q") ?? "",
scope: parseSearchScope(params.get("scope")),
};
if (applied && applied.query === seed.query && applied.scope === seed.scope) return null;
return seed;
}
// ---------------------------------------------------------------------------
// 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<SearchGroupId, string> = {
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<SearchGroupId, Exclude<SearchScope, "all"> | 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<SearchGroupId, string[]> = {
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<string, SearchGroupId[]> = {
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<string>): 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<string>(DEFAULT_GROUP_ORDER);
const seen = new Set<SearchGroupId>();
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<T> {
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<T extends { type?: string | null }>(
results: readonly T[],
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER,
): SearchGroup<T>[] {
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;
}