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

@ -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;
}
}