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:
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
229
test/t43LiveListVariantsEpgRegression.test.js
Normal file
229
test/t43LiveListVariantsEpgRegression.test.js
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user