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 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-08 19:00:14 +02:00
parent 9e6b1d4593
commit 8f2825f697
9 changed files with 298 additions and 11 deletions

View File

@ -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;

View File

@ -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'),

View File

@ -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 `<button class="channel-variant ${hidden ? 'is-hidden' : ''} ${isPlaying ? 'is-playing' : ''}" type="button"
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
@ -910,6 +922,7 @@ class ChannelList {
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
${isPlaying ? '<span class="variant-playing-badge">Playing</span>' : ''}
${isSelected ? '<span class="variant-selected-badge">Selected</span>' : ''}
</button>`;
}).join('') : '';
const variantCount = Number(channel.variantCount) || channel.variants.length;
@ -928,7 +941,7 @@ class ChannelList {
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${variantCount} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-label="${this.escapeHtml(expanded ? `Hide streams for ${channel.displayName}` : `Show ${variantCount} streams for ${channel.displayName}`)}" title="${expanded ? 'Hide streams' : 'Show streams'}"><span>${variantCount} streams</span><span class="variant-chevron" aria-hidden="true">⌄</span></button>` : ''}
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
</div>
${variantCount > 1 && expanded ? `<div class="channel-variants">${variants}${hasMoreVariants ? `<button type="button" class="btn btn-ghost load-more-variants" data-logical-id="${this.escapeHtml(channel.fingerprint)}">Load more streams</button>` : ''}</div>` : ''}
@ -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
});

View File

@ -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,

View File

@ -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 {

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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();
}

View File

@ -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(`<!doctype html>
<input id="channel-search">
<select id="source-select"><option value="">All</option></select>
<input id="show-hidden" type="checkbox">
<button id="toggle-groups"></button>
<div id="context-menu"></div>
<div id="channel-language-filter"></div>
<div id="channel-list-status"></div>
<div id="channel-list"></div>`, { 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;
}
});