140 lines
4.3 KiB
JavaScript
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 };
|