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:
@ -26,6 +26,10 @@ class ChannelList {
|
||||
this.logicalChannels = [];
|
||||
this.selectedVariants = new Map();
|
||||
this.expandedVariants = new Set();
|
||||
this.preferredLanguageKey = 'nodecast_tv_preferred_language';
|
||||
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
|
||||
this.preferredLanguage = this.loadPreferredLanguage();
|
||||
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
|
||||
|
||||
this.loadCollapsedState();
|
||||
this.init();
|
||||
@ -62,6 +66,32 @@ class ChannelList {
|
||||
}
|
||||
}
|
||||
|
||||
loadPreferredLanguage() {
|
||||
try {
|
||||
return localStorage.getItem(this.preferredLanguageKey) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
loadChannelLanguagePreferences() {
|
||||
try {
|
||||
return new Map(Object.entries(JSON.parse(localStorage.getItem(this.channelLanguagePreferencesKey) || '{}')));
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
saveLanguagePreferences() {
|
||||
try {
|
||||
if (this.preferredLanguage) localStorage.setItem(this.preferredLanguageKey, this.preferredLanguage);
|
||||
else localStorage.removeItem(this.preferredLanguageKey);
|
||||
localStorage.setItem(this.channelLanguagePreferencesKey, JSON.stringify(Object.fromEntries(this.channelLanguagePreferences)));
|
||||
} catch (err) {
|
||||
console.warn('[ChannelList] Unable to persist language preferences:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save collapsed state to localStorage
|
||||
*/
|
||||
@ -302,6 +332,8 @@ class ChannelList {
|
||||
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
|
||||
showHidden,
|
||||
selectedVariants: this.selectedVariants,
|
||||
preferredLanguage: this.preferredLanguage,
|
||||
languagePreferences: this.channelLanguagePreferences,
|
||||
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
|
||||
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
|
||||
});
|
||||
@ -320,7 +352,8 @@ class ChannelList {
|
||||
this.filteredChannels = this.logicalChannels.filter(ch =>
|
||||
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
|
||||
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
|
||||
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm))
|
||||
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) ||
|
||||
ch.variants.some(variant => `${variant.language.code} ${variant.language.label}`.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
@ -584,10 +617,18 @@ class ChannelList {
|
||||
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
|
||||
if (hidden && !this.showHidden) return '';
|
||||
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
|
||||
const language = variant.language;
|
||||
const languageLabel = language.code === 'unknown'
|
||||
? 'Unknown'
|
||||
: `${language.code.toUpperCase()} ${language.confidence}%`;
|
||||
const languageDescription = language.code === 'unknown'
|
||||
? 'Unknown language'
|
||||
: `${language.label} — ${language.confidence}% confidence`;
|
||||
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''}" type="button"
|
||||
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
|
||||
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
|
||||
<span class="variant-quality">${ChannelVariantGrouping.getQuality(variant)}</span>
|
||||
<span class="variant-language" title="${this.escapeHtml(languageDescription)}" aria-label="${this.escapeHtml(languageDescription)}">${languageLabel}</span>
|
||||
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
||||
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
||||
</button>`;
|
||||
@ -774,7 +815,8 @@ class ChannelList {
|
||||
id: `xtream_${sourceId}_${cat.category_id}`,
|
||||
name: cat.category_name,
|
||||
sourceId,
|
||||
sourceType: 'xtream'
|
||||
sourceType: 'xtream',
|
||||
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
|
||||
}));
|
||||
|
||||
this.groups = this.groups.concat(categoryGroups);
|
||||
@ -790,7 +832,8 @@ class ChannelList {
|
||||
// Use string comparison to handle type mismatches (number vs string category_id)
|
||||
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
|
||||
sourceId,
|
||||
sourceType: 'xtream'
|
||||
sourceType: 'xtream',
|
||||
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
|
||||
}));
|
||||
|
||||
this.channels = this.channels.concat(channelList);
|
||||
@ -815,7 +858,8 @@ class ChannelList {
|
||||
id: `m3u_${sourceId}_${cat.category_id}`,
|
||||
name: cat.category_name,
|
||||
sourceId,
|
||||
sourceType: 'm3u'
|
||||
sourceType: 'm3u',
|
||||
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
|
||||
}));
|
||||
|
||||
this.groups = this.groups.concat(m3uGroups);
|
||||
@ -831,7 +875,8 @@ class ChannelList {
|
||||
groupId: `m3u_${sourceId}_${stream.category_id}`,
|
||||
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
|
||||
sourceId,
|
||||
sourceType: 'm3u'
|
||||
sourceType: 'm3u',
|
||||
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
|
||||
}));
|
||||
|
||||
this.channels = this.channels.concat(channelList);
|
||||
@ -1168,10 +1213,33 @@ class ChannelList {
|
||||
|
||||
// Play channel
|
||||
if (window.app?.player) {
|
||||
window.app.player.updateLanguageControl?.(logicalChannel);
|
||||
window.app.player.play(channel, streamUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async selectLanguageForCurrent(languageCode) {
|
||||
if (!this.currentChannel) return;
|
||||
const fingerprint = this.currentChannel.fingerprint;
|
||||
if (!languageCode || languageCode === 'auto') {
|
||||
this.channelLanguagePreferences.delete(fingerprint);
|
||||
this.preferredLanguage = null;
|
||||
} else {
|
||||
this.channelLanguagePreferences.set(fingerprint, languageCode);
|
||||
this.preferredLanguage = languageCode;
|
||||
}
|
||||
this.selectedVariants.delete(fingerprint);
|
||||
this.saveLanguagePreferences();
|
||||
this.render();
|
||||
const logicalChannel = this.logicalChannels.find(channel => channel.fingerprint === fingerprint);
|
||||
if (!logicalChannel) return;
|
||||
await this.selectChannel({
|
||||
logicalId: fingerprint,
|
||||
variantId: logicalChannel.preferredVariant.id,
|
||||
sourceId: logicalChannel.preferredVariant.sourceId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu
|
||||
*/
|
||||
|
||||
@ -35,6 +35,7 @@ class VideoPlayer {
|
||||
this.qualitySwitchInProgress = false;
|
||||
this.originalStreamUrl = null;
|
||||
this.sourceStreamInfo = null;
|
||||
this.currentLogicalChannel = null;
|
||||
|
||||
// Settings - start with defaults, load from server async
|
||||
this.settings = this.getDefaultSettings();
|
||||
@ -365,6 +366,7 @@ class VideoPlayer {
|
||||
const overflowMenu = document.getElementById('player-overflow-menu');
|
||||
|
||||
this.initQualityControl(overflowMenu);
|
||||
this.initLanguageControl(overflowMenu);
|
||||
|
||||
btnOverflow?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@ -465,6 +467,49 @@ class VideoPlayer {
|
||||
this.renderQualityControl();
|
||||
}
|
||||
|
||||
initLanguageControl(overflowMenu) {
|
||||
if (!overflowMenu || document.getElementById('player-language-control')) return;
|
||||
const control = document.createElement('div');
|
||||
control.id = 'player-language-control';
|
||||
control.hidden = true;
|
||||
control.setAttribute('role', 'group');
|
||||
control.setAttribute('aria-label', 'Language');
|
||||
control.style.borderTop = '1px solid var(--glass-border)';
|
||||
control.innerHTML = `
|
||||
<div id="player-language-label" style="padding: .55rem 1rem .25rem; font-size: .75rem; text-transform: uppercase; color: var(--color-text-muted);">Language</div>
|
||||
<div id="player-language-options" role="menu" aria-labelledby="player-language-label"></div>`;
|
||||
overflowMenu.append(control);
|
||||
}
|
||||
|
||||
updateLanguageControl(logicalChannel) {
|
||||
this.currentLogicalChannel = logicalChannel;
|
||||
const control = document.getElementById('player-language-control');
|
||||
const list = document.getElementById('player-language-options');
|
||||
if (!control || !list) return;
|
||||
const languages = logicalChannel?.availableLanguages || [];
|
||||
control.hidden = languages.length < 2;
|
||||
list.innerHTML = '';
|
||||
if (control.hidden) return;
|
||||
|
||||
const channelList = window.app?.channelList;
|
||||
const selected = channelList?.channelLanguagePreferences?.get(logicalChannel.fingerprint) ||
|
||||
channelList?.preferredLanguage || 'auto';
|
||||
[{ code: 'auto', label: 'Auto' }, ...languages].forEach(language => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'overflow-menu-item';
|
||||
button.setAttribute('role', 'menuitemradio');
|
||||
button.setAttribute('aria-checked', String(selected === language.code));
|
||||
button.style.justifyContent = 'space-between';
|
||||
button.innerHTML = `<span>${language.label}</span><span aria-hidden="true">${selected === language.code ? '✓' : ''}</span>`;
|
||||
button.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
await window.app?.channelList?.selectLanguageForCurrent(language.code);
|
||||
});
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
qualityLabel(mode) {
|
||||
return ({ auto: 'Auto', source: 'Originale', '1080p': '1080p', '720p': '720p', '480p': '480p' })[mode] || mode;
|
||||
}
|
||||
|
||||
84
public/js/services/channelLanguageDetection.js
Normal file
84
public/js/services/channelLanguageDetection.js
Normal 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 };
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user