From 8f2825f697efefb6c310968698544ceb02c67ab3 Mon Sep 17 00:00:00 2001 From: blomios Date: Thu, 8 Oct 2026 19:00:14 +0200 Subject: [PATCH] feat(live): show language-scoped stream variants Keep live stream variant expansion scoped to the selected language while preserving the legacy all-language variants response when no language is provided. Display projected current EPG programme data in live rows and distinguish selected streams from actively playing streams in the collapsed variant UI. Refs #43 Co-Authored-By: Claude Opus 4.8 --- public/css/main.css | 11 + public/js/api.js | 3 +- public/js/components/ChannelList.js | 24 +- server/routes/live.js | 7 + server/services/liveProjectionService.js | 10 +- test/liveProjectionRoutes.test.js | 2 + test/t23Frontend.test.js | 5 + ...t39FullRefreshAndVariantRegression.test.js | 18 +- test/t43LiveListVariantsEpgRegression.test.js | 229 ++++++++++++++++++ 9 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 test/t43LiveListVariantsEpgRegression.test.js 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; + } +});