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,84 @@
(function (root, factory) {
const service = factory();
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.ChannelLanguageDetection = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const LANGUAGES = {
fr: { code: 'fr', label: 'French' },
en: { code: 'en', label: 'English' },
es: { code: 'es', label: 'Spanish' }
};
const UNKNOWN = { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' };
const EXPLICIT_CODES = { FR: 'fr', SFR: 'fr', 'FR-CAR': 'fr', EN: 'en', UK: 'en', US: 'en', AU: 'en', ES: 'es' };
const WORDS = {
fr: ['french', 'francais', 'france'],
en: ['english', 'anglais', 'england', 'british'],
es: ['spanish', 'espagnol', 'espanol', 'spain']
};
function normalized(value) {
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
}
function addEvidence(scores, evidence, code, weight, type, value) {
if (!code) return;
scores[code] = (scores[code] || 0) + weight;
evidence.push({ code, weight, type, value: String(value || '') });
}
function findExplicitPrefix(name) {
const match = String(name || '').trim().match(/^(?:\|\s*([A-Z-]{2,8})\s*\||\[\s*([A-Z-]{2,8})\s*\]|([A-Z-]{2,8})\s*\|)/i);
return match ? (match[1] || match[2] || match[3]).toUpperCase() : null;
}
function scoreText(scores, evidence, value, weight, type, includeCountryCodes) {
const text = normalized(value);
if (!text) return;
Object.entries(WORDS).forEach(([code, terms]) => {
if (terms.some(term => new RegExp(`(?:^|[^a-z])${term}(?:$|[^a-z])`, 'i').test(text))) {
addEvidence(scores, evidence, code, weight, type, value);
}
});
if (includeCountryCodes) {
const tokens = text.toUpperCase().split(/[^A-Z]+/).filter(Boolean);
const mapped = tokens.map(token => EXPLICIT_CODES[token]).filter(Boolean);
[...new Set(mapped)].forEach(code => addEvidence(scores, evidence, code, weight, type, value));
}
}
function detectLanguage(variant, context = {}) {
const scores = {};
const evidence = [];
const prefix = findExplicitPrefix(variant.name);
addEvidence(scores, evidence, EXPLICIT_CODES[prefix], 60, 'name-prefix', prefix);
scoreText(scores, evidence, variant.name, 45, 'name-token', false);
scoreText(scores, evidence, variant.groupTitle, 35, 'group', true);
scoreText(scores, evidence, context.sourceName || variant.sourceName, 20, 'source', true);
scoreText(scores, evidence, `${variant.tvgId || ''} ${variant.tvgName || ''}`, 20, 'metadata', true);
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const total = ranked.reduce((sum, entry) => sum + entry[1], 0);
const percentages = Object.fromEntries(ranked.map(([code, score]) => [code, Math.round(score / total * 100)]));
if (!ranked.length || total < 20) {
return { language: { ...UNKNOWN }, languageScores: percentages, languageEvidence: evidence };
}
const [topCode, topScore] = ranked[0];
const topConfidence = percentages[topCode];
const secondConfidence = ranked[1] ? percentages[ranked[1][0]] : 0;
if (topScore < 20 || topConfidence < 45 || topConfidence - secondConfidence < 10) {
return { language: { ...UNKNOWN }, languageScores: percentages, languageEvidence: evidence };
}
let status = 'low';
if (topScore >= 60 && topConfidence >= 75) status = 'high';
else if (topScore >= 35 && topConfidence >= 60) status = 'medium';
return {
language: { ...LANGUAGES[topCode], confidence: topConfidence, status },
languageScores: percentages,
languageEvidence: evidence
};
}
return { LANGUAGES, detectLanguage };
});

View File

@ -1,8 +1,11 @@
(function (root, factory) {
const service = factory();
const detector = typeof module === 'object' && module.exports
? require('./channelLanguageDetection.js')
: root.ChannelLanguageDetection;
const service = factory(detector);
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.ChannelVariantGrouping = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection) {
const QUALITY_TOKENS = new Set([
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
'1080p', '1080i', '720p', '576p', '480p'
@ -55,11 +58,27 @@
const selected = variants.find(variant => getVariantKey(variant) === selectedKey);
if (selected && (options.showHidden || !options.isHidden(selected))) return selected;
return variants.slice().sort((a, b) => {
const requestedLanguageCandidate = options.channelLanguagePreference || options.preferredLanguage;
const requestedLanguage = variants.some(variant => variant.language.code === requestedLanguageCandidate)
? requestedLanguageCandidate
: null;
const majorityLanguage = options.majorityLanguage;
return variants.map((variant, index) => ({ variant, index })).sort((aEntry, bEntry) => {
const a = aEntry.variant;
const b = bEntry.variant;
const hiddenDifference = Number(options.isHidden(a)) - Number(options.isHidden(b));
if (!options.showHidden && hiddenDifference) return hiddenDifference;
return qualityScore(b) - qualityScore(a);
})[0];
if (requestedLanguage && requestedLanguage !== 'auto') {
const languageDifference = Number(b.language.code === requestedLanguage) - Number(a.language.code === requestedLanguage);
if (languageDifference) return languageDifference;
} else if (majorityLanguage) {
const majorityDifference = Number(b.language.code === majorityLanguage) - Number(a.language.code === majorityLanguage);
if (majorityDifference) return majorityDifference;
}
const favoriteDifference = Number(options.isFavorite(b)) - Number(options.isFavorite(a));
if (favoriteDifference) return favoriteDifference;
return qualityScore(b) - qualityScore(a) || aEntry.index - bEntry.index;
})[0].variant;
}
function groupChannels(channels, options = {}) {
@ -67,6 +86,7 @@
const isHidden = options.isHidden || (() => false);
const showHidden = Boolean(options.showHidden);
const selectedVariants = options.selectedVariants || new Map();
const languagePreferences = options.languagePreferences || new Map();
const buckets = new Map();
channels.forEach(channel => {
@ -75,13 +95,41 @@
buckets.get(fingerprint).push(channel);
});
return Array.from(buckets, ([fingerprint, variants]) => {
return Array.from(buckets, ([fingerprint, rawVariants]) => {
const variants = rawVariants.map(variant => ({
...variant,
...languageDetection.detectLanguage(variant, { sourceName: variant.sourceName })
}));
const visibleVariants = variants.filter(variant => !isHidden(variant));
const selectableVariants = showHidden ? variants : visibleVariants;
const languageCounts = selectableVariants.reduce((counts, variant) => {
counts[variant.language.code] = (counts[variant.language.code] || 0) + 1;
return counts;
}, {});
const majorityLanguage = Object.entries(languageCounts)
.filter(([code]) => code !== 'unknown')
.sort((a, b) => b[1] - a[1])[0]?.[0];
const preferredVariant = choosePreferredVariant(variants, {
isHidden,
isFavorite,
showHidden,
selectedVariantKey: selectedVariants.get(fingerprint)
selectedVariantKey: selectedVariants.get(fingerprint),
channelLanguagePreference: languagePreferences.get(fingerprint),
preferredLanguage: options.preferredLanguage,
majorityLanguage
});
const availableLanguages = [...new Map(selectableVariants.map(variant => [
variant.language.code,
variant.language
])).values()];
const aggregateScores = {};
selectableVariants.forEach(variant => Object.entries(variant.languageScores).forEach(([code, score]) => {
aggregateScores[code] = (aggregateScores[code] || 0) + score;
}));
const scoreTotal = Object.values(aggregateScores).reduce((sum, score) => sum + score, 0);
const languageScores = Object.fromEntries(Object.entries(aggregateScores).map(([code, score]) => [
code, Math.round(score / scoreTotal * 100)
]));
return {
...preferredVariant,
id: `logical:${fingerprint}`,
@ -91,6 +139,9 @@
variants,
visibleVariants,
preferredVariant,
language: preferredVariant.language,
languageScores,
availableLanguages,
selectedVariantId: getVariantKey(preferredVariant),
isFavorite: variants.some(isFavorite),
isHidden: visibleVariants.length === 0,