diff --git a/public/css/main.css b/public/css/main.css index 5859ddc..fce24a7 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -824,6 +824,17 @@ h4 { white-space: nowrap; } +.variant-selected-badge { + align-self: center; + padding: 2px 6px; + border: 1px solid var(--color-border); + border-radius: 999px; + color: var(--color-text-muted); + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; +} + .variant-language { justify-self: start; padding: 1px 5px; diff --git a/public/js/api.js b/public/js/api.js index 3cf8047..a5e7c63 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -101,13 +101,14 @@ const API = { params.set('page', String(Math.floor(offset / limit) + 1)); return API.request('GET', `/live/channels?${params.toString()}`); }, - getChannelVariants: (logicalChannelId, { includeHidden = false, limit = 100, offset = 0 } = {}) => { + getChannelVariants: (logicalChannelId, { includeHidden = false, language = '', limit = 100, offset = 0 } = {}) => { const params = new URLSearchParams({ includeHidden: String(Boolean(includeHidden)), limit: String(limit), offset: String(offset), page: String(Math.floor(offset / limit) + 1) }); + if (language) params.set('language', language); return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`); }, checkNewStreams: () => API.request('POST', '/live/projection/check-new'), diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 284bb87..44fb3be 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -311,6 +311,14 @@ class ChannelList { */ getProgramInfo(channel) { try { + const projectedProgram = channel?.epg?.currentProgram; + if (projectedProgram?.title) { + return { + ...projectedProgram, + start: projectedProgram.start ?? projectedProgram.startTime, + end: projectedProgram.end ?? projectedProgram.endTime + }; + } if (!window.app || !window.app.epgGuide) return null; // Cache key: channel_id + current_minute (invalidate every minute) @@ -335,7 +343,11 @@ class ChannelList { if (program) break; } } - const result = program ? program.title : null; + const result = program ? { + ...program, + start: program.start ?? program.startTime, + end: program.end ?? program.endTime + } : null; this._programInfoCache.set(cacheKey, result); return result; @@ -346,8 +358,8 @@ class ChannelList { } getProgramStatusText(channel) { - const title = this.getProgramInfo(channel); - if (title) return `Live: ${title}`; + const program = this.getProgramInfo(channel); + if (program?.title) return `Live: ${program.title}`; return window.app?.epgGuide?.loadError ? 'Program information unavailable' : 'No program information'; @@ -895,13 +907,13 @@ class ChannelList { const playerVariant = window.app?.player?.currentChannel; const currentVariant = this.currentChannel?.preferredVariant; const playingKeys = new Set([ - channel.selectedVariantId, playerVariant?.sourceId !== undefined && playerVariant?.id !== undefined ? `${playerVariant.sourceId}:${playerVariant.id}` : '', currentVariant?.sourceId !== undefined && currentVariant?.id !== undefined ? `${currentVariant.sourceId}:${currentVariant.id}` : '' ].filter(Boolean).map(String)); const isPlaying = playingKeys.has(variantKey); + const isSelected = !isPlaying && String(channel.selectedVariantId) === variantKey; return ``; }).join('') : ''; const variantCount = Number(channel.variantCount) || channel.variants.length; @@ -928,7 +941,7 @@ class ChannelList { ${this.escapeHtml(languageBadge)}
${this.escapeHtml(channel.displayName)}
${this.escapeHtml(programLabel)}
- ${variantCount > 1 ? `` : ''} + ${variantCount > 1 ? `` : ''} ${variantCount > 1 && expanded ? `
${variants}${hasMoreVariants ? `` : ''}
` : ''} @@ -944,6 +957,7 @@ class ChannelList { const offset = append ? (current?.page?.nextOffset || 0) : 0; const response = await API.live.getChannelVariants(channel.logicalChannelId || logicalId, { includeHidden: Boolean(this.showHiddenCheckbox?.checked), + language: channel.language?.code || '', limit: 100, offset }); diff --git a/server/routes/live.js b/server/routes/live.js index 591f8f8..47e2a04 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -67,12 +67,19 @@ router.get('/channels', requireAuth, async (req, res) => { }); router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => { + const language = req.query.language === undefined + ? null + : String(req.query.language).trim().toLowerCase(); + if (language !== null && !/^(?:[a-z]{2,3}|und)$/.test(language)) { + return res.status(400).json({ error: 'language must be a valid language code' }); + } try { const page = Number.parseInt(req.query.page, 10) || 1; const limit = Number.parseInt(req.query.limit, 10) || 100; const includeHidden = req.query.includeHidden === 'true'; return res.json(liveProjection.getChannelVariants({ logicalChannelId: req.params.logicalChannelId, + language, includeHidden, userId: req.user.id, page, diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index 933fceb..d6bd0bc 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -625,8 +625,12 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null, }; } -function getChannelVariants({ logicalChannelId, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) { +function getChannelVariants({ logicalChannelId, language = null, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) { database ||= defaultDatabase(); + const languageFilter = language === null + ? '' + : "AND COALESCE(p.language_code, 'und') = ?"; + const params = language === null ? [] : [language]; const rows = database.prepare(` SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite, l.display_name, l.preferred_variant_id, c.name AS category_name, @@ -636,9 +640,9 @@ function getChannelVariants({ logicalChannelId, includeHidden = false, userId = LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id AND f.item_id = p.item_id AND f.item_type = 'channel' - WHERE p.type = 'live' AND p.logical_channel_id = ? + WHERE p.type = 'live' AND p.logical_channel_id = ? ${languageFilter} ORDER BY p.variant_rank, p.id - `).all(userId ?? -1, logicalChannelId); + `).all(userId ?? -1, logicalChannelId, ...params); const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants); const bounds = pageBounds(page, limit); return { diff --git a/test/liveProjectionRoutes.test.js b/test/liveProjectionRoutes.test.js index cee2bd6..7f3e238 100644 --- a/test/liveProjectionRoutes.test.js +++ b/test/liveProjectionRoutes.test.js @@ -28,6 +28,8 @@ test('live channels route accepts pagination and forwards page bounds to the pro test('live variants route is paginated and authenticated', () => { assert.ok(/router\.get\('\/channels\/:logicalChannelId\/variants', requireAuth,/.test(routeSource), 'GET /channels/:logicalChannelId/variants should exist and require auth'); assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*page[^}]*limit/s.test(routeSource), 'variants route should call getChannelVariants with page and limit'); + assert.ok(/language[^\n]*valid language code/.test(routeSource), 'variants route should reject invalid language codes'); + assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*language[^}]*page[^}]*limit/s.test(routeSource), 'variants route should forward language to getChannelVariants'); }); test('projection check-new route exists separately from complete rebuild', () => { diff --git a/test/t23Frontend.test.js b/test/t23Frontend.test.js index 1ff606b..0692a65 100644 --- a/test/t23Frontend.test.js +++ b/test/t23Frontend.test.js @@ -47,6 +47,11 @@ test('live API sends flexible search and selected language filters', async () => const params = new URL(requestedUrl, 'http://nodecast.test').searchParams; assert.equal(params.get('q'), 'Bein Sport 1'); assert.equal(params.get('languages'), 'fr,en'); + + await dom.window.API.live.getChannelVariants('logical:bein-1', { language: 'fr' }); + const variantUrl = new URL(requestedUrl, 'http://nodecast.test'); + assert.equal(variantUrl.pathname, '/api/live/channels/logical%3Abein-1/variants'); + assert.equal(variantUrl.searchParams.get('language'), 'fr'); }); test('built-in EPG sources expose only refresh and enable controls', () => { diff --git a/test/t39FullRefreshAndVariantRegression.test.js b/test/t39FullRefreshAndVariantRegression.test.js index 6d9d20b..63be5e5 100644 --- a/test/t39FullRefreshAndVariantRegression.test.js +++ b/test/t39FullRefreshAndVariantRegression.test.js @@ -152,9 +152,10 @@ test('full refresh analyzeAll runs Laya language detection after identity and pe } }); -test('search result variant expansion uses logicalChannelId and lists the currently selected stream', async () => { +test('search result variant expansion keeps language and distinguishes selected from playing stream', async () => { const { dom, window, channelList } = createChannelListDom(); let variantsRequestedFor = null; + let variantRequestOptions = null; window.API = { live: { @@ -181,8 +182,9 @@ test('search result variant expansion uses logicalChannelId and lists the curren }] }; }, - getChannelVariants: async logicalChannelId => { + getChannelVariants: async (logicalChannelId, options) => { variantsRequestedFor = logicalChannelId; + variantRequestOptions = options; if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } }; return { page: { hasMore: false }, @@ -202,14 +204,26 @@ test('search result variant expansion uses logicalChannelId and lists the curren const toggle = window.document.querySelector('.variant-toggle'); assert.ok(toggle, 'search result should expose a stream list toggle'); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(toggle.getAttribute('aria-label'), 'Show 3 streams for beIN Sports 1'); toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); await wait(20); assert.equal(variantsRequestedFor, 'logical:bein-sports-1'); + assert.equal(variantRequestOptions.language, 'fr'); assert.match(channelList.container.textContent, /beIN Sports 1 4K/); + assert.equal(channelList.container.querySelector('.variant-playing-badge'), null); + const selected = channelList.container.querySelector('.variant-selected-badge'); + assert.equal(selected?.textContent, 'Selected'); + assert.equal(selected?.closest('.channel-variant')?.dataset.variantId, 'bein-4k'); + + window.app.player.currentChannel = { id: 'bein-4k', sourceId: 1 }; + channelList.render(); const playing = channelList.container.querySelector('.variant-playing-badge'); assert.equal(playing?.textContent, 'Playing'); assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k'); + assert.equal(channelList.container.querySelector('.variant-selected-badge'), null); + assert.equal(channelList.container.querySelector('.variant-toggle').getAttribute('aria-label'), 'Hide streams for beIN Sports 1'); } finally { dom.window.close(); } diff --git a/test/t43LiveListVariantsEpgRegression.test.js b/test/t43LiveListVariantsEpgRegression.test.js new file mode 100644 index 0000000..3a7642c --- /dev/null +++ b/test/t43LiveListVariantsEpgRegression.test.js @@ -0,0 +1,229 @@ +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; + } +});