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>
230 lines
8.5 KiB
JavaScript
230 lines
8.5 KiB
JavaScript
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;
|
|
}
|
|
});
|