From a7a83a6faafc03d20026f550a1fe2ae71fcfae97 Mon Sep 17 00:00:00 2001 From: blomios Date: Fri, 2 Oct 2026 08:25:42 +0200 Subject: [PATCH] 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 --- public/css/main.css | 54 +++++ public/index.html | 3 +- public/js/components/ChannelList.js | 241 +++++++++---------- public/js/components/VideoPlayer.js | 22 +- public/js/pages/LivePage.js | 5 +- public/js/services/channelVariantGrouping.js | 111 +++++++++ test/channelVariantGrouping.test.js | 44 ++++ 7 files changed, 349 insertions(+), 131 deletions(-) create mode 100644 public/js/services/channelVariantGrouping.js create mode 100644 test/channelVariantGrouping.test.js 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'); +});