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:
4gray authored and GitHub committed 2026-06-20 23:05:48 +02:00
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);