Initial Arcana FX soundboard
This commit is contained in:
30
src/config.js
Normal file
30
src/config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, '..', 'config', 'config.json');
|
||||
|
||||
let cached = null;
|
||||
|
||||
function loadConfig() {
|
||||
if (cached) return cached;
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
|
||||
const env = {
|
||||
haUrl: (process.env.HA_URL || '').replace(/\/+$/, ''),
|
||||
haToken: process.env.HA_TOKEN || '',
|
||||
publicBaseUrl: (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''),
|
||||
port: Number(process.env.PORT || 8091),
|
||||
allowedOrigins: (process.env.ALLOWED_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
cached = { ...json, env };
|
||||
return cached;
|
||||
}
|
||||
|
||||
module.exports = { loadConfig };
|
||||
149
src/effects.js
vendored
Normal file
149
src/effects.js
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
/**
|
||||
* Walks the Filestash share recursively, collects audio files,
|
||||
* and caches the result in memory with a TTL.
|
||||
*
|
||||
* Effects are identified by their share-relative path (e.g. "/Combat/Sword.mp3").
|
||||
* This identity is what the API accepts to play or proxy a file — clients
|
||||
* must never pass an arbitrary absolute Filestash path.
|
||||
*/
|
||||
class EffectsService {
|
||||
constructor({ client, extensions, ignoreNames, cacheTtlSeconds, maxDepth }) {
|
||||
this.client = client;
|
||||
this.extensions = new Set(extensions.map((e) => e.toLowerCase()));
|
||||
this.ignore = new Set(ignoreNames.map((n) => n.toLowerCase()));
|
||||
this.cacheTtlMs = Math.max(0, cacheTtlSeconds * 1000);
|
||||
this.maxDepth = Math.max(1, maxDepth);
|
||||
this.cache = null;
|
||||
this.cacheLoadedAt = 0;
|
||||
this.inflight = null;
|
||||
}
|
||||
|
||||
isAudioFile(name) {
|
||||
const lower = name.toLowerCase();
|
||||
if (this.ignore.has(lower)) return false;
|
||||
const ext = path.extname(lower);
|
||||
return this.extensions.has(ext);
|
||||
}
|
||||
|
||||
contentTypeFor(name) {
|
||||
switch (path.extname(name).toLowerCase()) {
|
||||
case '.mp3': return 'audio/mpeg';
|
||||
case '.ogg': return 'audio/ogg';
|
||||
case '.flac': return 'audio/flac';
|
||||
case '.wav': return 'audio/wav';
|
||||
case '.m4a': return 'audio/mp4';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached effects list, refreshing if expired or forced.
|
||||
* Single-flight: concurrent callers share the same refresh promise.
|
||||
*/
|
||||
async list({ force = false } = {}) {
|
||||
const fresh =
|
||||
this.cache &&
|
||||
!force &&
|
||||
Date.now() - this.cacheLoadedAt < this.cacheTtlMs;
|
||||
if (fresh) return this.cache;
|
||||
|
||||
if (!this.inflight) {
|
||||
this.inflight = this._refresh().finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
}
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
async _refresh() {
|
||||
const effects = [];
|
||||
const stack = [{ rel: '/', depth: 0 }];
|
||||
const visited = new Set();
|
||||
|
||||
while (stack.length) {
|
||||
const { rel, depth } = stack.pop();
|
||||
if (visited.has(rel)) continue;
|
||||
visited.add(rel);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await this.client.listDir(rel);
|
||||
} catch (err) {
|
||||
if (rel === '/') throw err;
|
||||
// Skip unreadable sub-folders instead of aborting the whole scan.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[effects] failed to list ${rel}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name || entry.name === '.' || entry.name === '..') continue;
|
||||
if (this.ignore.has(entry.name.toLowerCase())) continue;
|
||||
const childRel = joinRel(rel, entry.name);
|
||||
if (entry.type === 'directory') {
|
||||
if (depth + 1 < this.maxDepth) {
|
||||
stack.push({ rel: childRel + '/', depth: depth + 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!this.isAudioFile(entry.name)) continue;
|
||||
|
||||
const category = deriveCategory(childRel);
|
||||
effects.push({
|
||||
id: childRel,
|
||||
path: childRel,
|
||||
name: stripExt(entry.name),
|
||||
fileName: entry.name,
|
||||
category,
|
||||
type: path.extname(entry.name).slice(1).toLowerCase(),
|
||||
size: entry.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
effects.sort((a, b) => {
|
||||
const c = a.category.localeCompare(b.category);
|
||||
return c !== 0 ? c : a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
this.cache = effects;
|
||||
this.cacheLoadedAt = Date.now();
|
||||
return effects;
|
||||
}
|
||||
|
||||
async findByPath(relPath) {
|
||||
if (!isSafeRel(relPath)) return null;
|
||||
const list = await this.list();
|
||||
return list.find((e) => e.path === relPath) || null;
|
||||
}
|
||||
}
|
||||
|
||||
function joinRel(parent, name) {
|
||||
const p = parent.endsWith('/') ? parent.slice(0, -1) : parent;
|
||||
return `${p}/${name}`;
|
||||
}
|
||||
|
||||
function deriveCategory(relPath) {
|
||||
const parts = relPath.split('/').filter(Boolean);
|
||||
if (parts.length <= 1) return 'Uncategorized';
|
||||
return parts[parts.length - 2];
|
||||
}
|
||||
|
||||
function stripExt(name) {
|
||||
const ext = path.extname(name);
|
||||
return ext ? name.slice(0, -ext.length) : name;
|
||||
}
|
||||
|
||||
function isSafeRel(p) {
|
||||
if (typeof p !== 'string' || !p.length) return false;
|
||||
if (!p.startsWith('/')) return false;
|
||||
if (p.includes('..')) return false;
|
||||
if (p.includes('\0')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { EffectsService, isSafeRel };
|
||||
188
src/filestash.js
Normal file
188
src/filestash.js
Normal file
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Filestash client. Holds a single proof cookie obtained by POSTing to the
|
||||
* share proof endpoint, refreshes it on auth-style failures, and exposes
|
||||
* directory listing plus a streaming download for the media proxy.
|
||||
*/
|
||||
class FilestashClient {
|
||||
constructor({ baseUrl, shareId, fetchImpl = globalThis.fetch }) {
|
||||
if (!baseUrl || !shareId) {
|
||||
throw new Error('FilestashClient requires baseUrl and shareId');
|
||||
}
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.shareId = shareId;
|
||||
this.fetch = fetchImpl;
|
||||
this.proofCookie = null;
|
||||
this.basePath = '/';
|
||||
this.proofPromise = null;
|
||||
}
|
||||
|
||||
_commonHeaders(extra = {}) {
|
||||
const headers = {
|
||||
'X-Requested-With': 'XmlHttpRequest',
|
||||
'Accept': 'application/json',
|
||||
...extra,
|
||||
};
|
||||
if (this.proofCookie) {
|
||||
headers['Cookie'] = this.proofCookie;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Set-Cookie header(s) and keep cookies relevant to this share.
|
||||
* Returns a single "name=value; name2=value2" string suitable for Cookie header.
|
||||
*/
|
||||
_captureCookies(response) {
|
||||
// Node fetch exposes raw Set-Cookie via headers.getSetCookie() (Node 20+).
|
||||
let raws = [];
|
||||
if (typeof response.headers.getSetCookie === 'function') {
|
||||
raws = response.headers.getSetCookie();
|
||||
} else {
|
||||
const single = response.headers.get('set-cookie');
|
||||
if (single) raws = [single];
|
||||
}
|
||||
if (!raws.length) return;
|
||||
|
||||
const existing = new Map();
|
||||
if (this.proofCookie) {
|
||||
for (const pair of this.proofCookie.split(/;\s*/)) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) existing.set(pair.slice(0, eq), pair.slice(eq + 1));
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of raws) {
|
||||
const firstSemi = raw.indexOf(';');
|
||||
const pair = firstSemi === -1 ? raw : raw.slice(0, firstSemi);
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
const value = pair.slice(eq + 1).trim();
|
||||
if (!name) continue;
|
||||
existing.set(name, value);
|
||||
}
|
||||
|
||||
this.proofCookie = [...existing.entries()]
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
async _doProof() {
|
||||
const url = `${this.baseUrl}/api/share/${encodeURIComponent(this.shareId)}/proof`;
|
||||
const res = await this.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XmlHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await safeText(res);
|
||||
throw new Error(`Filestash proof failed: ${res.status} ${text}`);
|
||||
}
|
||||
this._captureCookies(res);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const result = body && body.result;
|
||||
if (result && typeof result.path === 'string' && result.path.length) {
|
||||
this.basePath = result.path;
|
||||
}
|
||||
return { basePath: this.basePath };
|
||||
}
|
||||
|
||||
async ensureProof() {
|
||||
if (this.proofCookie) return;
|
||||
if (!this.proofPromise) {
|
||||
this.proofPromise = this._doProof().finally(() => {
|
||||
this.proofPromise = null;
|
||||
});
|
||||
}
|
||||
await this.proofPromise;
|
||||
}
|
||||
|
||||
_resetProof() {
|
||||
this.proofCookie = null;
|
||||
}
|
||||
|
||||
async _request(buildRequest) {
|
||||
await this.ensureProof();
|
||||
let res = await buildRequest();
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
this._resetProof();
|
||||
await this.ensureProof();
|
||||
res = await buildRequest();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a directory. `relPath` is share-relative — "/" is the share root,
|
||||
* a sub-path like "/Ambiences/" lists that folder. The absolute filesystem
|
||||
* path reported by /proof (basePath) is metadata only and is NOT passed
|
||||
* to ls/cat — the Filestash share API rewrites paths internally.
|
||||
* Returns array of entries { name, type: 'file'|'directory', size, time }.
|
||||
*/
|
||||
async listDir(relPath = '/') {
|
||||
const sharePath = normalizeSharePath(relPath);
|
||||
const url = new URL(`${this.baseUrl}/api/files/ls`);
|
||||
url.searchParams.set('path', sharePath);
|
||||
url.searchParams.set('share', this.shareId);
|
||||
|
||||
const res = await this._request(() =>
|
||||
this.fetch(url.toString(), { method: 'GET', headers: this._commonHeaders() })
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await safeText(res);
|
||||
throw new Error(`Filestash ls failed: ${res.status} ${text} (${sharePath})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const results = (body && body.results) || [];
|
||||
return results.map((entry) => ({
|
||||
name: String(entry.name || ''),
|
||||
type: entry.type === 'directory' ? 'directory' : 'file',
|
||||
size: typeof entry.size === 'number' ? entry.size : null,
|
||||
time: typeof entry.time === 'number' ? entry.time : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a single file by its share-relative path (must start with "/").
|
||||
* Returns the underlying fetch Response so the caller can pipe headers + body.
|
||||
*/
|
||||
async streamFile(relPath, { range } = {}) {
|
||||
const sharePath = normalizeSharePath(relPath);
|
||||
const url = new URL(`${this.baseUrl}/api/files/cat`);
|
||||
url.searchParams.set('path', sharePath);
|
||||
url.searchParams.set('share', this.shareId);
|
||||
|
||||
const headers = this._commonHeaders();
|
||||
if (range) headers['Range'] = range;
|
||||
|
||||
const res = await this._request(() =>
|
||||
this.fetch(url.toString(), { method: 'GET', headers })
|
||||
);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSharePath(p) {
|
||||
if (!p || p === '/') return '/';
|
||||
let s = String(p);
|
||||
if (!s.startsWith('/')) s = `/${s}`;
|
||||
// Collapse any accidental double slashes.
|
||||
s = s.replace(/\/{2,}/g, '/');
|
||||
return s;
|
||||
}
|
||||
|
||||
async function safeText(res) {
|
||||
try {
|
||||
return (await res.text()).slice(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FilestashClient };
|
||||
108
src/homeassistant.js
Normal file
108
src/homeassistant.js
Normal file
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Narrow Home Assistant client. Only exposes the two methods the speaker
|
||||
* controls actually need: play a known media URL on the configured player,
|
||||
* and stop playback on that same player. No arbitrary service calls.
|
||||
*/
|
||||
class HomeAssistantClient {
|
||||
constructor({ baseUrl, token, allowedEntity, timeoutMs = 8000, fetchImpl = globalThis.fetch }) {
|
||||
if (!baseUrl) throw new Error('HomeAssistantClient requires baseUrl');
|
||||
if (!token) throw new Error('HomeAssistantClient requires token');
|
||||
if (!allowedEntity) throw new Error('HomeAssistantClient requires allowedEntity');
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.token = token;
|
||||
this.allowedEntity = allowedEntity;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.fetch = fetchImpl;
|
||||
}
|
||||
|
||||
_headers() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async _call(servicePath, body) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await this.fetch(`${this.baseUrl}/api/services/${servicePath}`, {
|
||||
method: 'POST',
|
||||
headers: this._headers(),
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await safeText(res);
|
||||
const err = new Error(`Home Assistant ${servicePath} failed: ${res.status} ${text}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json().catch(() => ({}));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
_assertEntity(entityId) {
|
||||
if (entityId !== this.allowedEntity) {
|
||||
const err = new Error(`entity_id ${entityId} not permitted`);
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async playMedia({ entityId, mediaContentId, mediaContentType }) {
|
||||
this._assertEntity(entityId);
|
||||
if (!mediaContentId || typeof mediaContentId !== 'string') {
|
||||
const err = new Error('mediaContentId is required');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!mediaContentType || typeof mediaContentType !== 'string') {
|
||||
const err = new Error('mediaContentType is required');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return this._call('media_player/play_media', {
|
||||
entity_id: entityId,
|
||||
media_content_id: mediaContentId,
|
||||
media_content_type: mediaContentType,
|
||||
});
|
||||
}
|
||||
|
||||
async stop({ entityId }) {
|
||||
this._assertEntity(entityId);
|
||||
return this._call('media_player/media_stop', { entity_id: entityId });
|
||||
}
|
||||
|
||||
async ping() {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await this.fetch(`${this.baseUrl}/api/`, {
|
||||
method: 'GET',
|
||||
headers: this._headers(),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function safeText(res) {
|
||||
try {
|
||||
return (await res.text()).slice(0, 200);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HomeAssistantClient };
|
||||
152
src/routes.js
Normal file
152
src/routes.js
Normal file
@@ -0,0 +1,152 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { isSafeRel } = require('./effects');
|
||||
|
||||
/**
|
||||
* Builds the API router. Each route is intentionally narrow: clients can
|
||||
* list effects, play a known effect path, stop, or pull a known effect
|
||||
* via the media proxy. No arbitrary HA service / Filestash path passthrough.
|
||||
*/
|
||||
function createApiRouter({ config, effects, filestash, ha }) {
|
||||
const router = express.Router();
|
||||
const allowedEntity = config.homeAssistant.allowedMediaPlayer;
|
||||
const publicBaseUrl = config.env.publicBaseUrl;
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
time: new Date().toISOString(),
|
||||
cachedEffects: effects.cache ? effects.cache.length : 0,
|
||||
cacheAgeSec: effects.cache
|
||||
? Math.round((Date.now() - effects.cacheLoadedAt) / 1000)
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api/effects', async (req, res, next) => {
|
||||
try {
|
||||
const force = req.query.refresh === '1' || req.query.refresh === 'true';
|
||||
const list = await effects.list({ force });
|
||||
const categories = [...new Set(list.map((e) => e.category))].sort();
|
||||
res.json({
|
||||
count: list.length,
|
||||
categories,
|
||||
effects: list,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/config', (_req, res) => {
|
||||
res.json({
|
||||
mediaPlayer: allowedEntity,
|
||||
publicBaseUrlConfigured: Boolean(publicBaseUrl),
|
||||
lightControl: config.lightControl,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/api/play', express.json({ limit: '4kb' }), async (req, res, next) => {
|
||||
try {
|
||||
const { path: effectPath } = req.body || {};
|
||||
if (!isSafeRel(effectPath)) {
|
||||
return res.status(400).json({ error: 'invalid effect path' });
|
||||
}
|
||||
const effect = await effects.findByPath(effectPath);
|
||||
if (!effect) {
|
||||
return res.status(404).json({ error: 'effect not found' });
|
||||
}
|
||||
if (!publicBaseUrl) {
|
||||
return res.status(503).json({
|
||||
error: 'PUBLIC_BASE_URL is not configured; Home Assistant cannot fetch /media',
|
||||
});
|
||||
}
|
||||
|
||||
const mediaUrl = `${publicBaseUrl}/media?path=${encodeURIComponent(effect.path)}`;
|
||||
const mediaContentType = effects.contentTypeFor(effect.fileName);
|
||||
|
||||
await ha.playMedia({
|
||||
entityId: allowedEntity,
|
||||
mediaContentId: mediaUrl,
|
||||
mediaContentType,
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
played: {
|
||||
name: effect.name,
|
||||
path: effect.path,
|
||||
category: effect.category,
|
||||
mediaContentType,
|
||||
mediaUrl,
|
||||
entity: allowedEntity,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/stop', async (_req, res, next) => {
|
||||
try {
|
||||
await ha.stop({ entityId: allowedEntity });
|
||||
res.json({ ok: true, stopped: allowedEntity });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/media', async (req, res, next) => {
|
||||
try {
|
||||
const effectPath = typeof req.query.path === 'string' ? req.query.path : '';
|
||||
if (!isSafeRel(effectPath)) {
|
||||
return res.status(400).json({ error: 'invalid path' });
|
||||
}
|
||||
const effect = await effects.findByPath(effectPath);
|
||||
if (!effect) {
|
||||
return res.status(404).json({ error: 'effect not found' });
|
||||
}
|
||||
|
||||
const upstream = await filestash.streamFile(effect.path, {
|
||||
range: req.headers.range,
|
||||
});
|
||||
if (!upstream.ok && upstream.status !== 206) {
|
||||
return res
|
||||
.status(upstream.status || 502)
|
||||
.json({ error: 'upstream fetch failed', status: upstream.status });
|
||||
}
|
||||
|
||||
const passthrough = ['content-length', 'content-range', 'accept-ranges', 'last-modified', 'etag'];
|
||||
for (const h of passthrough) {
|
||||
const val = upstream.headers.get(h);
|
||||
if (val) res.setHeader(h, val);
|
||||
}
|
||||
res.setHeader('Content-Type', effects.contentTypeFor(effect.fileName));
|
||||
res.status(upstream.status === 206 ? 206 : 200);
|
||||
|
||||
if (!upstream.body) {
|
||||
return res.end();
|
||||
}
|
||||
// upstream.body is a Web ReadableStream; pipe it to the Node response.
|
||||
const reader = upstream.body.getReader();
|
||||
res.on('close', () => {
|
||||
try { reader.cancel(); } catch { /* noop */ }
|
||||
});
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!res.write(Buffer.from(value))) {
|
||||
await new Promise((resolve) => res.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = { createApiRouter };
|
||||
125
src/server.js
Normal file
125
src/server.js
Normal file
@@ -0,0 +1,125 @@
|
||||
'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 { createApiRouter } = require('./routes');
|
||||
|
||||
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 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 }));
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const config = loadConfig();
|
||||
const { app, effects } = 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.');
|
||||
}
|
||||
});
|
||||
|
||||
// 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.`);
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000).unref();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { buildApp };
|
||||
Reference in New Issue
Block a user