feat: split live analysis actions and page catalog loading

Separate incremental live checks from full catalog rebuilds and page projected channel/variant loading so first live-list display stays bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-07 23:45:42 +02:00
parent bbb1afb238
commit 4cca030afa
12 changed files with 434 additions and 35 deletions

View File

@ -6,6 +6,8 @@ const iptvOrgCatalog = require('./iptvOrgCatalogService');
const epgMatching = require('./epgMatchingService');
const MAX_SYNCHRONOUS_ENRICHMENTS = 250;
const DEFAULT_PAGE_LIMIT = 100;
const MAX_PAGE_LIMIT = 200;
const QUALITY_TOKENS = new Set([
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
@ -395,7 +397,45 @@ function parseData(value) {
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
}
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) {
function pageBounds(page = 1, limit = DEFAULT_PAGE_LIMIT) {
const normalizedPage = Math.max(1, Number.parseInt(page, 10) || 1);
const normalizedLimit = Math.min(MAX_PAGE_LIMIT, Math.max(1, Number.parseInt(limit, 10) || DEFAULT_PAGE_LIMIT));
return { page: normalizedPage, limit: normalizedLimit, offset: (normalizedPage - 1) * normalizedLimit };
}
function pageMetadata(total, bounds) {
const nextOffset = bounds.offset + bounds.limit;
const hasMore = nextOffset < total;
return {
page: bounds.page,
limit: bounds.limit,
offset: bounds.offset,
total,
hasMore,
nextOffset: hasMore ? nextOffset : null
};
}
function listChannel(channel) {
const preferredVariant = channel.preferredVariant || channel.variants[0] || null;
const variants = [];
const addPreview = variant => {
if (variant && variants.length < 3 && !variants.some(candidate =>
candidate.id === variant.id && candidate.sourceId === variant.sourceId
)) variants.push(variant);
};
addPreview(preferredVariant);
channel.variants.filter(variant => variant.isFavorite).forEach(addPreview);
channel.variants.filter(variant => !variant.isHidden).forEach(addPreview);
return {
...channel,
preferredVariant,
variantCount: channel.variants.length,
variants
};
}
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
database ||= defaultDatabase();
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
const epgMatcher = dependencies.epgMatching || epgMatching;
@ -431,11 +471,13 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
const filtered = serialized.filter(channel =>
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
);
const bounds = pageBounds(page, limit);
const paged = filtered.slice(bounds.offset, bounds.offset + bounds.limit);
// 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 enriched = !shouldEnrich ? paged : paged.map(channel => {
const match = standardCatalog.findMatch(database, channel);
if (match) {
channel.standardName = match.name;
@ -445,7 +487,37 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
return channel;
});
return { channels, availableLanguages };
return {
channels: enriched.map(listChannel),
availableLanguages,
page: pageMetadata(filtered.length, bounds)
};
}
function getChannelVariants({ logicalChannelId, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) {
database ||= defaultDatabase();
const rows = database.prepare(`
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
l.display_name, l.preferred_variant_id, c.name AS category_name,
COALESCE(c.is_hidden, 0) AS category_hidden
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' AND p.logical_channel_id = ?
ORDER BY p.variant_rank, p.id
`).all(userId ?? -1, logicalChannelId);
const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants);
const bounds = pageBounds(page, limit);
return {
variants: variants.slice(bounds.offset, bounds.offset + bounds.limit),
page: pageMetadata(variants.length, bounds)
};
}
function checkNewStreams() {
return require('./syncService').syncAll();
}
function getChannels(options, database = null) {
@ -527,7 +599,9 @@ module.exports = {
collectIdentityHints,
collectOptionalIdentityHints,
displayNameForFingerprint,
checkNewStreams,
getChannelPage,
getChannelVariants,
getChannels,
identityCandidatePairs,
normalizeChannelName,