Add a languageDetectionMode setting (algo | laya | laya+algo) exposed in Settings with three engine choices. When Laya is enabled, a laya service (Dockerfile.laya, optional compose profile, models stored outside the repo under ./data/models/laya) can be started, with model download and deletion from the UI via /laya routes to manage disk space. Add a channelLanguageFusion service combining Laya results with the existing algorithm, and wire detection mode through grouping, channel list and player. Cover engine selection, fusion and laya service behavior with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
8.5 KiB
JavaScript
183 lines
8.5 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;
|
|
|
|
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 = 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: `logical:${fingerprint}`,
|
|
fingerprint,
|
|
displayName: displayNameForFingerprint(fingerprint),
|
|
name: 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';
|
|
}
|
|
|
|
return {
|
|
normalizeChannelName,
|
|
displayNameForFingerprint,
|
|
getVariantKey,
|
|
getQuality,
|
|
groupChannels,
|
|
orderFlatChannels,
|
|
shouldUseFavoritesGroup
|
|
};
|
|
});
|