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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user