fix(live): prevent analysis crash on large catalogs (#26)

Bound synchronous live channel enrichment and tolerate minimal projection payloads so analysis and listings do not blank the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 15:13:48 +02:00
parent de1d3d9a6e
commit 63a5946e75
6 changed files with 146 additions and 15 deletions

View File

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

View File

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

69
test/t26Frontend.test.js Normal file
View File

@ -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('<div id="result"></div>', { 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();
});