fix: make Laya status consistent between Settings and ChannelList (#12)

The model was reported as ready while the AI runtime was actually
unreachable, and the Download action stayed visible afterwards.
Distinguish model-downloaded from runtime-available in
layaStatusPresentation, derive the Download action from the real
combined state, and align Settings and ChannelList rendering on that
single source of truth. Cover the combined status cases with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 13:31:35 +02:00
parent 726d78e05b
commit d253642dec
6 changed files with 142 additions and 17 deletions

View File

@ -776,6 +776,30 @@ h4 {
gap: var(--space-sm); gap: var(--space-sm);
} }
.laya-component-status {
display: flex;
align-items: baseline;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
#laya-service-state {
margin-top: 4px;
}
.laya-start-command {
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
overflow-x: auto;
user-select: all;
}
.laya-notice-action {
margin-left: var(--space-sm);
padding: 2px var(--space-xs);
}
.laya-progress { .laya-progress {
margin-top: var(--space-md); margin-top: var(--space-md);
} }

View File

@ -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=4"> <link rel="stylesheet" href="css/main.css?v=5">
<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"
@ -485,9 +485,10 @@
</div> </div>
<div class="laya-model-panel" id="laya-model-panel"> <div class="laya-model-panel" id="laya-model-panel">
<div class="laya-model-heading"> <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> <div><strong>Local AI · 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>
<div class="laya-component-status"><span id="laya-model-state">Model: Checking…</span><span id="laya-model-size" class="setting-hint"></span></div>
<div id="laya-service-state">Local AI service: Checking…</div>
<div id="laya-model-message" class="setting-hint"></div> <div id="laya-model-message" class="setting-hint"></div>
<div id="laya-download-progress" class="laya-progress" hidden> <div id="laya-download-progress" class="laya-progress" hidden>
<div id="laya-progress-track" class="laya-progress-track" role="progressbar" aria-label="Local AI model download progress"> <div id="laya-progress-track" class="laya-progress-track" role="progressbar" aria-label="Local AI model download progress">
@ -500,8 +501,11 @@
<div class="laya-model-actions"> <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-primary" id="laya-download-model">Download model</button>
<button type="button" class="btn btn-ghost" id="laya-remove-model">Remove downloaded model</button> <button type="button" class="btn btn-ghost" id="laya-remove-model">Remove downloaded model</button>
<button type="button" class="btn btn-ghost" id="laya-check-again" hidden>Check again</button>
<button type="button" class="btn btn-ghost" id="laya-copy-command" hidden>Copy start command</button>
<button type="button" class="btn btn-ghost" id="laya-use-standard" hidden>Use Standard</button> <button type="button" class="btn btn-ghost" id="laya-use-standard" hidden>Use Standard</button>
</div> </div>
<div id="laya-start-command" class="laya-start-command" hidden><code>docker compose --profile laya up -d</code></div>
<p class="setting-hint">The model runs on this server. Channel names and metadata are not sent to an AI service.</p> <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>
@ -1180,9 +1184,9 @@
<script src="/js/components/VideoPlayer.js?v=5"></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/channelLanguageFusion.js?v=1"></script> <script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/layaStatusPresentation.js?v=1"></script> <script src="/js/services/layaStatusPresentation.js?v=2"></script>
<script src="/js/services/channelVariantGrouping.js?v=4"></script> <script src="/js/services/channelVariantGrouping.js?v=4"></script>
<script src="/js/components/ChannelList.js?v=7"></script> <script src="/js/components/ChannelList.js?v=8"></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>
@ -1190,7 +1194,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=5"></script> <script src="/js/pages/Settings.js?v=6"></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>

View File

@ -33,6 +33,7 @@ class ChannelList {
this.channelLanguagePreferences = this.loadChannelLanguagePreferences(); this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
this.layaLanguageResults = new Map(); this.layaLanguageResults = new Map();
this.languageDetectionNotice = ''; this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
this._languageDetectionRequest = 0; this._languageDetectionRequest = 0;
this.loadCollapsedState(); this.loadCollapsedState();
@ -335,6 +336,7 @@ class ChannelList {
const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo'; const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo';
this.layaLanguageResults.clear(); this.layaLanguageResults.clear();
this.languageDetectionNotice = ''; this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
if (this.channels.length > 0) { if (this.channels.length > 0) {
this.render(); this.render();
void this.refreshLayaLanguages(safeMode); void this.refreshLayaLanguages(safeMode);
@ -346,6 +348,7 @@ class ChannelList {
if (mode === 'algo') { if (mode === 'algo') {
this.layaLanguageResults.clear(); this.layaLanguageResults.clear();
this.languageDetectionNotice = ''; this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
return; return;
} }
@ -359,6 +362,15 @@ class ChannelList {
}); });
const results = new Map(); const results = new Map();
try { try {
const status = await API.laya.getStatus();
if (!status?.modelPresent || !status?.runtimeAvailable) {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear();
this.languageDetectionFallback = true;
this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.';
this.render();
return;
}
for (let index = 0; index < descriptors.length; index += 64) { for (let index = 0; index < descriptors.length; index += 64) {
const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64)); const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64));
(response.results || []).forEach(result => results.set(String(result.id), result)); (response.results || []).forEach(result => results.set(String(result.id), result));
@ -366,12 +378,12 @@ class ChannelList {
if (requestId !== this._languageDetectionRequest) return; if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults = results; this.layaLanguageResults = results;
this.languageDetectionNotice = ''; this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
} catch (err) { } catch (err) {
if (requestId !== this._languageDetectionRequest) return; if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear(); this.layaLanguageResults.clear();
this.languageDetectionNotice = mode === 'hybrid' this.languageDetectionFallback = true;
? 'Local AI is unavailable; using standard channel matching.' this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.';
: 'Local AI is unavailable. Download or start the model in Settings.';
console.warn('[ChannelList] Local AI language detection unavailable:', err.message); console.warn('[ChannelList] Local AI language detection unavailable:', err.message);
} }
this.render(); this.render();
@ -405,7 +417,9 @@ 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', languageMode: this.languageDetectionFallback
? 'algo'
: (window.app?.player?.settings?.languageDetectionMode || 'algo'),
languageResults: this.layaLanguageResults, 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)
@ -420,6 +434,15 @@ class ChannelList {
notice.className = 'laya-language-notice'; notice.className = 'laya-language-notice';
notice.setAttribute('role', 'status'); notice.setAttribute('role', 'status');
notice.textContent = this.languageDetectionNotice; notice.textContent = this.languageDetectionNotice;
const settingsButton = document.createElement('button');
settingsButton.type = 'button';
settingsButton.className = 'btn btn-ghost laya-notice-action';
settingsButton.textContent = 'Open settings';
settingsButton.addEventListener('click', () => {
window.app?.navigateTo?.('settings');
window.app?.pages?.settings?.switchTab?.('player');
});
notice.appendChild(settingsButton);
this.container.appendChild(notice); this.container.appendChild(notice);
} }

View File

@ -126,6 +126,8 @@ class SettingsPage {
} }
}); });
document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection()); document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection());
document.getElementById('laya-check-again')?.addEventListener('click', () => void this.loadLayaStatus());
document.getElementById('laya-copy-command')?.addEventListener('click', () => void this.copyLayaStartCommand());
document.getElementById('laya-show-details')?.addEventListener('click', () => { document.getElementById('laya-show-details')?.addEventListener('click', () => {
const details = document.getElementById('laya-error-details'); const details = document.getElementById('laya-error-details');
const button = document.getElementById('laya-show-details'); const button = document.getElementById('laya-show-details');
@ -149,11 +151,16 @@ class SettingsPage {
renderLayaStatus(status = {}) { renderLayaStatus(status = {}) {
const statusElement = document.getElementById('laya-model-status'); const statusElement = document.getElementById('laya-model-status');
const modelStateElement = document.getElementById('laya-model-state');
const serviceStateElement = document.getElementById('laya-service-state');
const sizeElement = document.getElementById('laya-model-size'); const sizeElement = document.getElementById('laya-model-size');
const messageElement = document.getElementById('laya-model-message'); const messageElement = document.getElementById('laya-model-message');
const downloadButton = document.getElementById('laya-download-model'); const downloadButton = document.getElementById('laya-download-model');
const removeButton = document.getElementById('laya-remove-model'); const removeButton = document.getElementById('laya-remove-model');
const useStandardButton = document.getElementById('laya-use-standard'); const useStandardButton = document.getElementById('laya-use-standard');
const checkAgainButton = document.getElementById('laya-check-again');
const copyCommandButton = document.getElementById('laya-copy-command');
const startCommand = document.getElementById('laya-start-command');
const progress = document.getElementById('laya-download-progress'); const progress = document.getElementById('laya-download-progress');
const progressTrack = document.getElementById('laya-progress-track'); const progressTrack = document.getElementById('laya-progress-track');
const progressBar = document.getElementById('laya-progress-bar'); const progressBar = document.getElementById('laya-progress-bar');
@ -165,12 +172,17 @@ class SettingsPage {
const mode = this.app.player?.settings?.languageDetectionMode || 'algo'; const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
const presentation = LayaStatusPresentation.presentLayaStatus(status, mode); const presentation = LayaStatusPresentation.presentLayaStatus(status, mode);
statusElement.textContent = presentation.statusLabel; statusElement.textContent = presentation.statusLabel;
modelStateElement.textContent = presentation.modelLabel;
serviceStateElement.textContent = presentation.serviceLabel;
const size = status.sizeBytes || status.runtime?.modelSizeBytes; const size = status.sizeBytes || status.runtime?.modelSizeBytes;
sizeElement.textContent = size ? this.formatBytes(size) : ''; sizeElement.textContent = size ? `· ${this.formatBytes(size)}` : '';
downloadButton.hidden = !presentation.showDownload; downloadButton.hidden = !presentation.showDownload;
downloadButton.textContent = presentation.downloadLabel; downloadButton.textContent = presentation.downloadLabel;
removeButton.hidden = !presentation.showRemove; removeButton.hidden = !presentation.showRemove;
useStandardButton.hidden = !presentation.showUseStandard; useStandardButton.hidden = !presentation.showUseStandard;
checkAgainButton.hidden = !presentation.showCheckAgain;
copyCommandButton.hidden = !presentation.showStartCommand;
startCommand.hidden = !presentation.showStartCommand;
downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending); downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending); removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
@ -199,6 +211,7 @@ class SettingsPage {
details.hidden = true; details.hidden = true;
details.textContent = presentation.errorDetail; details.textContent = presentation.errorDetail;
if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage; if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage;
else if (presentation.availabilityMessage) messageElement.textContent = presentation.availabilityMessage;
else if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending; else if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
else if (mode !== 'algo' && (!status.modelPresent || !status.runtimeAvailable)) { else if (mode !== 'algo' && (!status.modelPresent || !status.runtimeAvailable)) {
messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.'; messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.';
@ -266,6 +279,24 @@ class SettingsPage {
this.renderLayaStatus(this.layaStatus); this.renderLayaStatus(this.layaStatus);
} }
async copyLayaStartCommand() {
const command = 'docker compose --profile laya up -d';
const button = document.getElementById('laya-copy-command');
try {
await navigator.clipboard.writeText(command);
button.textContent = 'Copied';
} catch {
const code = document.querySelector('#laya-start-command code');
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(code);
selection.removeAllRanges();
selection.addRange(range);
button.textContent = 'Command selected';
}
setTimeout(() => { button.textContent = 'Copy start command'; }, 2000);
}
async initTranscodingSettings() { async initTranscodingSettings() {
// Encoder settings // Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder'); const hwEncoderSelect = document.getElementById('setting-hw-encoder');

View File

@ -37,11 +37,24 @@
const progressPercent = determined ? Math.max(0, Math.min(100, Math.round(percentValue))) : null; const progressPercent = determined ? Math.max(0, Math.min(100, Math.round(percentValue))) : null;
const error = errorParts(status); const error = errorParts(status);
const failed = phase === 'failed'; const failed = phase === 'failed';
const modelPresent = status.modelPresent === true;
const runtimeAvailable = status.runtimeAvailable === true;
const ready = modelPresent && runtimeAvailable && !busy && !failed;
const statusLabel = ready
? 'Ready'
: failed || busy
? PHASE_LABELS[phase]
: modelPresent && !runtimeAvailable
? 'Local AI service unavailable'
: !modelPresent
? 'Model not downloaded'
: PHASE_LABELS[phase];
return { return {
phase, phase,
statusLabel: status.modelPresent && phase === 'idle' ready,
? (status.runtimeAvailable ? 'Model ready' : 'Model downloaded · runtime unavailable') statusLabel,
: PHASE_LABELS[phase], modelLabel: `Model: ${modelPresent ? 'Downloaded' : 'Not downloaded'}`,
serviceLabel: `Local AI service: ${runtimeAvailable ? 'Available' : 'Unavailable'}`,
busy, busy,
showProgress: phase === 'downloading', showProgress: phase === 'downloading',
progressIndeterminate: phase === 'downloading' && progressPercent === null, progressIndeterminate: phase === 'downloading' && progressPercent === null,
@ -53,9 +66,14 @@
errorMessage: failed ? (error.message || 'The local AI model could not be downloaded.') : '', errorMessage: failed ? (error.message || 'The local AI model could not be downloaded.') : '',
errorDetail: failed ? error.detail : '', errorDetail: failed ? error.detail : '',
downloadLabel: failed ? 'Retry download' : 'Download model', downloadLabel: failed ? 'Retry download' : 'Download model',
showDownload: !busy && !status.modelPresent && (phase === 'idle' || failed), showDownload: !busy && !modelPresent && (phase === 'idle' || failed),
showRemove: !busy && Boolean(status.modelPresent) && !failed, showRemove: !busy && modelPresent,
showUseStandard: failed && mode !== 'algo' showUseStandard: failed && mode !== 'algo',
showCheckAgain: !busy && modelPresent && !runtimeAvailable,
showStartCommand: !busy && modelPresent && !runtimeAvailable,
availabilityMessage: modelPresent && !runtimeAvailable
? 'The model is installed, but the Local AI service is not running. Standard channel matching is being used.'
: ''
}; };
} }

View File

@ -41,3 +41,28 @@ test('ready models expose removal without download actions', () => {
assert.equal(view.showRemove, true); assert.equal(view.showRemove, true);
assert.equal(view.showDownload, false); assert.equal(view.showDownload, false);
}); });
test('installed model with unavailable service is not reported ready', () => {
const view = presentLayaStatus({
phase: 'idle',
modelPresent: true,
runtimeAvailable: false,
sizeBytes: 10
}, 'hybrid');
assert.equal(view.ready, false);
assert.equal(view.statusLabel, 'Local AI service unavailable');
assert.equal(view.modelLabel, 'Model: Downloaded');
assert.equal(view.serviceLabel, 'Local AI service: Unavailable');
assert.equal(view.showDownload, false);
assert.equal(view.showRemove, true);
assert.equal(view.showCheckAgain, true);
assert.equal(view.showStartCommand, true);
assert.equal(view.availabilityMessage, 'The model is installed, but the Local AI service is not running. Standard channel matching is being used.');
});
test('missing model keeps download available', () => {
const view = presentLayaStatus({ phase: 'idle', modelPresent: false, runtimeAvailable: false });
assert.equal(view.showDownload, true);
assert.equal(view.showRemove, false);
assert.equal(view.showCheckAgain, false);
});