feat: detect variant language with confidence scoring and player language selector (#10)

Add a channelLanguageDetection service that scores language evidence
per IPTV variant (country/language prefixes in name and group, source
metadata, original title words) and picks the highest-scored language.
Integrate detected languages into channel variant grouping and add a
language selector to the live player to switch to a variant of the
logical channel in the desired language, keeping raw streams
non-destructive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 09:33:28 +02:00
parent b8849aedda
commit b95a408071
7 changed files with 342 additions and 17 deletions

View File

@ -0,0 +1,66 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const detection = require('../public/js/services/channelLanguageDetection.js');
const grouping = require('../public/js/services/channelVariantGrouping.js');
const channel = (id, name, extra = {}) => ({
id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra
});
test('scores explicit French prefixes with strong confidence', () => {
for (const name of ['|FR| BEIN SPORTS 1 UHD', 'FR-CAR| bein sports 1']) {
const result = detection.detectLanguage(channel(name, name));
assert.equal(result.language.code, 'fr');
assert.match(result.language.status, /medium|high/);
assert.ok(result.languageScores.fr >= 75);
assert.ok(result.languageEvidence.some(item => item.type === 'name-prefix'));
}
});
test('maps UK group evidence to English with medium confidence', () => {
const result = detection.detectLanguage(channel('1', 'Sports One', { groupTitle: 'UK Sports' }));
assert.equal(result.language.code, 'en');
assert.equal(result.language.status, 'medium');
});
test('uses a France source as weak French evidence', () => {
const result = detection.detectLanguage(channel('1', 'Sports One'), { sourceName: 'France IPTV' });
assert.equal(result.language.code, 'fr');
assert.equal(result.language.status, 'low');
});
test('does not force multilingual BE to a language', () => {
const result = detection.detectLanguage(channel('1', 'BE Sports 1'));
assert.equal(result.language.code, 'unknown');
});
test('classifies contradictory close evidence as unknown', () => {
const result = detection.detectLanguage(channel('1', 'French English Sports'));
assert.equal(result.language.code, 'unknown');
assert.equal(result.language.status, 'unknown');
});
test('global and per-channel language preferences resolve tied variants', () => {
const variants = [channel('fr', '|FR| Sports One HD'), channel('en', '|UK| Sports One HD')];
const globalEnglish = grouping.groupChannels(variants, { preferredLanguage: 'en' })[0];
assert.equal(globalEnglish.preferredVariant.id, 'en');
const perChannel = grouping.groupChannels(variants, {
preferredLanguage: 'en',
languagePreferences: new Map([['sports one', 'fr']])
})[0];
assert.equal(perChannel.preferredVariant.id, 'fr');
});
test('explicit language preference beats quality and hidden variants are ignored', () => {
const frenchSd = channel('fr', '|FR| Sports One SD');
const english4k = channel('en', '|UK| Sports One 4K');
const preferredFrench = grouping.groupChannels([frenchSd, english4k], { preferredLanguage: 'fr' })[0];
assert.equal(preferredFrench.preferredVariant.id, 'fr');
const hiddenFrench = grouping.groupChannels([frenchSd, english4k], {
preferredLanguage: 'fr',
isHidden: variant => variant.id === 'fr'
})[0];
assert.equal(hiddenFrench.preferredVariant.id, 'en');
});