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
160 lines
6.0 KiB
TypeScript
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;
|
|
}
|