From dbd5b7fea30a9910b9f3d8a37f764a1ccf9d21d4 Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 16:22:57 +0200 Subject: [PATCH] fix: group beIN Sports 1 variants and clarify Laya language warning (#15) 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 --- public/index.html | 4 +-- public/js/services/channelVariantGrouping.js | 20 +++++++++++- public/js/services/layaStatusPresentation.js | 2 +- server/services/liveProjectionService.js | 20 +++++++++++- test/channelVariantGrouping.test.js | 32 ++++++++++++++++++++ test/layaStatusPresentation.test.js | 5 ++- test/liveProjectionService.test.js | 29 ++++++++++++++++++ 7 files changed, 106 insertions(+), 6 deletions(-) diff --git a/public/index.html b/public/index.html index 274d708..5bba3ea 100644 --- a/public/index.html +++ b/public/index.html @@ -1204,10 +1204,10 @@ - + - + diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js index 3372d73..2067b78 100644 --- a/public/js/services/channelVariantGrouping.js +++ b/public/js/services/channelVariantGrouping.js @@ -15,6 +15,8 @@ ]); const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i; + const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']); + const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']); function words(value) { return String(value || '') @@ -28,7 +30,23 @@ } function normalizeChannelName(name) { - return words(name).filter(token => !QUALITY_TOKENS.has(token)).join(' ').trim(); + const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token)); + const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports'); + if (brandIndex >= 0) { + const branded = tokens.slice(brandIndex); + const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3]) + ? 4 + : (/^\d+$/.test(branded[2]) ? 3 : 0); + if (numberedBaseLength) { + const suffix = branded.slice(numberedBaseLength); + const removable = suffix.every(token => + BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token) + ); + if (removable) return branded.slice(0, numberedBaseLength).join(' '); + } + return branded.join(' ').trim(); + } + return tokens.join(' ').trim(); } function displayNameForFingerprint(fingerprint) { diff --git a/public/js/services/layaStatusPresentation.js b/public/js/services/layaStatusPresentation.js index 4222897..e017165 100644 --- a/public/js/services/layaStatusPresentation.js +++ b/public/js/services/layaStatusPresentation.js @@ -80,7 +80,7 @@ function channelListWarning(mode, status = {}, batchFailed = false) { if (mode === 'algo') return ''; if (batchFailed) { - return 'Local AI language matching failed. Standard channel matching is being used.'; + return 'Local AI language detection failed. Channels were grouped with Standard matching and remain available.'; } if (status.modelPresent !== true || status.runtimeAvailable !== true) { return 'Local AI service is unavailable. Standard channel matching is being used.'; diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index 94e1706..37681c5 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -5,6 +5,8 @@ const QUALITY_TOKENS = new Set([ '1080p', '1080i', '720p', '576p', '480p' ]); const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i; +const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']); +const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']); function words(value) { return String(value || '') @@ -18,7 +20,23 @@ function words(value) { } function normalizeChannelName(name) { - return words(name).filter(token => !QUALITY_TOKENS.has(token)).join(' ').trim(); + const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token)); + const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports'); + if (brandIndex >= 0) { + const branded = tokens.slice(brandIndex); + const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3]) + ? 4 + : (/^\d+$/.test(branded[2]) ? 3 : 0); + if (numberedBaseLength) { + const suffix = branded.slice(numberedBaseLength); + const removable = suffix.every(token => + BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token) + ); + if (removable) return branded.slice(0, numberedBaseLength).join(' '); + } + return branded.join(' ').trim(); + } + return tokens.join(' ').trim(); } function displayNameForFingerprint(fingerprint) { diff --git a/test/channelVariantGrouping.test.js b/test/channelVariantGrouping.test.js index 1ef82c8..b1b074b 100644 --- a/test/channelVariantGrouping.test.js +++ b/test/channelVariantGrouping.test.js @@ -21,6 +21,38 @@ test('does not merge distinct numbered channels', () => { assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']); }); +test('normalizes captured beIN Sports aliases like the server projection', () => { + 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(grouping.normalizeChannelName), Array(aliases.length).fill('bein sports 1')); + const logical = grouping.groupChannels(aliases.map((name, index) => channel(String(index), name))); + assert.equal(logical.length, 1); + assert.equal(logical[0].variants.length, aliases.length); +}); + +test('keeps distinct beIN numbers and unknown suffixes separate', () => { + assert.equal(grouping.normalizeChannelName('beIN Sports 1'), 'bein sports 1'); + assert.equal(grouping.normalizeChannelName('beIN Sports 2'), 'bein sports 2'); + assert.equal(grouping.normalizeChannelName('beIN Sports Max 10'), 'bein sports max 10'); + assert.equal(grouping.normalizeChannelName('Partner beIN Sports 1 Event'), 'bein sports 1 event'); + assert.notEqual( + grouping.normalizeChannelName('Partner beIN Sports 1 Event'), + grouping.normalizeChannelName('beIN Sports 1') + ); +}); + 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' }), diff --git a/test/layaStatusPresentation.test.js b/test/layaStatusPresentation.test.js index 16544d4..79cc08a 100644 --- a/test/layaStatusPresentation.test.js +++ b/test/layaStatusPresentation.test.js @@ -55,7 +55,10 @@ test('ChannelList warning only appears for active Laya modes when unavailable or channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: false }), 'Local AI service is unavailable. Standard channel matching is being used.' ); - assert.equal(channelListWarning('laya', ready, true), 'Local AI language matching failed. Standard channel matching is being used.'); + assert.equal( + channelListWarning('laya', ready, true), + 'Local AI language detection failed. Channels were grouped with Standard matching and remain available.' + ); }); test('installed model with unavailable service is not reported ready', () => { diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js index f4c142a..72e78d4 100644 --- a/test/liveProjectionService.test.js +++ b/test/liveProjectionService.test.js @@ -1,6 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { + normalizeChannelName, projectCatalog, serializeChannels, startRebuild @@ -63,12 +64,40 @@ test('projects beIN quality variants into one logical channel', () => { 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', () => {