beIN Sports 1 variants stayed separated after projection because names with region prefixes (C Car, C Reu, Thai), time-shift suffixes (2h/4h/8h/12h) and language markers (En, Fr, Thai) produced distinct fingerprints. Extend normalization in the server projection service and the frontend grouping fallback to fold these into one logical channel, with tests on the exact reported names. Clarify the Laya language detection warning so a language-matching failure is not mistaken for a grouping failure, and bump script versions for cache refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
6.5 KiB
JavaScript
152 lines
6.5 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
normalizeChannelName,
|
|
projectCatalog,
|
|
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
|
|
});
|
|
|
|
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('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');
|
|
});
|