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:
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user