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:
blomios
2026-10-02 16:09:13 +02:00
parent 9116d90526
commit 67d9869364
14 changed files with 386 additions and 25 deletions

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