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);
|
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] {
|
.laya-model-panel [hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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=7">
|
<link rel="stylesheet" href="css/main.css?v=8">
|
||||||
<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"
|
||||||
@ -469,6 +469,26 @@
|
|||||||
<div class="channel-matching-settings">
|
<div class="channel-matching-settings">
|
||||||
<h4>Channel matching</h4>
|
<h4>Channel matching</h4>
|
||||||
<p class="setting-hint">Choose how Nodecast TV identifies matching streams and languages.</p>
|
<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">
|
<div class="matching-mode-options">
|
||||||
<label class="matching-mode-card">
|
<label class="matching-mode-card">
|
||||||
<input type="radio" name="language-detection-mode" value="algo" checked>
|
<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/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=4"></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/liveChannelProjection.js?v=1"></script>
|
||||||
<script src="/js/services/channelVariantGrouping.js?v=6"></script>
|
<script src="/js/services/channelVariantGrouping.js?v=6"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=11"></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/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=7"></script>
|
<script src="/js/pages/Settings.js?v=8"></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>
|
||||||
|
|||||||
@ -83,7 +83,9 @@ const API = {
|
|||||||
if (sourceId) params.set('sourceId', sourceId);
|
if (sourceId) params.set('sourceId', sourceId);
|
||||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
params.set('includeHidden', String(Boolean(includeHidden)));
|
||||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||||
}
|
},
|
||||||
|
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||||
|
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Favorites
|
// Favorites
|
||||||
|
|||||||
@ -87,6 +87,7 @@ class SettingsPage {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.initLayaSettings();
|
this.initLayaSettings();
|
||||||
|
this.initLiveAnalysis();
|
||||||
|
|
||||||
// EPG refresh interval
|
// EPG refresh interval
|
||||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||||
@ -105,6 +106,110 @@ class SettingsPage {
|
|||||||
this.updateEpgLastRefreshed();
|
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() {
|
initLayaSettings() {
|
||||||
const radios = document.querySelectorAll('input[name="language-detection-mode"]');
|
const radios = document.querySelectorAll('input[name="language-detection-mode"]');
|
||||||
const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||||
@ -116,6 +221,7 @@ class SettingsPage {
|
|||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
this.app.channelList.setLanguageDetectionMode(radio.value);
|
this.app.channelList.setLanguageDetectionMode(radio.value);
|
||||||
this.renderLayaStatus(this.layaStatus);
|
this.renderLayaStatus(this.layaStatus);
|
||||||
|
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -221,6 +327,7 @@ class SettingsPage {
|
|||||||
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.';
|
||||||
} else messageElement.textContent = '';
|
} else messageElement.textContent = '';
|
||||||
|
if (this.liveAnalysisStatus) this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
formatBytes(bytes) {
|
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 };
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
|
const { isAdmin } = require('./authz');
|
||||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||||
const { Strategy: LocalStrategy } = require('passport-local');
|
const { Strategy: LocalStrategy } = require('passport-local');
|
||||||
|
|
||||||
@ -234,7 +235,7 @@ const requireAuth = passport.authenticate('jwt', { session: false });
|
|||||||
* Middleware: Require admin role
|
* Middleware: Require admin role
|
||||||
*/
|
*/
|
||||||
function requireAdmin(req, res, next) {
|
function requireAdmin(req, res, next) {
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
if (!isAdmin(req.user)) {
|
||||||
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
|||||||
5
server/authz.js
Normal file
5
server/authz.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
function isAdmin(user) {
|
||||||
|
return user?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isAdmin };
|
||||||
@ -13,6 +13,11 @@ const LIVE_PROJECTION_COLUMNS = {
|
|||||||
language_input_hash: 'TEXT'
|
language_input_hash: 'TEXT'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATALOG_PROJECTION_STATE_COLUMNS = {
|
||||||
|
processed_count: 'INTEGER NOT NULL DEFAULT 0',
|
||||||
|
total_count: 'INTEGER NOT NULL DEFAULT 0'
|
||||||
|
};
|
||||||
|
|
||||||
const LIVE_PROJECTION_SCHEMA_SQL = `
|
const LIVE_PROJECTION_SCHEMA_SQL = `
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id);
|
CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint);
|
CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint);
|
||||||
@ -35,6 +40,8 @@ const LIVE_PROJECTION_SCHEMA_SQL = `
|
|||||||
completed_at INTEGER,
|
completed_at INTEGER,
|
||||||
input_hash TEXT,
|
input_hash TEXT,
|
||||||
projected_count INTEGER NOT NULL DEFAULT 0,
|
projected_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
processed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
error TEXT
|
error TEXT
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
@ -46,4 +53,9 @@ function ensureColumns(database, table, columns) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ensureColumns, LIVE_PROJECTION_COLUMNS, LIVE_PROJECTION_SCHEMA_SQL };
|
module.exports = {
|
||||||
|
CATALOG_PROJECTION_STATE_COLUMNS,
|
||||||
|
ensureColumns,
|
||||||
|
LIVE_PROJECTION_COLUMNS,
|
||||||
|
LIVE_PROJECTION_SCHEMA_SQL
|
||||||
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const {
|
const {
|
||||||
|
CATALOG_PROJECTION_STATE_COLUMNS,
|
||||||
ensureColumns,
|
ensureColumns,
|
||||||
LIVE_PROJECTION_COLUMNS,
|
LIVE_PROJECTION_COLUMNS,
|
||||||
LIVE_PROJECTION_SCHEMA_SQL
|
LIVE_PROJECTION_SCHEMA_SQL
|
||||||
@ -80,6 +81,7 @@ function initSchema() {
|
|||||||
|
|
||||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||||
|
ensureColumns(db, 'catalog_projection_state', CATALOG_PROJECTION_STATE_COLUMNS);
|
||||||
|
|
||||||
// EPG Programs
|
// EPG Programs
|
||||||
// Optimized for range queries
|
// Optimized for range queries
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { requireAuth } = require('../auth');
|
const { requireAuth, requireAdmin } = require('../auth');
|
||||||
const liveProjection = require('../services/liveProjectionService');
|
const liveProjection = require('../services/liveProjectionService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -12,6 +12,8 @@ function serializeState(state) {
|
|||||||
completedAt: state.completed_at,
|
completedAt: state.completed_at,
|
||||||
inputHash: state.input_hash,
|
inputHash: state.input_hash,
|
||||||
projectedCount: state.projected_count,
|
projectedCount: state.projected_count,
|
||||||
|
processed: state.processed_count,
|
||||||
|
total: state.total_count,
|
||||||
error: state.error
|
error: state.error
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -28,7 +30,9 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let state = liveProjection.projectionState();
|
let state = liveProjection.projectionState();
|
||||||
if (state.status !== 'success') state = liveProjection.rebuild();
|
if (state.status !== 'success' && state.status !== 'building') {
|
||||||
|
state = await liveProjection.startRebuild().promise;
|
||||||
|
}
|
||||||
const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id });
|
const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id });
|
||||||
return res.json({ projection: serializeState(state), channels });
|
return res.json({ projection: serializeState(state), channels });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -42,5 +46,29 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/projection/status', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
return res.json(serializeState(liveProjection.projectionState()));
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'Unable to read Live projection status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const started = liveProjection.startRebuild();
|
||||||
|
started.promise.catch(error => {
|
||||||
|
console.error('[LiveProjection] Manual rebuild failed:', error.message);
|
||||||
|
});
|
||||||
|
return res.status(202).json({
|
||||||
|
alreadyRunning: started.alreadyRunning,
|
||||||
|
projection: serializeState(started.state)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LiveProjection] Unable to start manual rebuild:', error.message);
|
||||||
|
return res.status(500).json({ error: 'Unable to start Live projection rebuild' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.serializeState = serializeState;
|
module.exports.serializeState = serializeState;
|
||||||
|
|||||||
@ -89,11 +89,25 @@ function projectionState(database = null) {
|
|||||||
database ||= defaultDatabase();
|
database ||= defaultDatabase();
|
||||||
return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || {
|
return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || {
|
||||||
projection: 'live', status: 'missing', started_at: null, completed_at: null,
|
projection: 'live', status: 'missing', started_at: null, completed_at: null,
|
||||||
input_hash: null, projected_count: 0, error: null
|
input_hash: null, projected_count: 0, processed_count: 0, total_count: 0, error: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuild(database = null) {
|
function markProjectionBuilding(database = null) {
|
||||||
|
database ||= defaultDatabase();
|
||||||
|
const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO catalog_projection_state
|
||||||
|
(projection, status, started_at, completed_at, input_hash, projected_count, processed_count, total_count, error)
|
||||||
|
VALUES ('live', 'building', ?, NULL, NULL, 0, 0, ?, NULL)
|
||||||
|
ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at,
|
||||||
|
completed_at = NULL, processed_count = 0, total_count = excluded.total_count, error = NULL
|
||||||
|
`).run(startedAt, total);
|
||||||
|
return projectionState(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuild(database = null, options = {}) {
|
||||||
database ||= defaultDatabase();
|
database ||= defaultDatabase();
|
||||||
const items = database.prepare(`
|
const items = database.prepare(`
|
||||||
SELECT id, source_id, item_id, name, category_id
|
SELECT id, source_id, item_id, name, category_id
|
||||||
@ -101,13 +115,7 @@ function rebuild(database = null) {
|
|||||||
`).all();
|
`).all();
|
||||||
const groups = projectCatalog(items);
|
const groups = projectCatalog(items);
|
||||||
const inputHash = catalogInputHash(items);
|
const inputHash = catalogInputHash(items);
|
||||||
const startedAt = Date.now();
|
if (!options.stateAlreadyBuilding) markProjectionBuilding(database);
|
||||||
database.prepare(`
|
|
||||||
INSERT INTO catalog_projection_state (projection, status, started_at, completed_at, input_hash, projected_count, error)
|
|
||||||
VALUES ('live', 'building', ?, NULL, NULL, 0, NULL)
|
|
||||||
ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at,
|
|
||||||
completed_at = NULL, error = NULL
|
|
||||||
`).run(startedAt);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apply = database.transaction(() => {
|
const apply = database.transaction(() => {
|
||||||
@ -137,8 +145,9 @@ function rebuild(database = null) {
|
|||||||
}
|
}
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE catalog_projection_state SET status = 'success', completed_at = ?,
|
UPDATE catalog_projection_state SET status = 'success', completed_at = ?,
|
||||||
input_hash = ?, projected_count = ?, error = NULL WHERE projection = 'live'
|
input_hash = ?, projected_count = ?, processed_count = ?, total_count = ?, error = NULL
|
||||||
`).run(Date.now(), inputHash, groups.length);
|
WHERE projection = 'live'
|
||||||
|
`).run(Date.now(), inputHash, groups.length, items.length, items.length);
|
||||||
});
|
});
|
||||||
apply();
|
apply();
|
||||||
return projectionState(database);
|
return projectionState(database);
|
||||||
@ -151,11 +160,31 @@ function rebuild(database = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rebuildQueue = Promise.resolve();
|
let activeRebuild = null;
|
||||||
function queueRebuild() {
|
let activeState = null;
|
||||||
const next = rebuildQueue.catch(() => {}).then(() => rebuild());
|
|
||||||
rebuildQueue = next;
|
function startRebuild(database = null, options = {}) {
|
||||||
return next;
|
if (activeRebuild) return { alreadyRunning: true, state: activeState, promise: activeRebuild };
|
||||||
|
database ||= defaultDatabase();
|
||||||
|
const mark = options.mark || markProjectionBuilding;
|
||||||
|
const runner = options.runner || rebuild;
|
||||||
|
const schedule = options.schedule || setImmediate;
|
||||||
|
activeState = mark(database);
|
||||||
|
activeRebuild = new Promise((resolve, reject) => {
|
||||||
|
schedule(() => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => runner(database, { stateAlreadyBuilding: true }))
|
||||||
|
.then(resolve, reject);
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
activeRebuild = null;
|
||||||
|
activeState = null;
|
||||||
|
});
|
||||||
|
return { alreadyRunning: false, state: activeState, promise: activeRebuild };
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueRebuild(database = null) {
|
||||||
|
return startRebuild(database).promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseData(value) {
|
function parseData(value) {
|
||||||
@ -242,10 +271,12 @@ module.exports = {
|
|||||||
displayNameForFingerprint,
|
displayNameForFingerprint,
|
||||||
getChannels,
|
getChannels,
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
|
markProjectionBuilding,
|
||||||
projectCatalog,
|
projectCatalog,
|
||||||
projectionState,
|
projectionState,
|
||||||
qualityForName,
|
qualityForName,
|
||||||
queueRebuild,
|
queueRebuild,
|
||||||
rebuild,
|
rebuild,
|
||||||
serializeChannels
|
serializeChannels,
|
||||||
|
startRebuild
|
||||||
};
|
};
|
||||||
|
|||||||
39
test/liveAnalysisPresentation.test.js
Normal file
39
test/liveAnalysisPresentation.test.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { layaFallbackMessage, present } = require('../public/js/services/liveAnalysisPresentation.js');
|
||||||
|
|
||||||
|
test('presents determinate channel analysis progress', () => {
|
||||||
|
const view = present({ status: 'building', processed: 25, total: 100 });
|
||||||
|
assert.equal(view.busy, true);
|
||||||
|
assert.equal(view.progressIndeterminate, false);
|
||||||
|
assert.equal(view.progressPercent, 25);
|
||||||
|
assert.equal(view.progressText, '25 of 100 · 25%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presents indeterminate progress when totals are unavailable', () => {
|
||||||
|
const view = present({ status: 'queued', processed: null, total: null });
|
||||||
|
assert.equal(view.busy, true);
|
||||||
|
assert.equal(view.progressIndeterminate, true);
|
||||||
|
assert.equal(view.buttonLabel, 'Analyze channels');
|
||||||
|
assert.equal(present({ status: 'running', processed: null, total: 100 }).progressIndeterminate, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presents success and retry states', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
{ label: present({ status: 'success', projectedCount: 12 }).statusLabel, button: present({ status: 'success' }).buttonLabel },
|
||||||
|
{ label: 'Analysis complete', button: 'Analyze again' }
|
||||||
|
);
|
||||||
|
const failed = present({ status: 'error', error: 'Safe projection error' });
|
||||||
|
assert.equal(failed.statusLabel, 'Analysis failed');
|
||||||
|
assert.equal(failed.buttonLabel, 'Try again');
|
||||||
|
assert.equal(failed.errorDetail, 'Safe projection error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mentions Standard fallback only for unavailable active Local AI modes', () => {
|
||||||
|
assert.equal(layaFallbackMessage('algo', { modelPresent: false, runtimeAvailable: false }), '');
|
||||||
|
assert.equal(layaFallbackMessage('hybrid', { modelPresent: true, runtimeAvailable: true }), '');
|
||||||
|
assert.equal(
|
||||||
|
layaFallbackMessage('laya', { modelPresent: true, runtimeAvailable: false }),
|
||||||
|
'Local AI is unavailable. Standard channel matching will be used for this analysis.'
|
||||||
|
);
|
||||||
|
});
|
||||||
21
test/liveProjectionRoutes.test.js
Normal file
21
test/liveProjectionRoutes.test.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { isAdmin } = require('../server/authz');
|
||||||
|
|
||||||
|
const routeSource = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'live.js'), 'utf8');
|
||||||
|
|
||||||
|
test('projection status route requires authentication', () => {
|
||||||
|
assert.match(routeSource, /router\.get\('\/projection\/status', requireAuth,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projection rebuild route requires authentication and admin authorization', () => {
|
||||||
|
assert.match(routeSource, /router\.post\('\/projection\/rebuild', requireAuth, requireAdmin,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projection authorization distinguishes viewer and admin roles', () => {
|
||||||
|
assert.equal(isAdmin({ role: 'viewer' }), false);
|
||||||
|
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||||
|
assert.equal(isAdmin(null), false);
|
||||||
|
});
|
||||||
@ -2,7 +2,8 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const {
|
const {
|
||||||
projectCatalog,
|
projectCatalog,
|
||||||
serializeChannels
|
serializeChannels,
|
||||||
|
startRebuild
|
||||||
} = require('../server/services/liveProjectionService');
|
} = require('../server/services/liveProjectionService');
|
||||||
const {
|
const {
|
||||||
LIVE_PROJECTION_COLUMNS,
|
LIVE_PROJECTION_COLUMNS,
|
||||||
@ -30,6 +31,25 @@ test('projection migration declares grouping and language columns', () => {
|
|||||||
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/);
|
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual rebuild scheduling coalesces concurrent requests', async () => {
|
||||||
|
let scheduled;
|
||||||
|
let runs = 0;
|
||||||
|
const state = { status: 'building', processed_count: 0, total_count: 2 };
|
||||||
|
const options = {
|
||||||
|
mark: () => state,
|
||||||
|
runner: async () => { runs++; return { status: 'success' }; },
|
||||||
|
schedule: callback => { scheduled = callback; }
|
||||||
|
};
|
||||||
|
const first = startRebuild({}, options);
|
||||||
|
const second = startRebuild({}, options);
|
||||||
|
assert.equal(first.alreadyRunning, false);
|
||||||
|
assert.equal(second.alreadyRunning, true);
|
||||||
|
assert.equal(second.promise, first.promise);
|
||||||
|
scheduled();
|
||||||
|
await first.promise;
|
||||||
|
assert.equal(runs, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('projects beIN quality variants into one logical channel', () => {
|
test('projects beIN quality variants into one logical channel', () => {
|
||||||
const groups = projectCatalog([
|
const groups = projectCatalog([
|
||||||
item('uhd', '|FR| BEIN SPORTS 1 UHD'),
|
item('uhd', '|FR| BEIN SPORTS 1 UHD'),
|
||||||
|
|||||||
Reference in New Issue
Block a user