feat: group variants of the same IPTV channel into a logical channel (#9)
Add a channelVariantGrouping service that normalizes channel names (strips region prefixes and quality tokens) to fingerprint duplicate stream entries and cluster them under a single logical channel with quality/source variants. Wire it into the Live TV channel list and player variant selection, reducing duplicates without destructively merging streams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
111
public/js/services/channelVariantGrouping.js
Normal file
111
public/js/services/channelVariantGrouping.js
Normal file
@ -0,0 +1,111 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.ChannelVariantGrouping = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
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;
|
||||
|
||||
return variants.slice().sort((a, b) => {
|
||||
const hiddenDifference = Number(options.isHidden(a)) - Number(options.isHidden(b));
|
||||
if (!options.showHidden && hiddenDifference) return hiddenDifference;
|
||||
return qualityScore(b) - qualityScore(a);
|
||||
})[0];
|
||||
}
|
||||
|
||||
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 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, variants]) => {
|
||||
const visibleVariants = variants.filter(variant => !isHidden(variant));
|
||||
const preferredVariant = choosePreferredVariant(variants, {
|
||||
isHidden,
|
||||
showHidden,
|
||||
selectedVariantKey: selectedVariants.get(fingerprint)
|
||||
});
|
||||
return {
|
||||
...preferredVariant,
|
||||
id: `logical:${fingerprint}`,
|
||||
fingerprint,
|
||||
displayName: displayNameForFingerprint(fingerprint),
|
||||
name: displayNameForFingerprint(fingerprint),
|
||||
variants,
|
||||
visibleVariants,
|
||||
preferredVariant,
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
normalizeChannelName,
|
||||
displayNameForFingerprint,
|
||||
getVariantKey,
|
||||
getQuality,
|
||||
groupChannels
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user