fix(search): preserve accented global search tokens

This commit is contained in:
4gray committed 2026-06-20 22:26:25 +02:00
1 parent 18f75e0757
commit 8ac86a213b
4 files changed
+191 -49

No files matched your search

@@ -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);
});
});
@@ -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<string>();
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<string>();
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,
};
}
+15 -8
View File
@@ -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.
@@ -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;