feat: precompute stream grouping during source sync (#13)

Move variant grouping work out of Live TV rendering into the
catalog pipeline: a liveProjectionService builds the logical channel
projection (schema in liveProjectionSchema) whenever a source is
added, synced or modified (name change, new address), and the
projection is served through a /live route. ChannelList now consumes
the precomputed projection via a liveChannelProjection client
service, recalculating only when the catalog changes. Cover
projection building, incremental recompute and client consumption
with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 15:35:02 +02:00
parent 7b6525708c
commit 9f97020996
16 changed files with 619 additions and 10 deletions

46
server/routes/live.js Normal file
View File

@ -0,0 +1,46 @@
const express = require('express');
const { requireAuth } = require('../auth');
const liveProjection = require('../services/liveProjectionService');
const router = express.Router();
function serializeState(state) {
return {
status: state.status,
stale: state.status !== 'success',
startedAt: state.started_at,
completedAt: state.completed_at,
inputHash: state.input_hash,
projectedCount: state.projected_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';
try {
let state = liveProjection.projectionState();
if (state.status !== 'success') state = liveProjection.rebuild();
const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id });
return res.json({ projection: serializeState(state), channels });
} 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'
});
}
});
module.exports = router;
module.exports.serializeState = serializeState;

View File

@ -5,6 +5,7 @@ const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser');
const liveProjection = require('../services/liveProjectionService');
// Get all sources
router.get('/', async (req, res) => {
@ -132,6 +133,7 @@ router.delete('/:id', async (req, res) => {
// Delete source config and related hidden items (favorites handled by db.js)
await sources.delete(sourceId);
await liveProjection.queueRebuild();
res.json({ success: true });
} catch (err) {
@ -278,4 +280,3 @@ router.post('/sync-all', async (req, res) => {
});
module.exports = router;