mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
Merge origin/master into claude/parental-control-feature-31dde2
Resolves the settings-store defaults split, the electron-conf key list and the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental lock contract is linked from the agent context map instead). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
ae5d3f49de
732 files changed
+42572
-15380
No files matched your search
@@ -139,8 +139,16 @@ describe('runMigrations error tolerance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TMDB search lookup v2 cache cleanup', () => {
|
||||
it('deletes legacy search rows and records the migration atomically', () => {
|
||||
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([
|
||||
@@ -151,22 +159,56 @@ describe('TMDB search lookup v2 cache cleanup', () => {
|
||||
|
||||
cleanupLegacyTmdbSearchCache(sqlite);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(deleteRun).toHaveBeenCalledTimes(1);
|
||||
expect(markerRun).toHaveBeenCalledWith(
|
||||
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
|
||||
);
|
||||
const deleteSql = compactSql(
|
||||
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
|
||||
statement.includes('DELETE FROM tmdb_metadata')
|
||||
)?.[0]
|
||||
);
|
||||
expect(deleteSql).toContain(
|
||||
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('does nothing after the migration has completed', () => {
|
||||
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,
|
||||
]);
|
||||
@@ -174,7 +216,8 @@ describe('TMDB search lookup v2 cache cleanup', () => {
|
||||
cleanupLegacyTmdbSearchCache(sqlite);
|
||||
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
// One marker read per retired generation, nothing else
|
||||
expect(prepare).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
|
||||
'migration:epg-program-source-url-backfill:v1';
|
||||
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
|
||||
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
|
||||
const TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY =
|
||||
'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
|
||||
const TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY =
|
||||
'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
|
||||
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
|
||||
|
||||
function readTraceFlag(name: string): boolean {
|
||||
@@ -966,50 +970,90 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-match cache keys gained a v2 suffix when title normalization changed.
|
||||
* Remove the now-unreachable unversioned rows once rather than leaving negative
|
||||
* resolutions and other legacy search matches in long-lived installations.
|
||||
* Every search-match cache key generation that has been retired, oldest
|
||||
* first, each with the predicate selecting exactly the rows written under
|
||||
* it. A retired generation's rows are unreachable — the resolver only ever
|
||||
* reads the current key — so they would otherwise sit in long-lived
|
||||
* installations forever, negative resolutions included.
|
||||
*
|
||||
* - unversioned → v2: title normalization learned to strip appended
|
||||
* language/quality tags.
|
||||
* - v2 → v3: the search query stopped being the folded comparison key. Under
|
||||
* v2 every title with a Cyrillic "й"/"ё" was searched folded — the fold
|
||||
* spells them "и" and "е", as in the illustrative "леика" for "Лейка" —
|
||||
* got no answer, and was cached as missing for 7 days.
|
||||
* - v3 → v4: year evidence became tiered. Under v3 a series admitted only by
|
||||
* the "premiered earlier" tolerance competed with an exact-year match on
|
||||
* popularity alone, so a new series resolved to its older, better-known
|
||||
* namesake — and that positive row stays fresh for 30 days.
|
||||
*/
|
||||
const LEGACY_TMDB_SEARCH_CACHE_CLEANUPS: ReadonlyArray<{
|
||||
migrationKey: string;
|
||||
rowPredicate: string;
|
||||
}> = [
|
||||
{
|
||||
migrationKey: TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY,
|
||||
rowPredicate: `lookup_key LIKE 'title:%|year:%'
|
||||
AND lookup_key NOT LIKE 'title:%|year:%|v%'`,
|
||||
},
|
||||
{
|
||||
migrationKey: TMDB_SEARCH_LOOKUP_V3_CACHE_CLEANUP_MIGRATION_KEY,
|
||||
rowPredicate: `lookup_key LIKE 'title:%|year:%|v2'`,
|
||||
},
|
||||
{
|
||||
migrationKey: TMDB_SEARCH_LOOKUP_V4_CACHE_CLEANUP_MIGRATION_KEY,
|
||||
rowPredicate: `lookup_key LIKE 'title:%|year:%|v3'`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Remove search-match rows written under a retired key generation, once per
|
||||
* generation, recorded in `app_state`. Each generation is its own marker so
|
||||
* an installation that skipped a release still runs every cleanup it missed,
|
||||
* in order.
|
||||
*/
|
||||
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
|
||||
try {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
|
||||
{ value?: unknown } | undefined;
|
||||
for (const cleanup of LEGACY_TMDB_SEARCH_CACHE_CLEANUPS) {
|
||||
try {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(cleanup.migrationKey) as { value?: unknown } | undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
if (migrationState?.value === 'done') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const executeCleanup = sqliteDb.transaction(() => {
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`DELETE FROM tmdb_metadata
|
||||
WHERE ${cleanup.rowPredicate}`
|
||||
)
|
||||
.run();
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`INSERT INTO app_state (key, value, updated_at)
|
||||
VALUES (?, 'done', datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(cleanup.migrationKey);
|
||||
});
|
||||
|
||||
executeCleanup();
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error
|
||||
? String((error as { message?: unknown }).message ?? error)
|
||||
: String(error);
|
||||
|
||||
console.warn(
|
||||
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
const executeCleanup = sqliteDb.transaction(() => {
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`DELETE FROM tmdb_metadata
|
||||
WHERE lookup_key LIKE 'title:%|year:%'
|
||||
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
|
||||
)
|
||||
.run();
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`INSERT INTO app_state (key, value, updated_at)
|
||||
VALUES (?, 'done', datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at`
|
||||
)
|
||||
.run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
|
||||
});
|
||||
|
||||
executeCleanup();
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error !== null && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? error)
|
||||
: String(error);
|
||||
|
||||
console.warn(
|
||||
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/**
|
||||
* Real-SQLite coverage for the retired search-key cleanups, run inside
|
||||
* Electron so better-sqlite3 links against the ABI the app ships with. The
|
||||
* mock-based spec proves the SQL shape; this one proves what a persisted
|
||||
* database looks like after an upgrade — from a release that skipped every
|
||||
* cleanup, from the previous release, on a fresh database, and on the next
|
||||
* startup after that.
|
||||
*/
|
||||
it('drops only retired search rows across skipped, previous, pre-person, fresh and repeated startups', () => {
|
||||
const electron = createRequire(__filename)('electron') as string;
|
||||
const connectionUrl = pathToFileURL(
|
||||
resolve(__dirname, 'connection.ts')
|
||||
).href;
|
||||
const result = execFileSync(
|
||||
electron,
|
||||
[
|
||||
'--import',
|
||||
'tsx',
|
||||
'--eval',
|
||||
`
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const { __databaseConnectionTestHooks: hooks } = await import(${JSON.stringify(connectionUrl)});
|
||||
console.log = () => undefined;
|
||||
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
|
||||
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
|
||||
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
|
||||
const ROWS = [
|
||||
['tv', 'title:леика|year:2026', 'ru-RU', null],
|
||||
['tv', 'title:леика|year:2026|v2', 'ru-RU', null],
|
||||
['tv', 'title:the boys|year:2019|v2', 'en-US', 76479],
|
||||
['tv', 'title:лейка|year:2026|v3', 'ru-RU', 101101],
|
||||
['tv', 'title:nightfall|year:2026|v4', 'ru-RU', 424242],
|
||||
['tv', 'id:101101|v2', 'ru-RU', 101101],
|
||||
['tv', 'id:101101|season:1', 'ru-RU', 101101],
|
||||
['person', 'person:287', 'en-US', 287],
|
||||
['movie', 'badProviderId:999', 'any', null],
|
||||
['movie', 'trending:week', 'en-US', null],
|
||||
];
|
||||
function openDb(markers) {
|
||||
const db = new Database(':memory:');
|
||||
hooks.createTables(db);
|
||||
const insert = db.prepare('INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id, payload) VALUES (?, ?, ?, ?, ?)');
|
||||
for (const [type, key, lang, id] of ROWS) insert.run(type, key, lang, id, id === null ? null : '{"id":' + id + '}');
|
||||
const mark = db.prepare("INSERT INTO app_state (key, value, updated_at) VALUES (?, 'done', datetime('now'))");
|
||||
for (const marker of markers) mark.run(marker);
|
||||
return db;
|
||||
}
|
||||
function snapshot(db) {
|
||||
return {
|
||||
keys: db.prepare('SELECT lookup_key FROM tmdb_metadata ORDER BY lookup_key').all().map((r) => r.lookup_key),
|
||||
payloads: db.prepare("SELECT payload FROM tmdb_metadata WHERE lookup_key LIKE 'id:%' ORDER BY lookup_key").all().map((r) => r.payload),
|
||||
markers: db.prepare("SELECT key FROM app_state WHERE key LIKE 'migration:tmdb-search-lookup-%' AND value = 'done' ORDER BY key").all().map((r) => r.key),
|
||||
};
|
||||
}
|
||||
// Skipped every release since the unversioned keys: both cleanups run
|
||||
// through the real initialization path
|
||||
const skipped = openDb([]);
|
||||
hooks.runMigrations(skipped);
|
||||
const skippedAfter = snapshot(skipped);
|
||||
// Next startup: a row written meanwhile under the current key survives
|
||||
skipped.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:сосенка|year:2026|v4', 'ru-RU', 101103)").run();
|
||||
hooks.runMigrations(skipped);
|
||||
const repeated = snapshot(skipped);
|
||||
// Previous release: the earlier cleanups already ran; only v3 rows go
|
||||
const previous = openDb([V2_MARKER, V3_MARKER]);
|
||||
hooks.runMigrations(previous);
|
||||
const previousAfter = snapshot(previous);
|
||||
hooks.runMigrations(previous);
|
||||
const previousRepeated = snapshot(previous);
|
||||
// Oldest historical schema: the pre-'person' CHECK. That migration
|
||||
// rebuilds the pure-cache table empty by design; the cleanups must
|
||||
// still record their markers on the rebuilt table and stay idempotent.
|
||||
const prePerson = new Database(':memory:');
|
||||
hooks.createTables(prePerson);
|
||||
prePerson.exec("DROP TABLE tmdb_metadata; CREATE TABLE tmdb_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now'))); CREATE UNIQUE INDEX tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)");
|
||||
prePerson.prepare("INSERT INTO tmdb_metadata (media_type, lookup_key, language, tmdb_id) VALUES ('tv', 'title:леика|year:2026', 'ru-RU', NULL)").run();
|
||||
hooks.runMigrations(prePerson);
|
||||
const prePersonAfter = { ...snapshot(prePerson), check: prePerson.prepare("SELECT sql FROM sqlite_master WHERE name = 'tmdb_metadata'").get().sql.includes("'person'") };
|
||||
hooks.runMigrations(prePerson);
|
||||
const prePersonRepeated = snapshot(prePerson);
|
||||
// Fresh database through the real initialization path
|
||||
const fresh = new Database(':memory:');
|
||||
hooks.createTables(fresh);
|
||||
hooks.runMigrations(fresh);
|
||||
const freshAfter = snapshot(fresh);
|
||||
hooks.runMigrations(fresh);
|
||||
const freshRepeated = snapshot(fresh);
|
||||
process.stdout.write(JSON.stringify({ skippedAfter, repeated, previousAfter, previousRepeated, prePersonAfter, prePersonRepeated, freshAfter, freshRepeated }));
|
||||
`,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const V2_MARKER = 'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
|
||||
const V3_MARKER = 'migration:tmdb-search-lookup-v3-cache-cleanup:v1';
|
||||
const V4_MARKER = 'migration:tmdb-search-lookup-v4-cache-cleanup:v1';
|
||||
const survivors = [
|
||||
'badProviderId:999',
|
||||
'id:101101|season:1',
|
||||
'id:101101|v2',
|
||||
'person:287',
|
||||
'title:nightfall|year:2026|v4',
|
||||
'trending:week',
|
||||
];
|
||||
const detailsPayloads = ['{"id":101101}', '{"id":101101}'];
|
||||
|
||||
expect(JSON.parse(result)).toEqual({
|
||||
skippedAfter: {
|
||||
keys: survivors,
|
||||
payloads: detailsPayloads,
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
repeated: {
|
||||
keys: [...survivors, 'title:сосенка|year:2026|v4'].sort(),
|
||||
payloads: detailsPayloads,
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
previousAfter: {
|
||||
// The older generations' rows are their business, already done
|
||||
keys: [
|
||||
...survivors,
|
||||
'title:леика|year:2026',
|
||||
'title:леика|year:2026|v2',
|
||||
'title:the boys|year:2019|v2',
|
||||
].sort(),
|
||||
payloads: detailsPayloads,
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
previousRepeated: {
|
||||
keys: [
|
||||
...survivors,
|
||||
'title:леика|year:2026',
|
||||
'title:леика|year:2026|v2',
|
||||
'title:the boys|year:2019|v2',
|
||||
].sort(),
|
||||
payloads: detailsPayloads,
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
prePersonAfter: {
|
||||
keys: [],
|
||||
payloads: [],
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
check: true,
|
||||
},
|
||||
prePersonRepeated: {
|
||||
keys: [],
|
||||
payloads: [],
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
freshAfter: {
|
||||
keys: [],
|
||||
payloads: [],
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
freshRepeated: {
|
||||
keys: [],
|
||||
payloads: [],
|
||||
markers: [V2_MARKER, V3_MARKER, V4_MARKER],
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user