diff --git a/server/services/liveGroupingScoring.js b/server/services/liveGroupingScoring.js index 5473b3b..844b4af 100644 --- a/server/services/liveGroupingScoring.js +++ b/server/services/liveGroupingScoring.js @@ -90,7 +90,7 @@ function scoreCandidate(left, right, options = {}) { 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: total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } }; + return { matched: exact || total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } }; } module.exports = { diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index e209834..80fed49 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -82,10 +82,10 @@ function projectCatalog(items, options = {}) { : fingerprint; if (!candidateBuckets.has(candidateKey)) candidateBuckets.set(candidateKey, []); const groups = candidateBuckets.get(candidateKey); - const matchingGroup = groups.find(group => scoreCandidate(group[0], projected, { + const matchingGroup = groups.find(group => group.some(member => scoreCandidate(member, projected, { mode: options.mode || 'algo', - layaHint: options.identityHints?.get(pairKey(group[0], projected)) - }).matched); + layaHint: options.identityHints?.get(pairKey(member, projected)) + }).matched)); if (matchingGroup) matchingGroup.push(projected); else groups.push([projected]); } @@ -152,6 +152,16 @@ async function collectIdentityHints(items, mode, layaClient) { return hints; } +async function collectOptionalIdentityHints(items, mode, layaClient) { + if (mode === 'algo') return new Map(); + try { + return await collectIdentityHints(items, mode, layaClient); + } catch (error) { + console.warn('[LiveProjection] Laya identity hints unavailable; continuing deterministically'); + return new Map(); + } +} + function catalogInputHash(items) { return stableHash(items .map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`) @@ -241,10 +251,12 @@ function rebuild(database = null, options = {}) { apply(); return projectionState(database); } catch (error) { + const diagnosticCode = /^SQLITE_[A-Z_]+$/.test(error.code || '') ? error.code : 'INTERNAL'; + console.error(`[LiveProjection] Rebuild transaction failed (${diagnosticCode}, ${error.name || 'Error'})`); database.prepare(` UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ? WHERE projection = 'live' - `).run(Date.now(), 'Live catalog projection failed'); + `).run(Date.now(), `Live catalog projection failed [${diagnosticCode}]`); throw error; } } @@ -256,12 +268,8 @@ async function rebuildWithSignals(database = null, options = {}) { 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'); - } + const layaClient = options.layaClient || new (require('./layaService').LayaService)(); + identityHints = await collectOptionalIdentityHints(items, mode, layaClient); } return rebuild(database, { ...options, items, mode, identityHints }); } @@ -375,6 +383,7 @@ function serializeChannels(rows, includeHidden = false) { module.exports = { catalogInputHash, collectIdentityHints, + collectOptionalIdentityHints, displayNameForFingerprint, getChannels, identityCandidatePairs, diff --git a/test/liveGroupingScoring.test.js b/test/liveGroupingScoring.test.js index dee2b92..f133471 100644 --- a/test/liveGroupingScoring.test.js +++ b/test/liveGroupingScoring.test.js @@ -7,6 +7,7 @@ const { } = require('../server/services/liveGroupingScoring'); const { collectIdentityHints, + collectOptionalIdentityHints, normalizeChannelName, projectCatalog } = require('../server/services/liveProjectionService'); @@ -44,6 +45,16 @@ test('exact algorithm match survives a contradictory Laya opinion', () => { assert.equal(score.signals.laya, 0); }); +test('exact generic fingerprints merge in Laya-only mode without family metadata', () => { + const left = channel('a', '1000 x Hora TV', { category_id: 'one' }); + const right = channel('b', '|UY| 1000 x Hora TV', { category_id: 'two' }); + const score = scoreCandidate(left, right, { mode: 'laya' }); + assert.equal(score.total, 55); + assert.equal(score.matched, true); + const groups = projectCatalog([left, right], { mode: 'laya' }); + assert.equal(groups.length, 1); +}); + test('different channel numbers block even when Laya proposes a merge', () => { const left = channel('a', 'beIN Sports 1'); const right = channel('b', 'beIN Sports 2'); @@ -110,3 +121,28 @@ test('hybrid mode caps the positive Laya contribution at weight 10', () => { assert.equal(score.total, 72); assert.equal(score.matched, true); }); + +test('Laya bridge followed by an exact alias stays in one unique fingerprint group', () => { + const items = [ + channel('a', 'beIN Sports 1 Event'), + channel('b', 'beIN Sports 1'), + channel('c', 'beIN Sports 1') + ]; + const hints = new Map([[ + ['a', 'b'].sort().join('\0'), + { sameIdentityProbability: 1, confidence: 1 } + ]]); + const groups = projectCatalog(items, { mode: 'hybrid', identityHints: hints }); + assert.equal(groups.length, 1); + assert.equal(groups[0].fingerprint, 'bein sports 1'); + assert.equal(groups[0].variants.length, 3); +}); + +test('optional Laya failure degrades to an empty signal set', async () => { + const hints = await collectOptionalIdentityHints([ + channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1') + ], 'hybrid', { + identityHintBatch: async () => { throw new Error('runtime unavailable at https://private.invalid'); } + }); + assert.equal(hints.size, 0); +});