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

@ -31,6 +31,9 @@ class ChannelList {
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
this.preferredLanguage = this.loadPreferredLanguage();
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
this.layaLanguageResults = new Map();
this.languageDetectionNotice = '';
this._languageDetectionRequest = 0;
this.loadCollapsedState();
this.init();
@ -328,6 +331,52 @@ class ChannelList {
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) {
if (!text) return '';
return text
@ -356,6 +405,8 @@ class ChannelList {
selectedVariants: this.selectedVariants,
preferredLanguage: this.preferredLanguage,
languagePreferences: this.channelLanguagePreferences,
languageMode: window.app?.player?.settings?.languageDetectionMode || 'algo',
languageResults: this.layaLanguageResults,
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
});
@ -364,6 +415,13 @@ class ChannelList {
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
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
const groupedChannels = {};
@ -837,6 +895,7 @@ class ChannelList {
]);
this.render();
void this.refreshLayaLanguages();
} catch (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>`;
@ -872,6 +931,7 @@ class ChannelList {
this.loadFavorites()
]);
this.render();
void this.refreshLayaLanguages();
} catch (err) {
console.error('Error loading all channels:', err);
}

View File

@ -58,6 +58,7 @@ class VideoPlayer {
lastVolume: 80,
autoPlayNextEpisode: false,
liveChannelListMode: 'grouped',
languageDetectionMode: 'algo',
forceProxy: false,
forceTranscode: false,
forceRemux: false,