Filter projected live channels locally in the browser and apply server-side search and language filters before costly enrichment work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const Database = require('better-sqlite3');
|
|
const {
|
|
normalizeChannelName,
|
|
getChannelPage,
|
|
projectCatalog,
|
|
markProjectionBuilding,
|
|
projectionState,
|
|
rebuildWithSignals,
|
|
serializeChannels,
|
|
startRebuild
|
|
} = require('../server/services/liveProjectionService');
|
|
const {
|
|
LIVE_PROJECTION_COLUMNS,
|
|
LIVE_PROJECTION_SCHEMA_SQL
|
|
} = require('../server/db/liveProjectionSchema');
|
|
|
|
const item = (id, name, extra = {}) => ({
|
|
id: `1:${id}`,
|
|
source_id: 1,
|
|
item_id: String(id),
|
|
name,
|
|
category_id: 'sports',
|
|
...extra
|
|
});
|
|
|
|
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
|
|
);
|
|
`);
|
|
const { ensureColumns } = require('../server/db/liveProjectionSchema');
|
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
|
return db;
|
|
}
|
|
|
|
function seedProjectionChannel(db, itemId, name) {
|
|
db.prepare(`
|
|
INSERT INTO playlist_items
|
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
|
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, '{}')
|
|
`).run(`1:${itemId}`, itemId, name, `https://streams.test/${itemId}.m3u8`);
|
|
}
|
|
|
|
function waitFor(condition, message) {
|
|
return new Promise((resolve, reject) => {
|
|
const started = Date.now();
|
|
const poll = () => {
|
|
if (condition()) return resolve();
|
|
if (Date.now() - started > 1000) return reject(new Error(message));
|
|
setTimeout(poll, 5);
|
|
};
|
|
poll();
|
|
});
|
|
}
|
|
|
|
test('projection migration declares grouping and language columns', () => {
|
|
assert.deepEqual(Object.keys(LIVE_PROJECTION_COLUMNS), [
|
|
'logical_channel_id', 'grouping_fingerprint', 'grouping_input_hash',
|
|
'quality_label', 'quality_rank', 'variant_rank', 'language_code',
|
|
'language_confidence', 'language_scores', 'language_evidence',
|
|
'language_detector', 'language_input_hash'
|
|
]);
|
|
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS logical_channels/);
|
|
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS catalog_projection_state/);
|
|
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/);
|
|
});
|
|
|
|
test('manual rebuild scheduling coalesces concurrent requests', async () => {
|
|
let scheduled;
|
|
let runs = 0;
|
|
const state = { status: 'building', processed_count: 0, total_count: 2 };
|
|
const options = {
|
|
mark: () => state,
|
|
runner: async () => { runs++; return { status: 'success' }; },
|
|
schedule: callback => { scheduled = callback; }
|
|
};
|
|
const first = startRebuild({}, options);
|
|
const second = startRebuild({}, options);
|
|
assert.equal(first.alreadyRunning, false);
|
|
assert.equal(second.alreadyRunning, true);
|
|
assert.equal(second.promise, first.promise);
|
|
scheduled();
|
|
await first.promise;
|
|
assert.equal(runs, 1);
|
|
});
|
|
|
|
test('Laya rebuild progress advances after a successful identity batch while analysis is still running', async () => {
|
|
const db = createProjectionDb();
|
|
for (let index = 0; index < 12; index++) {
|
|
seedProjectionChannel(db, `bein-${index}`, `beIN Sports 1 regional ${index}`);
|
|
}
|
|
|
|
let calls = 0;
|
|
let secondBatchStarted = false;
|
|
let releaseSecondBatch;
|
|
const secondBatchGate = new Promise(resolve => { releaseSecondBatch = resolve; });
|
|
const layaClient = {
|
|
identityHintBatch: async pairs => {
|
|
calls += 1;
|
|
if (calls === 2) {
|
|
secondBatchStarted = true;
|
|
await secondBatchGate;
|
|
}
|
|
return pairs.map(pair => ({
|
|
id: pair.id,
|
|
sameIdentityProbability: 0.9,
|
|
confidence: 0.9,
|
|
family: 'bein sports',
|
|
number: '1'
|
|
}));
|
|
}
|
|
};
|
|
|
|
markProjectionBuilding(db);
|
|
const rebuild = rebuildWithSignals(db, {
|
|
mode: 'laya',
|
|
layaClient,
|
|
stateAlreadyBuilding: true
|
|
});
|
|
|
|
try {
|
|
await waitFor(() => secondBatchStarted, 'Expected the second Laya identity batch to start');
|
|
const state = projectionState(db);
|
|
assert.equal(state.status, 'building');
|
|
assert.equal(state.phase, 'laya_identity');
|
|
assert.equal(state.total_count, 12);
|
|
assert.equal(state.processed_count, 0);
|
|
assert.equal(state.laya_processed_count, 1);
|
|
assert.equal(state.laya_total_count, 2);
|
|
} finally {
|
|
releaseSecondBatch();
|
|
await rebuild;
|
|
}
|
|
});
|
|
|
|
test('projects beIN quality variants into one logical channel', () => {
|
|
const groups = projectCatalog([
|
|
item('uhd', '|FR| BEIN SPORTS 1 UHD'),
|
|
item('4k', '|FR| BEIN SPORTS 1 4K'),
|
|
item('car', 'FR-CAR| bein sports 1')
|
|
]);
|
|
assert.equal(groups.length, 1);
|
|
assert.equal(groups[0].fingerprint, 'bein sports 1');
|
|
assert.equal(groups[0].displayName, 'beIN Sports 1');
|
|
assert.equal(groups[0].variants.length, 3);
|
|
assert.deepEqual(groups[0].variants.map(variant => variant.variantRank), [0, 1, 2]);
|
|
});
|
|
|
|
test('normalizes the captured beIN Sports 1 aliases to one exact fingerprint', () => {
|
|
const aliases = [
|
|
'C Car beIN Sports 1',
|
|
'C Reu beIN Sports 1',
|
|
'Thai beIN Sports 1 Thai',
|
|
'beIN Sports 1',
|
|
'beIN Sports 1 12h',
|
|
'beIN Sports 1 2h',
|
|
'beIN Sports 1 4h',
|
|
'beIN Sports 1 8h',
|
|
'beIN Sports 1 Appel',
|
|
'beIN Sports 1 En',
|
|
'beIN Sports 1 Fr',
|
|
'beIN Sports 1 Tod'
|
|
];
|
|
assert.deepEqual(aliases.map(normalizeChannelName), Array(aliases.length).fill('bein sports 1'));
|
|
const groups = projectCatalog(aliases.map((name, index) => item(index, name)));
|
|
assert.equal(groups.length, 1);
|
|
assert.equal(groups[0].fingerprint, 'bein sports 1');
|
|
assert.equal(groups[0].variants.length, aliases.length);
|
|
});
|
|
|
|
test('does not merge distinct numbered beIN channels', () => {
|
|
const groups = projectCatalog([
|
|
item('1', 'beIN Sports 1 HD'),
|
|
item('2', 'beIN Sports 2 HD')
|
|
]);
|
|
assert.deepEqual(groups.map(group => group.fingerprint), ['bein sports 1', 'bein sports 2']);
|
|
assert.equal(normalizeChannelName('beIN Sports Max 10'), 'bein sports max 10');
|
|
});
|
|
|
|
test('keeps unknown beIN suffixes distinct instead of applying fuzzy matching', () => {
|
|
assert.equal(normalizeChannelName('Partner beIN Sports 1 Event'), 'bein sports 1 event');
|
|
assert.notEqual(normalizeChannelName('Partner beIN Sports 1 Event'), normalizeChannelName('beIN Sports 1'));
|
|
});
|
|
|
|
test('renaming a stream changes its projection membership and input hash', () => {
|
|
const before = projectCatalog([item('1', 'beIN Sports 1 HD'), item('2', 'beIN Sports 1 4K')]);
|
|
const after = projectCatalog([item('1', 'beIN Sports 2 HD'), item('2', 'beIN Sports 1 4K')]);
|
|
assert.equal(before.length, 1);
|
|
assert.equal(after.length, 2);
|
|
const beforeRename = before.flatMap(group => group.variants).find(variant => variant.id === '1:1');
|
|
const afterRename = after.flatMap(group => group.variants).find(variant => variant.id === '1:1');
|
|
assert.notEqual(beforeRename.groupingInputHash, afterRename.groupingInputHash);
|
|
});
|
|
|
|
test('API serialization filters source rows upstream and hidden variants by option', () => {
|
|
const rows = [
|
|
{
|
|
...item('1', 'beIN Sports 1 HD'), logical_channel_id: 'logical:1',
|
|
grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1',
|
|
preferred_variant_id: '1:1', quality_label: 'HD', quality_rank: 2,
|
|
variant_rank: 0, is_hidden: 0, category_hidden: 0, category_name: 'Sports Premium',
|
|
user_favorite: 1, data: '{}'
|
|
},
|
|
{
|
|
...item('2', 'beIN Sports 1 4K'), logical_channel_id: 'logical:1',
|
|
grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1',
|
|
preferred_variant_id: '1:1', quality_label: '4K', quality_rank: 4,
|
|
variant_rank: 1, is_hidden: 1, category_hidden: 0, category_name: 'Sports Premium',
|
|
user_favorite: 0, data: '{}'
|
|
}
|
|
];
|
|
const visible = serializeChannels(rows, false);
|
|
assert.equal(visible[0].variants.length, 1);
|
|
assert.equal(visible[0].isFavorite, true);
|
|
assert.equal(visible[0].groupTitle, 'Sports Premium');
|
|
assert.equal(visible[0].categoryName, 'Sports Premium');
|
|
assert.equal(visible[0].variants[0].groupTitle, 'Sports Premium');
|
|
assert.equal(serializeChannels(rows, true)[0].variants.length, 2);
|
|
});
|
|
|
|
test('API serialization preserves an M3U groupTitle over the stored category fallback', () => {
|
|
const rows = [{
|
|
...item('m3u', 'M3U Sports HD'), logical_channel_id: 'logical:m3u',
|
|
grouping_fingerprint: 'm3u sports', display_name: 'M3U Sports',
|
|
preferred_variant_id: '1:m3u', quality_label: 'HD', quality_rank: 2,
|
|
variant_rank: 0, is_hidden: 0, category_hidden: 0,
|
|
category_name: 'Fallback category', user_favorite: 0,
|
|
data: JSON.stringify({ groupTitle: 'M3U Sports France' })
|
|
}];
|
|
const [channel] = serializeChannels(rows, false);
|
|
assert.equal(channel.groupTitle, 'M3U Sports France');
|
|
assert.equal(channel.variants[0].categoryName, 'M3U Sports France');
|
|
});
|
|
|
|
test('search and language filters run before expensive standard and EPG enrichment', () => {
|
|
const db = createProjectionDb();
|
|
seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD');
|
|
seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K');
|
|
seedProjectionChannel(db, 'euro-fr', '|FR| Eurosport HD');
|
|
require('../server/services/liveProjectionService').rebuild(db);
|
|
|
|
let standardCalls = 0;
|
|
let epgCalls = 0;
|
|
const page = getChannelPage({ q: 'Bein Sport 1', languages: ['fr'], epgSources: [] }, db, {
|
|
iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } },
|
|
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
|
});
|
|
|
|
assert.equal(page.channels.length, 1);
|
|
assert.equal(page.channels[0].language.code, 'fr');
|
|
assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
|
|
assert.equal(standardCalls, 1);
|
|
assert.equal(epgCalls, 1);
|
|
});
|