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

@ -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
*/

View File

@ -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;
}