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

@ -63,6 +63,7 @@ function getDefaultSettings() {
lastVolume: 80,
autoPlayNextEpisode: false,
liveChannelListMode: 'grouped',
languageDetectionMode: 'algo',
forceProxy: false,
forceTranscode: false, // Force Audio Transcode
forceVideoTranscode: false, // Force Video Transcode

View File

@ -135,6 +135,18 @@ function initSchema() {
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS laya_language_cache (
cache_key TEXT PRIMARY KEY,
mode TEXT NOT NULL,
model TEXT NOT NULL,
response TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_laya_cache_mode_model
ON laya_language_cache(mode, model);
`);
// Migration: Add source_id column if missing (for existing databases)
try {
db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`);
@ -209,8 +221,32 @@ const favorites = {
}
};
const layaLanguageCache = {
get(cacheKey) {
const row = getDb().prepare(
'SELECT response FROM laya_language_cache WHERE cache_key = ?'
).get(cacheKey);
return row ? JSON.parse(row.response) : null;
},
set(cacheKey, mode, model, response) {
getDb().prepare(`
INSERT INTO laya_language_cache (cache_key, mode, model, response, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
response = excluded.response,
created_at = excluded.created_at
`).run(cacheKey, mode, model, JSON.stringify(response), Date.now());
},
clear() {
getDb().prepare('DELETE FROM laya_language_cache').run();
}
};
module.exports = {
getDb,
initSchema,
favorites
favorites,
layaLanguageCache
};

View File

@ -180,6 +180,7 @@ app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history'));
app.use('/api/laya', require('./routes/laya'));
// Version endpoint
app.get('/api/version', (req, res) => {

42
server/routes/laya.js Normal file
View File

@ -0,0 +1,42 @@
const express = require('express');
const { requireAuth, requireAdmin } = require('../auth');
const { LayaRequestError, LayaService } = require('../services/layaService');
const router = express.Router();
const laya = new LayaService();
function sendError(res, error) {
const status = error instanceof LayaRequestError ? error.statusCode : 500;
const message = error instanceof LayaRequestError ? error.message : 'Internal server error';
res.status(status).json({ runtimeAvailable: false, error: message });
}
router.get('/status', requireAuth, async (req, res) => {
res.json(await laya.status());
});
router.post('/model/download', requireAuth, requireAdmin, async (req, res) => {
try {
res.json(await laya.downloadModel(req.body?.model));
} catch (error) {
sendError(res, error);
}
});
router.delete('/model', requireAuth, requireAdmin, async (req, res) => {
try {
res.json(await laya.deleteModel());
} catch (error) {
sendError(res, error);
}
});
router.post('/language/batch', requireAuth, async (req, res) => {
try {
res.json(await laya.detectBatch(req.body));
} catch (error) {
sendError(res, error);
}
});
module.exports = router;

View File

@ -0,0 +1,195 @@
const crypto = require('crypto');
const fs = require('fs/promises');
const path = require('path');
const MODEL_DIR = path.resolve(__dirname, '..', '..', 'data', 'models', 'laya');
const MAX_BATCH_ITEMS = 64;
const MAX_BATCH_BYTES = 64 * 1024;
const MAX_TEXT_LENGTH = 4096;
const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']);
class LayaRequestError extends Error {
constructor(message, statusCode = 503) {
super(message);
this.name = 'LayaRequestError';
this.statusCode = statusCode;
}
}
function validateBatchPayload(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new LayaRequestError('Request body must be an object', 400);
}
if (!Array.isArray(payload.items) || payload.items.length === 0) {
throw new LayaRequestError('items must be a non-empty array', 400);
}
if (Object.keys(payload).some(key => !['items', 'model'].includes(key))) {
throw new LayaRequestError('Request body contains unsupported fields', 400);
}
if (payload.items.length > MAX_BATCH_ITEMS || Buffer.byteLength(JSON.stringify(payload)) > MAX_BATCH_BYTES) {
throw new LayaRequestError('Batch payload is too large', 413);
}
payload.items.forEach((item, index) => {
if (!item || typeof item !== 'object' || typeof item.text !== 'string' || !item.text.trim()) {
throw new LayaRequestError(`items[${index}].text must be a non-empty string`, 400);
}
if (item.text.length > MAX_TEXT_LENGTH) {
throw new LayaRequestError(`items[${index}].text is too long`, 413);
}
if (Object.keys(item).some(key => !['id', 'text'].includes(key))) {
throw new LayaRequestError(`items[${index}] contains unsupported fields`, 400);
}
if (item.id !== undefined && !['string', 'number'].includes(typeof item.id)) {
throw new LayaRequestError(`items[${index}].id must be a string or number`, 400);
}
});
if (payload.model !== undefined && !ALLOWED_MODELS.has(payload.model)) {
throw new LayaRequestError('Unsupported Laya model', 400);
}
return payload;
}
async function clearDirectoryContents(directory) {
const root = path.resolve(directory);
await fs.mkdir(root, { recursive: true });
const entries = await fs.readdir(root);
for (const entry of entries) {
const target = path.resolve(root, entry);
if (path.dirname(target) !== root) {
throw new Error('Refusing to delete outside the Laya model directory');
}
await fs.rm(target, { recursive: true, force: true });
}
}
class LayaService {
constructor(options = {}) {
this.baseUrl = options.baseUrl || process.env.LAYA_BASE_URL || 'http://laya:8000';
this.modelDir = path.resolve(options.modelDir || MODEL_DIR);
this.fetch = options.fetch || globalThis.fetch;
this.cache = options.cache === undefined
? require('../db/sqlite').layaLanguageCache
: options.cache;
this.timeoutMs = options.timeoutMs || 3000;
}
async request(endpoint, options = {}) {
if (typeof this.fetch !== 'function') throw new LayaRequestError('Laya runtime client is unavailable');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
const headers = { 'content-type': 'application/json' };
if (process.env.LAYA_API_KEY) headers.authorization = `Bearer ${process.env.LAYA_API_KEY}`;
try {
const response = await this.fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: { ...headers, ...options.headers },
signal: controller.signal
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new LayaRequestError(`Laya runtime returned HTTP ${response.status}`, 502);
return data;
} catch (error) {
if (error instanceof LayaRequestError) throw error;
throw new LayaRequestError('Laya runtime is unavailable');
} finally {
clearTimeout(timeout);
}
}
async status() {
await fs.mkdir(this.modelDir, { recursive: true });
const modelPresent = (await fs.readdir(this.modelDir)).length > 0;
try {
const runtime = await this.request('/health', { method: 'GET' });
return { runtimeAvailable: true, modelPresent, runtime };
} catch (error) {
return { runtimeAvailable: false, modelPresent, error: error.message };
}
}
async downloadModel(model = 'multilingual') {
if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400);
await fs.mkdir(this.modelDir, { recursive: true });
await this.request('/v1/systemone', {
method: 'POST',
body: JSON.stringify({
state: 'Bonjour',
model,
questions: {
language: {
type: 'choice',
instructions: 'Identify the language of this text.',
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
}
}
})
});
return this.status();
}
async deleteModel() {
await clearDirectoryContents(this.modelDir);
if (this.cache) this.cache.clear();
return { deleted: true, modelPresent: false };
}
cacheKey(text, mode, model) {
return crypto.createHash('sha256').update(`${mode}\0${model}\0${text}`).digest('hex');
}
async detectBatch(payload) {
validateBatchPayload(payload);
const model = payload.model || 'multilingual';
const mode = 'laya';
const results = new Array(payload.items.length);
const misses = [];
payload.items.forEach((item, index) => {
const key = this.cacheKey(item.text, mode, model);
const cached = this.cache ? this.cache.get(key) : null;
if (cached) results[index] = { ...cached, cached: true };
else misses.push({ item, index, key });
});
if (misses.length) {
const response = await this.request('/v1/systemone/batch', {
method: 'POST',
body: JSON.stringify({
states: misses.map(({ item }) => item.text),
model,
questions: {
language: {
type: 'choice',
instructions: 'Identify the language of this text.',
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
}
}
})
});
if (!response || !Array.isArray(response.results) || response.results.length !== misses.length) {
throw new LayaRequestError('Laya runtime returned an invalid batch response', 502);
}
misses.forEach(({ item, index, key }, responseIndex) => {
const answer = response.results[responseIndex]?.answers?.language;
const result = {
id: item.id ?? index,
language: answer?.choice || 'unknown',
confidence: answer?.answer_confidence ?? answer?.confidence ?? 0,
cached: false
};
results[index] = result;
if (this.cache) this.cache.set(key, mode, model, { ...result, cached: undefined });
});
}
return { runtimeAvailable: true, model, results };
}
}
module.exports = {
ALLOWED_MODELS,
LayaRequestError,
LayaService,
MODEL_DIR,
clearDirectoryContents,
validateBatchPayload
};