// Player route registration and filesystem-backed media helpers. const fs = require('fs'); const express = require('express'); const path = require('path'); const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth'); const { buildThumbnailPreviewData } = require('./thumbnail-preview'); const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED']; function isTransientDbError(error) { return Boolean(error && TRANSIENT_DB_ERROR_CODES.indexOf(String(error.code || '').trim()) !== -1); } function registerPlayerRoutes(app, options) { const pool = options && options.pool ? options.pool : null; const common = options && options.common ? options.common : null; const mediaDir = options && options.mediaDir ? options.mediaDir : null; const assetDir = options && options.assetDir ? options.assetDir : null; const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null; const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null; const rtmpStreamService = options && options.rtmpStreamService ? options.rtmpStreamService : null; const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, ''); const playerIdentifier = String(options && options.playerIdentifier || '1').trim() || '1'; if (!app || !pool || !common || !mediaDir || !assetDir || !playerRuntime || !playerPlaylistService || !rtmpStreamService) { throw new Error('registerPlayerRoutes requires app, pool, common, mediaDir, assetDir, playerRuntime, playerPlaylistService, and rtmpStreamService.'); } const sharedSecret = getSharedSecret(); function requirePageAuth(allowedScopes) { return function (req, res, next) { if (!sharedSecret) { return next(); } const token = String(req.headers['x-pulse-page-auth'] || '').trim(); const payload = verifyPageAuthToken(token); if (!payload) { return res.status(401).json({ error: 'Page authentication required.' }); } const scopes = Array.isArray(allowedScopes) ? allowedScopes : []; if (scopes.length && scopes.indexOf(String(payload.scope || '').trim()) === -1) { return res.status(403).json({ error: 'Page authentication scope is not allowed for this route.' }); } req.playerPageAuth = payload; next(); }; } function requireRequestAuth(req, res, next) { if (!sharedSecret) { return next(); } if (!verifyRequestAuth(req)) { return res.status(401).json({ error: 'Request authentication required.' }); } next(); } function resolveMediaFilePath(fileName) { const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, ''); if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return null; } const resolvedMediaDir = path.resolve(mediaDir); const resolvedFilePath = path.resolve(mediaDir, relativePath); if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) { return null; } return resolvedFilePath; } app.use('/assets', express.static(assetDir)); app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons'))); app.use('/media', express.static(mediaDir)); app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist'))); app.get('/sw.js', function (_req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.type('application/javascript'); res.sendFile(path.join(__dirname, 'public', 'sw.js')); }); app.post('/api/auth/page', function (req, res, next) { try { if (!sharedSecret) { return res.status(404).json({ error: 'Page authentication is disabled.' }); } const token = String(req.headers['x-pulse-page-auth'] || '').trim(); const payload = verifyPageAuthToken(token); if (!payload || ['player', 'onboarding'].indexOf(String(payload.scope || '').trim()) === -1) { return res.status(401).json({ error: 'Page authentication required.' }); } const tokenBundle = createPageAuthBundle({ scope: payload.scope, slug: payload.slug || null, deviceId: payload.deviceId || null }); res.json(tokenBundle); } catch (error) { next(error); } }); app.get('/api/media/config', requireRequestAuth, function (_req, res) { res.json({ mediaDir: mediaDir, uploadDir: path.join(mediaDir, 'uploads') }); }); app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) { try { const filePath = resolveMediaFilePath(req.params.filename); if (!filePath) { return res.status(400).json({ error: 'Filename is required' }); } const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ''); await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, body); res.json({ ok: true, filename: req.params.filename }); } catch (error) { next(error); } }); app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) { try { const filePath = resolveMediaFilePath(req.params.filename); if (!filePath) { return res.status(400).json({ error: 'Filename is required' }); } try { await fs.promises.unlink(filePath); } catch (error) { if (!error || error.code !== 'ENOENT') { throw error; } } res.json({ ok: true, filename: req.params.filename }); } catch (error) { next(error); } }); app.get('/api/rtmp/session', requirePageAuth(['player']), async function (req, res, next) { try { const source = String(req.query.source || '').trim(); const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase(); const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on'; const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput); const session = status.session; const ready = Boolean(status.ready); const live = Boolean(status.live); res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); if (!ready || !live) { return res.status(503).json({ ready: false, live: false, timedOut: Boolean(status.timedOut), stderr: String(status.stderr || '') }); } res.json({ key: session.key, playlistUrl: session.playlistUrl, disableAudio: session.disableAudio, ready: true, live: true }); } catch (error) { next(error); } }); app.get('/api/rtmp/streams/:key/index.m3u8', async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const manifestPath = await rtmpStreamService.getManifestFilePath(req.params.key); if (!manifestPath) { return res.status(404).send('Stream not found'); } res.sendFile(manifestPath); } catch (error) { next(error); } }); app.get('/api/rtmp/streams/:key/:fileName', async function (req, res, next) { try { const segmentPath = await rtmpStreamService.getSegmentFilePath(req.params.key, req.params.fileName); if (!segmentPath) { return res.status(404).send('Stream not found'); } res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.sendFile(segmentPath); } catch (error) { next(error); } }); app.get('/screen/:slug', function (req, res) { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); const { bindPlayerToScreen } = require('./onboarding'); if (playerIdentifier) { void bindPlayerToScreen(pool, playerIdentifier, req.params.slug) .catch(function (error) { console.error(error); }); } playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) { res.send(common.renderPlayerPage(req.params.slug, data)); }).catch(function (error) { console.error(error); res.set('X-Player-Offline', '1'); res.send(common.renderPlayerPage(req.params.slug, null)); }); }); app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) { try { const slide = await common.fetchSlideById(pool, Number(req.params.id)); if (!slide) { return res.status(404).send('Slide not found'); } const data = buildThumbnailPreviewData(slide); if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { const rssData = await common.fetchRssFeedsData(pool); data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) { const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id); return Object.assign({}, feed, { items: items.map(function (item) { return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item; }) }); })); } if (typeof common.fetchApiSourcesData === 'function') { const apiData = await common.fetchApiSourcesData(pool); data.apiSources = (apiData.apiSources || []).map(function (source) { return Object.assign({}, source, { responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null }); }); } if (typeof common.fetchTimetablesData === 'function') { const timetableData = await common.fetchTimetablesData(pool); data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data)); } catch (error) { next(error); } }); app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug); if (!data.screen) { return res.status(404).json({ error: 'Screen not found' }); } const etag = '"' + String(data.revision || '') + '"'; res.set('ETag', etag); if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { return String(value || '').trim(); }).includes(etag)) { return res.status(304).end(); } res.json(data); } catch (error) { next(error); } }); app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) { try { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); const announcement = typeof common.fetchActiveAnnouncement === 'function' ? await common.fetchActiveAnnouncement(pool, req.params.slug) : null; const revision = announcement ? [announcement.id, announcement.modified_at || '', announcement.expires_at || '', announcement.enabled ? '1' : '0'].join(':') : 'none'; const etag = '"' + String(revision || 'none') + '"'; res.set('ETag', etag); if (String(req.headers['if-none-match'] || '').split(',').map(function (value) { return String(value || '').trim(); }).includes(etag)) { return res.status(304).end(); } res.json({ announcement: announcement, revision: revision }); } catch (error) { if (isTransientDbError(error)) { return res.status(503).json({ error: 'Announcement state unavailable.' }); } next(error); } }); app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, async function (req, res, next) { try { const sent = typeof playerRuntime.broadcastAnnouncementRefresh === 'function' ? playerRuntime.broadcastAnnouncementRefresh(req.params.slug) : 0; res.json({ ok: true, screenSlug: req.params.slug, sent: sent }); } catch (error) { next(error); } }); app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], requireRequestAuth, async function (req, res, next) { try { const connections = playerRuntime.snapshotConnections(req.params.slug); let screen = null; let screenLookupFailed = false; try { const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); screen = screenRows[0] || null; } catch (error) { screenLookupFailed = isTransientDbError(error); if (!screenLookupFailed) { throw error; } } res.json({ screen: screen, screenSlug: req.params.slug, count: connections.length, connections: connections, degraded: screenLookupFailed }); } catch (error) { next(error); } }); app.post('/api/screens/:slug/commands', requireRequestAuth, async function (req, res, next) { try { const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim(); const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; if (!command) { return res.status(400).json({ error: 'Command is required' }); } if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) === -1) { return res.status(400).json({ error: 'Unsupported command' }); } const liveConnections = playerRuntime.snapshotConnections(req.params.slug); const isRedirectCommand = command === 'redirect'; let screen = null; let screenLookupFailed = false; if (!isRedirectCommand) { try { const [screenRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ?', [req.params.slug]); screen = screenRows[0] || null; } catch (error) { screenLookupFailed = isTransientDbError(error); if (!screenLookupFailed) { throw error; } } } if (!screen && liveConnections.length) { screen = { name: req.params.slug, slug: req.params.slug }; } const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? Object.assign({}, req.body, { command: command }) : command; if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') { commandPayload.blackout = blackoutValue; } const sent = connectionId ? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload) : await playerRuntime.broadcastCommand(req.params.slug, commandPayload); if (!screen && !screenLookupFailed && !liveConnections.length) { return res.status(404).json({ error: 'Screen not found' }); } if (!screen && screenLookupFailed && !liveConnections.length) { return res.status(503).json({ error: 'Screen metadata unavailable while the database is down.' }); } res.json({ screen: screen, screenSlug: req.params.slug, command: command, connectionId: connectionId || null, sent: sent, degraded: screenLookupFailed }); } catch (error) { next(error); } }); } module.exports = { registerPlayerRoutes: registerPlayerRoutes };