Files
arcana-fx/src/server.js
2026-05-21 00:48:55 +02:00

140 lines
4.3 KiB
JavaScript

'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 };