mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(search): add workspace global search with M3U support
Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements.
This commit is contained in:
1 parent
ffc07146fe
commit
89916af9d8
65 files changed
+3010
-391
No files matched your search
@@ -23,6 +23,7 @@ describe('database schema statements', () => {
|
||||
columnMigrationStatements,
|
||||
indexMigrationStatements,
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
} = __databaseConnectionTestHooks;
|
||||
|
||||
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
||||
@@ -36,6 +37,10 @@ describe('database schema statements', () => {
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5'
|
||||
);
|
||||
expect(schemaSql).toContain("tokenize='trigram'");
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TABLE IF NOT EXISTS playback_positions'
|
||||
);
|
||||
@@ -55,6 +60,15 @@ describe('database schema statements', () => {
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS epg_programs_au'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS content_title_fts_ai'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS content_title_fts_ad'
|
||||
);
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE TRIGGER IF NOT EXISTS content_title_fts_au'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy column migrations idempotent ALTER TABLE statements', () => {
|
||||
@@ -170,4 +184,127 @@ describe('database schema statements', () => {
|
||||
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rebuilds the content title FTS index once for existing content', () => {
|
||||
const selectGet = jest.fn().mockReturnValue(undefined);
|
||||
const rebuildRun = jest.fn();
|
||||
const stateRun = jest.fn();
|
||||
const prepare = jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return { get: selectGet };
|
||||
}
|
||||
if (statement.includes('content_title_fts')) {
|
||||
return { run: rebuildRun };
|
||||
}
|
||||
if (statement.includes('INSERT INTO app_state')) {
|
||||
return { run: stateRun };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
||||
});
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
const sqlite = {
|
||||
prepare,
|
||||
transaction,
|
||||
} as unknown as Parameters<typeof ensureContentTitleFts>[0];
|
||||
|
||||
ensureContentTitleFts(sqlite);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(rebuildRun).toHaveBeenCalledTimes(1);
|
||||
expect(stateRun).toHaveBeenCalledWith(
|
||||
'migration:content-title-fts-trigram:v1'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips content title FTS rebuild after it has run', () => {
|
||||
const selectGet = jest.fn().mockReturnValue({ value: 'done' });
|
||||
const prepare = jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return { get: selectGet };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
||||
});
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
const sqlite = {
|
||||
prepare,
|
||||
transaction,
|
||||
} as unknown as Parameters<typeof ensureContentTitleFts>[0];
|
||||
|
||||
ensureContentTitleFts(sqlite);
|
||||
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('backfills the content title FTS index before content-mutating migrations', () => {
|
||||
const callOrder: string[] = [];
|
||||
const runMigrations = (
|
||||
__databaseConnectionTestHooks as unknown as {
|
||||
runMigrations: (sqlite: {
|
||||
exec: (statement: string) => void;
|
||||
prepare: (statement: string) => {
|
||||
all?: () => unknown[];
|
||||
get?: (...args: unknown[]) => unknown;
|
||||
run?: (...args: unknown[]) => unknown;
|
||||
};
|
||||
transaction: (callback: () => void) => () => void;
|
||||
}) => void;
|
||||
}
|
||||
).runMigrations;
|
||||
const sqlite = {
|
||||
exec: jest.fn(),
|
||||
prepare: jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return {
|
||||
get: (): undefined => {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
statement.includes(
|
||||
'INSERT INTO content_title_fts(content_title_fts)'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
run: () => {
|
||||
callOrder.push('fts-rebuild');
|
||||
},
|
||||
};
|
||||
}
|
||||
if (statement.includes('INSERT INTO app_state')) {
|
||||
return { run: jest.fn() };
|
||||
}
|
||||
if (statement.includes('UPDATE content')) {
|
||||
return {
|
||||
run: () => {
|
||||
callOrder.push('content-update');
|
||||
},
|
||||
};
|
||||
}
|
||||
if (statement.includes('DELETE FROM content')) {
|
||||
return {
|
||||
run: () => {
|
||||
callOrder.push('content-delete');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
all: () => [],
|
||||
get: (): undefined => undefined,
|
||||
run: jest.fn(),
|
||||
};
|
||||
}),
|
||||
transaction: jest.fn((callback: () => void) => callback),
|
||||
};
|
||||
|
||||
runMigrations(sqlite);
|
||||
|
||||
expect(callOrder).toEqual(['fts-rebuild', 'content-update']);
|
||||
expect(sqlite.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE content')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ let initPromise: Promise<DatabaseInstance> | null = null;
|
||||
const XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000;
|
||||
const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY =
|
||||
'migration:xtream-content-added-epoch-seconds:v1';
|
||||
const CONTENT_TITLE_FTS_MIGRATION_KEY =
|
||||
'migration:content-title-fts-trigram:v1';
|
||||
|
||||
function readTraceFlag(name: string): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
@@ -158,6 +160,29 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
// categories row, and hidden categories are absent so they're skipped
|
||||
// before any row lookup.
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0`,
|
||||
// Trigram FTS index for global Xtream title search. It supports fast
|
||||
// contains matches such as "max" -> "beIN MAX" and "Cinemax" without
|
||||
// scanning the full content table.
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5(
|
||||
title,
|
||||
content='content',
|
||||
content_rowid='id',
|
||||
tokenize='trigram'
|
||||
)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS content_title_fts_ai AFTER INSERT ON content BEGIN
|
||||
INSERT INTO content_title_fts(rowid, title)
|
||||
VALUES (new.id, new.title);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS content_title_fts_ad AFTER DELETE ON content BEGIN
|
||||
INSERT INTO content_title_fts(content_title_fts, rowid, title)
|
||||
VALUES ('delete', old.id, old.title);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS content_title_fts_au AFTER UPDATE ON content BEGIN
|
||||
INSERT INTO content_title_fts(content_title_fts, rowid, title)
|
||||
VALUES ('delete', old.id, old.title);
|
||||
INSERT INTO content_title_fts(rowid, title)
|
||||
VALUES (new.id, new.title);
|
||||
END`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`,
|
||||
@@ -303,6 +328,8 @@ export const __databaseConnectionTestHooks = {
|
||||
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
|
||||
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
runMigrations,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -523,6 +550,50 @@ function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureContentTitleFts(sqliteDb: Database.Database): void {
|
||||
try {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(CONTENT_TITLE_FTS_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
}
|
||||
|
||||
const executeMigration = sqliteDb.transaction(() => {
|
||||
sqliteDb
|
||||
.prepare(
|
||||
`INSERT INTO content_title_fts(content_title_fts)
|
||||
VALUES ('rebuild')`
|
||||
)
|
||||
.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(CONTENT_TITLE_FTS_MIGRATION_KEY);
|
||||
});
|
||||
|
||||
executeMigration();
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error !== null && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? error)
|
||||
: String(error);
|
||||
|
||||
console.warn(
|
||||
`Content title FTS rebuild failed (continuing): ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrationStatements(
|
||||
sqliteDb: Database.Database,
|
||||
statements: string[]
|
||||
@@ -556,6 +627,7 @@ function runMigrationStatements(
|
||||
*/
|
||||
function runMigrations(sqliteDb: Database.Database): void {
|
||||
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
|
||||
ensureContentTitleFts(sqliteDb);
|
||||
deduplicateXtreamCache(sqliteDb);
|
||||
normalizeXtreamContentAddedEpochs(sqliteDb);
|
||||
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
|
||||
|
||||
Reference in new issue
Block a user