From 1abc56c90ee453c040ac26fc01b6b5e63ac49b3a Mon Sep 17 00:00:00 2001 From: blomios Date: Mon, 5 Oct 2026 23:10:31 +0200 Subject: [PATCH] fix: enable Laya language detection and harden live analysis (#18 #19 #20) Ensure Docker images keep their container-built native dependencies, wait for the Laya sidecar healthcheck, expose accurate Laya readiness, and treat empty AI language batches as fallback failures. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 18 ++++ Dockerfile | 5 +- docker-compose.yml | 11 ++- public/js/components/ChannelList.js | 3 + server/services/layaService.js | 32 ++++++- test/layaService.test.js | 61 ++++++++++++ test/liveAnalysisUserFlowDom.test.js | 133 +++++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b554b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +node_modules +npm-debug.log* +*.log +.npm +.cache +coverage + +# Runtime state must come from volumes or be created inside the image. +data +transcode-cache + +# Never bake local secrets into an image. +.env +.env.* +!.env.example + diff --git a/Dockerfile b/Dockerfile index 73316e7..084f2c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,8 +40,9 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies (better-sqlite3 will build from source using g++ installed above) -RUN npm ci --only=production +# Install dependencies (better-sqlite3 will build for the image's Node.js ABI). +# node_modules is excluded by .dockerignore so COPY below cannot overwrite it. +RUN npm ci --omit=dev # Copy application files COPY . . diff --git a/docker-compose.yml b/docker-compose.yml index 8231126..3a86400 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,11 +14,14 @@ services: - NODE_ENV=production - PORT=3000 # Internal container port - LAYA_BASE_URL=http://laya:8000 + - LAYA_BATCH_TIMEOUT_MS=120000 + depends_on: + laya: + condition: service_healthy devices: - /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux) laya: - profiles: ["laya"] build: context: . dockerfile: Dockerfile.laya @@ -29,4 +32,10 @@ services: - LAYA_PRELOAD=0 - LAYA_HOST=0.0.0.0 - LAYA_PORT=8000 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s restart: unless-stopped diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 5af913d..86eb9e9 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -384,6 +384,9 @@ class ChannelList { (response.results || []).forEach(result => results.set(String(result.id), result)); } if (requestId !== this._languageDetectionRequest) return; + if (descriptors.length > 0 && results.size === 0) { + throw new Error('Local AI language detection returned no results'); + } this.layaLanguageResults = results; this.languageDetectionNotice = ''; this.languageDetectionFallback = false; diff --git a/server/services/layaService.js b/server/services/layaService.js index eedf673..b1f82a3 100644 --- a/server/services/layaService.js +++ b/server/services/layaService.js @@ -8,6 +8,7 @@ const MAX_BATCH_BYTES = 64 * 1024; const MAX_TEXT_LENGTH = 4096; const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']); const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000; +const BATCH_TIMEOUT_MS = 2 * 60 * 1000; class LayaRequestError extends Error { constructor(message, statusCode = 503) { @@ -128,6 +129,9 @@ class LayaService { ? require('../db/sqlite').layaLanguageCache : options.cache; this.timeoutMs = options.timeoutMs || 3000; + this.batchTimeoutMs = options.batchTimeoutMs + || Number(process.env.LAYA_BATCH_TIMEOUT_MS) + || BATCH_TIMEOUT_MS; this.downloadTimeoutMs = options.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS; this.progressIntervalMs = options.progressIntervalMs || 500; this.downloadState = initialDownloadState(); @@ -152,6 +156,9 @@ class LayaService { return data; } catch (error) { if (error instanceof LayaRequestError) throw error; + if (error?.name === 'AbortError') { + throw new LayaRequestError(`Laya runtime request timed out after ${timeoutMs} ms`, 504); + } throw new LayaRequestError('Laya runtime is unavailable'); } finally { clearTimeout(timeout); @@ -168,9 +175,27 @@ class LayaService { if (!this.downloadState.downloading && this.downloadState.phase !== 'failed' && modelPresent) { this.downloadState.phase = 'ready'; } - return { ...this.downloadState, runtimeAvailable: true, modelPresent, sizeBytes, runtime }; + return { + ...this.downloadState, + runtimeAvailable: true, + modelPresent, + ready: modelPresent, + sizeBytes, + runtime, + runtimeError: null + }; } catch (error) { - return { ...this.downloadState, runtimeAvailable: false, modelPresent, sizeBytes }; + return { + ...this.downloadState, + runtimeAvailable: false, + modelPresent, + ready: false, + sizeBytes, + runtimeError: { + message: error.message, + detail: 'Start the Laya sidecar and verify LAYA_BASE_URL.' + } + }; } } @@ -287,6 +312,7 @@ class LayaService { if (misses.length) { const response = await this.request('/v1/systemone/batch', { method: 'POST', + timeoutMs: this.batchTimeoutMs, body: JSON.stringify({ states: misses.map(({ item }) => item.text), model, @@ -323,6 +349,7 @@ class LayaService { } const response = await this.request('/v1/systemone/batch', { method: 'POST', + timeoutMs: this.batchTimeoutMs, body: JSON.stringify({ states: pairs.map(pair => ({ left: sanitizeIdentityDescriptor(pair.left), @@ -355,6 +382,7 @@ class LayaService { module.exports = { ALLOWED_MODELS, + BATCH_TIMEOUT_MS, LayaRequestError, LayaService, MODEL_DIR, diff --git a/test/layaService.test.js b/test/layaService.test.js index d77e324..e1c2f59 100644 --- a/test/layaService.test.js +++ b/test/layaService.test.js @@ -19,6 +19,7 @@ test('status reports an unavailable runtime without throwing', async () => { }); const status = await service.status(); assert.equal(status.runtimeAvailable, false); + assert.equal(status.ready, false); assert.equal(status.modelPresent, false); assert.equal(status.phase, 'idle'); assert.equal(status.downloading, false); @@ -27,6 +28,24 @@ test('status reports an unavailable runtime without throwing', async () => { assert.equal(status.totalBytes, null); assert.equal(status.sizeBytes, 0); assert.equal(status.error, null); + assert.match(status.runtimeError.detail, /Start the Laya sidecar/); +}); + +test('status does not report ready when a model exists but the runtime is unavailable', async () => { + const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-offline-model-')); + await fs.writeFile(path.join(modelDir, 'model.bin'), 'model'); + const service = new LayaService({ + modelDir, + cache: null, + fetch: async () => { throw new Error('connection refused'); } + }); + + const status = await service.status(); + + assert.equal(status.modelPresent, true); + assert.equal(status.runtimeAvailable, false); + assert.equal(status.ready, false); + assert.equal(status.runtimeError.message, 'Laya runtime is unavailable'); }); test('model download exposes an indeterminate transition and finishes ready', async () => { @@ -120,6 +139,48 @@ test('batch validation rejects oversized item counts', () => { ); }); +test('language batch sends the Laya contract and parses language confidence without fallback', async () => { + let requestedUrl; + let requestBody; + const service = new LayaService({ + cache: null, + batchTimeoutMs: 120000, + fetch: async (url, options) => { + requestedUrl = url; + requestBody = JSON.parse(options.body); + return { + ok: true, + json: async () => ({ + results: [ + { answers: { language: { choice: 'fr', answer_confidence: 0.93 } } }, + { answers: { language: { choice: 'en', confidence: 0.81 } } } + ] + }) + }; + } + }); + + const response = await service.detectBatch({ + model: 'multilingual', + items: [ + { id: 'fr-channel', text: 'France 2 FR' }, + { id: 'en-channel', text: 'BBC News UK' } + ] + }); + + assert.equal(requestedUrl, 'http://laya:8000/v1/systemone/batch'); + assert.deepEqual(requestBody.states, ['France 2 FR', 'BBC News UK']); + assert.equal(requestBody.model, 'multilingual'); + assert.deepEqual(response, { + runtimeAvailable: true, + model: 'multilingual', + results: [ + { id: 'fr-channel', language: 'fr', confidence: 0.93, cached: false }, + { id: 'en-channel', language: 'en', confidence: 0.81, cached: false } + ] + }); +}); + test('identity hints send only sanitized channel metadata to Laya', async () => { let requestBody; const service = new LayaService({ diff --git a/test/liveAnalysisUserFlowDom.test.js b/test/liveAnalysisUserFlowDom.test.js index d2c8b04..c5e90ce 100644 --- a/test/liveAnalysisUserFlowDom.test.js +++ b/test/liveAnalysisUserFlowDom.test.js @@ -178,6 +178,139 @@ test('user analysis click keeps visible channels after Local AI language failure assert.ok(consoleWarnings.some(line => line.includes('Local AI language detection unavailable'))); }); +test('successful Standard fallback after Local AI failure keeps channels and reports the AI failure', async () => { + const { window } = createDom(); + let rebuildClicked = 0; + let layaBatchCalls = 0; + window.API = { + live: { + getChannels: async () => projectedChannels(), + rebuildProjection: async () => { + rebuildClicked += 1; + return { projection: { status: 'building', processed: 0, total: 2 } }; + }, + getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 }) + }, + laya: { + getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), + detectLanguages: async () => { + layaBatchCalls += 1; + throw new Error('simulated local AI language failure'); + } + } + }; + + const app = { + player: { + settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' }, + saveSettings() {} + }, + epgGuide: null, + navigateTo() {}, + pages: { settings: { switchTab() {} } } + }; + window.app = app; + const channelList = new window.ChannelList(); + channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; + app.channelList = channelList; + + await channelList.loadChannels(); + channelList.expandAll(); + assert.deepEqual( + [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent), + ['Sports One', 'Sports One'] + ); + + app.player.settings.languageDetectionMode = 'hybrid'; + const settingsPage = Object.create(window.SettingsPage.prototype); + settingsPage.app = app; + settingsPage.initLiveAnalysis(); + await Promise.resolve(); + + window.document.getElementById('live-analysis-button').click(); + assert.equal(rebuildClicked, 1); + await new Promise(resolve => setTimeout(resolve, 1100)); + channelList.expandAll(); + window.clearInterval(channelList._epgRefreshTimer); + window.clearInterval(settingsPage.liveAnalysisPollTimer); + + const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); + const notice = window.document.querySelector('.laya-language-notice'); + + assert.equal(layaBatchCalls, 1); + assert.deepEqual(afterNames, ['Sports One', 'Sports One']); + assert.equal(window.document.querySelector('.empty-state'), null); + assert.equal(window.document.querySelector('#channel-list > .loading'), null); + assert.ok(notice); + assert.match(notice.textContent, /Local AI language detection failed/); + assert.match(notice.textContent, /Standard matching/); + assert.equal(channelList.languageDetectionFallback, true); +}); + +test('successful Laya language batch updates visible variant languages without Standard fallback', async () => { + const { window } = createDom(); + let layaBatchCalls = 0; + let batchPayload = null; + window.API = { + live: { + getChannels: async () => projectedChannels(), + rebuildProjection: async () => ({ projection: { status: 'success', processed: 2, total: 2, projectedCount: 1 } }), + getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 }) + }, + laya: { + getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), + detectLanguages: async items => { + layaBatchCalls += 1; + batchPayload = items; + return { + runtimeAvailable: true, + model: 'multilingual', + results: items.map(item => ({ + id: item.id, + language: item.id.endsWith('fr-sd') ? 'en' : 'fr', + confidence: item.id.endsWith('fr-sd') ? 0.91 : 0.87 + })) + }; + } + } + }; + + const app = { + player: { + settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' }, + saveSettings() {} + }, + epgGuide: null, + navigateTo() {}, + pages: { settings: { switchTab() {} } } + }; + window.app = app; + const channelList = new window.ChannelList(); + channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; + app.channelList = channelList; + + await channelList.loadChannels(); + app.player.settings.languageDetectionMode = 'laya'; + await channelList.refreshLayaLanguages('laya'); + channelList.expandedVariants.add('sports one'); + channelList.expandAll(); + + const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent); + const evidenceTypes = channelList.logicalChannels + .flatMap(channel => channel.variants) + .flatMap(variant => variant.languageEvidence.map(item => item.type)); + + window.clearInterval(channelList._epgRefreshTimer); + + assert.equal(layaBatchCalls, 1); + assert.equal(JSON.stringify(batchPayload.map(item => item.id).sort()), JSON.stringify(['1:en-4k', '1:fr-sd'])); + assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 87%']); + assert.ok(evidenceTypes.every(type => type === 'laya')); + assert.equal(channelList.languageDetectionFallback, false); + assert.equal(channelList.languageDetectionNotice, ''); + assert.equal(window.document.querySelector('.laya-language-notice'), null); +}); + test('analysis click does not replace visible channels with an empty successful projection', async () => { const { window } = createDom(); let rebuildClicked = 0;