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

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();
}
/**