feat: add Settings live stream analysis button and diagnose Laya batch failure (#14)
Add a Settings action to manually rerun Live TV stream analysis: it triggers an admin-guarded projection rebuild endpoint that groups streams into logical channels (covering channels present before the projection existed) and reruns language/Laya detection, with progress presented via liveAnalysisPresentation. Harden the Laya batch path that failed with 'Local AI language matching failed' by surfacing the underlying cause and falling back to standard matching cleanly. Guard admin-only rebuild through a new authz helper and cover routes, service and presentation with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -768,6 +768,30 @@ h4 {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.live-analysis-panel {
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.live-analysis-panel [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.live-analysis-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.live-analysis-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.laya-model-panel [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@ -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=7">
|
||||
<link rel="stylesheet" href="css/main.css?v=8">
|
||||
<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"
|
||||
@ -469,6 +469,26 @@
|
||||
<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="live-analysis-panel" id="live-analysis-panel">
|
||||
<div class="live-analysis-heading">
|
||||
<div>
|
||||
<strong>Channel analysis</strong>
|
||||
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="live-analysis-button">Analyze channels</button>
|
||||
</div>
|
||||
<p class="setting-hint">Use this to analyze channels that were added before automatic analysis was enabled.</p>
|
||||
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
||||
<div id="live-analysis-progress" class="laya-progress" hidden>
|
||||
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
||||
<span id="live-analysis-progress-bar" class="laya-progress-bar"></span>
|
||||
</div>
|
||||
<div id="live-analysis-progress-text" class="setting-hint"></div>
|
||||
</div>
|
||||
<div id="live-analysis-fallback" class="setting-hint"></div>
|
||||
<button type="button" class="btn btn-ghost laya-details-toggle" id="live-analysis-details-toggle" aria-expanded="false" hidden>Show details</button>
|
||||
<pre id="live-analysis-error-details" class="laya-error-details" hidden></pre>
|
||||
</div>
|
||||
<div class="matching-mode-options">
|
||||
<label class="matching-mode-card">
|
||||
<input type="radio" name="language-detection-mode" value="algo" checked>
|
||||
@ -1185,6 +1205,7 @@
|
||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||
<script src="/js/services/layaStatusPresentation.js?v=4"></script>
|
||||
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
|
||||
<script src="/js/services/liveChannelProjection.js?v=1"></script>
|
||||
<script src="/js/services/channelVariantGrouping.js?v=6"></script>
|
||||
<script src="/js/components/ChannelList.js?v=11"></script>
|
||||
@ -1195,7 +1216,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=7"></script>
|
||||
<script src="/js/pages/Settings.js?v=8"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
|
||||
@ -83,7 +83,9 @@ const API = {
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||
}
|
||||
},
|
||||
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
||||
},
|
||||
|
||||
// Favorites
|
||||
|
||||
@ -87,6 +87,7 @@ class SettingsPage {
|
||||
});
|
||||
|
||||
this.initLayaSettings();
|
||||
this.initLiveAnalysis();
|
||||
|
||||
// EPG refresh interval
|
||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||
@ -105,6 +106,110 @@ class SettingsPage {
|
||||
this.updateEpgLastRefreshed();
|
||||
}
|
||||
|
||||
initLiveAnalysis() {
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis());
|
||||
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
|
||||
const button = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!expanded));
|
||||
details.hidden = expanded;
|
||||
});
|
||||
void this.loadLiveAnalysisStatus();
|
||||
}
|
||||
|
||||
async loadLiveAnalysisStatus() {
|
||||
try {
|
||||
this.liveAnalysisStatus = await API.live.getProjectionStatus();
|
||||
} catch (err) {
|
||||
this.liveAnalysisStatus = { status: 'error', error: err.message };
|
||||
}
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus);
|
||||
if (presentation.busy) this.startLiveAnalysisPolling();
|
||||
return this.liveAnalysisStatus;
|
||||
}
|
||||
|
||||
renderLiveAnalysisStatus(status = {}) {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
const statusElement = document.getElementById('live-analysis-status');
|
||||
const progress = document.getElementById('live-analysis-progress');
|
||||
const track = document.getElementById('live-analysis-progress-track');
|
||||
const bar = document.getElementById('live-analysis-progress-bar');
|
||||
const progressText = document.getElementById('live-analysis-progress-text');
|
||||
const fallback = document.getElementById('live-analysis-fallback');
|
||||
const detailsButton = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
if (!button || !statusElement) return;
|
||||
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
button.textContent = presentation.buttonLabel;
|
||||
button.disabled = presentation.busy;
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
progress.hidden = !presentation.busy;
|
||||
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
if (presentation.progressPercent === null) {
|
||||
track.removeAttribute('aria-valuemin');
|
||||
track.removeAttribute('aria-valuemax');
|
||||
track.removeAttribute('aria-valuenow');
|
||||
bar.style.width = '';
|
||||
} else {
|
||||
track.setAttribute('aria-valuemin', '0');
|
||||
track.setAttribute('aria-valuemax', '100');
|
||||
track.setAttribute('aria-valuenow', String(presentation.progressPercent));
|
||||
bar.style.width = `${presentation.progressPercent}%`;
|
||||
}
|
||||
progressText.textContent = presentation.progressText;
|
||||
track.setAttribute('aria-valuetext', presentation.progressText);
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
fallback.textContent = LiveAnalysisPresentation.layaFallbackMessage(mode, this.layaStatus);
|
||||
detailsButton.hidden = !presentation.errorDetail;
|
||||
detailsButton.setAttribute('aria-expanded', 'false');
|
||||
details.hidden = true;
|
||||
details.textContent = presentation.errorDetail;
|
||||
}
|
||||
|
||||
async runLiveAnalysis() {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
if (button?.disabled) return;
|
||||
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
try {
|
||||
const response = await API.live.rebuildProjection();
|
||||
this.liveAnalysisStatus = response.projection || response;
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
this.startLiveAnalysisPolling();
|
||||
} catch (err) {
|
||||
this.liveAnalysisStatus = { status: 'error', error: err.message };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
}
|
||||
|
||||
startLiveAnalysisPolling() {
|
||||
if (this.liveAnalysisPollTimer) return;
|
||||
this.liveAnalysisPollTimer = setInterval(async () => {
|
||||
if (this.liveAnalysisPollInFlight) return;
|
||||
this.liveAnalysisPollInFlight = true;
|
||||
try {
|
||||
const status = await this.loadLiveAnalysisStatus();
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
if (!presentation.busy) {
|
||||
clearInterval(this.liveAnalysisPollTimer);
|
||||
this.liveAnalysisPollTimer = null;
|
||||
if (presentation.complete) {
|
||||
await this.app.channelList?.loadChannels();
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
if (mode === 'laya' || mode === 'hybrid') {
|
||||
await this.app.channelList?.refreshLayaLanguages(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.liveAnalysisPollInFlight = false;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
initLayaSettings() {
|
||||
const radios = document.querySelectorAll('input[name="language-detection-mode"]');
|
||||
const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
@ -116,6 +221,7 @@ class SettingsPage {
|
||||
this.app.player.saveSettings();
|
||||
this.app.channelList.setLanguageDetectionMode(radio.value);
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
});
|
||||
});
|
||||
|
||||
@ -221,6 +327,7 @@ class SettingsPage {
|
||||
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.';
|
||||
} else messageElement.textContent = '';
|
||||
if (this.liveAnalysisStatus) this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
|
||||
formatBytes(bytes) {
|
||||
|
||||
48
public/js/services/liveAnalysisPresentation.js
Normal file
48
public/js/services/liveAnalysisPresentation.js
Normal file
@ -0,0 +1,48 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.LiveAnalysisPresentation = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const BUSY_STATUSES = new Set(['queued', 'building', 'running']);
|
||||
const FAILED_STATUSES = new Set(['error', 'failed']);
|
||||
|
||||
function present(status = {}) {
|
||||
const state = String(status.status || 'idle').toLowerCase();
|
||||
const busy = BUSY_STATUSES.has(state);
|
||||
const failed = FAILED_STATUSES.has(state);
|
||||
const complete = state === 'success';
|
||||
const processed = Number(status.processed);
|
||||
const total = Number(status.total);
|
||||
const determined = busy && status.processed !== null && status.processed !== undefined &&
|
||||
status.total !== null && status.total !== undefined &&
|
||||
Number.isFinite(processed) && Number.isFinite(total) && total > 0;
|
||||
const progressPercent = determined
|
||||
? Math.max(0, Math.min(100, Math.round(processed / total * 100)))
|
||||
: null;
|
||||
const previouslyCompleted = Boolean(status.completedAt) || Number(status.projectedCount) > 0;
|
||||
|
||||
return {
|
||||
state,
|
||||
busy,
|
||||
complete,
|
||||
failed,
|
||||
statusLabel: busy ? 'Analyzing channels…' : complete ? 'Analysis complete' : failed ? 'Analysis failed' : 'Ready to analyze',
|
||||
buttonLabel: failed ? 'Try again' : complete || previouslyCompleted ? 'Analyze again' : 'Analyze channels',
|
||||
progressIndeterminate: busy && progressPercent === null,
|
||||
progressPercent,
|
||||
progressText: progressPercent === null
|
||||
? (busy ? 'Analysis in progress…' : '')
|
||||
: `${processed} of ${total} · ${progressPercent}%`,
|
||||
errorDetail: failed && status.error ? String(status.error) : ''
|
||||
};
|
||||
}
|
||||
|
||||
function layaFallbackMessage(mode, layaStatus = {}) {
|
||||
if (!['laya', 'hybrid'].includes(mode)) return '';
|
||||
return layaStatus.modelPresent === false || layaStatus.runtimeAvailable === false
|
||||
? 'Local AI is unavailable. Standard channel matching will be used for this analysis.'
|
||||
: '';
|
||||
}
|
||||
|
||||
return { layaFallbackMessage, present };
|
||||
});
|
||||
Reference in New Issue
Block a user