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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user