Files
Nodecast/server/routes/live.js
blomios 4cca030afa feat: split live analysis actions and page catalog loading
Separate incremental live checks from full catalog rebuilds and page projected channel/variant loading so first live-list display stays bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 23:45:42 +02:00

132 lines
5.6 KiB
JavaScript

const express = require('express');
const { requireAuth, requireAdmin } = require('../auth');
const liveProjection = require('../services/liveProjectionService');
const iptvOrgCatalog = require('../services/iptvOrgCatalogService');
const { getDb } = require('../db/sqlite');
const { sources } = require('../db');
const router = express.Router();
function serializeState(state) {
const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0;
return {
status: state.status,
stale: state.status !== 'success' || emptyProjectionWithLiveInput,
startedAt: state.started_at,
completedAt: state.completed_at,
inputHash: state.input_hash,
projectedCount: state.projected_count,
phase: state.phase,
processed: state.processed_count,
total: state.total_count,
layaProcessed: state.laya_processed_count,
layaTotal: state.laya_total_count,
error: state.error
};
}
router.get('/channels', requireAuth, async (req, res) => {
const sourceId = req.query.sourceId === undefined ? null : Number(req.query.sourceId);
if (sourceId !== null && (!Number.isInteger(sourceId) || sourceId <= 0)) {
return res.status(400).json({ error: 'sourceId must be a positive integer' });
}
if (req.query.includeHidden !== undefined && !['true', 'false'].includes(req.query.includeHidden)) {
return res.status(400).json({ error: 'includeHidden must be true or false' });
}
const includeHidden = req.query.includeHidden === 'true';
const q = String(req.query.q || '').slice(0, 200);
const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
const page = Number.parseInt(req.query.page, 10) || 1;
const limit = Number.parseInt(req.query.limit, 10) || 100;
if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) {
return res.status(400).json({ error: 'languages must be comma-separated language codes' });
}
try {
let state = liveProjection.projectionState();
if (state.status !== 'success' && state.status !== 'building') {
state = await liveProjection.startRebuild(null, { mode: 'algo' }).promise;
}
const epgSources = await sources.getByType('epg');
const result = liveProjection.getChannelPage({
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages, page, limit
});
return res.json({ projection: serializeState(state), filters: { availableLanguages: result.availableLanguages }, channels: result.channels, page: result.page });
} catch (error) {
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
const state = liveProjection.projectionState();
return res.status(503).json({
projection: serializeState(state),
channels: [],
error: 'Live channel projection is unavailable'
});
}
});
router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => {
try {
const page = Number.parseInt(req.query.page, 10) || 1;
const limit = Number.parseInt(req.query.limit, 10) || 100;
const includeHidden = req.query.includeHidden === 'true';
return res.json(liveProjection.getChannelVariants({
logicalChannelId: req.params.logicalChannelId,
includeHidden,
userId: req.user.id,
page,
limit
}));
} catch (error) {
console.error('[LiveProjection] Failed to serve channel variants:', error.message);
return res.status(500).json({ error: 'Unable to read channel variants' });
}
});
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(null, { analyzeAll: true });
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' });
}
});
router.post('/projection/check-new', requireAuth, requireAdmin, (req, res) => {
try {
const check = liveProjection.checkNewStreams();
check.catch(error => console.error('[LiveProjection] Incremental check failed:', error.message));
return res.status(202).json({ started: true });
} catch (error) {
console.error('[LiveProjection] Unable to start incremental check:', error.message);
return res.status(500).json({ error: 'Unable to start incremental Live analysis' });
}
});
router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => {
try {
const result = await iptvOrgCatalog.refresh(getDb());
await liveProjection.queueRebuild();
return res.json(result);
} catch (error) {
console.error('[LiveProjection] iptv-org refresh failed:', error.message);
return res.status(502).json({ error: 'Unable to refresh the iptv-org catalog' });
}
});
module.exports = router;
module.exports.serializeState = serializeState;