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:
blomios
2026-10-02 16:09:30 +02:00
14 changed files with 386 additions and 25 deletions

View File

@ -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;
}

View File

@ -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>

View File

@ -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

View File

@ -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) {

View 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 };
});

View File

@ -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
View File

@ -0,0 +1,5 @@
function isAdmin(user) {
return user?.role === 'admin';
}
module.exports = { isAdmin };

View File

@ -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
};

View File

@ -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

View File

@ -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;

View File

@ -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
};

View 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.'
);
});

View 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);
});

View File

@ -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'),