Move variant grouping work out of Live TV rendering into the catalog pipeline: a liveProjectionService builds the logical channel projection (schema in liveProjectionSchema) whenever a source is added, synced or modified (name change, new address), and the projection is served through a /live route. ChannelList now consumes the precomputed projection via a liveChannelProjection client service, recalculating only when the catalog changes. Cover projection building, incremental recompute and client consumption with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
4.6 KiB
JavaScript
103 lines
4.6 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
projectCatalog,
|
|
serializeChannels
|
|
} = 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('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('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']);
|
|
});
|
|
|
|
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');
|
|
});
|