const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { JSDOM } = require('jsdom'); const Database = require('better-sqlite3'); const { ensureColumns, LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL } = require('../server/db/liveProjectionSchema'); const liveProjection = require('../server/services/liveProjectionService'); const root = path.resolve(__dirname, '..'); const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8'); const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); function createDb() { 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 ); `); ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); db.exec(LIVE_PROJECTION_SCHEMA_SQL); return db; } function seedLive(db, id, 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:${id}`, id, name, `https://streams.test/${id}.m3u8`, JSON.stringify({ groupTitle: 'Sports' }) ); } function createChannelListDom() { const dom = new JSDOM(`
`, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true }); const { window } = dom; window.IntersectionObserver = class { observe() {} disconnect() {} }; window.eval(script('js/icons.js')); window.eval(script('js/services/channelLanguageDetection.js')); window.eval(script('js/services/channelLanguageFusion.js')); window.eval(script('js/services/channelVariantGrouping.js')); window.eval(script('js/services/layaStatusPresentation.js')); window.eval(script('js/services/liveChannelProjection.js')); window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`); window.app = { player: { settings: { liveChannelListMode: 'flat', languageDetectionMode: 'algo' } }, epgGuide: { getCurrentProgram: () => null, loadError: null } }; const channelList = new window.ChannelList(); channelList.sources = [{ id: 1, type: 'xtream', name: 'Fixture source', enabled: true }]; return { dom, window, channelList }; } test('full refresh analyzeAll runs Laya language detection after identity and persists recalculated languages', async () => { const db = createDb(); seedLive(db, 'bein-east', 'beIN Sports 1 East'); seedLive(db, 'bein-west', 'beIN Sports 1 West'); const order = []; const languagePayloads = []; const layaClient = { identityHintBatch: async pairs => { order.push('identity'); return pairs.map(pair => ({ id: pair.id, sameIdentityProbability: 0.9, confidence: 0.9, family: 'bein sports', number: '1' })); }, detectBatch: async payload => { order.push('language'); languagePayloads.push(payload); return { results: payload.items.map(item => ({ id: item.id, language: 'fr', confidence: 0.97 })) }; } }; try { await liveProjection.rebuildWithSignals(db, { mode: 'laya', layaClient, analyzeAll: true }); const rows = db.prepare(` SELECT id, language_code, language_detector, language_confidence FROM playlist_items WHERE type = 'live' ORDER BY id `).all(); assert.deepEqual(order, ['identity', 'language']); assert.equal(languagePayloads.length, 1); assert.deepEqual(rows.map(row => row.language_code), ['fr', 'fr']); assert.deepEqual(rows.map(row => row.language_detector), ['laya', 'laya']); assert.ok(rows.every(row => Number(row.language_confidence) === 0.97)); } finally { db.close(); } }); test('search result variant expansion uses logicalChannelId and lists the currently selected stream', async () => { const { dom, window, channelList } = createChannelListDom(); let variantsRequestedFor = null; window.API = { live: { getChannels: async options => { assert.equal(options.q, 'bein sports 1'); return { projection: { stale: false }, filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] }, channels: [{ id: 'logical:bein-sports-1:fr', logicalChannelId: 'logical:bein-sports-1', displayName: 'beIN Sports 1', language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 }, selectedVariantId: '1:bein-4k', variantCount: 3, variants: [{ id: 'bein-hd', sourceId: 1, name: 'beIN Sports 1 HD', categoryName: 'Sports', isHidden: false, language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 } }] }] }; }, getChannelVariants: async logicalChannelId => { variantsRequestedFor = logicalChannelId; if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } }; return { page: { hasMore: false }, variants: [ { id: 'bein-hd', sourceId: 1, name: 'beIN Sports 1 HD', categoryName: 'Sports', isHidden: false }, { id: 'bein-4k', sourceId: 1, name: 'beIN Sports 1 4K', categoryName: 'Sports', isHidden: false }, { id: 'bein-backup', sourceId: 1, name: 'beIN Sports 1 Backup', categoryName: 'Sports', isHidden: false } ] }; } } }; try { channelList.searchInput.value = 'bein sports 1'; await channelList.loadChannels(); const toggle = window.document.querySelector('.variant-toggle'); assert.ok(toggle, 'search result should expose a stream list toggle'); toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); await wait(20); assert.equal(variantsRequestedFor, 'logical:bein-sports-1'); assert.match(channelList.container.textContent, /beIN Sports 1 4K/); const playing = channelList.container.querySelector('.variant-playing-badge'); assert.equal(playing?.textContent, 'Playing'); assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k'); } finally { dom.window.close(); } });