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', () => {