diff --git a/public/index.html b/public/index.html index 82e34b8..728663b 100644 --- a/public/index.html +++ b/public/index.html @@ -1214,7 +1214,7 @@ - + diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index 5c6b885..763f0ef 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -23,6 +23,18 @@ return wanted.every(token => corpus.some(candidate => candidate.includes(token))); } + function normalizeLanguage(language) { + const rawCode = typeof language?.code === 'string' ? language.code.trim().toLowerCase() : ''; + const code = rawCode || 'und'; + const confidence = Number(language?.confidence); + return { + code, + short: language?.short || (code === 'und' ? 'UND' : code.toUpperCase()), + label: language?.label || (code === 'und' ? 'Unknown' : code.toUpperCase()), + confidence: Number.isFinite(confidence) ? confidence : 0 + }; + } + function mapResponse(response, sources = []) { if (!response || !Array.isArray(response.channels)) { throw new Error('Invalid live channel projection response'); @@ -33,12 +45,15 @@ const hiddenItems = new Set(); const channels = []; const logicalChannels = []; - const hasLanguageViews = response.channels.every(channel => channel?.language && channel?.logicalChannelId); + const hasLanguageViews = response.channels.every(channel => channel?.logicalChannelId); response.channels.forEach(logicalChannel => { - if (!logicalChannel || !Array.isArray(logicalChannel.variants)) return; + if (!logicalChannel) return; + const rawVariants = Array.isArray(logicalChannel.variants) + ? logicalChannel.variants.filter(Boolean) + : (logicalChannel.preferredVariant ? [logicalChannel.preferredVariant] : []); const mappedVariants = []; - logicalChannel.variants.forEach(variant => { + rawVariants.forEach(variant => { const source = sourceById.get(String(variant.sourceId)); const id = String(variant.id); const mapped = { @@ -55,9 +70,7 @@ sourceType: variant.sourceType ?? source?.type ?? 'xtream', sourceName: variant.sourceName ?? source?.name ?? '' }; - if (variant.language || logicalChannel.language) { - mapped.language = variant.language || logicalChannel.language; - } + mapped.language = normalizeLanguage(variant.language || logicalChannel.language); channels.push(mapped); mappedVariants.push(mapped); const key = `${mapped.sourceId}:${id}`; @@ -69,7 +82,7 @@ `${variant.sourceId}:${variant.id}` === String(logicalChannel.selectedVariantId) ) || mappedVariants.find(variant => String(variant.id) === String(logicalChannel.preferredVariant?.id)) || mappedVariants[0]; - const language = logicalChannel.language || preferredVariant.language; + const language = normalizeLanguage(logicalChannel.language || preferredVariant.language); if (hasLanguageViews) logicalChannels.push({ ...logicalChannel, fingerprint: logicalChannel.id, @@ -90,7 +103,7 @@ return { channels, logicalChannels: hasLanguageViews ? logicalChannels : null, favorites, hiddenItems, - availableLanguages: response.filters?.availableLanguages || [], + availableLanguages: (response.filters?.availableLanguages || []).filter(Boolean).map(normalizeLanguage), projection: response.projection || {} }; } @@ -109,5 +122,5 @@ return response.channels.length > 0 || !expectedChannelCount; } - return { canUse, mapResponse, matchesSearch, normalizeSearch }; + return { canUse, mapResponse, matchesSearch, normalizeLanguage, normalizeSearch }; }); diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index 89dea9a..c1913b1 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -5,6 +5,8 @@ const { matchesSearch } = require('./flexibleChannelSearch'); const iptvOrgCatalog = require('./iptvOrgCatalogService'); const epgMatching = require('./epgMatchingService'); +const MAX_SYNCHRONOUS_ENRICHMENTS = 250; + const QUALITY_TOKENS = new Set([ '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', '1080p', '1080i', '720p', '576p', '480p' @@ -357,17 +359,24 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null, params.push(sourceId); } const rows = database.prepare(` - SELECT p.*, c.name AS category_name, COALESCE(c.is_hidden, 0) AS category_hidden, - CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite, + SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite, l.display_name, l.preferred_variant_id FROM playlist_items p JOIN logical_channels l ON l.id = p.logical_channel_id - LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id AND f.item_id = p.item_id AND f.item_type = 'channel' WHERE p.type = 'live' ${sourceFilter} ORDER BY l.display_name, p.variant_rank `).all(userId ?? -1, ...params); + const categoryRows = sourceId === null + ? database.prepare("SELECT source_id, category_id, name, is_hidden FROM categories WHERE type = 'live'").all() + : database.prepare("SELECT source_id, category_id, name, is_hidden FROM categories WHERE type = 'live' AND source_id = ?").all(sourceId); + const categories = new Map(categoryRows.map(category => [`${category.source_id}\0${category.category_id}`, category])); + for (const row of rows) { + const category = categories.get(`${row.source_id}\0${row.category_id}`); + row.category_name = category?.name || null; + row.category_hidden = category?.is_hidden || 0; + } 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 })) @@ -375,7 +384,11 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null, const filtered = serialized.filter(channel => (!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) ); - const channels = filtered.map(channel => { + // Catalog loading must stay bounded: both enrichers perform synchronous SQLite work, + // and EPG matching is especially expensive against large XMLTV catalogs. Enrich only + // a genuinely targeted, bounded search; base projection data remains usable otherwise. + const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS; + const channels = !shouldEnrich ? filtered : filtered.map(channel => { const match = standardCatalog.findMatch(database, channel); if (match) { channel.standardName = match.name; @@ -472,6 +485,7 @@ module.exports = { identityCandidatePairs, normalizeChannelName, markProjectionBuilding, + MAX_SYNCHRONOUS_ENRICHMENTS, matchesSearch, projectCatalog, projectionState, diff --git a/test/liveChannelProjection.test.js b/test/liveChannelProjection.test.js index 9141d8f..0f4c455 100644 --- a/test/liveChannelProjection.test.js +++ b/test/liveChannelProjection.test.js @@ -21,7 +21,8 @@ test('maps projected logical channels to the frontend variant format', () => { isFavorite: true, isHidden: true, logicalId: 'logical:one', logicalFingerprint: 'news', logicalDisplayName: 'News', streamId: '42', tvgLogo: 'logo.png', - url: 'https://stream.test/live', groupTitle: 'News', sourceType: 'm3u', sourceName: 'Local' + url: 'https://stream.test/live', groupTitle: 'News', sourceType: 'm3u', sourceName: 'Local', + language: { code: 'und', short: 'UND', label: 'Unknown', confidence: 0 } }); assert.equal(mapped.favorites.has('7:42'), true); assert.equal(mapped.hiddenItems.has('channel:7:42'), true); diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js index de77cca..e321d89 100644 --- a/test/liveProjectionService.test.js +++ b/test/liveProjectionService.test.js @@ -289,3 +289,37 @@ test('search and language filters run before expensive standard and EPG enrichme assert.equal(standardCalls, 1); assert.equal(epgCalls, 1); }); + +test('full catalog listing skips synchronous per-channel enrichment', () => { + const db = createProjectionDb(); + for (let index = 0; index < 500; index++) { + seedProjectionChannel(db, `channel-${index}`, `Channel ${index} HD`); + } + require('../server/services/liveProjectionService').rebuild(db); + + let standardCalls = 0; + let epgCalls = 0; + const started = Date.now(); + const page = getChannelPage({ languages: [], q: '', epgSources: [{ id: 99, type: 'epg', enabled: true }] }, db, { + iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } }, + epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } } + }); + + assert.equal(page.channels.length, 500); + assert.equal(standardCalls, 0); + assert.equal(epgCalls, 0); + assert.ok(Date.now() - started < 1000); +}); + +test('broad searches above the enrichment guard remain bounded', () => { + const db = createProjectionDb(); + for (let index = 0; index < 300; index++) seedProjectionChannel(db, `news-${index}`, `News ${index}`); + require('../server/services/liveProjectionService').rebuild(db); + let calls = 0; + const page = getChannelPage({ q: 'news', epgSources: [] }, db, { + iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } }, + epgMatching: { findBestMatch: () => { calls += 1; return null; } } + }); + assert.equal(page.channels.length, 300); + assert.equal(calls, 0); +}); diff --git a/test/t26Frontend.test.js b/test/t26Frontend.test.js new file mode 100644 index 0000000..a89ac45 --- /dev/null +++ b/test/t26Frontend.test.js @@ -0,0 +1,69 @@ +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 projection = require('../public/js/services/liveChannelProjection.js'); +const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8'); + +test('minimal non-enriched live projection payload maps and renders without an exception', () => { + const mapped = projection.mapResponse({ + projection: { stale: false }, + filters: { availableLanguages: [null, {}] }, + channels: [ + { + id: 'logical-news:und', logicalChannelId: 'logical-news', displayName: 'News', + standard: null, language: {}, epg: null, preferredVariant: null, + variants: [{ + id: 'news-raw', sourceId: 1, name: 'News Raw', tvgId: 'news.raw', + categoryName: 'News', language: null, isHidden: false + }] + }, + { + id: 'logical-sports:und', logicalChannelId: 'logical-sports', displayName: 'Sports', + language: null, variants: null, + preferredVariant: { + id: 'sports-raw', sourceId: 1, name: 'Sports Raw', tvgId: 'sports.raw', + categoryName: 'Sports', isHidden: false + } + }, + { + id: 'empty', logicalChannelId: 'empty', displayName: 'Empty', + language: null, variants: null, preferredVariant: null + } + ] + }, [{ id: 1, type: 'm3u', name: 'Provider' }]); + + assert.equal(mapped.logicalChannels.length, 2); + assert.ok(mapped.logicalChannels.every(channel => channel.language.code === 'und')); + assert.equal(mapped.logicalChannels[0].tvgId, 'news.raw'); + assert.deepEqual(mapped.availableLanguages.map(language => language.code), ['und']); + + const dom = new JSDOM('
', { url: 'http://nodecast.test/', runScripts: 'outside-only' }); + const { window } = dom; + 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/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`); + window.app = { epgGuide: { getCurrentProgram: () => null, loadError: null } }; + const channelList = Object.create(window.ChannelList.prototype); + channelList.expandedVariants = new Set(); + channelList.sources = [{ id: 1, type: 'm3u', name: 'Provider' }]; + channelList.showHidden = false; + channelList.isFavorite = () => false; + channelList.isHidden = () => false; + + const result = window.document.getElementById('result'); + assert.doesNotThrow(() => { + result.innerHTML = mapped.logicalChannels.map(channel => + channelList.renderLogicalChannel(channel, 'render-id', 'News') + ).join(''); + }); + assert.equal(result.querySelectorAll('.logical-channel').length, 2); + assert.equal(result.querySelector('.channel-language-badge').getAttribute('aria-label'), 'Unknown language'); + assert.match(result.textContent, /No program information/); + dom.window.close(); +});