const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { JSDOM } = require('jsdom'); const Database = require('better-sqlite3'); const liveProjection = require('../server/services/liveProjectionService'); const { ensureColumns, LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL } = require('../server/db/liveProjectionSchema'); 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 ); `); ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); db.exec(LIVE_PROJECTION_SCHEMA_SQL); db.prepare(` INSERT INTO categories (id, source_id, category_id, type, name) VALUES ('sports', 1, 'sports', 'live', 'Sports') `).run(); return db; } function seedVariant(db, { itemId, name, language, rank, tvgId }) { db.prepare(` INSERT INTO playlist_items (id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data, logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank, variant_rank, language_code, language_confidence, language_detector, language_input_hash) VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, ?, 'logical:bein-sports-1', 'bein sports 1', 'group-hash', 'HD', 2, ?, ?, 0.97, 'laya', ?) `).run( `1:${itemId}`, itemId, name, `https://streams.test/${itemId}.m3u8`, JSON.stringify({ tvgId, groupTitle: 'Sports' }), rank, language, `lang-${itemId}` ); } function seedBeinProjection(db) { db.prepare(` INSERT INTO logical_channels (id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at) VALUES ('logical:bein-sports-1', 'bein sports 1', 'beIN Sports 1', '1:fr-hd', 3, 1, 1) `).run(); seedVariant(db, { itemId: 'fr-hd', name: 'beIN Sports 1 HD FR', language: 'fr', rank: 0, tvgId: 'bein1.fr' }); seedVariant(db, { itemId: 'fr-backup', name: 'beIN Sports 1 Backup FR', language: 'fr', rank: 1, tvgId: 'bein1.fr.backup' }); seedVariant(db, { itemId: 'en-hd', name: 'beIN Sports 1 HD EN', language: 'en', rank: 2, tvgId: 'bein1.en' }); db.prepare(` INSERT INTO live_epg_matches (logical_channel_id, language_code, source_id, channel_id, match_confidence, data, updated_at) VALUES ('logical:bein-sports-1', 'fr', 9, 'bein1.fr', 100, ?, 1) `).run(JSON.stringify({ sourceId: 9, channelId: 'bein1.fr', matchConfidence: 100, currentProgram: { title: 'Live Ligue 1', description: 'Current match', startTime: 1000, endTime: 2000 } })); } function loadScript(relativePath) { return fs.readFileSync(path.join(__dirname, '..', 'public', relativePath), 'utf8'); } function createChannelListDom() { const dom = new JSDOM(`
`, { url: 'http://localhost/' }); const { window } = dom; global.window = window; global.document = window.document; global.localStorage = window.localStorage; global.IntersectionObserver = class { observe() {} unobserve() {} disconnect() {} }; window.Icons = { favorite: 'fav', favoriteOutline: 'fav-outline', collapseAll: '-', expandAll: '+', chevronDown: 'v' }; global.Icons = window.Icons; window.eval(loadScript('js/services/channelLanguageDetection.js')); window.eval(loadScript('js/services/channelLanguageFusion.js')); window.eval(loadScript('js/services/channelVariantGrouping.js')); window.eval(loadScript('js/services/liveChannelProjection.js')); window.eval(loadScript('js/components/ChannelList.js')); window.app = { player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' } }, epgGuide: { getCurrentProgram: () => null, loadError: null } }; return { dom, window, channelList: new window.ChannelList() }; } test('variant endpoint should keep the selected language view when expanding a FR live channel', () => { const db = createProjectionDb(); seedBeinProjection(db); try { const page = liveProjection.getChannelPage({ q: 'bein sports', languages: ['fr'], page: 1, limit: 100 }, db); assert.equal(page.channels.length, 1); assert.equal(page.channels[0].language.code, 'fr'); assert.equal(page.channels[0].variantCount, 2); const variants = liveProjection.getChannelVariants({ logicalChannelId: page.channels[0].logicalChannelId, language: page.channels[0].language.code, includeHidden: false, page: 1, limit: 100 }, db); assert.deepEqual( variants.variants.map(variant => variant.id), ['fr-hd', 'fr-backup'], 'expanding a FR channel should not mix EN variants into the FR stream list' ); const allLanguages = liveProjection.getChannelVariants({ logicalChannelId: page.channels[0].logicalChannelId, includeHidden: false, page: 1, limit: 100 }, db); assert.deepEqual( allLanguages.variants.map(variant => variant.id), ['fr-hd', 'fr-backup', 'en-hd'], 'omitting language should preserve the legacy all-language response' ); } finally { db.close(); } }); test('live list should display persisted current EPG programme when the guide cache is empty', () => { const db = createProjectionDb(); seedBeinProjection(db); const page = liveProjection.getChannelPage({ q: 'bein sports', languages: ['fr'], page: 1, limit: 100 }, db); db.close(); const { dom, window, channelList } = createChannelListDom(); try { const projectionService = window.LiveChannelProjection || global.LiveChannelProjection; const mapped = projectionService.mapResponse({ projection: { stale: false }, filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] }, channels: page.channels }, [{ id: 1, type: 'xtream', name: 'Provider' }]); channelList.projectedLogicalChannels = mapped.logicalChannels; channelList.channels = mapped.channels; channelList._hasCollapsedState = true; channelList.collapsedGroups.clear(); channelList._userExpandedGroups.add('Sports'); channelList.render(); assert.match( channelList.container.textContent, /Live: Live Ligue 1/, 'channel row should use the persisted currentProgram from live_epg_matches before falling back to No program information' ); } finally { clearInterval(channelList._epgRefreshTimer); window.clearInterval(channelList._epgRefreshTimer); dom.window.close(); delete global.window; delete global.document; delete global.localStorage; delete global.IntersectionObserver; } });