(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; 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) { return words(name).filter(token => !QUALITY_TOKENS.has(token)).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 }; });