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