Initial Arcana FX soundboard

This commit is contained in:
Hermes Agent
2026-05-20 23:51:18 +02:00
commit 232e6923e3
19 changed files with 2520 additions and 0 deletions

30
src/config.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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 };