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 <noreply@anthropic.com>
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@ -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
|
||||||
|
|
||||||
@ -40,8 +40,9 @@ WORKDIR /app
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies (better-sqlite3 will build from source using g++ installed above)
|
# Install dependencies (better-sqlite3 will build for the image's Node.js ABI).
|
||||||
RUN npm ci --only=production
|
# node_modules is excluded by .dockerignore so COPY below cannot overwrite it.
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@ -14,11 +14,14 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000 # Internal container port
|
- PORT=3000 # Internal container port
|
||||||
- LAYA_BASE_URL=http://laya:8000
|
- LAYA_BASE_URL=http://laya:8000
|
||||||
|
- LAYA_BATCH_TIMEOUT_MS=120000
|
||||||
|
depends_on:
|
||||||
|
laya:
|
||||||
|
condition: service_healthy
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux)
|
- /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux)
|
||||||
|
|
||||||
laya:
|
laya:
|
||||||
profiles: ["laya"]
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.laya
|
dockerfile: Dockerfile.laya
|
||||||
@ -29,4 +32,10 @@ services:
|
|||||||
- LAYA_PRELOAD=0
|
- LAYA_PRELOAD=0
|
||||||
- LAYA_HOST=0.0.0.0
|
- LAYA_HOST=0.0.0.0
|
||||||
- LAYA_PORT=8000
|
- 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
|
restart: unless-stopped
|
||||||
|
|||||||
@ -384,6 +384,9 @@ class ChannelList {
|
|||||||
(response.results || []).forEach(result => results.set(String(result.id), result));
|
(response.results || []).forEach(result => results.set(String(result.id), result));
|
||||||
}
|
}
|
||||||
if (requestId !== this._languageDetectionRequest) return;
|
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.layaLanguageResults = results;
|
||||||
this.languageDetectionNotice = '';
|
this.languageDetectionNotice = '';
|
||||||
this.languageDetectionFallback = false;
|
this.languageDetectionFallback = false;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const MAX_BATCH_BYTES = 64 * 1024;
|
|||||||
const MAX_TEXT_LENGTH = 4096;
|
const MAX_TEXT_LENGTH = 4096;
|
||||||
const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']);
|
const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']);
|
||||||
const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
const BATCH_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
class LayaRequestError extends Error {
|
class LayaRequestError extends Error {
|
||||||
constructor(message, statusCode = 503) {
|
constructor(message, statusCode = 503) {
|
||||||
@ -128,6 +129,9 @@ class LayaService {
|
|||||||
? require('../db/sqlite').layaLanguageCache
|
? require('../db/sqlite').layaLanguageCache
|
||||||
: options.cache;
|
: options.cache;
|
||||||
this.timeoutMs = options.timeoutMs || 3000;
|
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.downloadTimeoutMs = options.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS;
|
||||||
this.progressIntervalMs = options.progressIntervalMs || 500;
|
this.progressIntervalMs = options.progressIntervalMs || 500;
|
||||||
this.downloadState = initialDownloadState();
|
this.downloadState = initialDownloadState();
|
||||||
@ -152,6 +156,9 @@ class LayaService {
|
|||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof LayaRequestError) throw 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');
|
throw new LayaRequestError('Laya runtime is unavailable');
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@ -168,9 +175,27 @@ class LayaService {
|
|||||||
if (!this.downloadState.downloading && this.downloadState.phase !== 'failed' && modelPresent) {
|
if (!this.downloadState.downloading && this.downloadState.phase !== 'failed' && modelPresent) {
|
||||||
this.downloadState.phase = 'ready';
|
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) {
|
} 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) {
|
if (misses.length) {
|
||||||
const response = await this.request('/v1/systemone/batch', {
|
const response = await this.request('/v1/systemone/batch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
timeoutMs: this.batchTimeoutMs,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
states: misses.map(({ item }) => item.text),
|
states: misses.map(({ item }) => item.text),
|
||||||
model,
|
model,
|
||||||
@ -323,6 +349,7 @@ class LayaService {
|
|||||||
}
|
}
|
||||||
const response = await this.request('/v1/systemone/batch', {
|
const response = await this.request('/v1/systemone/batch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
timeoutMs: this.batchTimeoutMs,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
states: pairs.map(pair => ({
|
states: pairs.map(pair => ({
|
||||||
left: sanitizeIdentityDescriptor(pair.left),
|
left: sanitizeIdentityDescriptor(pair.left),
|
||||||
@ -355,6 +382,7 @@ class LayaService {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ALLOWED_MODELS,
|
ALLOWED_MODELS,
|
||||||
|
BATCH_TIMEOUT_MS,
|
||||||
LayaRequestError,
|
LayaRequestError,
|
||||||
LayaService,
|
LayaService,
|
||||||
MODEL_DIR,
|
MODEL_DIR,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ test('status reports an unavailable runtime without throwing', async () => {
|
|||||||
});
|
});
|
||||||
const status = await service.status();
|
const status = await service.status();
|
||||||
assert.equal(status.runtimeAvailable, false);
|
assert.equal(status.runtimeAvailable, false);
|
||||||
|
assert.equal(status.ready, false);
|
||||||
assert.equal(status.modelPresent, false);
|
assert.equal(status.modelPresent, false);
|
||||||
assert.equal(status.phase, 'idle');
|
assert.equal(status.phase, 'idle');
|
||||||
assert.equal(status.downloading, false);
|
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.totalBytes, null);
|
||||||
assert.equal(status.sizeBytes, 0);
|
assert.equal(status.sizeBytes, 0);
|
||||||
assert.equal(status.error, null);
|
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 () => {
|
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 () => {
|
test('identity hints send only sanitized channel metadata to Laya', async () => {
|
||||||
let requestBody;
|
let requestBody;
|
||||||
const service = new LayaService({
|
const service = new LayaService({
|
||||||
|
|||||||
@ -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')));
|
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 () => {
|
test('analysis click does not replace visible channels with an empty successful projection', async () => {
|
||||||
const { window } = createDom();
|
const { window } = createDom();
|
||||||
let rebuildClicked = 0;
|
let rebuildClicked = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user