From 45d1fe29d35f81b96ec285ee86718e35b3d0870f Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 6 Oct 2026 13:08:16 +0200 Subject: [PATCH] fix(live): keep channel search responsive (#25) Filter projected live channels locally in the browser and apply server-side search and language filters before costly enrichment work. Co-Authored-By: Claude Opus 4.8 --- public/index.html | 4 +- public/js/components/ChannelList.js | 59 +++++------ public/js/services/liveChannelProjection.js | 22 +++- server/routes/live.js | 10 +- server/services/liveProjectionService.js | 27 +++-- test/liveProjectionService.test.js | 29 ++++++ test/t25Frontend.test.js | 107 ++++++++++++++++++++ 7 files changed, 210 insertions(+), 48 deletions(-) create mode 100644 test/t25Frontend.test.js diff --git a/public/index.html b/public/index.html index a90da5c..82e34b8 100644 --- a/public/index.html +++ b/public/index.html @@ -1214,9 +1214,9 @@ - + - + diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 5696985..1b541f4 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -189,13 +189,14 @@ class ChannelList { } init() { - // Search handler (debounced) + // Search locally whenever a catalog is already available. let searchTimeout; this.searchInput.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { - this.loadChannels({ refreshLanguages: false }); - }, 300); + if (this.projectedLogicalChannels || this.channels.length) this.render(); + else this.loadChannels({ refreshLanguages: false }); + }, this.projectedLogicalChannels || this.channels.length ? 75 : 250); }); // Source filter handler @@ -205,7 +206,18 @@ class ChannelList { }); document.addEventListener('keydown', event => { - if (event.key === 'Escape') this.closeLanguagePanel(); + if (event.key !== 'Escape') return; + const panel = this.languageFilter?.querySelector('.language-more-panel'); + if (panel && !panel.hidden) { + this.closeLanguagePanel(); + return; + } + if (this.searchInput?.value) { + this.searchInput.value = ''; + clearTimeout(searchTimeout); + this.render(); + this.searchInput.focus(); + } }); this.toggleGroupsBtn?.addEventListener('click', () => { @@ -496,15 +508,10 @@ class ChannelList { const groupedChannels = {}; // 1. Filter - this.filteredChannels = this.logicalChannels; - if (searchTerm && !this.projectedLogicalChannels) { - 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)) || - ch.variants.some(variant => `${variant.language.code} ${variant.language.label}`.toLowerCase().includes(searchTerm)) - ); - } + this.filteredChannels = this.logicalChannels.filter(channel => { + const languageMatches = !this.selectedLanguages.size || this.selectedLanguages.has(channel.language?.code); + return languageMatches && LiveChannelProjection.matchesSearch(channel, searchTerm); + }); let filteredChannels = this.filteredChannels; if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`; @@ -525,7 +532,7 @@ class ChannelList { }); // 3. Add Favorites - const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite); + const favoritedChannels = this.filteredChannels.filter(ch => ch.isFavorite); if (favoritedChannels.length > 0) { favoritedChannels.sort((a, b) => a.name.localeCompare(b.name)); groupedChannels['Favorites'] = favoritedChannels; @@ -1057,33 +1064,22 @@ class ChannelList { async loadProjectedChannels(sourceId = null) { try { - let response = await API.live.getChannels({ + const response = await API.live.getChannels({ sourceId, includeHidden: true, - q: this.searchInput?.value?.trim() || '', - languages: [...this.selectedLanguages] + q: '', + languages: [] }); if (!LiveChannelProjection.canUse(response)) { this.projectedLogicalChannels = null; this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.'; return false; } - let mapped = LiveChannelProjection.mapResponse(response, this.sources); + const mapped = LiveChannelProjection.mapResponse(response, this.sources); this.channels = mapped.channels; this.projectedLogicalChannels = mapped.logicalChannels; this.availableLanguages = mapped.availableLanguages; - if (this.reconcileLanguageSelection()) { - response = await API.live.getChannels({ - sourceId, - includeHidden: true, - q: this.searchInput?.value?.trim() || '', - languages: [...this.selectedLanguages] - }); - mapped = LiveChannelProjection.mapResponse(response, this.sources); - this.channels = mapped.channels; - this.projectedLogicalChannels = mapped.logicalChannels; - this.availableLanguages = mapped.availableLanguages; - } + this.reconcileLanguageSelection(); this.renderLanguageFilter(); this.visibleFavorites = mapped.favorites; this.hiddenItems = mapped.hiddenItems; @@ -1127,7 +1123,8 @@ class ChannelList { sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages])); } catch {} this.renderLanguageFilter(); - this.loadChannels({ refreshLanguages: false }); + if (this.projectedLogicalChannels || this.channels.length) this.render(); + else this.loadChannels({ refreshLanguages: false }); } closeLanguagePanel() { diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index 62adb9b..5c6b885 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -3,6 +3,26 @@ if (typeof module === 'object' && module.exports) module.exports = service; if (root) root.LiveChannelProjection = service; })(typeof globalThis !== 'undefined' ? globalThis : this, function () { + function normalizeSearch(value) { + return String(value || '') + .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + .split(/\s+/).filter(Boolean) + .map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token); + } + + function matchesSearch(channel, query) { + const wanted = normalizeSearch(query); + if (!wanted.length) return true; + const corpus = normalizeSearch([ + channel.displayName, channel.standardName, channel.fingerprint, + ...(channel.variants || []).flatMap(variant => [ + variant.name, variant.tvgId, variant.categoryName, variant.groupTitle + ]) + ].filter(Boolean).join(' ')); + return wanted.every(token => corpus.some(candidate => candidate.includes(token))); + } + function mapResponse(response, sources = []) { if (!response || !Array.isArray(response.channels)) { throw new Error('Invalid live channel projection response'); @@ -89,5 +109,5 @@ return response.channels.length > 0 || !expectedChannelCount; } - return { canUse, mapResponse }; + return { canUse, mapResponse, matchesSearch, normalizeSearch }; }); diff --git a/server/routes/live.js b/server/routes/live.js index 1fcf03a..887dd44 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -46,13 +46,9 @@ router.get('/channels', requireAuth, async (req, res) => { state = await liveProjection.startRebuild().promise; } const epgSources = await sources.getByType('epg'); - const allChannels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id, epgSources }); - const availableLanguages = [...new Map(allChannels.map(channel => [channel.language.code, channel.language])).values()] - .map(language => ({ code: language.code, short: language.short, label: language.label })) - .sort((left, right) => left.label.localeCompare(right.label)); - const channels = allChannels.filter(channel => - (!languages.length || languages.includes(channel.language.code)) && liveProjection.matchesSearch(channel, q) - ); + const { channels, availableLanguages } = liveProjection.getChannelPage({ + sourceId, includeHidden, userId: req.user.id, epgSources, q, languages + }); return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels }); } catch (error) { console.error('[LiveProjection] Failed to serve projected channels:', error.message); diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index 58f0577..89dea9a 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -346,8 +346,10 @@ function parseData(value) { try { return value ? JSON.parse(value) : {}; } catch { return {}; } } -function getChannels({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null) { +function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) { database ||= defaultDatabase(); + const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog; + const epgMatcher = dependencies.epgMatching || epgMatching; const params = []; let sourceFilter = ''; if (sourceId !== null) { @@ -366,18 +368,28 @@ function getChannels({ sourceId = null, includeHidden = false, userId = null, q WHERE p.type = 'live' ${sourceFilter} ORDER BY l.display_name, p.variant_rank `).all(userId ?? -1, ...params); - return serializeChannels(rows, includeHidden).map(channel => { - const match = iptvOrgCatalog.findMatch(database, channel); + const serialized = serializeChannels(rows, includeHidden); + const availableLanguages = [...new Map(serialized.map(channel => [channel.language.code, channel.language])).values()] + .map(language => ({ code: language.code, short: language.short, label: language.label })) + .sort((left, right) => left.label.localeCompare(right.label)); + const filtered = serialized.filter(channel => + (!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) + ); + const channels = filtered.map(channel => { + const match = standardCatalog.findMatch(database, channel); if (match) { channel.standardName = match.name; channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence }; } - const epgMatch = epgMatching.findBestMatch(database, channel, epgSources); + const epgMatch = epgMatcher.findBestMatch(database, channel, epgSources); if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code }; return channel; - }).filter(channel => - (!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) - ); + }); + return { channels, availableLanguages }; +} + +function getChannels(options, database = null) { + return getChannelPage(options, database).channels; } function serializeChannels(rows, includeHidden = false) { @@ -455,6 +467,7 @@ module.exports = { collectIdentityHints, collectOptionalIdentityHints, displayNameForFingerprint, + getChannelPage, getChannels, identityCandidatePairs, normalizeChannelName, diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js index 5193228..de77cca 100644 --- a/test/liveProjectionService.test.js +++ b/test/liveProjectionService.test.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const Database = require('better-sqlite3'); const { normalizeChannelName, + getChannelPage, projectCatalog, markProjectionBuilding, projectionState, @@ -55,6 +56,13 @@ function createProjectionDb() { is_favorite INTEGER DEFAULT 0, data JSON ); + CREATE TABLE favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, + item_type TEXT NOT NULL + ); `); const { ensureColumns } = require('../server/db/liveProjectionSchema'); ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); @@ -260,3 +268,24 @@ test('API serialization preserves an M3U groupTitle over the stored category fal assert.equal(channel.groupTitle, 'M3U Sports France'); assert.equal(channel.variants[0].categoryName, 'M3U Sports France'); }); + +test('search and language filters run before expensive standard and EPG enrichment', () => { + const db = createProjectionDb(); + seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD'); + seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K'); + seedProjectionChannel(db, 'euro-fr', '|FR| Eurosport HD'); + require('../server/services/liveProjectionService').rebuild(db); + + let standardCalls = 0; + let epgCalls = 0; + const page = getChannelPage({ q: 'Bein Sport 1', languages: ['fr'], epgSources: [] }, db, { + iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } }, + epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } } + }); + + assert.equal(page.channels.length, 1); + assert.equal(page.channels[0].language.code, 'fr'); + assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']); + assert.equal(standardCalls, 1); + assert.equal(epgCalls, 1); +}); diff --git a/test/t25Frontend.test.js b/test/t25Frontend.test.js new file mode 100644 index 0000000..7dcf81c --- /dev/null +++ b/test/t25Frontend.test.js @@ -0,0 +1,107 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const root = path.resolve(__dirname, '..'); +const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8'); +const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); + +function createChannelList() { + const dom = new JSDOM(` + + + + +
+
+
+
+ `, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + window.IntersectionObserver = class { observe() {} disconnect() {} }; + window.eval(script('js/icons.js')); + window.eval(script('js/services/channelLanguageDetection.js')); + window.eval(script('js/services/channelLanguageFusion.js')); + window.eval(script('js/services/channelVariantGrouping.js')); + window.eval(script('js/services/liveChannelProjection.js')); + window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`); + window.app = { + player: { settings: { liveChannelListMode: 'flat', languageDetectionMode: 'algo' } }, + epgGuide: { getCurrentProgram: () => null, loadError: null } + }; + return { dom, window, channelList: new window.ChannelList() }; +} + +function projectedResponse() { + const channel = (id, name, language) => ({ + id: `${id}:${language}`, logicalChannelId: id, displayName: name, + language: { code: language, short: language.toUpperCase(), label: language === 'fr' ? 'French' : 'English', confidence: 95 }, + selectedVariantId: `1:${id}-${language}`, + variants: [{ id: `${id}-${language}`, sourceId: 1, name: `${name} ${language.toUpperCase()}`, categoryName: 'Sports', isHidden: false }] + }); + return { + projection: { stale: false }, + filters: { availableLanguages: [ + { code: 'en', short: 'EN', label: 'English' }, + { code: 'fr', short: 'FR', label: 'French' } + ] }, + channels: [ + channel('bein-1', 'beIN Sports 1', 'fr'), + channel('bein-1', 'beIN Sports 1', 'en'), + channel('news', 'World News', 'en') + ] + }; +} + +test('typing filters a loaded projection locally with flexible matching and no API request', async () => { + const { dom, window, channelList } = createChannelList(); + let calls = 0; + window.API = { live: { getChannels: async options => { + calls++; + assert.equal(options.q, ''); + assert.deepEqual([...options.languages], []); + return projectedResponse(); + } } }; + await channelList.loadProjectedChannels(); + channelList.render(); + calls = 0; + + channelList.searchInput.value = 'Bein Sport 1'; + channelList.searchInput.dispatchEvent(new window.Event('input', { bubbles: true })); + await wait(100); + + assert.equal(calls, 0); + assert.equal(channelList.filteredChannels.length, 2); + assert.ok(channelList.filteredChannels.every(channel => channel.displayName === 'beIN Sports 1')); + assert.equal(channelList.listStatus.textContent, '2 channel entries shown'); + dom.window.close(); +}); + +test('language selection filters the complete loaded catalog immediately without API', async () => { + const { dom, window, channelList } = createChannelList(); + let calls = 0; + window.API = { live: { getChannels: async () => { calls++; return projectedResponse(); } } }; + await channelList.loadProjectedChannels(); + channelList.render(); + calls = 0; + + channelList.setLanguageSelection('fr'); + + assert.equal(calls, 0); + assert.equal(channelList.filteredChannels.length, 1); + assert.equal(channelList.filteredChannels[0].language.code, 'fr'); + dom.window.close(); +}); + +test('Escape clears channel search while preserving language-panel Escape behavior', async () => { + const { dom, window, channelList } = createChannelList(); + window.API = { live: { getChannels: async () => projectedResponse() } }; + await channelList.loadProjectedChannels(); + channelList.searchInput.value = 'news'; + window.document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.equal(channelList.searchInput.value, ''); + assert.equal(channelList.filteredChannels.length, 3); + dom.window.close(); +});