feat: add optional Laya engine mode for grouping and language detection (#12)

Add a languageDetectionMode setting (algo | laya | laya+algo) exposed
in Settings with three engine choices. When Laya is enabled, a laya
service (Dockerfile.laya, optional compose profile, models stored
outside the repo under ./data/models/laya) can be started, with model
download and deletion from the UI via /laya routes to manage disk
space. Add a channelLanguageFusion service combining Laya results
with the existing algorithm, and wire detection mode through
grouping, channel list and player. Cover engine selection, fusion
and laya service behavior with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 11:13:34 +02:00
parent 9e80655fd9
commit ce11ecc757
19 changed files with 780 additions and 13 deletions

View File

@ -0,0 +1,83 @@
(function (root, factory) {
const service = factory();
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.ChannelLanguageFusion = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const LABELS = { fr: 'French', en: 'English', es: 'Spanish', unknown: 'Unknown' };
function normalizeConfidence(value) {
const number = Number(value) || 0;
return Math.max(0, Math.min(100, Math.round(number <= 1 ? number * 100 : number)));
}
function layaDetection(result) {
const rawCode = result?.language?.code || result?.language || 'unknown';
const code = ['fr', 'en', 'es'].includes(rawCode) ? rawCode : 'unknown';
const confidence = normalizeConfidence(result?.language?.confidence ?? result?.confidence);
return {
language: {
code,
label: LABELS[code],
confidence: code === 'unknown' ? 0 : confidence,
status: code === 'unknown' ? 'unknown' : confidence >= 75 ? 'high' : confidence >= 60 ? 'medium' : 'low'
},
languageScores: code === 'unknown' ? {} : { [code]: confidence },
languageEvidence: code === 'unknown' ? [] : [{ code, weight: confidence, type: 'laya', value: 'Local AI model' }]
};
}
function mergeHybrid(algo, result) {
if (!result) return algo;
const laya = layaDetection(result);
if (laya.language.code === 'unknown') return {
...algo,
languageEvidence: [...algo.languageEvidence, ...laya.languageEvidence]
};
const combined = { ...algo.languageScores };
combined[laya.language.code] = (combined[laya.language.code] || 0) + laya.language.confidence;
const total = Object.values(combined).reduce((sum, value) => sum + value, 0);
const scores = Object.fromEntries(Object.entries(combined).map(([code, value]) => [code, Math.round(value / total * 100)]));
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const [code, confidence] = ranked[0];
const margin = confidence - (ranked[1]?.[1] || 0);
const certain = confidence >= 45 && margin >= 10;
return {
language: certain ? {
code,
label: LABELS[code] || code.toUpperCase(),
confidence,
status: confidence >= 75 ? 'high' : confidence >= 60 ? 'medium' : 'low'
} : { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' },
languageScores: scores,
languageEvidence: [...algo.languageEvidence, ...laya.languageEvidence]
};
}
function applyLanguageMode(algo, result, mode) {
if (mode === 'algo') return algo;
if (mode === 'hybrid') return mergeHybrid(algo, result);
return result ? layaDetection(result) : {
language: { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' },
languageScores: {},
languageEvidence: []
};
}
function buildLayaDescriptor(variant, variantKey, algo) {
const fields = [
['sourceId', variant.sourceId],
['name', variant.name],
['groupTitle', variant.groupTitle],
['sourceName', variant.sourceName],
['tvgId', variant.tvgId],
['algorithmScores', JSON.stringify(algo.languageScores || {})]
];
return {
id: variantKey,
text: fields.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${key}: ${value}`).join('\n')
};
}
return { applyLanguageMode, buildLayaDescriptor, layaDetection, mergeHybrid, normalizeConfidence };
});