Run full live catalog refreshes through live source sync, complete channel analysis, EPG source refresh, and persisted EPG rematching while keeping check-new incremental. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
112 lines
5.3 KiB
JavaScript
112 lines
5.3 KiB
JavaScript
const { numericPriority, sortEpgSources } = require('./epgSourceOrdering');
|
|
|
|
function normalize(value) {
|
|
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
}
|
|
|
|
function candidateScore(channel, epgChannel) {
|
|
const variantIds = new Set((channel.variants || []).flatMap(variant => [variant.tvgId, variant.epg_channel_id]).filter(Boolean).map(String));
|
|
if (variantIds.has(String(epgChannel.id))) return 100;
|
|
const wantedNames = new Set([channel.standardName, channel.displayName, ...(channel.variants || []).map(variant => variant.name)].map(normalize).filter(Boolean));
|
|
const candidateName = normalize(epgChannel.name);
|
|
if (wantedNames.has(candidateName)) return 90;
|
|
if (candidateName && [...wantedNames].some(name => name.includes(candidateName) || candidateName.includes(name))) return 50;
|
|
return 0;
|
|
}
|
|
|
|
function chooseBestMatch(channel, candidates, epgSources) {
|
|
const language = channel.language?.code;
|
|
const eligible = sortEpgSources(epgSources, { enabledOnly: true }).filter(source =>
|
|
!source.language || !language || language === 'und' || source.language === language
|
|
);
|
|
const priorities = new Map(eligible.map(source => [source.id, numericPriority(source)]));
|
|
return candidates
|
|
.filter(candidate => priorities.has(candidate.sourceId))
|
|
.map(candidate => ({ ...candidate, matchConfidence: candidateScore(channel, candidate) }))
|
|
.filter(candidate => candidate.matchConfidence > 0)
|
|
.sort((left, right) => right.matchConfidence - left.matchConfidence ||
|
|
priorities.get(left.sourceId) - priorities.get(right.sourceId) || left.sourceId - right.sourceId)[0] || null;
|
|
}
|
|
|
|
function findBestMatch(database, channel, epgSources, now = Date.now()) {
|
|
const language = channel.language?.code;
|
|
const enabled = sortEpgSources(epgSources, { enabledOnly: true }).filter(source =>
|
|
!source.language || !language || language === 'und' || source.language === language
|
|
);
|
|
if (!enabled.length) return null;
|
|
const ids = enabled.map(source => source.id);
|
|
const placeholders = ids.map(() => '?').join(',');
|
|
const epgChannels = database.prepare(`
|
|
SELECT source_id AS sourceId, item_id AS id, name
|
|
FROM playlist_items
|
|
WHERE type = 'epg_channel' AND source_id IN (${placeholders})
|
|
`).all(...ids);
|
|
const best = chooseBestMatch(channel, epgChannels, enabled);
|
|
if (!best) return null;
|
|
const current = database.prepare(`
|
|
SELECT title, description, start_time AS startTime, end_time AS endTime
|
|
FROM epg_programs
|
|
WHERE source_id = ? AND channel_id = ? AND start_time <= ? AND end_time > ?
|
|
ORDER BY start_time DESC LIMIT 1
|
|
`).get(best.sourceId, best.id, now, now);
|
|
return {
|
|
sourceId: best.sourceId,
|
|
channelId: best.id,
|
|
matchConfidence: best.matchConfidence,
|
|
currentProgram: current || undefined
|
|
};
|
|
}
|
|
|
|
function rebuildPersistedEpgMatches(database, epgSources, now = Date.now()) {
|
|
const rows = database.prepare(`
|
|
SELECT p.*, l.display_name, c.name AS category_name
|
|
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
|
|
WHERE p.type = 'live' AND p.logical_channel_id IS NOT NULL
|
|
ORDER BY p.logical_channel_id, p.language_code, p.variant_rank
|
|
`).all();
|
|
const groups = new Map();
|
|
for (const row of rows) {
|
|
const languageCode = row.language_code || 'und';
|
|
const key = `${row.logical_channel_id}\0${languageCode}`;
|
|
if (!groups.has(key)) groups.set(key, {
|
|
logicalChannelId: row.logical_channel_id,
|
|
displayName: row.display_name,
|
|
standardName: row.display_name,
|
|
language: { code: languageCode },
|
|
variants: []
|
|
});
|
|
let data = {};
|
|
try { data = row.data ? JSON.parse(row.data) : {}; } catch {}
|
|
groups.get(key).variants.push({
|
|
name: row.name,
|
|
tvgId: data.tvgId || data.tvg_id || null,
|
|
epg_channel_id: data.epg_channel_id || null
|
|
});
|
|
}
|
|
const replace = database.prepare(`
|
|
INSERT INTO live_epg_matches
|
|
(logical_channel_id, language_code, source_id, channel_id, match_confidence, data, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(logical_channel_id, language_code) DO UPDATE SET
|
|
source_id = excluded.source_id, channel_id = excluded.channel_id,
|
|
match_confidence = excluded.match_confidence, data = excluded.data,
|
|
updated_at = excluded.updated_at
|
|
`);
|
|
const persist = database.transaction(() => {
|
|
database.prepare('DELETE FROM live_epg_matches').run();
|
|
for (const channel of groups.values()) {
|
|
const match = findBestMatch(database, channel, epgSources, now);
|
|
replace.run(channel.logicalChannelId, channel.language.code,
|
|
match?.sourceId ?? null, match?.channelId ?? null,
|
|
match?.matchConfidence ?? 0, match ? JSON.stringify(match) : null, now);
|
|
}
|
|
});
|
|
persist();
|
|
return { matched: [...groups.values()].length };
|
|
}
|
|
|
|
module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize, rebuildPersistedEpgMatches };
|