mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
Metadata is looked up in the app's own language, so an unrelated older foreign series can come back under exactly the same localized name as a recent local-language one. `pickConfidentMatch` admitted the older row through the series "premiered earlier" tolerance — portals report the running season's year while TMDB reports the premiere — and then let `pickMostPopular` decide across every admitted candidate, discarding the year evidence that had just admitted them. The better-known show won on votes, and the newer series rendered its poster, cast, genres and rating. Rank admitted candidates by year evidence first (`yearEvidenceTier`: the provider's exact year, then a year off by one, then the series tolerance) and let popularity break ties only inside the strongest tier any candidate reached. The tolerance stays — three of eight real lookups from one install depend on it — but it is a last resort, not an equal. Measured over 400 Cyrillic series titles sampled from a real catalog, 20 normalized keys had a same-titled older series and 16 of those were the more popular row. The mirror case is accepted knowingly: a long-running show whose stated season year happens to BE another same-titled show's premiere year now resolves to the newer show. Only the older show's season air dates could separate the two and a search response does not carry them, while that shape needs three coincidences at once against one that needs none. Search cache keys move to `|v4` with a matching startup cleanup, because a positive row naming the wrong show stays fresh for 30 days. Merged with `Build on windows x64` red: the checked-in Windows Embedded MPV runtime pin points at an upstream release whose retention expired, so that job fails repository-wide on a cold cache. Unrelated to this change; tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import { __databaseConnectionTestHooks } from './connection';
|
|
|
|
const {
|
|
columnMigrationStatements,
|
|
createTables,
|
|
createTableStatements,
|
|
indexMigrationStatements,
|
|
runMigrations,
|
|
cleanupLegacyTmdbSearchCache,
|
|
upgradeContentTitleFtsTokenizer,
|
|
contentTitleFtsStatement,
|
|
} = __databaseConnectionTestHooks;
|
|
|
|
type SqliteHandle = Parameters<typeof runMigrations>[0];
|
|
|
|
function compactSql(statement: string): string {
|
|
return statement.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
type StatementHandler = {
|
|
all?: (...args: unknown[]) => unknown[];
|
|
get?: (...args: unknown[]) => unknown;
|
|
run?: jest.Mock;
|
|
};
|
|
|
|
type HandlerRule = [pattern: string, handler: StatementHandler];
|
|
|
|
function createSqliteMock(rules: HandlerRule[], exec: jest.Mock = jest.fn()) {
|
|
const prepare = jest.fn((statement: string) => {
|
|
const compact = compactSql(statement);
|
|
const rule = rules.find(([pattern]) => compact.includes(pattern));
|
|
|
|
return {
|
|
all: jest.fn(() => []),
|
|
get: jest.fn(),
|
|
run: jest.fn(),
|
|
...(rule?.[1] ?? {}),
|
|
};
|
|
});
|
|
const transaction = jest.fn(
|
|
(callback: (...args: unknown[]) => unknown) => callback
|
|
);
|
|
|
|
return {
|
|
exec,
|
|
prepare,
|
|
sqlite: { exec, prepare, transaction } as unknown as SqliteHandle,
|
|
transaction,
|
|
};
|
|
}
|
|
|
|
const completedMigrationStateRule: HandlerRule = [
|
|
'SELECT value FROM app_state',
|
|
{ get: () => ({ value: 'done' }) },
|
|
];
|
|
|
|
/** The live title index, already carrying the folding tokenizer. */
|
|
const foldedIndexRule: HandlerRule = [
|
|
"name = 'content_title_fts'",
|
|
{
|
|
get: () => ({
|
|
sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram remove_diacritics 1')",
|
|
}),
|
|
},
|
|
];
|
|
|
|
describe('createTables', () => {
|
|
it('executes every fresh-install statement against the connection in order', () => {
|
|
const { exec, sqlite } = createSqliteMock([]);
|
|
|
|
createTables(sqlite);
|
|
|
|
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
|
|
...createTableStatements,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('runMigrations error tolerance', () => {
|
|
let warnSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
warnSpy = jest
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('silently skips duplicate-column ALTER errors and still applies index migrations', () => {
|
|
const exec = jest.fn((statement: string) => {
|
|
if (compactSql(statement).startsWith('ALTER TABLE')) {
|
|
throw new Error('duplicate column name: hidden');
|
|
}
|
|
});
|
|
const { sqlite } = createSqliteMock(
|
|
[completedMigrationStateRule, foldedIndexRule],
|
|
exec
|
|
);
|
|
|
|
runMigrations(sqlite);
|
|
|
|
const executedStatements = exec.mock.calls.map(([statement]) =>
|
|
compactSql(statement)
|
|
);
|
|
|
|
expect(executedStatements).toEqual([
|
|
...columnMigrationStatements.map(compactSql),
|
|
...indexMigrationStatements.map(compactSql),
|
|
]);
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns and continues when a migration fails for another reason', () => {
|
|
const failingStatement = compactSql(columnMigrationStatements[0]);
|
|
const exec = jest.fn((statement: string) => {
|
|
if (compactSql(statement) === failingStatement) {
|
|
throw new Error('disk I/O error');
|
|
}
|
|
});
|
|
const { sqlite } = createSqliteMock(
|
|
[completedMigrationStateRule, foldedIndexRule],
|
|
exec
|
|
);
|
|
|
|
runMigrations(sqlite);
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Migration failed (continuing)')
|
|
);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('disk I/O error')
|
|
);
|
|
expect(exec).toHaveBeenCalledTimes(
|
|
columnMigrationStatements.length + indexMigrationStatements.length
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('TMDB search lookup cache cleanup', () => {
|
|
function deleteStatements(sqlite: SqliteHandle): string[] {
|
|
return (sqlite.prepare as jest.Mock).mock.calls
|
|
.map(([statement]) => compactSql(statement))
|
|
.filter((statement) =>
|
|
statement.includes('DELETE FROM tmdb_metadata')
|
|
);
|
|
}
|
|
|
|
it('deletes every retired key generation and records each migration atomically', () => {
|
|
const deleteRun = jest.fn();
|
|
const markerRun = jest.fn();
|
|
const { sqlite, transaction } = createSqliteMock([
|
|
['SELECT value FROM app_state', { get: () => undefined }],
|
|
['DELETE FROM tmdb_metadata', { run: deleteRun }],
|
|
['INSERT INTO app_state', { run: markerRun }],
|
|
]);
|
|
|
|
cleanupLegacyTmdbSearchCache(sqlite);
|
|
|
|
expect(transaction).toHaveBeenCalledTimes(3);
|
|
expect(deleteRun).toHaveBeenCalledTimes(3);
|
|
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
|
|
'migration:tmdb-search-lookup-v2-cache-cleanup:v1',
|
|
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
|
|
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
|
|
]);
|
|
const [unversionedDelete, v2Delete, v3Delete] =
|
|
deleteStatements(sqlite);
|
|
expect(unversionedDelete).toContain(
|
|
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
|
|
);
|
|
// Each generation deletes only its own rows: the v4 rows the resolver
|
|
// writes now, and the `id:`/`person:`/`badProviderId:` rows, survive.
|
|
expect(v2Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v2'");
|
|
expect(v2Delete).not.toContain('v3');
|
|
expect(v3Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v3'");
|
|
expect(v3Delete).not.toContain('v4');
|
|
});
|
|
|
|
it('runs only the generations that have not completed yet', () => {
|
|
const markerRun = jest.fn();
|
|
const { sqlite, transaction } = createSqliteMock([
|
|
[
|
|
'SELECT value FROM app_state',
|
|
{
|
|
get: (key: unknown) =>
|
|
key ===
|
|
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
|
|
? { value: 'done' }
|
|
: undefined,
|
|
},
|
|
],
|
|
['INSERT INTO app_state', { run: markerRun }],
|
|
]);
|
|
|
|
cleanupLegacyTmdbSearchCache(sqlite);
|
|
|
|
expect(transaction).toHaveBeenCalledTimes(2);
|
|
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
|
|
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
|
|
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
|
|
]);
|
|
expect(deleteStatements(sqlite)).toEqual([
|
|
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v2'"),
|
|
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v3'"),
|
|
]);
|
|
});
|
|
|
|
it('does nothing after every migration has completed', () => {
|
|
const { sqlite, prepare, transaction } = createSqliteMock([
|
|
completedMigrationStateRule,
|
|
]);
|
|
|
|
cleanupLegacyTmdbSearchCache(sqlite);
|
|
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
// One marker read per retired generation, nothing else
|
|
expect(prepare).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe('runMigrations Xtream cache deduplication', () => {
|
|
it('re-points content to the canonical duplicate category before deleting the rest', () => {
|
|
const candidatesAll = jest.fn(() => [
|
|
{ id: 1, hidden: 0, contentCount: 10 },
|
|
{ id: 2, hidden: 1, contentCount: 0 },
|
|
]);
|
|
const updateContentRun = jest.fn();
|
|
const deleteCategoryRun = jest.fn();
|
|
const { sqlite } = createSqliteMock([
|
|
completedMigrationStateRule,
|
|
[
|
|
'FROM categories GROUP BY playlist_id, type, xtream_id',
|
|
{
|
|
all: () => [
|
|
{ playlistId: 'p1', type: 'live', xtreamId: 5 },
|
|
],
|
|
},
|
|
],
|
|
['LEFT JOIN content', { all: candidatesAll }],
|
|
[
|
|
'UPDATE content SET category_id = ? WHERE category_id = ?',
|
|
{ run: updateContentRun },
|
|
],
|
|
['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }],
|
|
[
|
|
'FROM content GROUP BY category_id, type, xtream_id',
|
|
{ all: () => [] },
|
|
],
|
|
]);
|
|
|
|
runMigrations(sqlite);
|
|
|
|
expect(candidatesAll).toHaveBeenCalledWith('p1', 'live', 5);
|
|
expect(updateContentRun).toHaveBeenCalledTimes(1);
|
|
expect(updateContentRun).toHaveBeenCalledWith(1, 2);
|
|
expect(deleteCategoryRun).toHaveBeenCalledTimes(1);
|
|
expect(deleteCategoryRun).toHaveBeenCalledWith(2);
|
|
});
|
|
|
|
it('moves favorites and history to the canonical content row before deleting duplicates', () => {
|
|
const moveFavoritesRun = jest.fn();
|
|
const deleteFavoritesRun = jest.fn();
|
|
const moveRecentlyViewedRun = jest.fn();
|
|
const deleteRecentlyViewedRun = jest.fn();
|
|
const deleteContentRun = jest.fn();
|
|
const { sqlite } = createSqliteMock([
|
|
completedMigrationStateRule,
|
|
[
|
|
'FROM categories GROUP BY playlist_id, type, xtream_id',
|
|
{ all: () => [] },
|
|
],
|
|
[
|
|
'FROM content GROUP BY category_id, type, xtream_id',
|
|
{
|
|
all: () => [
|
|
{ categoryId: 7, type: 'movie', xtreamId: 300 },
|
|
],
|
|
},
|
|
],
|
|
[
|
|
'SELECT id FROM content WHERE category_id = ?',
|
|
{ all: () => [{ id: 10 }, { id: 11 }] },
|
|
],
|
|
['INSERT INTO favorites', { run: moveFavoritesRun }],
|
|
[
|
|
'DELETE FROM favorites WHERE content_id = ?',
|
|
{ run: deleteFavoritesRun },
|
|
],
|
|
['INSERT INTO recently_viewed', { run: moveRecentlyViewedRun }],
|
|
[
|
|
'DELETE FROM recently_viewed WHERE content_id = ?',
|
|
{ run: deleteRecentlyViewedRun },
|
|
],
|
|
['DELETE FROM content WHERE id = ?', { run: deleteContentRun }],
|
|
]);
|
|
|
|
runMigrations(sqlite);
|
|
|
|
expect(moveFavoritesRun).toHaveBeenCalledWith(10, 11);
|
|
expect(deleteFavoritesRun).toHaveBeenCalledWith(11);
|
|
expect(moveRecentlyViewedRun).toHaveBeenCalledWith(10, 11);
|
|
expect(deleteRecentlyViewedRun).toHaveBeenCalledWith(11);
|
|
expect(deleteContentRun).toHaveBeenCalledTimes(1);
|
|
expect(deleteContentRun).toHaveBeenCalledWith(11);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The title index folds diacritics, or the cross-playlist matcher cannot see
|
|
* accented titles at all: it compares normalized titles ("amelie") against an
|
|
* index built from the raw one ("Amélie").
|
|
*/
|
|
describe('content title FTS tokenizer upgrade', () => {
|
|
it('asks for diacritic folding in the statement it creates', () => {
|
|
expect(compactSql(contentTitleFtsStatement(true))).toContain(
|
|
"tokenize='trigram remove_diacritics 1'"
|
|
);
|
|
expect(compactSql(contentTitleFtsStatement(false))).toContain(
|
|
"tokenize='trigram'"
|
|
);
|
|
});
|
|
|
|
it('recreates and rebuilds the index, then records the migration', () => {
|
|
const rebuild = { run: jest.fn() };
|
|
const marker = { run: jest.fn() };
|
|
const { sqlite, exec, transaction } = createSqliteMock([
|
|
['SELECT value FROM app_state', { get: () => undefined }],
|
|
['INSERT INTO content_title_fts(content_title_fts)', rebuild],
|
|
['INSERT INTO app_state', marker],
|
|
]);
|
|
|
|
upgradeContentTitleFtsTokenizer(sqlite);
|
|
|
|
// The tokenizer is fixed at CREATE time, so the table has to go.
|
|
const statements = exec.mock.calls.map(([sql]) => compactSql(sql));
|
|
expect(statements).toContainEqual(
|
|
expect.stringContaining('DROP TABLE IF EXISTS content_title_fts')
|
|
);
|
|
expect(statements).toContainEqual(
|
|
expect.stringContaining("tokenize='trigram remove_diacritics 1'")
|
|
);
|
|
expect(rebuild.run).toHaveBeenCalled();
|
|
expect(marker.run).toHaveBeenCalled();
|
|
// One transaction, so a rejected CREATE rolls the drop back and the
|
|
// working index survives.
|
|
expect(transaction).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing once the migration has completed', () => {
|
|
const { sqlite, exec } = createSqliteMock([
|
|
completedMigrationStateRule,
|
|
foldedIndexRule,
|
|
]);
|
|
|
|
upgradeContentTitleFtsTokenizer(sqlite);
|
|
|
|
expect(exec).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rebuilds when the record says done but the index is not folded', () => {
|
|
const rebuild = { run: jest.fn() };
|
|
const { sqlite, exec } = createSqliteMock([
|
|
completedMigrationStateRule,
|
|
[
|
|
'SELECT sql FROM sqlite_master',
|
|
{
|
|
get: () => ({
|
|
sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram')",
|
|
}),
|
|
},
|
|
],
|
|
['INSERT INTO content_title_fts(content_title_fts)', rebuild],
|
|
]);
|
|
|
|
upgradeContentTitleFtsTokenizer(sqlite);
|
|
|
|
// `createTables` declares this table too, with the plain tokenizer, so
|
|
// the marker and the live table can disagree. Trusting the marker
|
|
// leaves a silently degraded index: discovery simply stops finding
|
|
// "Pokémon" for "pokemon", with nothing to show that it should have.
|
|
const statements = exec.mock.calls.map(([sql]) => compactSql(sql));
|
|
expect(statements).toContainEqual(
|
|
expect.stringContaining("tokenize='trigram remove_diacritics 1'")
|
|
);
|
|
expect(rebuild.run).toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves the index alone when the runtime rejects the tokenizer', () => {
|
|
const marker = { run: jest.fn() };
|
|
const exec = jest.fn((statement: string) => {
|
|
if (statement.includes('remove_diacritics')) {
|
|
throw new Error('unknown tokenizer option');
|
|
}
|
|
});
|
|
const { sqlite } = createSqliteMock(
|
|
[
|
|
['SELECT value FROM app_state', { get: () => undefined }],
|
|
['INSERT INTO app_state', marker],
|
|
],
|
|
exec
|
|
);
|
|
|
|
upgradeContentTitleFtsTokenizer(sqlite);
|
|
|
|
// The probe failed, so the real table was never dropped — and the
|
|
// migration is NOT marked done, so a newer SQLite retries it.
|
|
const statements = exec.mock.calls.map(([sql]) => compactSql(sql));
|
|
expect(statements).not.toContainEqual(
|
|
expect.stringContaining('DROP TABLE IF EXISTS content_title_fts')
|
|
);
|
|
expect(marker.run).not.toHaveBeenCalled();
|
|
});
|
|
});
|