feat: precompute stream grouping during source sync (#13)

Move variant grouping work out of Live TV rendering into the
catalog pipeline: a liveProjectionService builds the logical channel
projection (schema in liveProjectionSchema) whenever a source is
added, synced or modified (name change, new address), and the
projection is served through a /live route. ChannelList now consumes
the precomputed projection via a liveChannelProjection client
service, recalculating only when the catalog changes. Cover
projection building, incremental recompute and client consumption
with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 15:35:02 +02:00
parent 7b6525708c
commit 9f97020996
16 changed files with 619 additions and 10 deletions

View File

@ -0,0 +1,49 @@
const LIVE_PROJECTION_COLUMNS = {
logical_channel_id: 'TEXT',
grouping_fingerprint: 'TEXT',
grouping_input_hash: 'TEXT',
quality_label: 'TEXT',
quality_rank: 'INTEGER',
variant_rank: 'INTEGER',
language_code: 'TEXT',
language_confidence: 'REAL',
language_scores: 'JSON',
language_evidence: 'JSON',
language_detector: 'TEXT',
language_input_hash: 'TEXT'
};
const LIVE_PROJECTION_SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id);
CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint);
CREATE TABLE IF NOT EXISTS logical_channels (
id TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
preferred_variant_id TEXT,
variant_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logical_channels_fingerprint ON logical_channels(fingerprint);
CREATE TABLE IF NOT EXISTS catalog_projection_state (
projection TEXT PRIMARY KEY,
status TEXT NOT NULL,
started_at INTEGER,
completed_at INTEGER,
input_hash TEXT,
projected_count INTEGER NOT NULL DEFAULT 0,
error TEXT
);
`;
function ensureColumns(database, table, columns) {
const existing = new Set(database.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name));
for (const [name, definition] of Object.entries(columns)) {
if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
module.exports = { ensureColumns, LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL };

View File

@ -1,6 +1,11 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const {
ensureColumns,
LIVE_PROJECTION_COLUMNS,
LIVE_PROJECTION_SCHEMA_SQL
} = require('./liveProjectionSchema');
const dataDir = path.join(__dirname, '..', '..', 'data');
const dbPath = path.join(dataDir, 'content.db');
@ -73,6 +78,9 @@ function initSchema() {
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
`);
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
// EPG Programs
// Optimized for range queries
db.exec(`
@ -247,6 +255,8 @@ const layaLanguageCache = {
module.exports = {
getDb,
initSchema,
ensureColumns,
LIVE_PROJECTION_COLUMNS,
favorites,
layaLanguageCache
};