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:
23
Dockerfile.laya
Normal file
23
Dockerfile.laya
Normal 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"]
|
||||||
@ -13,5 +13,20 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000 # Internal container port
|
- PORT=3000 # Internal container port
|
||||||
|
- LAYA_BASE_URL=http://laya:8000
|
||||||
devices:
|
devices:
|
||||||
- /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux)
|
- /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
|
||||||
|
|||||||
@ -725,6 +725,71 @@ h4 {
|
|||||||
white-space: nowrap;
|
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 {
|
.variant-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<title>nodecast-tv | IPTV Player</title>
|
<title>nodecast-tv | IPTV Player</title>
|
||||||
<meta name="description" content="Stream live TV, movies, and series with EPG support">
|
<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="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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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"
|
<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>
|
<span class="setting-hint">Turn off to show all channels in a single list. Stream variants remain grouped by channel.</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@ -1137,11 +1167,12 @@
|
|||||||
|
|
||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/api.js?v=2"></script>
|
<script src="/js/api.js?v=3"></script>
|
||||||
<script src="/js/components/VideoPlayer.js?v=4"></script>
|
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||||
<script src="/js/services/channelVariantGrouping.js?v=3"></script>
|
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=6"></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/SourceManager.js?v=2"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||||
<script src="/js/pages/HomePage.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/Guide.js?v=2"></script>
|
||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></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/pages/WatchPage.js?v=1"></script>
|
||||||
<script src="/js/app.js?v=4"></script>
|
<script src="/js/app.js?v=4"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -95,6 +95,13 @@ const API = {
|
|||||||
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
|
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
|
||||||
proxy: {
|
proxy: {
|
||||||
// Xtream
|
// Xtream
|
||||||
|
|||||||
@ -31,6 +31,9 @@ class ChannelList {
|
|||||||
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
|
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
|
||||||
this.preferredLanguage = this.loadPreferredLanguage();
|
this.preferredLanguage = this.loadPreferredLanguage();
|
||||||
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
|
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
|
||||||
|
this.layaLanguageResults = new Map();
|
||||||
|
this.languageDetectionNotice = '';
|
||||||
|
this._languageDetectionRequest = 0;
|
||||||
|
|
||||||
this.loadCollapsedState();
|
this.loadCollapsedState();
|
||||||
this.init();
|
this.init();
|
||||||
@ -328,6 +331,52 @@ class ChannelList {
|
|||||||
if (this.channels.length > 0) this.render();
|
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) {
|
escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text
|
return text
|
||||||
@ -356,6 +405,8 @@ class ChannelList {
|
|||||||
selectedVariants: this.selectedVariants,
|
selectedVariants: this.selectedVariants,
|
||||||
preferredLanguage: this.preferredLanguage,
|
preferredLanguage: this.preferredLanguage,
|
||||||
languagePreferences: this.channelLanguagePreferences,
|
languagePreferences: this.channelLanguagePreferences,
|
||||||
|
languageMode: window.app?.player?.settings?.languageDetectionMode || 'algo',
|
||||||
|
languageResults: this.layaLanguageResults,
|
||||||
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
|
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
|
||||||
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
|
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
|
||||||
});
|
});
|
||||||
@ -364,6 +415,13 @@ class ChannelList {
|
|||||||
this.currentBatch = 0;
|
this.currentBatch = 0;
|
||||||
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
||||||
this.container.innerHTML = ''; // Clear container
|
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
|
// Filter and Group channels
|
||||||
const groupedChannels = {};
|
const groupedChannels = {};
|
||||||
@ -837,6 +895,7 @@ class ChannelList {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
void this.refreshLayaLanguages();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading channels:', 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>`;
|
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.loadFavorites()
|
||||||
]);
|
]);
|
||||||
this.render();
|
this.render();
|
||||||
|
void this.refreshLayaLanguages();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading all channels:', err);
|
console.error('Error loading all channels:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ class VideoPlayer {
|
|||||||
lastVolume: 80,
|
lastVolume: 80,
|
||||||
autoPlayNextEpisode: false,
|
autoPlayNextEpisode: false,
|
||||||
liveChannelListMode: 'grouped',
|
liveChannelListMode: 'grouped',
|
||||||
|
languageDetectionMode: 'algo',
|
||||||
forceProxy: false,
|
forceProxy: false,
|
||||||
forceTranscode: false,
|
forceTranscode: false,
|
||||||
forceRemux: false,
|
forceRemux: false,
|
||||||
|
|||||||
@ -86,6 +86,8 @@ class SettingsPage {
|
|||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.initLayaSettings();
|
||||||
|
|
||||||
// EPG refresh interval
|
// EPG refresh interval
|
||||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||||
if (epgRefreshSelect && this.app.player?.settings) {
|
if (epgRefreshSelect && this.app.player?.settings) {
|
||||||
@ -103,6 +105,95 @@ class SettingsPage {
|
|||||||
this.updateEpgLastRefreshed();
|
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() {
|
async initTranscodingSettings() {
|
||||||
// Encoder settings
|
// Encoder settings
|
||||||
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
|
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
|
||||||
@ -595,6 +686,9 @@ class SettingsPage {
|
|||||||
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
|
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
|
||||||
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
|
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
|
||||||
if (groupLiveChannelsToggle) groupLiveChannelsToggle.checked = s.liveChannelListMode !== 'flat';
|
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 (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
|
||||||
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
|
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
|
||||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||||
@ -617,6 +711,7 @@ class SettingsPage {
|
|||||||
|
|
||||||
// Update EPG last refreshed display
|
// Update EPG last refreshed display
|
||||||
this.updateEpgLastRefreshed();
|
this.updateEpgLastRefreshed();
|
||||||
|
void this.loadLayaStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
83
public/js/services/channelLanguageFusion.js
Normal file
83
public/js/services/channelLanguageFusion.js
Normal 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 };
|
||||||
|
});
|
||||||
@ -2,10 +2,13 @@
|
|||||||
const detector = typeof module === 'object' && module.exports
|
const detector = typeof module === 'object' && module.exports
|
||||||
? require('./channelLanguageDetection.js')
|
? require('./channelLanguageDetection.js')
|
||||||
: root.ChannelLanguageDetection;
|
: 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 (typeof module === 'object' && module.exports) module.exports = service;
|
||||||
if (root) root.ChannelVariantGrouping = 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([
|
const QUALITY_TOKENS = new Set([
|
||||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||||
'1080p', '1080i', '720p', '576p', '480p'
|
'1080p', '1080i', '720p', '576p', '480p'
|
||||||
@ -96,10 +99,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(buckets, ([fingerprint, rawVariants]) => {
|
return Array.from(buckets, ([fingerprint, rawVariants]) => {
|
||||||
const variants = rawVariants.map(variant => ({
|
const variants = rawVariants.map(variant => {
|
||||||
|
const algo = languageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
|
||||||
|
const result = options.languageResults?.get(getVariantKey(variant));
|
||||||
|
return {
|
||||||
...variant,
|
...variant,
|
||||||
...languageDetection.detectLanguage(variant, { sourceName: variant.sourceName })
|
...languageFusion.applyLanguageMode(algo, result, options.languageMode || 'algo')
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
const visibleVariants = variants.filter(variant => !isHidden(variant));
|
const visibleVariants = variants.filter(variant => !isHidden(variant));
|
||||||
const selectableVariants = showHidden ? variants : visibleVariants;
|
const selectableVariants = showHidden ? variants : visibleVariants;
|
||||||
const languageCounts = selectableVariants.reduce((counts, variant) => {
|
const languageCounts = selectableVariants.reduce((counts, variant) => {
|
||||||
|
|||||||
@ -63,6 +63,7 @@ function getDefaultSettings() {
|
|||||||
lastVolume: 80,
|
lastVolume: 80,
|
||||||
autoPlayNextEpisode: false,
|
autoPlayNextEpisode: false,
|
||||||
liveChannelListMode: 'grouped',
|
liveChannelListMode: 'grouped',
|
||||||
|
languageDetectionMode: 'algo',
|
||||||
forceProxy: false,
|
forceProxy: false,
|
||||||
forceTranscode: false, // Force Audio Transcode
|
forceTranscode: false, // Force Audio Transcode
|
||||||
forceVideoTranscode: false, // Force Video Transcode
|
forceVideoTranscode: false, // Force Video Transcode
|
||||||
|
|||||||
@ -135,6 +135,18 @@ function initSchema() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
|
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)
|
// Migration: Add source_id column if missing (for existing databases)
|
||||||
try {
|
try {
|
||||||
db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`);
|
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 = {
|
module.exports = {
|
||||||
getDb,
|
getDb,
|
||||||
initSchema,
|
initSchema,
|
||||||
favorites
|
favorites,
|
||||||
|
layaLanguageCache
|
||||||
};
|
};
|
||||||
|
|||||||
@ -180,6 +180,7 @@ app.use('/api/probe', require('./routes/probe'));
|
|||||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||||
app.use('/api/settings', require('./routes/settings'));
|
app.use('/api/settings', require('./routes/settings'));
|
||||||
app.use('/api/history', require('./routes/history'));
|
app.use('/api/history', require('./routes/history'));
|
||||||
|
app.use('/api/laya', require('./routes/laya'));
|
||||||
|
|
||||||
// Version endpoint
|
// Version endpoint
|
||||||
app.get('/api/version', (req, res) => {
|
app.get('/api/version', (req, res) => {
|
||||||
|
|||||||
42
server/routes/laya.js
Normal file
42
server/routes/laya.js
Normal 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;
|
||||||
195
server/services/layaService.js
Normal file
195
server/services/layaService.js
Normal 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
|
||||||
|
};
|
||||||
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('flat'), false);
|
||||||
assert.equal(grouping.shouldUseFavoritesGroup('grouped'), true);
|
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', () => {
|
test('defaults Live TV channel lists to grouped mode', () => {
|
||||||
assert.equal(getDefaultSettings().liveChannelListMode, 'grouped');
|
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