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:
blomios
2026-10-02 15:35:02 +02:00
parent 7b6525708c
commit 9f97020996
16 changed files with 619 additions and 10 deletions

View File

@ -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, {

View 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);
});

View 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');
});