Merge branch 'feature/t14-live-analysis-button' into develop (#14)
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 };
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const passport = require('passport');
|
||||
const { isAdmin } = require('./authz');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { Strategy: LocalStrategy } = require('passport-local');
|
||||
|
||||
@ -234,7 +235,7 @@ const requireAuth = passport.authenticate('jwt', { session: false });
|
||||
* Middleware: Require admin role
|
||||
*/
|
||||
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' });
|
||||
}
|
||||
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'
|
||||
};
|
||||
|
||||
const CATALOG_PROJECTION_STATE_COLUMNS = {
|
||||
processed_count: 'INTEGER NOT NULL DEFAULT 0',
|
||||
total_count: 'INTEGER NOT NULL DEFAULT 0'
|
||||
};
|
||||
|
||||
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_grouping_fingerprint ON playlist_items(grouping_fingerprint);
|
||||
@ -35,6 +40,8 @@ const LIVE_PROJECTION_SCHEMA_SQL = `
|
||||
completed_at INTEGER,
|
||||
input_hash TEXT,
|
||||
projected_count INTEGER NOT NULL DEFAULT 0,
|
||||
processed_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
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 fs = require('fs');
|
||||
const {
|
||||
CATALOG_PROJECTION_STATE_COLUMNS,
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
@ -80,6 +81,7 @@ function initSchema() {
|
||||
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
ensureColumns(db, 'catalog_projection_state', CATALOG_PROJECTION_STATE_COLUMNS);
|
||||
|
||||
// EPG Programs
|
||||
// Optimized for range queries
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
const liveProjection = require('../services/liveProjectionService');
|
||||
|
||||
const router = express.Router();
|
||||
@ -12,6 +12,8 @@ function serializeState(state) {
|
||||
completedAt: state.completed_at,
|
||||
inputHash: state.input_hash,
|
||||
projectedCount: state.projected_count,
|
||||
processed: state.processed_count,
|
||||
total: state.total_count,
|
||||
error: state.error
|
||||
};
|
||||
}
|
||||
@ -28,7 +30,9 @@ router.get('/channels', requireAuth, async (req, res) => {
|
||||
|
||||
try {
|
||||
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 });
|
||||
return res.json({ projection: serializeState(state), channels });
|
||||
} 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.serializeState = serializeState;
|
||||
|
||||
@ -89,11 +89,25 @@ function projectionState(database = null) {
|
||||
database ||= defaultDatabase();
|
||||
return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || {
|
||||
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();
|
||||
const items = database.prepare(`
|
||||
SELECT id, source_id, item_id, name, category_id
|
||||
@ -101,13 +115,7 @@ function rebuild(database = null) {
|
||||
`).all();
|
||||
const groups = projectCatalog(items);
|
||||
const inputHash = catalogInputHash(items);
|
||||
const startedAt = Date.now();
|
||||
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);
|
||||
if (!options.stateAlreadyBuilding) markProjectionBuilding(database);
|
||||
|
||||
try {
|
||||
const apply = database.transaction(() => {
|
||||
@ -137,8 +145,9 @@ function rebuild(database = null) {
|
||||
}
|
||||
database.prepare(`
|
||||
UPDATE catalog_projection_state SET status = 'success', completed_at = ?,
|
||||
input_hash = ?, projected_count = ?, error = NULL WHERE projection = 'live'
|
||||
`).run(Date.now(), inputHash, groups.length);
|
||||
input_hash = ?, projected_count = ?, processed_count = ?, total_count = ?, error = NULL
|
||||
WHERE projection = 'live'
|
||||
`).run(Date.now(), inputHash, groups.length, items.length, items.length);
|
||||
});
|
||||
apply();
|
||||
return projectionState(database);
|
||||
@ -151,11 +160,31 @@ function rebuild(database = null) {
|
||||
}
|
||||
}
|
||||
|
||||
let rebuildQueue = Promise.resolve();
|
||||
function queueRebuild() {
|
||||
const next = rebuildQueue.catch(() => {}).then(() => rebuild());
|
||||
rebuildQueue = next;
|
||||
return next;
|
||||
let activeRebuild = null;
|
||||
let activeState = null;
|
||||
|
||||
function startRebuild(database = null, options = {}) {
|
||||
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) {
|
||||
@ -242,10 +271,12 @@ module.exports = {
|
||||
displayNameForFingerprint,
|
||||
getChannels,
|
||||
normalizeChannelName,
|
||||
markProjectionBuilding,
|
||||
projectCatalog,
|
||||
projectionState,
|
||||
qualityForName,
|
||||
queueRebuild,
|
||||
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 {
|
||||
projectCatalog,
|
||||
serializeChannels
|
||||
serializeChannels,
|
||||
startRebuild
|
||||
} = require('../server/services/liveProjectionService');
|
||||
const {
|
||||
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/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const groups = projectCatalog([
|
||||
item('uhd', '|FR| BEIN SPORTS 1 UHD'),
|
||||
|
||||
Reference in New Issue
Block a user