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;
+ }
+});