diff --git a/public/css/main.css b/public/css/main.css
index 3177580..aa4627a 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -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
===================================================== */
diff --git a/public/index.html b/public/index.html
index 5f89d4a..1554b27 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1125,7 +1125,8 @@
-
+
+
diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js
index 5147892..a8f3f61 100644
--- a/public/js/components/ChannelList.js
+++ b/public/js/components/ChannelList.js
@@ -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 += `
-
-
})
-
-
${this.escapeHtml(channel.name)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
-
-
-
- `;
+ html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive);
}
html += '';
}
@@ -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 ``;
+ }).join('');
+ return `
+
+
})
+
${this.escapeHtml(channel.displayName)}
+
${this.escapeHtml(this.getProgramInfo(channel) || '')}
+ ${channel.variants.length > 1 ? `
` : ''}
+
+
+ ${channel.variants.length > 1 ? `
${variants}
` : ''}
+
`;
}
/**
@@ -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 += `
-
-
})
-
-
${this.escapeHtml(channel.name)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
-
-
-
- `;
+ 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;
}
}
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index 51d4f9a..166fed5 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -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
+ });
}
/**
diff --git a/public/js/pages/LivePage.js b/public/js/pages/LivePage.js
index 908840c..b61238e 100644
--- a/public/js/pages/LivePage.js
+++ b/public/js/pages/LivePage.js
@@ -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');
diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js
new file mode 100644
index 0000000..ace2767
--- /dev/null
+++ b/public/js/services/channelVariantGrouping.js
@@ -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
+ };
+});
diff --git a/test/channelVariantGrouping.test.js b/test/channelVariantGrouping.test.js
new file mode 100644
index 0000000..6d8ca7c
--- /dev/null
+++ b/test/channelVariantGrouping.test.js
@@ -0,0 +1,44 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const grouping = require('../public/js/services/channelVariantGrouping.js');
+
+const channel = (id, name, extra = {}) => ({ id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra });
+
+test('normalizes regional prefixes and quality markers into one logical channel', () => {
+ const names = ['|FR| BEIN SPORTS uhd 1', '|FR| BEIN SPORTS 1 4K', 'FR-CAR| bein sports 1'];
+ assert.deepEqual(names.map(grouping.normalizeChannelName), ['bein sports 1', 'bein sports 1', 'bein sports 1']);
+ const logical = grouping.groupChannels(names.map((name, index) => channel(String(index + 1), name)));
+ assert.equal(logical.length, 1);
+ assert.equal(logical[0].displayName, 'beIN Sports 1');
+ assert.equal(logical[0].variants.length, 3);
+});
+
+test('does not merge distinct numbered channels', () => {
+ const logical = grouping.groupChannels([
+ channel('1', 'beIN Sports 1 HD'),
+ channel('2', 'beIN Sports 2 HD')
+ ]);
+ assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']);
+});
+
+test('logical favorite and hidden state are derived from individual streams', () => {
+ const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')];
+ const logical = grouping.groupChannels(variants, {
+ isFavorite: item => item.id === '1',
+ isHidden: item => item.id === '2'
+ })[0];
+ assert.equal(logical.isFavorite, true);
+ assert.equal(logical.isHidden, false);
+ assert.equal(logical.preferredVariant.id, '1');
+
+ assert.equal(grouping.groupChannels(variants, { isHidden: () => true }).length, 0);
+ assert.equal(grouping.groupChannels(variants, { isHidden: () => true, showHidden: true })[0].isHidden, true);
+});
+
+test('an explicit visible selection overrides the automatic quality preference', () => {
+ const hd = channel('1', 'BEIN SPORTS 1 HD');
+ const uhd = channel('2', 'BEIN SPORTS 1 UHD');
+ const selectedVariants = new Map([['bein sports 1', grouping.getVariantKey(hd)]]);
+ const logical = grouping.groupChannels([hd, uhd], { selectedVariants })[0];
+ assert.equal(logical.preferredVariant.id, '1');
+});