Merge branch 'hotfix/t26-live-analysis-blank-crash' into develop (#26)

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

View File

@ -1214,7 +1214,7 @@
<script src="/js/services/channelLanguageFusion.js?v=1"></script> <script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/layaStatusPresentation.js?v=5"></script> <script src="/js/services/layaStatusPresentation.js?v=5"></script>
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script> <script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
<script src="/js/services/liveChannelProjection.js?v=3"></script> <script src="/js/services/liveChannelProjection.js?v=4"></script>
<script src="/js/services/channelVariantGrouping.js?v=7"></script> <script src="/js/services/channelVariantGrouping.js?v=7"></script>
<script src="/js/components/ChannelList.js?v=13"></script> <script src="/js/components/ChannelList.js?v=13"></script>
<script src="/js/components/SourceManager.js?v=4"></script> <script src="/js/components/SourceManager.js?v=4"></script>

View File

@ -23,6 +23,18 @@
return wanted.every(token => corpus.some(candidate => candidate.includes(token))); 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 = []) { function mapResponse(response, sources = []) {
if (!response || !Array.isArray(response.channels)) { if (!response || !Array.isArray(response.channels)) {
throw new Error('Invalid live channel projection response'); throw new Error('Invalid live channel projection response');
@ -33,12 +45,15 @@
const hiddenItems = new Set(); const hiddenItems = new Set();
const channels = []; const channels = [];
const logicalChannels = []; const logicalChannels = [];
const hasLanguageViews = response.channels.every(channel => channel?.language && channel?.logicalChannelId); const hasLanguageViews = response.channels.every(channel => channel?.logicalChannelId);
response.channels.forEach(logicalChannel => { 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 = []; const mappedVariants = [];
logicalChannel.variants.forEach(variant => { rawVariants.forEach(variant => {
const source = sourceById.get(String(variant.sourceId)); const source = sourceById.get(String(variant.sourceId));
const id = String(variant.id); const id = String(variant.id);
const mapped = { const mapped = {
@ -55,9 +70,7 @@
sourceType: variant.sourceType ?? source?.type ?? 'xtream', sourceType: variant.sourceType ?? source?.type ?? 'xtream',
sourceName: variant.sourceName ?? source?.name ?? '' sourceName: variant.sourceName ?? source?.name ?? ''
}; };
if (variant.language || logicalChannel.language) { mapped.language = normalizeLanguage(variant.language || logicalChannel.language);
mapped.language = variant.language || logicalChannel.language;
}
channels.push(mapped); channels.push(mapped);
mappedVariants.push(mapped); mappedVariants.push(mapped);
const key = `${mapped.sourceId}:${id}`; const key = `${mapped.sourceId}:${id}`;
@ -69,7 +82,7 @@
`${variant.sourceId}:${variant.id}` === String(logicalChannel.selectedVariantId) `${variant.sourceId}:${variant.id}` === String(logicalChannel.selectedVariantId)
) || mappedVariants.find(variant => String(variant.id) === String(logicalChannel.preferredVariant?.id)) ) || mappedVariants.find(variant => String(variant.id) === String(logicalChannel.preferredVariant?.id))
|| mappedVariants[0]; || mappedVariants[0];
const language = logicalChannel.language || preferredVariant.language; const language = normalizeLanguage(logicalChannel.language || preferredVariant.language);
if (hasLanguageViews) logicalChannels.push({ if (hasLanguageViews) logicalChannels.push({
...logicalChannel, ...logicalChannel,
fingerprint: logicalChannel.id, fingerprint: logicalChannel.id,
@ -90,7 +103,7 @@
return { return {
channels, logicalChannels: hasLanguageViews ? logicalChannels : null, favorites, hiddenItems, channels, logicalChannels: hasLanguageViews ? logicalChannels : null, favorites, hiddenItems,
availableLanguages: response.filters?.availableLanguages || [], availableLanguages: (response.filters?.availableLanguages || []).filter(Boolean).map(normalizeLanguage),
projection: response.projection || {} projection: response.projection || {}
}; };
} }
@ -109,5 +122,5 @@
return response.channels.length > 0 || !expectedChannelCount; return response.channels.length > 0 || !expectedChannelCount;
} }
return { canUse, mapResponse, matchesSearch, normalizeSearch }; return { canUse, mapResponse, matchesSearch, normalizeLanguage, normalizeSearch };
}); });

View File

@ -5,6 +5,8 @@ const { matchesSearch } = require('./flexibleChannelSearch');
const iptvOrgCatalog = require('./iptvOrgCatalogService'); const iptvOrgCatalog = require('./iptvOrgCatalogService');
const epgMatching = require('./epgMatchingService'); const epgMatching = require('./epgMatchingService');
const MAX_SYNCHRONOUS_ENRICHMENTS = 250;
const QUALITY_TOKENS = new Set([ const QUALITY_TOKENS = new Set([
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
'1080p', '1080i', '720p', '576p', '480p' '1080p', '1080i', '720p', '576p', '480p'
@ -357,17 +359,24 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
params.push(sourceId); params.push(sourceId);
} }
const rows = database.prepare(` const rows = database.prepare(`
SELECT p.*, c.name AS category_name, COALESCE(c.is_hidden, 0) AS category_hidden, SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
l.display_name, l.preferred_variant_id l.display_name, l.preferred_variant_id
FROM playlist_items p FROM playlist_items p
JOIN logical_channels l ON l.id = p.logical_channel_id 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 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' AND f.item_id = p.item_id AND f.item_type = 'channel'
WHERE p.type = 'live' ${sourceFilter} WHERE p.type = 'live' ${sourceFilter}
ORDER BY l.display_name, p.variant_rank ORDER BY l.display_name, p.variant_rank
`).all(userId ?? -1, ...params); `).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 serialized = serializeChannels(rows, includeHidden);
const availableLanguages = [...new Map(serialized.map(channel => [channel.language.code, channel.language])).values()] const availableLanguages = [...new Map(serialized.map(channel => [channel.language.code, channel.language])).values()]
.map(language => ({ code: language.code, short: language.short, label: language.label })) .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 => const filtered = serialized.filter(channel =>
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) (!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); const match = standardCatalog.findMatch(database, channel);
if (match) { if (match) {
channel.standardName = match.name; channel.standardName = match.name;
@ -472,6 +485,7 @@ module.exports = {
identityCandidatePairs, identityCandidatePairs,
normalizeChannelName, normalizeChannelName,
markProjectionBuilding, markProjectionBuilding,
MAX_SYNCHRONOUS_ENRICHMENTS,
matchesSearch, matchesSearch,
projectCatalog, projectCatalog,
projectionState, projectionState,

View File

@ -21,7 +21,8 @@ test('maps projected logical channels to the frontend variant format', () => {
isFavorite: true, isHidden: true, logicalId: 'logical:one', isFavorite: true, isHidden: true, logicalId: 'logical:one',
logicalFingerprint: 'news', logicalDisplayName: 'News', logicalFingerprint: 'news', logicalDisplayName: 'News',
streamId: '42', tvgLogo: 'logo.png', 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.favorites.has('7:42'), true);
assert.equal(mapped.hiddenItems.has('channel: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(standardCalls, 1);
assert.equal(epgCalls, 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();
});