Fix the regression introduced by the weighted Laya signal (#16): rebuild now merges exact fingerprints in every engine mode after evaluating hard blockers, compares candidates against all variants of a group, falls back to deterministic scoring when Laya is unavailable, and reports transaction errors safely. Non-regression tests cover the Laya-mode rebuild path; verified against a real catalog copy (39,341 streams rebuilt successfully). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
4.3 KiB
JavaScript
106 lines
4.3 KiB
JavaScript
const MODE_WEIGHTS = Object.freeze({
|
|
algo: Object.freeze({ algorithm: 80, structure: 15, metadata: 5, laya: 0 }),
|
|
hybrid: Object.freeze({ algorithm: 70, structure: 15, metadata: 5, laya: 10 }),
|
|
laya: Object.freeze({ algorithm: 55, structure: 15, metadata: 5, laya: 25 })
|
|
});
|
|
const PROTECTED_TOKENS = new Set(['max', 'news', 'premium', 'extra', 'xtra', 'kids', 'movies']);
|
|
const STRONG_FAMILIES = ['bein sports', 'sky sports', 'canal plus', 'eurosport', 'espn', 'dazn'];
|
|
const LAYA_CONFIDENCE_FLOOR = 0.65;
|
|
const MERGE_THRESHOLD = 70;
|
|
|
|
function tokens(value) {
|
|
return String(value || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
}
|
|
|
|
function strongFamily(value) {
|
|
const text = tokens(value).join(' ');
|
|
return STRONG_FAMILIES.find(family => text.includes(family)) || null;
|
|
}
|
|
|
|
function identityNumber(value, family = strongFamily(value)) {
|
|
const list = tokens(value);
|
|
let start = 0;
|
|
if (family) {
|
|
const familyTokens = family.split(' ');
|
|
for (let index = 0; index <= list.length - familyTokens.length; index++) {
|
|
if (familyTokens.every((token, offset) => list[index + offset] === token)) {
|
|
start = index + familyTokens.length;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return list.slice(start).find(token => /^\d+$/.test(token)) || null;
|
|
}
|
|
|
|
function identity(value) {
|
|
const list = tokens(value);
|
|
const family = strongFamily(value);
|
|
return {
|
|
family,
|
|
number: identityNumber(value, family),
|
|
protectedTokens: new Set(list.filter(token => PROTECTED_TOKENS.has(token)))
|
|
};
|
|
}
|
|
|
|
function sameSet(left, right) {
|
|
return left.size === right.size && [...left].every(value => right.has(value));
|
|
}
|
|
|
|
function hardBlock(left, right) {
|
|
const a = identity(left.fingerprint || left.name);
|
|
const b = identity(right.fingerprint || right.name);
|
|
if (!sameSet(a.protectedTokens, b.protectedTokens)) return 'protected_token_mismatch';
|
|
if (a.number && b.number && a.number !== b.number) return 'number_mismatch';
|
|
if (a.family && b.family && a.family !== b.family) return 'strong_family_mismatch';
|
|
return null;
|
|
}
|
|
|
|
function pairKey(left, right) {
|
|
return [String(left.id), String(right.id)].sort().join('\0');
|
|
}
|
|
|
|
function layaSignal(hint, leftIdentity, rightIdentity) {
|
|
if (!hint || Number(hint.confidence) < LAYA_CONFIDENCE_FLOOR) return 0;
|
|
if (hint.number && leftIdentity.number && hint.number !== leftIdentity.number) return 0;
|
|
if (hint.number && rightIdentity.number && hint.number !== rightIdentity.number) return 0;
|
|
if (hint.family && leftIdentity.family && hint.family !== leftIdentity.family) return 0;
|
|
if (hint.family && rightIdentity.family && hint.family !== rightIdentity.family) return 0;
|
|
return Number(hint.sameIdentityProbability) >= 0.5
|
|
? Math.min(1, Math.max(0, Number(hint.confidence)))
|
|
: 0;
|
|
}
|
|
|
|
function scoreCandidate(left, right, options = {}) {
|
|
const mode = MODE_WEIGHTS[options.mode] ? options.mode : 'algo';
|
|
const weights = MODE_WEIGHTS[mode];
|
|
const blocker = hardBlock(left, right);
|
|
if (blocker) return { matched: false, blocker, total: 0, signals: { algorithm: 0, structure: 0, metadata: 0, laya: 0 } };
|
|
|
|
const leftIdentity = identity(left.fingerprint || left.name);
|
|
const rightIdentity = identity(right.fingerprint || right.name);
|
|
const exact = left.fingerprint === right.fingerprint;
|
|
const sameStructure = Boolean(leftIdentity.family && leftIdentity.family === rightIdentity.family &&
|
|
leftIdentity.number && leftIdentity.number === rightIdentity.number);
|
|
const algorithm = exact ? 1 : (mode !== 'algo' && sameStructure ? 0.6 : 0);
|
|
const structure = sameStructure ? 1 : 0;
|
|
const metadata = Boolean(
|
|
(left.category_id && left.category_id === right.category_id) ||
|
|
(left.tvg_id && left.tvg_id === right.tvg_id)
|
|
) ? 1 : 0;
|
|
const laya = exact ? 0 : layaSignal(options.layaHint, leftIdentity, rightIdentity);
|
|
const total = algorithm * weights.algorithm + structure * weights.structure +
|
|
metadata * weights.metadata + laya * weights.laya;
|
|
return { matched: exact || total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } };
|
|
}
|
|
|
|
module.exports = {
|
|
LAYA_CONFIDENCE_FLOOR,
|
|
MERGE_THRESHOLD,
|
|
MODE_WEIGHTS,
|
|
PROTECTED_TOKENS,
|
|
hardBlock,
|
|
identity,
|
|
pairKey,
|
|
scoreCandidate
|
|
};
|