Files
Nodecast/server/services/liveProjectionService.js
blomios 925465ba39 feat: use Laya as a weighted signal in live stream grouping (#16)
Introduce a liveGroupingScoring engine that combines existing
membership signals (normalized name, brand, number, metadata tokens,
language, category/source when relevant) with a bounded Laya
contribution: Laya raises or lowers confidence instead of acting as
a final judge, with guard-rails against obvious contradictions.
Sanitize Laya batch identity inputs in layaService and wire the
weighted scoring into the live projection service. Weights are not
exposed in the Nodecast UI. Cover signal combination, bounds and
contradiction guard-rails with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:41:47 +02:00

393 lines
16 KiB
JavaScript

const crypto = require('crypto');
const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring');
const QUALITY_TOKENS = new Set([
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
'1080p', '1080i', '720p', '576p', '480p'
]);
const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i;
const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']);
const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']);
function words(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(REGION_PREFIX, '')
.replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ')
.split(/\s+/)
.filter(Boolean);
}
function normalizeChannelName(name) {
const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token));
const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports');
if (brandIndex >= 0) {
const branded = tokens.slice(brandIndex);
const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3])
? 4
: (/^\d+$/.test(branded[2]) ? 3 : 0);
if (numberedBaseLength) {
const suffix = branded.slice(numberedBaseLength);
const removable = suffix.every(token =>
BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token)
);
if (removable) return branded.slice(0, numberedBaseLength).join(' ');
}
return branded.join(' ').trim();
}
return tokens.join(' ').trim();
}
function displayNameForFingerprint(fingerprint) {
return fingerprint.split(' ').map(token => {
if (token === 'bein') return 'beIN';
if (/^\d+$/.test(token)) return token;
return token.charAt(0).toUpperCase() + token.slice(1);
}).join(' ');
}
function qualityForName(name) {
const tokens = words(name);
if (tokens.includes('4k') || tokens.includes('uhd')) return { label: '4K', rank: 4 };
if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) {
return { label: 'Full HD', rank: 3 };
}
if (tokens.includes('hd') || tokens.includes('720p')) return { label: 'HD', rank: 2 };
if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return { label: 'SD', rank: 0 };
return { label: 'Standard', rank: 1 };
}
function stableHash(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function projectCatalog(items, options = {}) {
const candidateBuckets = new Map();
for (const item of items) {
const fallback = `channel-${item.source_id}:${item.item_id}`;
const fingerprint = normalizeChannelName(item.name) || fallback;
const quality = qualityForName(item.name);
const projected = {
...item,
fingerprint,
groupingInputHash: stableHash(`${item.name || ''}\0${item.category_id || ''}`),
qualityLabel: quality.label,
qualityRank: quality.rank
};
const itemIdentity = identity(fingerprint);
const candidateKey = itemIdentity.family && itemIdentity.number
? `${itemIdentity.family}:${itemIdentity.number}`
: fingerprint;
if (!candidateBuckets.has(candidateKey)) candidateBuckets.set(candidateKey, []);
const groups = candidateBuckets.get(candidateKey);
const matchingGroup = groups.find(group => scoreCandidate(group[0], projected, {
mode: options.mode || 'algo',
layaHint: options.identityHints?.get(pairKey(group[0], projected))
}).matched);
if (matchingGroup) matchingGroup.push(projected);
else groups.push([projected]);
}
return Array.from(candidateBuckets.values()).flat().map(variants => {
const fingerprint = variants.map(variant => variant.fingerprint).sort((a, b) => a.length - b.length || a.localeCompare(b))[0];
variants.sort((a, b) => b.qualityRank - a.qualityRank || a.source_id - b.source_id || String(a.item_id).localeCompare(String(b.item_id)));
variants.forEach((variant, index) => { variant.variantRank = index; });
return {
id: `logical:${stableHash(fingerprint).slice(0, 24)}`,
fingerprint,
displayName: displayNameForFingerprint(fingerprint),
preferredVariantId: variants[0].id,
variants
};
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
}
function safeIdentityDescriptor(item) {
return {
name: String(item.name || '').slice(0, 300),
group: String(item.category_name || item.groupTitle || '').slice(0, 200),
source: String(item.source_name || '').slice(0, 120),
tvgId: String(item.tvg_id || '').slice(0, 160)
};
}
function identityCandidatePairs(items) {
const buckets = new Map();
for (const item of items) {
const fingerprint = normalizeChannelName(item.name);
const itemIdentity = identity(fingerprint);
if (!itemIdentity.family || !itemIdentity.number) continue;
const key = `${itemIdentity.family}:${itemIdentity.number}`;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push({ ...item, fingerprint });
}
const pairs = [];
for (const bucket of buckets.values()) {
for (let left = 0; left < bucket.length; left++) {
for (let right = left + 1; right < bucket.length; right++) {
if (bucket[left].fingerprint === bucket[right].fingerprint) continue;
if (scoreCandidate(bucket[left], bucket[right], { mode: 'algo' }).blocker) continue;
pairs.push({
id: pairKey(bucket[left], bucket[right]),
left: safeIdentityDescriptor(bucket[left]),
right: safeIdentityDescriptor(bucket[right])
});
if (pairs.length >= 256) return pairs;
}
}
}
return pairs;
}
async function collectIdentityHints(items, mode, layaClient) {
if (mode === 'algo' || !layaClient) return new Map();
const pairs = identityCandidatePairs(items);
const hints = new Map();
for (let index = 0; index < pairs.length; index += 64) {
const results = await layaClient.identityHintBatch(pairs.slice(index, index + 64));
results.forEach(result => hints.set(result.id, result));
}
return hints;
}
function catalogInputHash(items) {
return stableHash(items
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
.sort()
.join('\n'));
}
function defaultDatabase() {
return require('../db/sqlite').getDb();
}
function projectionState(database = null) {
database ||= defaultDatabase();
return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || {
projection: 'live', status: 'missing', started_at: null, completed_at: null,
input_hash: null, projected_count: 0, processed_count: 0, total_count: 0, error: null
};
}
function markProjectionBuilding(database = null) {
database ||= defaultDatabase();
const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count;
const startedAt = Date.now();
database.prepare(`
INSERT INTO catalog_projection_state
(projection, status, started_at, completed_at, input_hash, projected_count, processed_count, total_count, error)
VALUES ('live', 'building', ?, NULL, NULL, 0, 0, ?, NULL)
ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at,
completed_at = NULL, processed_count = 0, total_count = excluded.total_count, error = NULL
`).run(startedAt, total);
return projectionState(database);
}
function loadProjectionItems(database) {
return database.prepare(`
SELECT p.id, p.source_id, p.item_id, p.name, p.category_id, p.data,
c.name AS category_name
FROM playlist_items p
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'
ORDER BY p.id
`).all().map(item => {
const data = parseData(item.data);
return { ...item, tvg_id: data.tvgId || data.tvg_id || null };
});
}
function rebuild(database = null, options = {}) {
database ||= defaultDatabase();
const items = options.items || loadProjectionItems(database);
const groups = projectCatalog(items, { mode: options.mode, identityHints: options.identityHints });
const inputHash = catalogInputHash(items);
if (!options.stateAlreadyBuilding) markProjectionBuilding(database);
try {
const apply = database.transaction(() => {
database.prepare('DELETE FROM logical_channels').run();
database.prepare(`
UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL,
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL
WHERE type = 'live'
`).run();
const insertLogical = database.prepare(`
INSERT INTO logical_channels
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const updateVariant = database.prepare(`
UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?,
grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?
WHERE id = ? AND type = 'live'
`);
const now = Date.now();
for (const group of groups) {
insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now);
for (const variant of group.variants) {
updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash,
variant.qualityLabel, variant.qualityRank, variant.variantRank, variant.id);
}
}
database.prepare(`
UPDATE catalog_projection_state SET status = 'success', completed_at = ?,
input_hash = ?, projected_count = ?, processed_count = ?, total_count = ?, error = NULL
WHERE projection = 'live'
`).run(Date.now(), inputHash, groups.length, items.length, items.length);
});
apply();
return projectionState(database);
} catch (error) {
database.prepare(`
UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ?
WHERE projection = 'live'
`).run(Date.now(), 'Live catalog projection failed');
throw error;
}
}
async function rebuildWithSignals(database = null, options = {}) {
database ||= defaultDatabase();
const items = loadProjectionItems(database);
const configured = options.mode || (await require('../db').settings.get()).languageDetectionMode || 'algo';
const mode = ['algo', 'hybrid', 'laya'].includes(configured) ? configured : 'algo';
let identityHints = new Map();
if (mode !== 'algo') {
try {
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
identityHints = await collectIdentityHints(items, mode, layaClient);
} catch (error) {
console.warn('[LiveProjection] Laya identity hints unavailable; continuing deterministically');
}
}
return rebuild(database, { ...options, items, mode, identityHints });
}
let activeRebuild = null;
let activeState = null;
function startRebuild(database = null, options = {}) {
if (activeRebuild) return { alreadyRunning: true, state: activeState, promise: activeRebuild };
database ||= defaultDatabase();
const mark = options.mark || markProjectionBuilding;
const runner = options.runner || rebuildWithSignals;
const schedule = options.schedule || setImmediate;
activeState = mark(database);
activeRebuild = new Promise((resolve, reject) => {
schedule(() => {
Promise.resolve()
.then(() => runner(database, { stateAlreadyBuilding: true }))
.then(resolve, reject);
});
}).finally(() => {
activeRebuild = null;
activeState = null;
});
return { alreadyRunning: false, state: activeState, promise: activeRebuild };
}
function queueRebuild(database = null) {
return startRebuild(database).promise;
}
function parseData(value) {
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
}
function getChannels({ sourceId = null, includeHidden = false, userId = null }, database = null) {
database ||= defaultDatabase();
const params = [];
let sourceFilter = '';
if (sourceId !== null) {
sourceFilter = 'AND p.source_id = ?';
params.push(sourceId);
}
const rows = database.prepare(`
SELECT p.*, c.name AS category_name, COALESCE(c.is_hidden, 0) AS category_hidden,
CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
l.display_name, l.preferred_variant_id
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' ${sourceFilter}
ORDER BY l.display_name, p.variant_rank
`).all(userId ?? -1, ...params);
return serializeChannels(rows, includeHidden);
}
function serializeChannels(rows, includeHidden = false) {
const groups = new Map();
for (const row of rows) {
const hidden = Boolean(row.is_hidden || row.category_hidden);
if (hidden && !includeHidden) continue;
const raw = parseData(row.data);
const categoryName = raw.groupTitle || row.category_name || 'Uncategorized';
const variant = {
...raw,
id: row.item_id,
sourceId: row.source_id,
name: row.name,
categoryId: row.category_id,
categoryName,
groupTitle: categoryName,
streamIcon: row.stream_icon,
streamUrl: row.stream_url,
qualityLabel: row.quality_label,
qualityRank: row.quality_rank,
variantRank: row.variant_rank,
isHidden: hidden,
isFavorite: Boolean(row.user_favorite)
};
if (!groups.has(row.logical_channel_id)) {
groups.set(row.logical_channel_id, {
id: row.logical_channel_id,
fingerprint: row.grouping_fingerprint,
displayName: row.display_name,
name: row.display_name,
variants: [],
isFavorite: false,
isHidden: true
});
}
const group = groups.get(row.logical_channel_id);
group.variants.push(variant);
group.isFavorite ||= variant.isFavorite;
group.isHidden &&= variant.isHidden;
if (row.id === row.preferred_variant_id) group.preferredVariant = variant;
}
return Array.from(groups.values()).map(group => {
group.preferredVariant ||= group.variants[0];
group.categoryId = group.preferredVariant?.categoryId || null;
group.categoryName = group.preferredVariant?.categoryName || 'Uncategorized';
group.groupTitle = group.preferredVariant?.groupTitle || group.categoryName;
group.selectedVariantId = group.preferredVariant
? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}`
: null;
return group;
});
}
module.exports = {
catalogInputHash,
collectIdentityHints,
displayNameForFingerprint,
getChannels,
identityCandidatePairs,
normalizeChannelName,
markProjectionBuilding,
projectCatalog,
projectionState,
qualityForName,
queueRebuild,
rebuild,
rebuildWithSignals,
safeIdentityDescriptor,
serializeChannels,
startRebuild
};