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

23
Dockerfile.laya Normal file
View File

@ -0,0 +1,23 @@
FROM python:3.12-slim
ARG TORCH_VERSION=2.14.1
# Install the CPU wheel explicitly before Laya so pip does not select the much
# larger CUDA build. Model weights remain runtime-only in the mounted cache.
RUN python -m pip install --no-cache-dir \
"torch==${TORCH_VERSION}" \
--index-url https://download.pytorch.org/whl/cpu \
&& python -m pip install --no-cache-dir "laya[serve]>=0.3.22,<0.4" \
&& python -m pip check \
&& python -c "import torch; assert torch.version.cuda is None, torch.version.cuda"
ENV HF_HOME=/models \
LAYA_HOST=0.0.0.0 \
LAYA_PORT=8000 \
LAYA_PRELOAD=0 \
LAYA_DEVICE=cpu
VOLUME ["/models"]
EXPOSE 8000
CMD ["laya-serve"]

View File

@ -13,5 +13,20 @@ services:
environment:
- NODE_ENV=production
- PORT=3000 # Internal container port
- LAYA_BASE_URL=http://laya:8000
devices:
- /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux)
laya:
profiles: ["laya"]
build:
context: .
dockerfile: Dockerfile.laya
volumes:
- ./data/models/laya:/models
environment:
- HF_HOME=/models
- LAYA_PRELOAD=0
- LAYA_HOST=0.0.0.0
- LAYA_PORT=8000
restart: unless-stopped

View File

@ -725,6 +725,71 @@ h4 {
white-space: nowrap;
}
.channel-matching-settings {
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
.matching-mode-options {
display: grid;
gap: var(--space-sm);
margin: var(--space-md) 0;
}
.matching-mode-card {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.matching-mode-card:has(input:checked) {
border-color: var(--color-accent);
background: var(--color-accent-dim);
}
.matching-mode-card span,
.matching-mode-card small {
display: block;
}
.matching-mode-card small {
margin-top: 4px;
color: var(--color-text-muted);
}
.laya-model-panel {
padding: var(--space-md);
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
}
.laya-model-heading,
.laya-model-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.laya-model-actions {
justify-content: flex-start;
margin: var(--space-md) 0;
}
.laya-language-notice {
margin: var(--space-sm) var(--space-md);
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
font-size: .75rem;
}
.variant-name {
overflow: hidden;
text-overflow: ellipsis;

View File

@ -10,7 +10,7 @@
<title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="css/main.css?v=2">
<link rel="stylesheet" href="css/main.css?v=3">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
@ -466,6 +466,36 @@
<span class="setting-hint">Turn off to show all channels in a single list. Stream variants remain grouped by channel.</span>
</div>
</div>
<div class="channel-matching-settings">
<h4>Channel matching</h4>
<p class="setting-hint">Choose how Nodecast TV identifies matching streams and languages.</p>
<div class="matching-mode-options">
<label class="matching-mode-card">
<input type="radio" name="language-detection-mode" value="algo" checked>
<span><strong>Standard (Recommended)</strong><small>Use the built-in matching system. Fast and no download required.</small></span>
</label>
<label class="matching-mode-card">
<input type="radio" name="language-detection-mode" value="laya">
<span><strong>Local AI only</strong><small>Use the downloaded AI model instead of the built-in matching system.</small></span>
</label>
<label class="matching-mode-card">
<input type="radio" name="language-detection-mode" value="hybrid">
<span><strong>Enhanced</strong><small>Combine the local AI model with the built-in matching system for more evidence.</small></span>
</label>
</div>
<div class="laya-model-panel" id="laya-model-panel">
<div class="laya-model-heading">
<div><strong>Local AI model · Laya</strong><div id="laya-model-status" class="setting-hint" role="status">Checking status…</div></div>
<span id="laya-model-size" class="setting-hint"></span>
</div>
<div id="laya-model-message" class="setting-hint"></div>
<div class="laya-model-actions">
<button type="button" class="btn btn-primary" id="laya-download-model">Download model</button>
<button type="button" class="btn btn-ghost" id="laya-remove-model">Remove downloaded model</button>
</div>
<p class="setting-hint">The model runs on this server. Channel names and metadata are not sent to an AI service.</p>
</div>
</div>
</div>
<div class="settings-section">
@ -1137,11 +1167,12 @@
<!-- JavaScript -->
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script>
<script src="/js/components/VideoPlayer.js?v=4"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
<script src="/js/services/channelVariantGrouping.js?v=3"></script>
<script src="/js/components/ChannelList.js?v=6"></script>
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/channelVariantGrouping.js?v=4"></script>
<script src="/js/components/ChannelList.js?v=7"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
<script src="/js/pages/HomePage.js?v=2"></script>
@ -1149,7 +1180,7 @@
<script src="/js/pages/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=3"></script>
<script src="/js/pages/Settings.js?v=4"></script>
<script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script>
<script>

View File

@ -95,6 +95,13 @@ const API = {
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
},
laya: {
getStatus: () => API.request('GET', '/laya/status'),
downloadModel: (model = 'multilingual') => API.request('POST', '/laya/model/download', { model }),
removeModel: () => API.request('DELETE', '/laya/model'),
detectLanguages: (items, model = 'multilingual') => API.request('POST', '/laya/language/batch', { items, model })
},
// Proxy
proxy: {
// Xtream

View File

@ -31,6 +31,9 @@ class ChannelList {
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
this.preferredLanguage = this.loadPreferredLanguage();
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
this.layaLanguageResults = new Map();
this.languageDetectionNotice = '';
this._languageDetectionRequest = 0;
this.loadCollapsedState();
this.init();
@ -328,6 +331,52 @@ class ChannelList {
if (this.channels.length > 0) this.render();
}
setLanguageDetectionMode(mode) {
const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo';
this.layaLanguageResults.clear();
this.languageDetectionNotice = '';
if (this.channels.length > 0) {
this.render();
void this.refreshLayaLanguages(safeMode);
}
}
async refreshLayaLanguages(mode = window.app?.player?.settings?.languageDetectionMode || 'algo') {
const requestId = ++this._languageDetectionRequest;
if (mode === 'algo') {
this.layaLanguageResults.clear();
this.languageDetectionNotice = '';
return;
}
const descriptors = this.channels.map(variant => {
const algo = ChannelLanguageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
return ChannelLanguageFusion.buildLayaDescriptor(
variant,
ChannelVariantGrouping.getVariantKey(variant),
algo
);
});
const results = new Map();
try {
for (let index = 0; index < descriptors.length; index += 64) {
const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64));
(response.results || []).forEach(result => results.set(String(result.id), result));
}
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults = results;
this.languageDetectionNotice = '';
} catch (err) {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear();
this.languageDetectionNotice = mode === 'hybrid'
? 'Local AI is unavailable; using standard channel matching.'
: 'Local AI is unavailable. Download or start the model in Settings.';
console.warn('[ChannelList] Local AI language detection unavailable:', err.message);
}
this.render();
}
escapeHtml(text) {
if (!text) return '';
return text
@ -356,6 +405,8 @@ class ChannelList {
selectedVariants: this.selectedVariants,
preferredLanguage: this.preferredLanguage,
languagePreferences: this.channelLanguagePreferences,
languageMode: window.app?.player?.settings?.languageDetectionMode || 'algo',
languageResults: this.layaLanguageResults,
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
});
@ -364,6 +415,13 @@ class ChannelList {
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
this.container.innerHTML = ''; // Clear container
if (this.languageDetectionNotice) {
const notice = document.createElement('div');
notice.className = 'laya-language-notice';
notice.setAttribute('role', 'status');
notice.textContent = this.languageDetectionNotice;
this.container.appendChild(notice);
}
// Filter and Group channels
const groupedChannels = {};
@ -837,6 +895,7 @@ class ChannelList {
]);
this.render();
void this.refreshLayaLanguages();
} catch (err) {
console.error('Error loading channels:', err);
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
@ -872,6 +931,7 @@ class ChannelList {
this.loadFavorites()
]);
this.render();
void this.refreshLayaLanguages();
} catch (err) {
console.error('Error loading all channels:', err);
}

View File

@ -58,6 +58,7 @@ class VideoPlayer {
lastVolume: 80,
autoPlayNextEpisode: false,
liveChannelListMode: 'grouped',
languageDetectionMode: 'algo',
forceProxy: false,
forceTranscode: false,
forceRemux: false,

View File

@ -86,6 +86,8 @@ class SettingsPage {
this.app.player.saveSettings();
});
this.initLayaSettings();
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) {
@ -103,6 +105,95 @@ class SettingsPage {
this.updateEpgLastRefreshed();
}
initLayaSettings() {
const radios = document.querySelectorAll('input[name="language-detection-mode"]');
const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo';
radios.forEach(radio => {
radio.checked = radio.value === currentMode;
radio.addEventListener('change', () => {
if (!radio.checked) return;
this.app.player.settings.languageDetectionMode = radio.value;
this.app.player.saveSettings();
this.app.channelList.setLanguageDetectionMode(radio.value);
this.renderLayaStatus(this.layaStatus);
});
});
document.getElementById('laya-download-model')?.addEventListener('click', () => this.runLayaModelAction('download'));
document.getElementById('laya-remove-model')?.addEventListener('click', () => {
if (confirm('Remove local AI model?\n\nYour sources, favorites, and hidden channels will not be deleted.')) {
this.runLayaModelAction('remove');
}
});
void this.loadLayaStatus();
}
async loadLayaStatus() {
try {
this.layaStatus = await API.laya.getStatus();
} catch (err) {
this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message };
}
this.renderLayaStatus(this.layaStatus);
return this.layaStatus;
}
renderLayaStatus(status = {}) {
const statusElement = document.getElementById('laya-model-status');
const sizeElement = document.getElementById('laya-model-size');
const messageElement = document.getElementById('laya-model-message');
const downloadButton = document.getElementById('laya-download-model');
const removeButton = document.getElementById('laya-remove-model');
if (!statusElement) return;
const modelPresent = status.modelPresent === true;
const runtimeAvailable = status.runtimeAvailable === true;
statusElement.textContent = modelPresent
? (runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable')
: (runtimeAvailable ? 'Model not downloaded' : 'Local AI runtime unavailable');
const size = status.modelSizeBytes || status.sizeBytes || status.runtime?.modelSizeBytes;
sizeElement.textContent = size ? this.formatBytes(size) : '';
downloadButton.hidden = modelPresent;
removeButton.hidden = !modelPresent;
downloadButton.disabled = Boolean(this.layaOperationPending);
removeButton.disabled = Boolean(this.layaOperationPending);
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
else if (mode !== 'algo' && (!modelPresent || !runtimeAvailable)) {
messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.';
} else messageElement.textContent = status.error || '';
}
formatBytes(bytes) {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`;
}
async runLayaModelAction(action) {
if (this.layaOperationPending) return;
this.layaOperationPending = action === 'download' ? 'Downloading model…' : 'Removing model…';
this.renderLayaStatus(this.layaStatus);
const poll = setInterval(() => void this.loadLayaStatus(), 2000);
try {
this.layaStatus = action === 'download'
? await API.laya.downloadModel('multilingual')
: await API.laya.removeModel();
} catch (err) {
this.layaStatus = { ...(this.layaStatus || {}), error: err.message };
} finally {
clearInterval(poll);
this.layaOperationPending = '';
await this.loadLayaStatus();
if (this.app.player.settings.languageDetectionMode !== 'algo') {
this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode);
}
}
}
async initTranscodingSettings() {
// Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
@ -595,6 +686,9 @@ class SettingsPage {
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
if (groupLiveChannelsToggle) groupLiveChannelsToggle.checked = s.liveChannelListMode !== 'flat';
document.querySelectorAll('input[name="language-detection-mode"]').forEach(radio => {
radio.checked = radio.value === (s.languageDetectionMode || 'algo');
});
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
@ -617,6 +711,7 @@ class SettingsPage {
// Update EPG last refreshed display
this.updateEpgLastRefreshed();
void this.loadLayaStatus();
}
/**

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 };
});

View File

@ -2,10 +2,13 @@
const detector = typeof module === 'object' && module.exports
? require('./channelLanguageDetection.js')
: root.ChannelLanguageDetection;
const service = factory(detector);
const fusion = typeof module === 'object' && module.exports
? require('./channelLanguageFusion.js')
: root.ChannelLanguageFusion;
const service = factory(detector, fusion);
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.ChannelVariantGrouping = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection) {
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection, languageFusion) {
const QUALITY_TOKENS = new Set([
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
'1080p', '1080i', '720p', '576p', '480p'
@ -96,10 +99,14 @@
});
return Array.from(buckets, ([fingerprint, rawVariants]) => {
const variants = rawVariants.map(variant => ({
...variant,
...languageDetection.detectLanguage(variant, { sourceName: variant.sourceName })
}));
const variants = rawVariants.map(variant => {
const algo = languageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
const result = options.languageResults?.get(getVariantKey(variant));
return {
...variant,
...languageFusion.applyLanguageMode(algo, result, options.languageMode || 'algo')
};
});
const visibleVariants = variants.filter(variant => !isHidden(variant));
const selectableVariants = showHidden ? variants : visibleVariants;
const languageCounts = selectableVariants.reduce((counts, variant) => {

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
};

View 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:\/\//);
});

View File

@ -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
View 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
);
});

View File

@ -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');
});