'use strict'; const path = require('node:path'); const express = require('express'); const { loadConfig } = require('./config'); const { FilestashClient } = require('./filestash'); const { EffectsService } = require('./effects'); const { HomeAssistantClient } = require('./homeassistant'); const { StreamMixer } = require('./streamMixer'); const { createApiRouter } = require('./routes'); const { attachStatusWebSocket } = require('./wsHub'); function buildApp(config) { const filestash = new FilestashClient({ baseUrl: config.filestash.baseUrl, shareId: config.filestash.shareId, }); const effects = new EffectsService({ client: filestash, extensions: config.audio.extensions, ignoreNames: config.audio.ignoreNames, cacheTtlSeconds: config.audio.cacheTtlSeconds, maxDepth: config.audio.maxDepth, }); let ha = null; if (config.env.haUrl && config.env.haToken) { ha = new HomeAssistantClient({ baseUrl: config.env.haUrl, token: config.env.haToken, allowedEntity: config.homeAssistant.allowedMediaPlayer, timeoutMs: config.homeAssistant.requestTimeoutMs, }); } else { // Stub that fails closed so /api/play returns a clear error // instead of a server crash when env is incomplete. ha = { async playMedia() { const err = new Error('HA_URL and HA_TOKEN must be set to play media'); err.status = 503; throw err; }, async stop() { const err = new Error('HA_URL and HA_TOKEN must be set to stop playback'); err.status = 503; throw err; }, }; } const mixer = new StreamMixer({ effects, localMediaBaseUrl: `http://127.0.0.1:${config.env.port}`, }); const app = express(); app.disable('x-powered-by'); // Lightweight CORS: only when ALLOWED_ORIGINS is set. if (config.env.allowedOrigins.length) { app.use((req, res, next) => { const origin = req.headers.origin; if (origin && config.env.allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); } if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); } app.use(createApiRouter({ config, effects, filestash, ha, mixer })); const publicDir = path.join(__dirname, '..', 'public'); app.use(express.static(publicDir, { fallthrough: true, index: 'index.html' })); // JSON error handler — must keep 4 args for Express to recognize it. // eslint-disable-next-line no-unused-vars app.use((err, _req, res, _next) => { const status = err.status || 500; if (status >= 500) { // eslint-disable-next-line no-console console.error('[error]', err); } res.status(status).json({ error: err.message || 'internal error' }); }); return { app, effects, filestash, mixer }; } function main() { const config = loadConfig(); const { app, effects, mixer } = buildApp(config); const server = app.listen(config.env.port, () => { // eslint-disable-next-line no-console console.log(`Arcana FX listening on :${config.env.port}`); if (!config.env.publicBaseUrl) { // eslint-disable-next-line no-console console.warn('[warn] PUBLIC_BASE_URL is empty — /api/play will reject until it is set.'); } if (!config.env.haToken) { // eslint-disable-next-line no-console console.warn('[warn] HA_TOKEN is empty — playback endpoints will return 503.'); } }); const wsHub = attachStatusWebSocket({ server, mixer, entity: config.homeAssistant.allowedMediaPlayer, }); // Warm the cache in the background; ignore failures, they will surface on /api/effects. effects.list().catch((err) => { // eslint-disable-next-line no-console console.warn('[warn] initial effects scan failed:', err.message); }); for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => { // eslint-disable-next-line no-console console.log(`Received ${sig}, shutting down.`); wsHub.close(); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 5000).unref(); }); } } if (require.main === module) { main(); } module.exports = { buildApp };