mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
fix(search): harden global search pagination
This commit is contained in:
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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+47
-10
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user