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:
@ -21,6 +21,17 @@ test('does not merge distinct numbered channels', () => {
|
||||
assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']);
|
||||
});
|
||||
|
||||
test('preserves precomputed projection boundaries and display names', () => {
|
||||
const logical = grouping.groupChannels([
|
||||
channel('1', 'Shared Name HD', { logicalId: 'logical:a', logicalFingerprint: 'a', logicalDisplayName: 'Backend A' }),
|
||||
channel('2', 'Shared Name 4K', { logicalId: 'logical:b', logicalFingerprint: 'b', logicalDisplayName: 'Backend B' })
|
||||
]);
|
||||
assert.deepEqual(logical.map(item => [item.id, item.displayName]), [
|
||||
['logical:a', 'Backend A'],
|
||||
['logical:b', 'Backend B']
|
||||
]);
|
||||
});
|
||||
|
||||
test('logical favorite and hidden state are derived from individual streams', () => {
|
||||
const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')];
|
||||
const logical = grouping.groupChannels(variants, {
|
||||
|
||||
34
test/liveChannelProjection.test.js
Normal file
34
test/liveChannelProjection.test.js
Normal file
@ -0,0 +1,34 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const projection = require('../public/js/services/liveChannelProjection.js');
|
||||
|
||||
test('maps projected logical channels to the frontend variant format', () => {
|
||||
const response = {
|
||||
projection: { status: 'success', stale: false },
|
||||
channels: [{
|
||||
id: 'logical:one', fingerprint: 'news', displayName: 'News',
|
||||
variants: [{
|
||||
id: 42, sourceId: 7, name: 'News HD', categoryId: 'News',
|
||||
streamIcon: 'logo.png', streamUrl: 'https://stream.test/live',
|
||||
tvgId: 'news.fr', isFavorite: true, isHidden: true
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const mapped = projection.mapResponse(response, [{ id: 7, type: 'm3u', name: 'Local' }]);
|
||||
assert.deepEqual(mapped.channels[0], {
|
||||
id: '42', sourceId: 7, name: 'News HD', categoryId: 'News',
|
||||
streamIcon: 'logo.png', streamUrl: 'https://stream.test/live', tvgId: 'news.fr',
|
||||
isFavorite: true, isHidden: true, logicalId: 'logical:one',
|
||||
logicalFingerprint: 'news', logicalDisplayName: 'News',
|
||||
streamId: '42', tvgLogo: 'logo.png',
|
||||
url: 'https://stream.test/live', groupTitle: 'News', sourceType: 'm3u', sourceName: 'Local'
|
||||
});
|
||||
assert.equal(mapped.favorites.has('7:42'), true);
|
||||
assert.equal(mapped.hiddenItems.has('channel:7:42'), true);
|
||||
});
|
||||
|
||||
test('rejects stale projections so ChannelList can use its legacy fallback', () => {
|
||||
assert.equal(projection.canUse({ projection: { stale: true }, channels: [] }), false);
|
||||
assert.equal(projection.canUse({ projection: { stale: false }, channels: [] }), true);
|
||||
assert.equal(projection.canUse(null), false);
|
||||
});
|
||||
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