From 18f75e0757ac29ba472ed1af22ea20503330d092 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Jun 2026 21:51:56 +0200 Subject: [PATCH] fix(search): harden global search pagination --- .../operations/content.operations.spec.ts | 43 +++++++++++++- .../database/operations/content.operations.ts | 34 +++++------ docs/architecture/sqlite-db-worker.md | 15 +++-- .../search-layout.component.spec.ts | 57 +++++++++++++++---- .../search-layout/search-layout.component.ts | 7 ++- 5 files changed, 116 insertions(+), 40 deletions(-) diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index 3447438b4..bfad4b5c1 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -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); }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 5bf6b5092..ba494b28d 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -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( .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)) { diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 34fa62b7d..8e8f1ff96 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -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 diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts index 682fefcd3..a52cbd89c 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts @@ -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); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts index 3490224ad..6bf728dc5 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts @@ -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('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 */