From ab554e12a4c23ebeacca5b92db5752e52971a231 Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 6 Oct 2026 17:48:28 +0200 Subject: [PATCH] fix(live): tighten beIN Sports search matching (#27) Require numeric query tokens to match numeric channel tokens exactly while preserving singular/plural and compact-name tolerance. Co-Authored-By: Claude Opus 4.8 --- public/index.html | 2 +- public/js/services/liveChannelProjection.js | 10 ++++-- server/services/flexibleChannelSearch.js | 10 ++++-- test/t27BeinSearchRegression.test.js | 39 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 test/t27BeinSearchRegression.test.js diff --git a/public/index.html b/public/index.html index 728663b..e6d5c5a 100644 --- a/public/index.html +++ b/public/index.html @@ -1214,7 +1214,7 @@ - + diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index 763f0ef..c55feaa 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -6,7 +6,10 @@ function normalizeSearch(value) { return String(value || '') .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') - .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + .toLowerCase() + .replace(/([a-z])([0-9])/g, '$1 $2') + .replace(/([0-9])([a-z])/g, '$1 $2') + .replace(/[^a-z0-9]+/g, ' ').trim() .split(/\s+/).filter(Boolean) .map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token); } @@ -20,7 +23,10 @@ variant.name, variant.tvgId, variant.categoryName, variant.groupTitle ]) ].filter(Boolean).join(' ')); - return wanted.every(token => corpus.some(candidate => candidate.includes(token))); + return wanted.every(token => corpus.some(candidate => { + if (/^\d+$/.test(token)) return /^\d+$/.test(candidate) && Number(candidate) === Number(token); + return candidate.includes(token); + })); } function normalizeLanguage(language) { diff --git a/server/services/flexibleChannelSearch.js b/server/services/flexibleChannelSearch.js index 54f519a..703246c 100644 --- a/server/services/flexibleChannelSearch.js +++ b/server/services/flexibleChannelSearch.js @@ -1,7 +1,10 @@ function normalizeSearch(value) { return String(value || '') .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') - .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + .toLowerCase() + .replace(/([a-z])([0-9])/g, '$1 $2') + .replace(/([0-9])([a-z])/g, '$1 $2') + .replace(/[^a-z0-9]+/g, ' ').trim() .split(/\s+/).filter(Boolean) .map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token); } @@ -13,7 +16,10 @@ function matchesSearch(channel, query) { channel.displayName, channel.standardName, channel.fingerprint, ...(channel.variants || []).flatMap(variant => [variant.name, variant.tvgId, variant.categoryName]) ].filter(Boolean).join(' ')); - return wanted.every(token => corpus.some(candidate => candidate.includes(token))); + return wanted.every(token => corpus.some(candidate => { + if (/^\d+$/.test(token)) return /^\d+$/.test(candidate) && Number(candidate) === Number(token); + return candidate.includes(token); + })); } module.exports = { matchesSearch, normalizeSearch }; diff --git a/test/t27BeinSearchRegression.test.js b/test/t27BeinSearchRegression.test.js new file mode 100644 index 0000000..a857c63 --- /dev/null +++ b/test/t27BeinSearchRegression.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const backendSearch = require('../server/services/flexibleChannelSearch'); +const frontendProjection = require('../public/js/services/liveChannelProjection.js'); + +function channel(displayName) { + return { + displayName, + standardName: displayName, + fingerprint: displayName.toLowerCase(), + variants: [{ + name: `${displayName} HD`, + tvgId: displayName.replace(/\s+/g, '.').toLowerCase(), + categoryName: 'Sports', + groupTitle: 'Sports' + }] + }; +} + +test('backend beIN search keeps singular/plural tolerance without matching Max channels', () => { + assert.equal(backendSearch.matchesSearch(channel('beIN Sports 1'), 'bein sports 1'), true); + assert.equal(backendSearch.matchesSearch(channel('beIN Sports 1'), 'Bein Sport 1'), true); + assert.equal(backendSearch.matchesSearch(channel('beIN Sports Max 10'), 'bein sports 1'), false); + assert.equal(backendSearch.matchesSearch(channel('beIN Sports Max 4'), 'bein sports 1'), false); + assert.equal(backendSearch.matchesSearch(channel('beIN Sports 2'), 'bein sports 1'), false); + assert.equal(backendSearch.matchesSearch(channel('beINSports1'), 'bein sports 1'), true); + assert.equal(backendSearch.matchesSearch(channel('beIN Sports 01'), 'bein sports 1'), true); +}); + +test('frontend local beIN search mirrors backend Max-channel exclusion', () => { + assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 1'), 'bein sports 1'), true); + assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 1'), 'Bein Sport 1'), true); + assert.equal(frontendProjection.matchesSearch(channel('beIN Sports Max 10'), 'bein sports 1'), false); + assert.equal(frontendProjection.matchesSearch(channel('beIN Sports Max 4'), 'bein sports 1'), false); + assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 2'), 'bein sports 1'), false); + assert.equal(frontendProjection.matchesSearch(channel('beINSports1'), 'bein sports 1'), true); + assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 01'), 'bein sports 1'), true); +});