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:
@ -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