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:
41
test/channelLanguageFusion.test.js
Normal file
41
test/channelLanguageFusion.test.js
Normal file
@ -0,0 +1,41 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fusion = require('../public/js/services/channelLanguageFusion.js');
|
||||
|
||||
const algo = {
|
||||
language: { code: 'fr', label: 'French', confidence: 70, status: 'medium' },
|
||||
languageScores: { fr: 70, en: 30 },
|
||||
languageEvidence: [{ code: 'fr', weight: 60, type: 'name-prefix', value: 'FR' }]
|
||||
};
|
||||
|
||||
test('Laya-only mode maps backend confidence and language', () => {
|
||||
const result = fusion.applyLanguageMode(algo, { language: 'en', confidence: 0.88 }, 'laya');
|
||||
assert.deepEqual(result.language, { code: 'en', label: 'English', confidence: 88, status: 'high' });
|
||||
assert.equal(result.languageEvidence[0].type, 'laya');
|
||||
});
|
||||
|
||||
test('hybrid mode combines Laya and algorithm evidence', () => {
|
||||
const result = fusion.applyLanguageMode(algo, { language: 'en', confidence: 0.9 }, 'hybrid');
|
||||
assert.equal(result.language.code, 'en');
|
||||
assert.ok(result.languageScores.en > result.languageScores.fr);
|
||||
assert.deepEqual(result.languageEvidence.map(item => item.type), ['name-prefix', 'laya']);
|
||||
});
|
||||
|
||||
test('hybrid mode safely preserves algorithm output without Laya', () => {
|
||||
assert.equal(fusion.applyLanguageMode(algo, null, 'hybrid'), algo);
|
||||
});
|
||||
|
||||
test('batch descriptors contain only safe metadata in the backend id/text contract', () => {
|
||||
const descriptor = fusion.buildLayaDescriptor({
|
||||
sourceId: 4,
|
||||
name: '|FR| Sports',
|
||||
groupTitle: 'Sports',
|
||||
sourceName: 'Provider',
|
||||
tvgId: 'sports.fr',
|
||||
url: 'https://user:secret@example.test/live'
|
||||
}, '4:channel-1', algo);
|
||||
assert.deepEqual(Object.keys(descriptor), ['id', 'text']);
|
||||
assert.equal(descriptor.id, '4:channel-1');
|
||||
assert.match(descriptor.text, /sourceId: 4/);
|
||||
assert.doesNotMatch(descriptor.text, /secret|https:\/\//);
|
||||
});
|
||||
@ -59,3 +59,19 @@ test('favorites group updates are disabled for flat lists', () => {
|
||||
assert.equal(grouping.shouldUseFavoritesGroup('flat'), false);
|
||||
assert.equal(grouping.shouldUseFavoritesGroup('grouped'), true);
|
||||
});
|
||||
|
||||
test('Laya language results participate in variant selection', () => {
|
||||
const lowQualityFrench = channel('1', 'Sports One SD');
|
||||
const highQualityEnglish = channel('2', 'Sports One 4K');
|
||||
const languageResults = new Map([
|
||||
[grouping.getVariantKey(lowQualityFrench), { language: 'fr', confidence: 0.92 }],
|
||||
[grouping.getVariantKey(highQualityEnglish), { language: 'en', confidence: 0.94 }]
|
||||
]);
|
||||
const logical = grouping.groupChannels([lowQualityFrench, highQualityEnglish], {
|
||||
languageMode: 'laya',
|
||||
languageResults,
|
||||
preferredLanguage: 'fr'
|
||||
})[0];
|
||||
assert.equal(logical.preferredVariant.id, '1');
|
||||
assert.deepEqual(logical.availableLanguages.map(item => item.code), ['fr', 'en']);
|
||||
});
|
||||
|
||||
44
test/layaService.test.js
Normal file
44
test/layaService.test.js
Normal file
@ -0,0 +1,44 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
LayaRequestError,
|
||||
LayaService,
|
||||
clearDirectoryContents,
|
||||
validateBatchPayload
|
||||
} = require('../server/services/layaService');
|
||||
|
||||
test('status reports an unavailable runtime without throwing', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-status-'));
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
fetch: async () => { throw new Error('connection refused'); }
|
||||
});
|
||||
const status = await service.status();
|
||||
assert.equal(status.runtimeAvailable, false);
|
||||
assert.equal(status.modelPresent, false);
|
||||
});
|
||||
|
||||
test('model deletion remains limited to the configured model directory', async () => {
|
||||
const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-delete-'));
|
||||
const modelDir = path.join(parent, 'models');
|
||||
const sentinel = path.join(parent, 'keep.txt');
|
||||
await fs.mkdir(path.join(modelDir, 'nested'), { recursive: true });
|
||||
await fs.writeFile(path.join(modelDir, 'nested', 'model.bin'), 'model');
|
||||
await fs.writeFile(sentinel, 'keep');
|
||||
|
||||
await clearDirectoryContents(modelDir);
|
||||
|
||||
assert.deepEqual(await fs.readdir(modelDir), []);
|
||||
assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep');
|
||||
});
|
||||
|
||||
test('batch validation rejects oversized item counts', () => {
|
||||
assert.throws(
|
||||
() => validateBatchPayload({ items: Array.from({ length: 65 }, (_, id) => ({ id, text: 'channel' })) }),
|
||||
error => error instanceof LayaRequestError && error.statusCode === 413
|
||||
);
|
||||
});
|
||||
@ -5,3 +5,7 @@ const { getDefaultSettings } = require('../server/db');
|
||||
test('defaults Live TV channel lists to grouped mode', () => {
|
||||
assert.equal(getDefaultSettings().liveChannelListMode, 'grouped');
|
||||
});
|
||||
|
||||
test('defaults language detection to the safe algorithmic mode', () => {
|
||||
assert.equal(getDefaultSettings().languageDetectionMode, 'algo');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user