feat: precompute stream grouping during source sync (#13)
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>
This commit is contained in:
102
test/liveProjectionService.test.js
Normal file
102
test/liveProjectionService.test.js
Normal file
@ -0,0 +1,102 @@
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user