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}`; + 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'); +});