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