Files
ersatztv/web/src/api/libraryBrowse.ts
T
timothy 4be3f247d8 fix(685): move the picker bound into the helper; surface the per-kind cap
Independent review round 2. Verdict was MERGEABLE with no blockers; this takes
the two recommended fixes plus the structural one it listed as a follow-up.

- The bound was caller discipline, not code: getLibraryBrowseItems does not
  clamp pageSize, so the bound was only the constant this one call site chose
  to pass, and §3b is explicit that a bound a caller can exceed is not a bound.
  New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the
  pageSize clamp and the titleContainsQuery compile, returning full
  LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name}
  shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one
  early return, for the spinner only, and no longer re-implements the gate.

- The min-query guidance was keyed to the LIVE input, so backspacing below the
  gate after a search wiped the rendered rows and their checkmarks while
  `selected` and the Add button still counted them. Keyed to results.length too.

- "Nothing left to hint at" was false: each kind is still capped at
  LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk
  multi-select add, so the cap is surfaced — per-kind totalCounts are summed and
  rendered as "Showing N of M matches" once it exceeds the rendered rows. The
  registry note and the §3b bullet are corrected to stop claiming otherwise.

- Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >=
  slip passes the whole suite).

- The guard test's deviation loop iterates an empty list now, so it gains one
  bidirectional assertion that is non-vacuous: the set carrying an `issue` field
  must equal the set classified 'deviation'.

- The §3b bullet no longer reads as a conformance certificate: AddItemsDialog
  still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is
  PRE-EXISTING, not introduced here, and is tracked in #740.

refs #740
2026-08-05 18:46:43 +02:00

160 lines
6.0 KiB
TypeScript

import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type LibraryBrowseItem = components['schemas']['LibraryBrowseItemResponseModel'];
export type LibraryBrowseMediaType = components['schemas']['LibraryBrowseMediaType'];
export type PagedLibraryBrowseItems = components['schemas']['PagedLibraryBrowseItemsResponseModel'];
export interface GetLibraryBrowseItemsParams {
libraryId?: number;
mediaType?: LibraryBrowseMediaType;
pageNum?: number;
pageSize?: number;
// Only meaningful with mediaType 'TelevisionSeason': filters seasons to the given show id.
parentId?: number;
query?: string;
}
export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}): Promise<PagedLibraryBrowseItems> {
const searchParams = new URLSearchParams();
if (params.query) {
searchParams.set('query', params.query);
}
if (params.libraryId != null) {
searchParams.set('libraryId', String(params.libraryId));
}
if (params.mediaType) {
searchParams.set('mediaType', params.mediaType);
}
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
if (params.parentId != null) {
searchParams.set('parentId', String(params.parentId));
}
const queryString = searchParams.toString();
return request<PagedLibraryBrowseItems>(`/api/v1/library/browse${queryString ? `?${queryString}` : ''}`);
}
// A library picker compiles typed text; it never forwards raw Lucene (#440, #651). The search
// index's default field does NOT match bare title words (`Alpha` finds nothing for "Show Alpha" —
// docs/e2e-local.md), so forwarding the user's literal text the way the explicit query box does
// would look broken in a *name* picker. Escape every Lucene special (and whitespace) so the
// boundary stars are the only live wildcards — the same shape `builder/rules/compile.ts` emits for
// its `contains` operator.
//
// The exhaustive set of characters Lucene's QueryParser treats as syntax. `&` and `|` are in it
// because the boolean operators are `&&`/`||`: escaping each character individually neutralises the
// pair. Leaving them live (as this helper's original AutoTuneScreen-local version did) meant a
// title like `Rock && Roll` compiled to a query Lucene parsed as boolean syntax — or rejected — so
// an exactly-matching title returned nothing (#651 F2). `LIBRARY_PICKER_LUCENE_SPECIALS` is
// exported so the test asserts against the character list itself rather than a hand-copied sample
// that cannot see its own omissions.
export const LIBRARY_PICKER_LUCENE_SPECIALS = '+-&|!(){}[]^"~*?:\\/';
const LUCENE_WILD_SPECIAL = /([\s+\-&|!(){}[\]^"~*?:\\/])/g;
export function titleContainsQuery(text: string): string {
return `title:*${text.replace(LUCENE_WILD_SPECIAL, '\\$1')}*`;
}
export interface LibraryPickerOption {
id: number;
name: string;
}
// How many matches a search-driven library picker offers, and the shortest query worth issuing.
// Both are hard bounds: such a picker NEVER loads more than one page of this size, whatever the
// media type's row count (#651 — decision key `spa.library-pickers-resolve-by-search`).
export const LIBRARY_PICKER_RESULTS = 25;
export const LIBRARY_PICKER_MIN_QUERY = 2;
// Resolve picker options for one media-library type by SEARCH rather than by loading a window of
// the whole type. Exactly one bounded request per (debounced) query; a too-short query issues none
// at all.
//
// `pageSize` is CLAMPED to `LIBRARY_PICKER_RESULTS`, not merely defaulted to it (#651 F4): the
// bound is documented as a property of this helper, so it must not be defeatable by a caller
// passing a larger number.
export function searchLibraryPickerOptions(
mediaType: LibraryBrowseMediaType,
text: string,
pageSize: number = LIBRARY_PICKER_RESULTS
): Promise<LibraryPickerOption[]> {
const trimmed = text.trim();
if (trimmed.length < LIBRARY_PICKER_MIN_QUERY) {
return Promise.resolve([]);
}
const boundedPageSize = Math.max(1, Math.min(Math.floor(pageSize), LIBRARY_PICKER_RESULTS));
return getLibraryBrowseItems({
mediaType,
pageNum: 0,
pageSize: boundedPageSize,
query: titleContainsQuery(trimmed)
}).then((result) =>
(result.page ?? []).map((item) => {
const id = item.mediaItemId ?? item.id;
return { id, name: item.title ?? `#${id}` };
})
);
}
export interface LibraryBrowseSearchResult {
items: LibraryBrowseItem[];
totalCount: number;
}
// Like `searchLibraryPickerOptions` above — same min-query gate, same clamp, same compiled query —
// but for a MULTI-select caller (`CollectionsScreen`'s `AddItemsDialog`) that needs the full
// `LibraryBrowseItem` row (mediaType + id, for `toAddItemsRequest`) rather than the `{id, name}`
// shape a single-select `SearchPicker` renders, plus the response's `totalCount` so the caller can
// surface how much of a match was actually returned. The gate/clamp/compile live HERE, not at the
// call site, so no caller can accidentally skip them (§3b — "the bound belongs to the helper, not
// the caller").
export function searchLibraryBrowseItems(
mediaType: LibraryBrowseMediaType,
text: string,
pageSize: number = LIBRARY_PICKER_RESULTS
): Promise<LibraryBrowseSearchResult> {
const trimmed = text.trim();
if (trimmed.length < LIBRARY_PICKER_MIN_QUERY) {
return Promise.resolve({ items: [], totalCount: 0 });
}
const boundedPageSize = Math.max(1, Math.min(Math.floor(pageSize), LIBRARY_PICKER_RESULTS));
return getLibraryBrowseItems({
mediaType,
pageNum: 0,
pageSize: boundedPageSize,
query: titleContainsQuery(trimmed)
}).then((result) => ({
items: result.page ?? [],
totalCount: result.totalCount ?? 0
}));
}
export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}