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 bfad4b5c1..566993fbf 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 @@ -351,6 +351,9 @@ describe('content.operations', () => { group_title: 'News', radio: '', poster_url: 'https://image.test/news.png', + rating: null, + added: null, + xtream_id: -1, title: 'Daily News', type: 'live', }), @@ -574,6 +577,44 @@ describe('content.operations', () => { expect(matchSqlCall?.[1]).toBe('"and"'); }); + it('keeps accented FTS token variants in global search SQL prefilters', async () => { + const all = jest.fn().mockResolvedValue([ + { + id: 1, + category_id: 10, + title: 'Café TV', + rating: null, + added: null, + poster_url: null, + epg_channel_id: '', + tv_archive: 0, + tv_archive_duration: 0, + direct_source: '', + xtream_id: 99, + type: 'live', + playlist_id: 'playlist-1', + playlist_name: 'Playlist One', + }, + ]); + const db = { + all, + select: jest.fn(), + } as unknown as AppDatabase; + + await globalSearch(db, 'Café', ['live'], false, ['xtream'], { + limit: 10, + }); + + const matchSqlCall = sqlMock.mock.calls.find(([strings]) => + Array.from(strings as TemplateStringsArray) + .join(' ') + .includes('content_title_fts MATCH') + ); + + expect(matchSqlCall?.[1]).toContain('"café"'); + expect(matchSqlCall?.[1]).toContain('"cafe"'); + }); + it('uses a stable max candidate limit for M3U playlist payload searches', async () => { const limit = jest.fn().mockResolvedValue([]); const orderedQuery = { @@ -632,4 +673,44 @@ describe('content.operations', () => { ) ).toBe(true); }); + + it('keeps accented M3U payload text field variants in SQL prefilters', 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, 'Café', ['live'], false, ['m3u'], { + limit: 10, + }); + + const searchPatterns = sqlMock.mock.calls + .flatMap(([, ...values]) => values) + .filter( + (value): value is string => + typeof value === 'string' && + value.toLocaleLowerCase().includes('caf') + ); + + expect(searchPatterns).toEqual( + expect.arrayContaining([ + expect.stringContaining('café'), + expect.stringContaining('cafe'), + ]) + ); + 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 ba494b28d..bb8307bc6 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -37,18 +37,48 @@ function normalizeSearchMatchText(value: unknown): string { : ''; } +function normalizeSqlSearchText(value: unknown): string { + return typeof value === 'string' + ? value + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' ') + : ''; +} + function getSearchTokens(value: unknown): string[] { const normalized = normalizeSearchMatchText(value); return normalized ? normalized.split(' ') : []; } +function getSqlSearchTokenGroups(value: unknown): string[][] { + const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean); + const normalizedTokens = getSearchTokens(value); + const tokenCount = Math.max(rawTokens.length, normalizedTokens.length); + + return Array.from({ length: tokenCount }, (_, index) => + [...new Set([rawTokens[index], normalizedTokens[index]])].filter( + Boolean + ) + ).filter((group) => group.length > 0); +} + +function isShortSearchTokenGroup(tokens: readonly string[]): boolean { + return tokens.some((token) => token.length <= 2); +} + +function getSqlSearchTokenVariants(value: unknown): string[] { + return [...new Set(getSqlSearchTokenGroups(value).flat())]; +} + function buildLikePatterns( term: string, mode: 'contains' | 'prefix' = 'contains' ): string[] { const variants = new Set(); - for (const value of [term, ...getSearchTokens(term)]) { + for (const value of [term, ...getSqlSearchTokenVariants(term)]) { const trimmedValue = value.trim(); if (!trimmedValue) { continue; @@ -249,18 +279,22 @@ function getGlobalSearchCandidateLimit(): number { } function buildContentTitleSearchConditions(searchTerm: string) { - const tokens = getSearchTokens(searchTerm); - if (tokens.length === 0) { + const tokenGroups = getSqlSearchTokenGroups(searchTerm); + if (tokenGroups.length === 0) { return []; } - return tokens - .map((token, index) => { + return tokenGroups + .map((tokens, index) => { const mode = - index === 0 && token.length <= 2 ? 'prefix' : 'contains'; - const likeConditions = buildLikePatterns(token, mode).map( - (pattern) => - sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + index === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + const likeConditions = tokens.flatMap((token) => + buildLikePatterns(token, mode).map( + (pattern) => + sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + ) ); return or(...likeConditions); }) @@ -268,15 +302,25 @@ function buildContentTitleSearchConditions(searchTerm: string) { } function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean { - const [firstToken] = getSearchTokens(searchTerm); + const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm); - return !!firstToken && firstToken.length <= 2; + return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup); } function buildContentTitleFtsMatchQuery(searchTerm: string): string { - return getSearchTokens(searchTerm) - .filter((token) => token.length >= 3) - .map((token) => `"${token.replace(/"/g, '""')}"`) + return getSqlSearchTokenGroups(searchTerm) + .map((tokens) => { + const quotedTokens = tokens + .filter((token) => token.length >= 3) + .map((token) => `"${token.replace(/"/g, '""')}"`); + + if (quotedTokens.length <= 1) { + return quotedTokens[0] ?? ''; + } + + return `(${quotedTokens.join(' OR ')})`; + }) + .filter(Boolean) .join(' AND '); } @@ -290,30 +334,37 @@ function shouldUseContentTitleFts(searchTerm: string): boolean { function buildGlobPrefixPatterns(token: string): string[] { const variants = new Set(); - variants.add(token); - variants.add(token.toLocaleLowerCase()); - variants.add(token.toLocaleUpperCase()); - variants.add( - token.charAt(0).toLocaleUpperCase() + token.slice(1).toLocaleLowerCase() - ); + for (const value of [token, ...getSqlSearchTokenVariants(token)]) { + variants.add(value); + variants.add(value.toLocaleLowerCase()); + variants.add(value.toLocaleUpperCase()); + variants.add( + value.charAt(0).toLocaleUpperCase() + + value.slice(1).toLocaleLowerCase() + ); + } return [...variants].map((value) => `${value}*`); } function buildRawContentTitleSearchSql(searchTerm: string): SQL[] { - return getSearchTokens(searchTerm).map((token, index) => { - if (index === 0 && token.length <= 2) { + return getSqlSearchTokenGroups(searchTerm).map((tokens, index) => { + if (index === 0 && isShortSearchTokenGroup(tokens)) { return sql`(${sql.join( - buildGlobPrefixPatterns(token).map( - (pattern) => sql`c.title GLOB ${pattern}` + tokens.flatMap((token) => + buildGlobPrefixPatterns(token).map( + (pattern) => sql`c.title GLOB ${pattern}` + ) ), sql` OR ` )})`; } return sql`(${sql.join( - buildLikePatterns(token).map( - (pattern) => sql`c.title LIKE ${pattern} ESCAPE '\\'` + tokens.flatMap((token) => + buildLikePatterns(token).map( + (pattern) => sql`c.title LIKE ${pattern} ESCAPE '\\'` + ) ), sql` OR ` )})`; @@ -457,17 +508,20 @@ function buildM3uPayloadTextFieldPatterns( } function buildM3uPayloadSearchConditions(searchTerm: string) { - const tokens = getSearchTokens(searchTerm); - if (tokens.length === 0) { + const tokenGroups = getSqlSearchTokenGroups(searchTerm); + if (tokenGroups.length === 0) { return []; } - return tokens - .map((token, index) => { - const patterns = - index === 0 && token.length <= 2 - ? buildM3uPayloadTextFieldPatterns(token, 'prefix') - : buildM3uPayloadTextFieldPatterns(token, 'contains'); + return tokenGroups + .map((tokens, index) => { + const mode = + index === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + const patterns = tokens.flatMap((token) => + buildM3uPayloadTextFieldPatterns(token, mode) + ); const likeConditions = patterns.map( (pattern) => sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'` @@ -584,14 +638,14 @@ function toM3uGlobalSearchResult( stream_url: channel.url, group_title: channel.group.title, radio: channel.radio, - poster_url: channel.tvg.logo, + poster_url: channel.tvg.logo || null, channel, id: `${row.id}::${channel.id || channel.url}`, category_id: 'm3u', title: channel.name || channel.tvg.name || channel.url, - rating: '', - added: '', - xtream_id: 0, + rating: null, + added: null, + xtream_id: -1, type: GLOBAL_SEARCH_CONTENT_TYPES.Live, }; } diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 8e8f1ff96..973b6c746 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -211,17 +211,21 @@ Xtream-only row shape: `content_title_fts`, an FTS5 trigram index over `content.title`, for search terms with at least one 3+ character token. Tokens are quoted before being passed to `MATCH`, so FTS reserved words such as `and` are treated as search - text instead of degrading to the slow fallback path. Existing databases - rebuild this index once through the `migration:content-title-fts-trigram:v1` - app-state marker before legacy content cleanup/normalization migrations run; - fresh inserts, deletes, and title updates stay synchronized through SQLite - triggers. + text instead of degrading to the slow fallback path. SQL prefilters keep both + raw lower-case tokens and accent-normalized tokens, so an accented query such + as `Café` can still reach rows stored as either `Café` or `Cafe` before the + worker's accent-insensitive ranking step. Existing databases rebuild this + index once through the `migration:content-title-fts-trigram:v1` app-state + marker before legacy content cleanup/normalization migrations run; 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` 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 + case-insensitively in the worker. M3U payload prefilters also preserve raw + accented token variants next to normalized variants. 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 @@ -242,7 +246,10 @@ Xtream-only row shape: channels whose `group.title` is listed in the playlist payload's `hiddenGroupTitles`. 7. M3U radio entries are returned as live results with `radio = "true"` and the - serialized `Channel` attached for renderer playback routing. + serialized `Channel` attached for renderer playback routing. M3U global + search rows are not Xtream content rows: they use `xtream_id = -1`, and + Xtream-only metadata such as `rating`, `added`, and a missing `poster_url` + is represented as `null`. 8. EPG-program text is not part of this contract; EPG search remains a separate feature because it uses different persistence and freshness rules. diff --git a/libs/shared/interfaces/src/lib/global-search-result.interface.ts b/libs/shared/interfaces/src/lib/global-search-result.interface.ts index 53d9bd5e8..29cdfa9cc 100644 --- a/libs/shared/interfaces/src/lib/global-search-result.interface.ts +++ b/libs/shared/interfaces/src/lib/global-search-result.interface.ts @@ -28,9 +28,9 @@ export interface GlobalSearchBaseResult { id: number | string; category_id: number | string; title: string; - rating: string; - added: string; - poster_url: string; + rating: string | null; + added: string | null; + poster_url: string | null; xtream_id: number; type: GlobalSearchContentType; playlist_id: string;