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:
@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user