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:
@ -725,6 +725,71 @@ h4 {
|
||||
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 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<title>nodecast-tv | IPTV Player</title>
|
||||
<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="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.gstatic.com" crossorigin>
|
||||
<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>
|
||||
</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 class="settings-section">
|
||||
@ -1137,11 +1167,12 @@
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=2"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=4"></script>
|
||||
<script src="/js/api.js?v=3"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||
<script src="/js/services/channelVariantGrouping.js?v=3"></script>
|
||||
<script src="/js/components/ChannelList.js?v=6"></script>
|
||||
<script src="/js/services/channelLanguageFusion.js?v=1"></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/EpgGuide.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/MoviesPage.js?v=4"></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/app.js?v=4"></script>
|
||||
<script>
|
||||
|
||||
@ -95,6 +95,13 @@ const API = {
|
||||
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: {
|
||||
// Xtream
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -58,6 +58,7 @@ class VideoPlayer {
|
||||
lastVolume: 80,
|
||||
autoPlayNextEpisode: false,
|
||||
liveChannelListMode: 'grouped',
|
||||
languageDetectionMode: 'algo',
|
||||
forceProxy: false,
|
||||
forceTranscode: false,
|
||||
forceRemux: false,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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
|
||||
? require('./channelLanguageDetection.js')
|
||||
: 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 (root) root.ChannelVariantGrouping = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection) {
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection, languageFusion) {
|
||||
const QUALITY_TOKENS = new Set([
|
||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||
'1080p', '1080i', '720p', '576p', '480p'
|
||||
@ -96,10 +99,14 @@
|
||||
});
|
||||
|
||||
return Array.from(buckets, ([fingerprint, rawVariants]) => {
|
||||
const variants = rawVariants.map(variant => ({
|
||||
...variant,
|
||||
...languageDetection.detectLanguage(variant, { sourceName: variant.sourceName })
|
||||
}));
|
||||
const variants = rawVariants.map(variant => {
|
||||
const algo = languageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
|
||||
const result = options.languageResults?.get(getVariantKey(variant));
|
||||
return {
|
||||
...variant,
|
||||
...languageFusion.applyLanguageMode(algo, result, options.languageMode || 'algo')
|
||||
};
|
||||
});
|
||||
const visibleVariants = variants.filter(variant => !isHidden(variant));
|
||||
const selectableVariants = showHidden ? variants : visibleVariants;
|
||||
const languageCounts = selectableVariants.reduce((counts, variant) => {
|
||||
|
||||
Reference in New Issue
Block a user