Files
Nodecast/server/routes/live.js
blomios 8f2825f697 feat(live): show language-scoped stream variants
Keep live stream variant expansion scoped to the selected language while preserving the legacy all-language variants response when no language is provided.

Display projected current EPG programme data in live rows and distinguish selected streams from actively playing streams in the collapsed variant UI.

Refs #43

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-08 19:00:14 +02:00

142 lines
6.2 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;
const isLayaPhase = state.phase === 'laya_identity' || state.phase === 'laya_language';
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,
phaseProcessed: isLayaPhase ? state.laya_processed_count : state.processed_count,
phaseTotal: isLayaPhase ? state.laya_total_count : state.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) => {
const language = req.query.language === undefined
? null
: String(req.query.language).trim().toLowerCase();
if (language !== null && !/^(?:[a-z]{2,3}|und)$/.test(language)) {
return res.status(400).json({ error: 'language must be a valid language code' });
}
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,
language,
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 fullRebuild = liveProjection.fullRebuildOrchestrator();
fullRebuild.catch(error => {
console.error('[LiveProjection] Manual rebuild failed:', error.message);
});
return res.status(202).json({
started: true,
projection: serializeState(liveProjection.projectionState())
});
} 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;