Files
Nodecast/public/js/services/channelVariantGrouping.js
blomios dbd5b7fea3 fix: group beIN Sports 1 variants and clarify Laya language warning (#15)
beIN Sports 1 variants stayed separated after projection because
names with region prefixes (C Car, C Reu, Thai), time-shift suffixes
(2h/4h/8h/12h) and language markers (En, Fr, Thai) produced distinct
fingerprints. Extend normalization in the server projection service
and the frontend grouping fallback to fold these into one logical
channel, with tests on the exact reported names. Clarify the Laya
language detection warning so a language-matching failure is not
mistaken for a grouping failure, and bump script versions for cache
refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:22:57 +02:00

220 lines
11 KiB
JavaScript

(function (root, factory) {
const detector = typeof module === 'object' && module.exports
? require('./channelLanguageDetection.js')
: root.ChannelLanguageDetection;
const fusion = typeof module === 'object' && module.exports
? require('./channelLanguageFusion.js')
: root.ChannelLanguageFusion;
const service = factory(detector, fusion);
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.ChannelVariantGrouping = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection, languageFusion) {
const QUALITY_TOKENS = new Set([
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
'1080p', '1080i', '720p', '576p', '480p'
]);
const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i;
const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']);
const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']);
function words(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(REGION_PREFIX, '')
.replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ')
.split(/\s+/)
.filter(Boolean);
}
function normalizeChannelName(name) {
const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token));
const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports');
if (brandIndex >= 0) {
const branded = tokens.slice(brandIndex);
const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3])
? 4
: (/^\d+$/.test(branded[2]) ? 3 : 0);
if (numberedBaseLength) {
const suffix = branded.slice(numberedBaseLength);
const removable = suffix.every(token =>
BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token)
);
if (removable) return branded.slice(0, numberedBaseLength).join(' ');
}
return branded.join(' ').trim();
}
return tokens.join(' ').trim();
}
function displayNameForFingerprint(fingerprint) {
return fingerprint.split(' ').map(token => {
if (token === 'bein') return 'beIN';
if (/^\d+$/.test(token)) return token;
return token.charAt(0).toUpperCase() + token.slice(1);
}).join(' ');
}
function getVariantKey(channel) {
return `${channel.sourceId}:${channel.id}`;
}
function getQuality(channel) {
const tokens = words(channel.name);
if (tokens.includes('4k') || tokens.includes('uhd')) return '4K';
if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) return 'Full HD';
if (tokens.includes('hd') || tokens.includes('720p')) return 'HD';
if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return 'SD';
return 'Standard';
}
function qualityScore(channel) {
return { '4K': 4, 'Full HD': 3, HD: 2, Standard: 1, SD: 0 }[getQuality(channel)] ?? 1;
}
function choosePreferredVariant(variants, options) {
const selectedKey = options.selectedVariantKey;
const selected = variants.find(variant => getVariantKey(variant) === selectedKey);
if (selected && (options.showHidden || !options.isHidden(selected))) return selected;
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;
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 = {}) {
const isFavorite = options.isFavorite || (() => false);
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 => {
const fingerprint = channel.logicalFingerprint || normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`;
if (!buckets.has(fingerprint)) buckets.set(fingerprint, []);
buckets.get(fingerprint).push(channel);
});
return Array.from(buckets, ([fingerprint, rawVariants]) => {
const variants = rawVariants.map(variant => {
const algo = languageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
const result = options.languageResults?.get(getVariantKey(variant));
return {
...variant,
...languageFusion.applyLanguageMode(algo, result, options.languageMode || 'algo')
};
});
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),
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: preferredVariant.logicalId || `logical:${fingerprint}`,
fingerprint,
displayName: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint),
name: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint),
variants,
visibleVariants,
preferredVariant,
language: preferredVariant.language,
languageScores,
availableLanguages,
selectedVariantId: getVariantKey(preferredVariant),
isFavorite: variants.some(isFavorite),
isHidden: visibleVariants.length === 0,
groupTitle: preferredVariant.groupTitle,
tvgLogo: preferredVariant.tvgLogo,
tvgId: preferredVariant.tvgId
};
}).filter(channel => showHidden || !channel.isHidden);
}
function orderFlatChannels(channels) {
return channels.map((channel, index) => ({ channel, index })).sort((a, b) => {
const favoriteDifference = Number(b.channel.isFavorite) - Number(a.channel.isFavorite);
return favoriteDifference || a.index - b.index;
}).map(entry => entry.channel);
}
function shouldUseFavoritesGroup(listMode) {
return listMode === 'grouped';
}
function selectVariantByLanguage(logicalChannel, languageCode, options = {}) {
const isFavorite = options.isFavorite || (() => false);
const isHidden = options.isHidden || (() => false);
const visible = (logicalChannel?.variants || []).filter(variant => !isHidden(variant));
if (!visible.length) return null;
const preferredVisible = visible.find(variant => getVariantKey(variant) === getVariantKey(logicalChannel.preferredVariant));
if (!languageCode || languageCode === 'auto') return preferredVisible || visible[0];
const matching = languageCode && languageCode !== 'auto'
? visible.filter(variant => variant.language?.code === languageCode)
: visible;
const candidates = matching.length ? matching : (preferredVisible ? [preferredVisible] : visible);
return candidates.map((variant, index) => ({ variant, index })).sort((a, b) =>
Number(isFavorite(b.variant)) - Number(isFavorite(a.variant)) ||
qualityScore(b.variant) - qualityScore(a.variant) ||
a.index - b.index
)[0].variant;
}
return {
normalizeChannelName,
displayNameForFingerprint,
getVariantKey,
getQuality,
groupChannels,
orderFlatChannels,
selectVariantByLanguage,
shouldUseFavoritesGroup
};
});