diff --git a/public/css/main.css b/public/css/main.css
index aa4627a..32f45a1 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -691,7 +691,7 @@ h4 {
.channel-variant {
width: 100%;
display: grid;
- grid-template-columns: minmax(52px, auto) minmax(64px, auto) 1fr;
+ grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr;
gap: var(--space-sm);
padding: 6px var(--space-sm);
border: 0;
@@ -715,6 +715,16 @@ h4 {
font-weight: 600;
}
+.variant-language {
+ justify-self: start;
+ padding: 1px 5px;
+ border-radius: 999px;
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-muted);
+ font-size: 0.68rem;
+ white-space: nowrap;
+}
+
.variant-name {
overflow: hidden;
text-overflow: ellipsis;
diff --git a/public/index.html b/public/index.html
index 1554b27..3239c7d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -10,7 +10,7 @@
nodecast-tv | IPTV Player
-
+
-
-
-
+
+
+
+
diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js
index a8f3f61..c708a73 100644
--- a/public/js/components/ChannelList.js
+++ b/public/js/components/ChannelList.js
@@ -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 ``;
@@ -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
*/
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index 166fed5..198e3ae 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -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 = `
+ Language
+ `;
+ 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 = `${language.label}${selected === language.code ? '✓' : ''}`;
+ 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;
}
diff --git a/public/js/services/channelLanguageDetection.js b/public/js/services/channelLanguageDetection.js
new file mode 100644
index 0000000..5d3f462
--- /dev/null
+++ b/public/js/services/channelLanguageDetection.js
@@ -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 };
+});
diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js
index ace2767..e0bd013 100644
--- a/public/js/services/channelVariantGrouping.js
+++ b/public/js/services/channelVariantGrouping.js
@@ -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,
diff --git a/test/channelLanguageDetection.test.js b/test/channelLanguageDetection.test.js
new file mode 100644
index 0000000..83f8a48
--- /dev/null
+++ b/test/channelLanguageDetection.test.js
@@ -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');
+});