fix(search): harden global search pagination

This commit is contained in:
4gray committed 2026-06-20 21:51:56 +02:00
1 parent 8e7b002914
commit 18f75e0757
5 files changed
+116 -40

No files matched your search

@@ -574,7 +574,30 @@ describe('content.operations', () => {
expect(matchSqlCall?.[1]).toBe('"and"');
});
it('limits M3U playlist payload candidates before parsing large JSON payloads', async () => {
it('uses a stable max candidate limit for M3U playlist payload searches', async () => {
const limit = jest.fn().mockResolvedValue([]);
const orderedQuery = {
limit,
then: (resolve: (value: unknown[]) => void) => resolve([]),
};
const orderBy = jest.fn().mockReturnValue(orderedQuery);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await globalSearch(db, 'news', ['live'], false, ['m3u'], {
limit: 10,
offset: 100,
});
expect(orderBy).toHaveBeenCalledWith(schema.playlists.name);
expect(limit).toHaveBeenCalledWith(5000);
});
it('prefilters M3U payloads by searchable text fields instead of raw payload contains', async () => {
const limit = jest.fn().mockResolvedValue([]);
const orderedQuery = {
limit,
@@ -592,7 +615,21 @@ describe('content.operations', () => {
limit: 10,
});
expect(orderBy).toHaveBeenCalledWith(schema.playlists.name);
expect(limit).toHaveBeenCalledWith(500);
const searchPatterns = sqlMock.mock.calls
.flatMap(([, ...values]) => values)
.filter(
(value): value is string =>
typeof value === 'string' &&
value.toLocaleLowerCase().includes('news')
);
expect(searchPatterns.length).toBeGreaterThan(0);
expect(searchPatterns).not.toContain('%news%');
expect(
searchPatterns.every(
(pattern) =>
pattern.includes('"name"') || pattern.includes('"title"')
)
).toBe(true);
});
});
@@ -74,7 +74,6 @@ function buildLikePatterns(
const DEFAULT_GLOBAL_SEARCH_LIMIT = 50;
const MAX_GLOBAL_SEARCH_LIMIT = 500;
const MIN_GLOBAL_SEARCH_CANDIDATE_LIMIT = 500;
const MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT = 5000;
const M3U_PLAYLIST_TYPES = ['m3u-file', 'm3u-text', 'm3u-url'] as const;
@@ -245,16 +244,8 @@ function paginateScoredResults<T extends GlobalSearchResult>(
.map((item) => item.result);
}
function getGlobalSearchCandidateLimit(
pagination: NormalizedGlobalSearchPagination
): number {
return Math.min(
Math.max(
(pagination.offset + pagination.limit) * 4,
MIN_GLOBAL_SEARCH_CANDIDATE_LIMIT
),
MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT
);
function getGlobalSearchCandidateLimit(): number {
return MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT;
}
function buildContentTitleSearchConditions(searchTerm: string) {
@@ -453,12 +444,15 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan(
.limit(candidateLimit);
}
function buildM3uPayloadPrefixPatterns(token: string): string[] {
return buildLikePatterns(token, 'prefix').flatMap((pattern) => [
`%"name":"${pattern}`,
`%"name": "${pattern}`,
`%"title":"${pattern}`,
`%"title": "${pattern}`,
function buildM3uPayloadTextFieldPatterns(
token: string,
mode: 'contains' | 'prefix'
): string[] {
return buildLikePatterns(token, mode).flatMap((pattern) => [
`%"name":"${pattern}"%`,
`%"name": "${pattern}"%`,
`%"title":"${pattern}"%`,
`%"title": "${pattern}"%`,
]);
}
@@ -472,8 +466,8 @@ function buildM3uPayloadSearchConditions(searchTerm: string) {
.map((token, index) => {
const patterns =
index === 0 && token.length <= 2
? buildM3uPayloadPrefixPatterns(token)
: buildLikePatterns(token);
? buildM3uPayloadTextFieldPatterns(token, 'prefix')
: buildM3uPayloadTextFieldPatterns(token, 'contains');
const likeConditions = patterns.map(
(pattern) =>
sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'`
@@ -1099,7 +1093,7 @@ export async function globalSearch(
}
const pagination = normalizeGlobalSearchPagination(options);
const candidateLimit = getGlobalSearchCandidateLimit(pagination);
const candidateLimit = getGlobalSearchCandidateLimit();
const results: ScoredGlobalSearchResult[] = [];
if (hasGlobalSearchSource(sources, GLOBAL_SEARCH_RESULT_SOURCES.Xtream)) {
+9 -6
View File
@@ -217,18 +217,21 @@ Xtream-only row shape:
fresh inserts, deletes, and title updates stay synchronized through SQLite
triggers.
2. `source_type = "m3u"` rows come from M3U playlist payloads stored in
`playlists.payload`. The worker uses SQL `payload LIKE` only as a coarse
candidate prefilter, then parses candidate JSON payloads and matches channel
name, TVG name, and group title case-insensitively in the worker. The SQL
candidate query is capped by the same candidate limit used for Xtream search,
so large matching M3U payloads are not loaded without an upper bound.
`playlists.payload`. The worker uses SQL `payload LIKE` against channel
`name`/`title` JSON fields only as a coarse candidate prefilter, then parses
candidate JSON payloads and matches channel name, TVG name, and group title
case-insensitively in the worker. The SQL candidate query is capped by the
same stable 5000-row candidate limit used for Xtream search, so large
matching M3U payloads are not loaded without an upper bound.
3. The optional `sources` argument can restrict search to `xtream` or `m3u`,
but omitted callers keep the backward-compatible behavior of searching all
supported global-search sources.
4. The optional pagination argument accepts `{ limit, offset }`. The worker
keeps the legacy default of 50 results when the argument is omitted, but the
routed global-search view requests one extra row per page to implement
lazy loading without requiring a separate count query.
lazy loading without requiring a separate count query. Candidate selection
uses a stable max-size pool for every page so score-based in-memory ranking
cannot shift already-rendered items into later pages.
5. Candidate rows are ranked in the worker after the coarse SQL prefilter.
Exact and prefix matches sort ahead of word-prefix and substring matches;
short first tokens such as `tv` stay anchored to the start of the title, so
@@ -27,33 +27,70 @@ describe('SearchLayoutComponent', () => {
fixture = TestBed.createComponent(SearchLayoutComponent);
});
it('emits nearEnd when the results container is scrolled near the bottom', () => {
const nearEndSpy = jest.fn();
function renderResultsContainer(): HTMLElement {
fixture.componentRef.setInput('searchTerm', 'matrix');
fixture.componentRef.setInput('resultsCount', 1);
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
fixture.detectChanges();
const resultsContainer = fixture.debugElement.query(
By.css('.results-container')
).nativeElement as HTMLElement;
Object.defineProperties(resultsContainer, {
return fixture.debugElement.query(By.css('.results-container'))
.nativeElement as HTMLElement;
}
function setScrollMetrics(
element: HTMLElement,
scrollTop: number,
scrollHeight = 1000,
clientHeight = 120
): void {
Object.defineProperties(element, {
scrollHeight: {
configurable: true,
value: 1000,
value: scrollHeight,
},
scrollTop: {
configurable: true,
value: 700,
value: scrollTop,
},
clientHeight: {
configurable: true,
value: 120,
value: clientHeight,
},
});
}
it('emits nearEnd when the results container is scrolled near the bottom', () => {
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
const resultsContainer = renderResultsContainer();
setScrollMetrics(resultsContainer, 700);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(1);
});
it('emits nearEnd only when crossing into the bottom threshold', () => {
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
const resultsContainer = renderResultsContainer();
setScrollMetrics(resultsContainer, 500);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).not.toHaveBeenCalled();
setScrollMetrics(resultsContainer, 700);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(1);
setScrollMetrics(resultsContainer, 710);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(1);
setScrollMetrics(resultsContainer, 500);
resultsContainer.dispatchEvent(new Event('scroll'));
setScrollMetrics(resultsContainer, 760);
resultsContainer.dispatchEvent(new Event('scroll'));
expect(nearEndSpy).toHaveBeenCalledTimes(2);
});
});
@@ -28,6 +28,7 @@ import { SearchFormComponent } from '../search-form/search-form.component';
export class SearchLayoutComponent {
private readonly searchFormComponent = viewChild(SearchFormComponent);
private readonly nearEndThresholdPx = 240;
private isWithinNearEndThreshold = false;
/** Page title translation key */
readonly title = input<string>('PORTALS.SIDEBAR.SEARCH');
@@ -91,9 +92,13 @@ export class SearchLayoutComponent {
const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceToBottom <= this.nearEndThresholdPx) {
const isNearEnd = distanceToBottom <= this.nearEndThresholdPx;
if (isNearEnd && !this.isWithinNearEndThreshold) {
this.nearEnd.emit();
}
this.isWithinNearEndThreshold = isNearEnd;
}
/** Check if we should show the "no results" state */