const test = require('node:test'); const assert = require('node:assert/strict'); const Database = require('better-sqlite3'); const { normalizeChannelName, getChannelPage, projectCatalog, markProjectionBuilding, projectionState, rebuildWithSignals, serializeChannels, startRebuild } = require('../server/services/liveProjectionService'); const { LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL } = require('../server/db/liveProjectionSchema'); const item = (id, name, extra = {}) => ({ id: `1:${id}`, source_id: 1, item_id: String(id), name, category_id: 'sports', ...extra }); function createProjectionDb() { const db = new Database(':memory:'); db.exec(` CREATE TABLE categories ( id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, category_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, parent_id TEXT, is_hidden INTEGER DEFAULT 0, data JSON ); CREATE TABLE playlist_items ( id TEXT PRIMARY KEY, source_id INTEGER NOT NULL, item_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, category_id TEXT, parent_id TEXT, stream_icon TEXT, stream_url TEXT, container_extension TEXT, rating REAL, year TEXT, added_at TEXT, is_hidden INTEGER DEFAULT 0, is_favorite INTEGER DEFAULT 0, data JSON ); CREATE TABLE favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, source_id INTEGER NOT NULL, item_id TEXT NOT NULL, item_type TEXT NOT NULL ); `); const { ensureColumns } = require('../server/db/liveProjectionSchema'); ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); db.exec(LIVE_PROJECTION_SCHEMA_SQL); return db; } function seedProjectionChannel(db, itemId, name) { db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data) VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, '{}') `).run(`1:${itemId}`, itemId, name, `https://streams.test/${itemId}.m3u8`); } function waitFor(condition, message) { return new Promise((resolve, reject) => { const started = Date.now(); const poll = () => { if (condition()) return resolve(); if (Date.now() - started > 1000) return reject(new Error(message)); setTimeout(poll, 5); }; poll(); }); } test('projection migration declares grouping and language columns', () => { assert.deepEqual(Object.keys(LIVE_PROJECTION_COLUMNS), [ 'logical_channel_id', 'grouping_fingerprint', 'grouping_input_hash', 'quality_label', 'quality_rank', 'variant_rank', 'language_code', 'language_confidence', 'language_scores', 'language_evidence', 'language_detector', 'language_input_hash' ]); assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS logical_channels/); assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS catalog_projection_state/); assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/); }); test('manual rebuild scheduling coalesces concurrent requests', async () => { let scheduled; let runs = 0; const state = { status: 'building', processed_count: 0, total_count: 2 }; const options = { mark: () => state, runner: async () => { runs++; return { status: 'success' }; }, schedule: callback => { scheduled = callback; } }; const first = startRebuild({}, options); const second = startRebuild({}, options); assert.equal(first.alreadyRunning, false); assert.equal(second.alreadyRunning, true); assert.equal(second.promise, first.promise); scheduled(); await first.promise; assert.equal(runs, 1); }); test('Laya rebuild progress advances after a successful identity batch while analysis is still running', async () => { const db = createProjectionDb(); for (let index = 0; index < 12; index++) { seedProjectionChannel(db, `bein-${index}`, `beIN Sports 1 regional ${index}`); } let calls = 0; let secondBatchStarted = false; let releaseSecondBatch; const secondBatchGate = new Promise(resolve => { releaseSecondBatch = resolve; }); const layaClient = { identityHintBatch: async pairs => { calls += 1; if (calls === 2) { secondBatchStarted = true; await secondBatchGate; } return pairs.map(pair => ({ id: pair.id, sameIdentityProbability: 0.9, confidence: 0.9, family: 'bein sports', number: '1' })); } }; markProjectionBuilding(db); const rebuild = rebuildWithSignals(db, { mode: 'laya', layaClient, stateAlreadyBuilding: true }); try { await waitFor(() => secondBatchStarted, 'Expected the second Laya identity batch to start'); const state = projectionState(db); assert.equal(state.status, 'building'); assert.equal(state.phase, 'laya_identity'); assert.equal(state.total_count, 12); assert.equal(state.processed_count, 0); assert.equal(state.laya_processed_count, 1); assert.equal(state.laya_total_count, 2); } finally { releaseSecondBatch(); await rebuild; } }); test('projects beIN quality variants into one logical channel', () => { const groups = projectCatalog([ item('uhd', '|FR| BEIN SPORTS 1 UHD'), item('4k', '|FR| BEIN SPORTS 1 4K'), item('car', 'FR-CAR| bein sports 1') ]); assert.equal(groups.length, 1); assert.equal(groups[0].fingerprint, 'bein sports 1'); assert.equal(groups[0].displayName, 'beIN Sports 1'); assert.equal(groups[0].variants.length, 3); assert.deepEqual(groups[0].variants.map(variant => variant.variantRank), [0, 1, 2]); }); test('normalizes the captured beIN Sports 1 aliases to one exact fingerprint', () => { const aliases = [ 'C Car beIN Sports 1', 'C Reu beIN Sports 1', 'Thai beIN Sports 1 Thai', 'beIN Sports 1', 'beIN Sports 1 12h', 'beIN Sports 1 2h', 'beIN Sports 1 4h', 'beIN Sports 1 8h', 'beIN Sports 1 Appel', 'beIN Sports 1 En', 'beIN Sports 1 Fr', 'beIN Sports 1 Tod' ]; assert.deepEqual(aliases.map(normalizeChannelName), Array(aliases.length).fill('bein sports 1')); const groups = projectCatalog(aliases.map((name, index) => item(index, name))); assert.equal(groups.length, 1); assert.equal(groups[0].fingerprint, 'bein sports 1'); assert.equal(groups[0].variants.length, aliases.length); }); test('does not merge distinct numbered beIN channels', () => { const groups = projectCatalog([ item('1', 'beIN Sports 1 HD'), item('2', 'beIN Sports 2 HD') ]); assert.deepEqual(groups.map(group => group.fingerprint), ['bein sports 1', 'bein sports 2']); assert.equal(normalizeChannelName('beIN Sports Max 10'), 'bein sports max 10'); }); test('keeps unknown beIN suffixes distinct instead of applying fuzzy matching', () => { assert.equal(normalizeChannelName('Partner beIN Sports 1 Event'), 'bein sports 1 event'); assert.notEqual(normalizeChannelName('Partner beIN Sports 1 Event'), normalizeChannelName('beIN Sports 1')); }); test('renaming a stream changes its projection membership and input hash', () => { const before = projectCatalog([item('1', 'beIN Sports 1 HD'), item('2', 'beIN Sports 1 4K')]); const after = projectCatalog([item('1', 'beIN Sports 2 HD'), item('2', 'beIN Sports 1 4K')]); assert.equal(before.length, 1); assert.equal(after.length, 2); const beforeRename = before.flatMap(group => group.variants).find(variant => variant.id === '1:1'); const afterRename = after.flatMap(group => group.variants).find(variant => variant.id === '1:1'); assert.notEqual(beforeRename.groupingInputHash, afterRename.groupingInputHash); }); test('API serialization filters source rows upstream and hidden variants by option', () => { const rows = [ { ...item('1', 'beIN Sports 1 HD'), logical_channel_id: 'logical:1', grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1', preferred_variant_id: '1:1', quality_label: 'HD', quality_rank: 2, variant_rank: 0, is_hidden: 0, category_hidden: 0, category_name: 'Sports Premium', user_favorite: 1, data: '{}' }, { ...item('2', 'beIN Sports 1 4K'), logical_channel_id: 'logical:1', grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1', preferred_variant_id: '1:1', quality_label: '4K', quality_rank: 4, variant_rank: 1, is_hidden: 1, category_hidden: 0, category_name: 'Sports Premium', user_favorite: 0, data: '{}' } ]; const visible = serializeChannels(rows, false); assert.equal(visible[0].variants.length, 1); assert.equal(visible[0].isFavorite, true); assert.equal(visible[0].groupTitle, 'Sports Premium'); assert.equal(visible[0].categoryName, 'Sports Premium'); assert.equal(visible[0].variants[0].groupTitle, 'Sports Premium'); assert.equal(serializeChannels(rows, true)[0].variants.length, 2); }); test('API serialization preserves an M3U groupTitle over the stored category fallback', () => { const rows = [{ ...item('m3u', 'M3U Sports HD'), logical_channel_id: 'logical:m3u', grouping_fingerprint: 'm3u sports', display_name: 'M3U Sports', preferred_variant_id: '1:m3u', quality_label: 'HD', quality_rank: 2, variant_rank: 0, is_hidden: 0, category_hidden: 0, category_name: 'Fallback category', user_favorite: 0, data: JSON.stringify({ groupTitle: 'M3U Sports France' }) }]; const [channel] = serializeChannels(rows, false); assert.equal(channel.groupTitle, 'M3U Sports France'); assert.equal(channel.variants[0].categoryName, 'M3U Sports France'); }); test('search and language filters run before expensive standard and EPG enrichment', () => { const db = createProjectionDb(); seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD'); seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K'); seedProjectionChannel(db, 'euro-fr', '|FR| Eurosport HD'); require('../server/services/liveProjectionService').rebuild(db); let standardCalls = 0; let epgCalls = 0; const page = getChannelPage({ q: 'Bein Sport 1', languages: ['fr'], epgSources: [] }, db, { iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } }, epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } } }); assert.equal(page.channels.length, 1); assert.equal(page.channels[0].language.code, 'fr'); assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']); assert.equal(standardCalls, 1); assert.equal(epgCalls, 1); }); test('full catalog listing skips synchronous per-channel enrichment', () => { const db = createProjectionDb(); for (let index = 0; index < 500; index++) { seedProjectionChannel(db, `channel-${index}`, `Channel ${index} HD`); } require('../server/services/liveProjectionService').rebuild(db); let standardCalls = 0; let epgCalls = 0; const started = Date.now(); const page = getChannelPage({ languages: [], q: '', epgSources: [{ id: 99, type: 'epg', enabled: true }] }, db, { iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } }, epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } } }); assert.equal(page.channels.length, 500); assert.equal(standardCalls, 0); assert.equal(epgCalls, 0); assert.ok(Date.now() - started < 1000); }); test('broad searches above the enrichment guard remain bounded', () => { const db = createProjectionDb(); for (let index = 0; index < 300; index++) seedProjectionChannel(db, `news-${index}`, `News ${index}`); require('../server/services/liveProjectionService').rebuild(db); let calls = 0; const page = getChannelPage({ q: 'news', epgSources: [] }, db, { iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } }, epgMatching: { findBestMatch: () => { calls += 1; return null; } } }); assert.equal(page.channels.length, 300); assert.equal(calls, 0); });