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:
@ -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>
|
||||||
|
|||||||
@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
69
test/t26Frontend.test.js
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user