mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
fix(search): preserve accented global search tokens
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user