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:
blomios
2026-10-02 08:25:42 +02:00
parent e1342348d1
commit a7a83a6faa
7 changed files with 349 additions and 131 deletions

View File

@ -667,6 +667,60 @@ h4 {
text-overflow: ellipsis;
}
.variant-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--color-text-muted);
font-size: 0.72rem;
white-space: nowrap;
cursor: pointer;
}
.logical-channel.expanded .variant-chevron {
transform: rotate(180deg);
}
.channel-variants {
margin: 0 var(--space-md) var(--space-xs) 56px;
border-left: 1px solid var(--color-border);
}
.channel-variant {
width: 100%;
display: grid;
grid-template-columns: minmax(52px, auto) minmax(64px, auto) 1fr;
gap: var(--space-sm);
padding: 6px var(--space-sm);
border: 0;
background: transparent;
color: var(--color-text-muted);
text-align: left;
cursor: pointer;
}
.channel-variant:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.channel-variant.is-hidden {
opacity: 0.5;
}
.variant-quality {
color: var(--color-accent);
font-weight: 600;
}
.variant-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* =====================================================
Video Player Section
===================================================== */

View File

@ -1125,7 +1125,8 @@
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script>
<script src="/js/components/VideoPlayer.js?v=2"></script>
<script src="/js/components/ChannelList.js?v=3"></script>
<script src="/js/services/channelVariantGrouping.js?v=1"></script>
<script src="/js/components/ChannelList.js?v=4"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
<script src="/js/pages/HomePage.js?v=2"></script>

View File

@ -23,6 +23,9 @@ class ChannelList {
this.sources = [];
this.isLoading = false;
this.renderedChannels = [];
this.logicalChannels = [];
this.selectedVariants = new Map();
this.expandedVariants = new Set();
this.loadCollapsedState();
this.init();
@ -216,14 +219,7 @@ class ChannelList {
// Find all visible channel items and update their program info
const channelItems = this.container.querySelectorAll('.channel-item');
channelItems.forEach(item => {
const channelId = item.dataset.channelId;
const sourceId = item.dataset.sourceId;
// Find the channel data
const channel = this.channels.find(c =>
String(c.id) === String(channelId) &&
String(c.sourceId) === String(sourceId)
);
const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId);
if (channel) {
const programInfo = this.getProgramInfo(channel);
@ -246,7 +242,8 @@ class ChannelList {
// Cache key: channel_id + current_minute (invalidate every minute)
const currentMinute = Math.floor(Date.now() / 60000);
const cacheKey = `${channel.tvgId || channel.name}:${currentMinute}`;
const variant = channel.preferredVariant || channel;
const cacheKey = `${variant.tvgId || variant.name}:${currentMinute}`;
if (this._programInfoCache && this._programInfoCache.has(cacheKey)) {
return this._programInfoCache.get(cacheKey);
@ -258,7 +255,13 @@ class ChannelList {
this._lastCacheMinute = currentMinute;
}
const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name);
let program = window.app.epgGuide.getCurrentProgram(variant.tvgId, variant.name);
if (!program && channel.variants) {
for (const fallback of channel.variants) {
program = window.app.epgGuide.getCurrentProgram(fallback.tvgId, fallback.name);
if (program) break;
}
}
const result = program ? program.title : null;
this._programInfoCache.set(cacheKey, result);
@ -296,6 +299,13 @@ class ChannelList {
const searchTerm = this.searchInput.value.toLowerCase();
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
showHidden,
selectedVariants: this.selectedVariants,
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
});
// Reset batching
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
@ -305,11 +315,12 @@ class ChannelList {
const groupedChannels = {};
// 1. Filter
this.filteredChannels = this.channels;
this.filteredChannels = this.logicalChannels;
if (searchTerm) {
this.filteredChannels = this.channels.filter(ch =>
String(ch.name ?? "").toLowerCase().includes(searchTerm) ||
String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm)
this.filteredChannels = this.logicalChannels.filter(ch =>
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm))
);
}
@ -325,7 +336,7 @@ class ChannelList {
});
// 3. Add Favorites
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite);
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
@ -344,9 +355,7 @@ class ChannelList {
const channels = groupedChannels[groupName];
// Check if any channel in this group is visible
return channels.some(channel => {
const rawChannelId = channel.streamId || channel.id;
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !isHidden || showHidden;
return !channel.isHidden || showHidden;
});
});
@ -373,9 +382,7 @@ class ChannelList {
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
return !channel.isHidden || this.showHidden;
});
// Assign unique render IDs for linear navigation
@ -454,9 +461,7 @@ class ChannelList {
// Pre-filter visible channels for this group
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
return !channel.isHidden || this.showHidden;
});
// Skip group if no visible channels (derived visibility)
@ -487,39 +492,14 @@ class ChannelList {
for (const channel of visibleChannels) {
// Check hidden again for styling (showHidden mode)
const rawChannelId = channel.streamId || channel.id;
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
const isActive = this.currentChannel?.id === channel.id;
// Check if this specific instance is the "active" one for navigation purposes
const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId;
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
renderIndex++;
html += `
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}"
data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
</div>
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
</button>
</div>
`;
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive);
}
html += '</div></div>';
}
@ -565,9 +545,13 @@ class ChannelList {
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
}
this.attachChannelListeners(groupEl);
}
attachChannelListeners(groupEl) {
groupEl.querySelectorAll('.channel-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) return;
if (e.target.closest('.favorite-btn') || e.target.closest('.variant-toggle')) return;
this.selectChannel(item.dataset);
});
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
@ -576,10 +560,51 @@ class ChannelList {
if (favBtn) {
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId);
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.variantId);
});
}
});
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => {
e.stopPropagation();
const fingerprint = button.closest('.channel-item').dataset.logicalId;
this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint);
this.render();
}));
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
e.stopPropagation();
this.selectChannel(item.dataset);
}));
}
renderLogicalChannel(channel, renderId, renderGroup, isActive = false, isRenderActive = false) {
const selected = channel.preferredVariant;
const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id);
const expanded = this.expandedVariants.has(channel.fingerprint);
const variants = channel.variants.map(variant => {
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
if (hidden && !this.showHidden) return '';
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''}" type="button"
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
<span class="variant-quality">${ChannelVariantGrouping.getQuality(variant)}</span>
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
</button>`;
}).join('');
return `<div class="logical-channel ${expanded ? 'expanded' : ''}">
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channel.isHidden ? 'hidden' : ''}"
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(selected.id))}"
data-source-id="${selected.sourceId}" data-stream-id="${selected.streamId || ''}"
data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div></div>
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
</div>
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
</div>`;
}
/**
@ -594,17 +619,12 @@ class ChannelList {
// Filter visible channels
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
const rawChannelId = channel.streamId || channel.id;
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
return !channelHidden || this.showHidden;
return !channel.isHidden || this.showHidden;
});
let html = '';
for (const channel of visibleChannels) {
const rawChannelId = channel.streamId || channel.id;
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
const isActive = this.currentChannel?.id === channel.id;
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
// Find the matching rendered channel to get its unique IDs
const renderedChannel = this.renderedChannels.find(rc =>
@ -613,46 +633,12 @@ class ChannelList {
const renderId = renderedChannel?._renderId || '';
const renderGroup = renderedChannel?._renderGroup || groupName;
html += `
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
data-channel-id="${channel.id}"
data-source-id="${channel.sourceId}"
data-source-type="${channel.sourceType}"
data-stream-id="${channel.streamId || ''}"
data-url="${channel.url || ''}"
data-render-id="${renderId}"
data-render-group="${renderGroup}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
</div>
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
</button>
</div>
`;
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive);
}
container.innerHTML = html;
// Attach listeners to the new channel items
container.querySelectorAll('.channel-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) return;
this.selectChannel(item.dataset);
});
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
const favBtn = item.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId);
});
}
});
this.attachChannelListeners(container);
}
/**
@ -927,6 +913,7 @@ class ChannelList {
if (channel) {
this.updateFavoritesGroup(channel, !wasFavorite);
}
this.render();
// Do NOT call this.render() - it causes lag
// Perform API call
@ -962,6 +949,7 @@ class ChannelList {
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) this.updateFavoritesGroup(channel, false);
}
this.render();
}
}
@ -1074,10 +1062,21 @@ class ChannelList {
* Select and play a channel
*/
async selectChannel(dataset) {
const channel = this.channels.find(c => c.id === dataset.channelId);
if (!channel) return;
const logicalChannel = this.logicalChannels.find(c => c.fingerprint === dataset.logicalId) ||
this.renderedChannels.find(c => c.fingerprint === dataset.logicalId) ||
this.logicalChannels.find(c => c.variants.some(variant =>
String(variant.id) === String(dataset.channelId) &&
(!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId))
));
if (!logicalChannel) return;
const channel = logicalChannel.variants.find(variant =>
String(variant.id) === String(dataset.variantId || dataset.channelId) &&
(!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId))
) || logicalChannel.preferredVariant;
this.selectedVariants.set(logicalChannel.fingerprint, ChannelVariantGrouping.getVariantKey(channel));
logicalChannel.preferredVariant = channel;
this.currentChannel = channel;
this.currentChannel = logicalChannel;
this.currentRenderId = dataset.renderId; // Track which visual instance is active
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
@ -1106,7 +1105,8 @@ class ChannelList {
// Fallback checks if still not found
if (!activeItem) {
activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
activeItem = Array.from(this.container.querySelectorAll('.channel-item'))
.find(item => item.dataset.logicalId === logicalChannel.fingerprint);
// If we fell back to channel ID, update currentRenderId to match what we found
if (activeItem && activeItem.dataset.renderId) {
this.currentRenderId = activeItem.dataset.renderId;
@ -1177,10 +1177,17 @@ class ChannelList {
*/
showContextMenu(e, type, data) {
e.preventDefault();
const logicalChannel = type === 'channel'
? this.logicalChannels.find(channel => channel.fingerprint === data.logicalId)
: null;
const variant = logicalChannel?.variants.find(item =>
String(item.id) === String(data.variantId) && String(item.sourceId) === String(data.sourceId)
) || logicalChannel?.preferredVariant;
this.contextMenu.dataset.type = type;
this.contextMenu.dataset.sourceId = data.sourceId;
this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId;
this.contextMenu.dataset.streamId = data.streamId || '';
this.contextMenu.dataset.sourceId = variant?.sourceId ?? data.sourceId;
this.contextMenu.dataset.itemId = type === 'group' ? data.group : (variant?.id || data.variantId);
this.contextMenu.dataset.streamId = variant?.streamId || data.streamId || '';
this.contextMenu.dataset.logicalId = data.logicalId || '';
this.contextMenu.style.left = `${e.clientX}px`;
this.contextMenu.style.top = `${e.clientY}px`;
@ -1199,15 +1206,12 @@ class ChannelList {
*/
async handleContextAction(e) {
const action = e.target.dataset.action;
const { type, sourceId, itemId, streamId } = this.contextMenu.dataset;
const { type, sourceId, itemId, streamId, logicalId } = this.contextMenu.dataset;
switch (action) {
case 'play':
if (type === 'channel') {
const channel = this.channels.find(c => c.id === itemId);
if (channel) {
await this.selectChannel({ channelId: channel.id });
}
await this.selectChannel({ logicalId, variantId: itemId, sourceId });
}
break;
case 'hide':
@ -1345,6 +1349,7 @@ class ChannelList {
if (channel) {
this.updateFavoritesGroup(channel, isFavorite);
}
this.render();
}
/**
@ -1382,11 +1387,9 @@ class ChannelList {
const nextChannel = this.renderedChannels[nextIndex];
this.selectChannel({
channelId: nextChannel.id,
sourceId: nextChannel.sourceId,
sourceType: nextChannel.sourceType,
streamId: nextChannel.streamId,
url: nextChannel.url,
logicalId: nextChannel.fingerprint,
variantId: nextChannel.preferredVariant.id,
sourceId: nextChannel.preferredVariant.sourceId,
renderId: nextChannel._renderId // Pass the unique render ID
});
}
@ -1425,11 +1428,9 @@ class ChannelList {
const prevChannel = this.renderedChannels[prevIndex];
this.selectChannel({
channelId: prevChannel.id,
sourceId: prevChannel.sourceId,
sourceType: prevChannel.sourceType,
streamId: prevChannel.streamId,
url: prevChannel.url,
logicalId: prevChannel.fingerprint,
variantId: prevChannel.preferredVariant.id,
sourceId: prevChannel.preferredVariant.sourceId,
renderId: prevChannel._renderId
});
}
@ -1449,13 +1450,7 @@ class ChannelList {
* Get list of visible (non-hidden) channels in display order
*/
getVisibleChannels() {
const showHidden = this.showHiddenCheckbox?.checked ?? false;
return this.channels.filter(ch => {
if (showHidden) return true;
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
return !channelHidden && !groupHidden;
});
return this.logicalChannels;
}
}

View File

@ -1819,11 +1819,18 @@ class VideoPlayer {
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
? channels.findIndex(c => c.variants.some(variant =>
variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId
))
: -1;
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
const previous = channels[prevIdx];
window.app.channelList.selectChannel({
logicalId: previous.fingerprint,
variantId: previous.preferredVariant.id,
sourceId: previous.preferredVariant.sourceId
});
}
/**
@ -1835,11 +1842,18 @@ class VideoPlayer {
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
? channels.findIndex(c => c.variants.some(variant =>
variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId
))
: -1;
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
const next = channels[nextIdx];
window.app.channelList.selectChannel({
logicalId: next.fingerprint,
variantId: next.preferredVariant.id,
sourceId: next.preferredVariant.sourceId
});
}
/**

View File

@ -36,7 +36,7 @@ class LivePage {
// Build a map for O(1) channel lookups
const channelMap = new Map();
this.app.channelList.channels.forEach(c => channelMap.set(c.id, c));
this.app.channelList.logicalChannels.forEach(c => channelMap.set(c.fingerprint, c));
// Process in small batches to avoid blocking UI
const BATCH_SIZE = 50;
@ -47,8 +47,7 @@ class LivePage {
for (let i = index; i < end; i++) {
const item = channelItems[i];
const channelId = item.dataset.channelId;
const channel = channelMap.get(channelId);
const channel = channelMap.get(item.dataset.logicalId);
if (channel) {
const programDiv = item.querySelector('.channel-program');

View 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
};
});