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

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
npm-debug.log
.env
.env.local
.git
.gitignore
.DS_Store
*.log
README.md
BUILD_BRIEF.md
.cache

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# --- Home Assistant ---
# Base URL of your Home Assistant instance (reachable from the container)
HA_URL=http://192.168.33.33:8123
# Long-lived access token. NEVER commit a real token.
HA_TOKEN=replace-me-with-a-long-lived-access-token
# --- Arcana FX backend ---
# Public base URL the Home Assistant media player will use to fetch /media.
# Must be reachable from HA — typically the LAN address of the host running this container.
PUBLIC_BASE_URL=http://192.168.33.150:8091
# Port the backend listens on inside the container (compose maps host 8091 -> 8091).
PORT=8091
# Optional: comma separated origins allowed by CORS. Leave empty to allow same-origin only.
ALLOWED_ORIGINS=

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
npm-debug.log
.env
.env.local
.DS_Store
*.log
dist/
.cache/

41
BUILD_BRIEF.md Normal file
View File

@@ -0,0 +1,41 @@
Build a deployable Docker Compose webapp named Arcana FX.
Purpose:
- D&D effects soundboard now; future control surface for lights/effects later.
- Frontend dashboard with tiles for audio effects. Pressing a tile tells backend to play that audio through Home Assistant.
Source audio:
- Filestash public share URL: https://files.fisoft.eu/s/FHBaJ8Z
- Put that URL and share id in config/config.json.
- The Filestash API flow discovered externally:
1. POST https://files.fisoft.eu/api/share/FHBaJ8Z/proof with header X-Requested-With: XmlHttpRequest. It returns JSON with path /DnD/Audio/ and sets proof cookie.
2. List directories with GET https://files.fisoft.eu/api/files/ls?path=<path>&share=FHBaJ8Z with same header and proof cookie. Root path is '/'. Results contain files/directories.
3. Stream/download file with GET https://files.fisoft.eu/api/files/cat?path=<path>&share=FHBaJ8Z with proof cookie.
- Backend should cache/list recursive audio effects. Accept .mp3 .ogg .flac .wav .m4a. Ignore desktop.ini. Prefer not to preload/download all audio.
- Backend must expose an intranet media proxy endpoint for a selected path, e.g. /media?path=..., because Home Assistant needs a URL it can fetch. The backend should fetch from Filestash with its proof cookie and stream it.
Home Assistant:
- URL from env: HA_URL=http://192.168.33.33:8123
- Token from env only, never frontend and do not commit it.
- Allowed media player entity must be hardcoded/config-limited to media_player.audio_test_media_player.
- Backend service should expose only narrow endpoints: list effects, play a selected known effect path, stop optional. It must not accept arbitrary HA service/entity calls.
- Play implementation should call POST /api/services/media_player/play_media with entity_id media_player.audio_test_media_player, media_content_id as PUBLIC_BASE_URL + /media?path=..., media_content_type audio/mpeg or audio/ogg etc.
Deployment:
- Use docker-compose.yml, not raw docker run.
- Expose app on host port 8091.
- Frontend can be served by backend or nginx; keep simple. Backend service must be separate logical component/code from frontend speaker controls.
- Include Dockerfile, package files, README.
- Add health endpoint.
- Include .env.example but not .env.
Frontend:
- Creative dark fantasy control-room style.
- Search, category filters, status messages.
- Tiles should show name/category/path/type.
- Add future-light-control placeholders visibly but disabled/coming soon.
Tech preference:
- Use Node.js/Express backend and static frontend unless you have a better simple choice.
- Keep dependencies minimal.
- Include basic validation and error handling.

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
COPY public ./public
COPY config ./config
EXPOSE 8091
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT:-8091}/health > /dev/null || exit 1
CMD ["node", "src/server.js"]

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# Arcana FX
A dark-fantasy soundboard for D&D nights. The frontend shows a tile grid of
audio effects; pressing a tile asks the backend to play that file through a
configured Home Assistant `media_player`. Future panels for light control are
present but disabled.
## Architecture
```
┌────────────┐ /api/play ┌──────────────────────┐ POST media_player/play_media
│ Frontend │ ─────────────▶│ Arcana FX backend │ ───────────────────────────────▶ Home Assistant
│ (static) │ │ (Node 20 + Express) │ │
└────────────┘ │ │◀──── fetches /media ─────────────────┘
│ Filestash client │ ◀── share proof cookie + cat ── Filestash
└──────────────────────┘
```
- **Backend** (`src/`) is the only component that holds the Home Assistant
token and the Filestash proof cookie. It exposes a deliberately narrow API.
- **Frontend** (`public/`) is plain static HTML/CSS/JS — no build step.
- Home Assistant pulls audio from `PUBLIC_BASE_URL/media?path=...`, which the
backend streams from Filestash on demand. Nothing is preloaded.
## API surface
| Method | Path | Description |
|-------:|------------------|------------------------------------------------------------|
| GET | `/health` | Liveness probe + cache stats |
| GET | `/api/config` | Public config (media player entity, light-control stubs) |
| GET | `/api/effects` | Recursive list of audio effects (`?refresh=1` to force) |
| POST | `/api/play` | Body `{ "path": "/Combat/Sword.mp3" }` |
| POST | `/api/stop` | Stops the configured media player |
| GET | `/media?path=…` | Intranet proxy streaming the file from Filestash |
`path` must be a share-relative path that was returned by `/api/effects`.
Arbitrary Filestash paths, HA entities, or HA services cannot be reached
through this API.
## Configuration
Two layers:
- `config/config.json` — non-secret, version-controlled. Filestash share,
allowed extensions, allowed media player entity, cache TTL, light-control
placeholders.
- `.env` — secrets and per-deployment values. Copy from `.env.example`:
```
HA_URL=http://192.168.33.33:8123
HA_TOKEN=... # long-lived access token, never commit
PUBLIC_BASE_URL=http://<lan-ip>:8091 # URL HA uses to fetch /media
PORT=8091
ALLOWED_ORIGINS= # optional CORS allow-list
```
`PUBLIC_BASE_URL` must be reachable **from Home Assistant**, not just from
your browser. On a typical home LAN it is `http://<docker-host-lan-ip>:8091`.
The allowed media player entity is `media_player.audio_test_media_player`,
hardcoded in `config/config.json`. Requests for any other entity are rejected
with `403`.
## Run with Docker Compose
```bash
cp .env.example .env
# edit .env: set HA_TOKEN and PUBLIC_BASE_URL
docker compose up -d --build
```
Then open `http://<host>:8091`.
## Run locally (no Docker)
Requires Node.js 20+.
```bash
cp .env.example .env
# edit .env
npm install
npm run check # syntax check on backend sources
npm start
```
## Filestash flow
The backend obtains a proof cookie on first request:
1. `POST /api/share/<id>/proof` with `X-Requested-With: XmlHttpRequest`.
Response sets the proof cookie and tells us the share's base path (e.g.
`/DnD/Audio/`).
2. `GET /api/files/ls?path=<absolute>&share=<id>` to walk directories.
3. `GET /api/files/cat?path=<absolute>&share=<id>` to stream a file.
Recursive listing is cached in memory (`cacheTtlSeconds` in
`config/config.json`, default 10 minutes). `/api/effects?refresh=1` forces
a rescan.
## Security notes
- The HA token lives only in the backend process environment. It is never
sent to the frontend.
- Only the configured `media_player` entity is allowed. The HA client refuses
any other entity id at the client layer.
- Effect paths must match a known effect from the cached share scan, must
start with `/`, and must not contain `..` or NUL bytes.
- `.env` is git-ignored; only `.env.example` is committed.
## Future work
The "Light Control" panel renders disabled placeholders (`config/config.json
> lightControl.placeholders`). Wiring real switches/lights will be a new
narrow set of endpoints, on the same principle as the audio side.

26
config/config.json Normal file
View File

@@ -0,0 +1,26 @@
{
"filestash": {
"baseUrl": "https://files.fisoft.eu",
"shareId": "FHBaJ8Z",
"rootPath": "/"
},
"audio": {
"extensions": [".mp3", ".ogg", ".flac", ".wav", ".m4a"],
"ignoreNames": ["desktop.ini", ".DS_Store", "Thumbs.db"],
"cacheTtlSeconds": 600,
"maxDepth": 8
},
"homeAssistant": {
"allowedMediaPlayer": "media_player.audio_test_media_player",
"requestTimeoutMs": 8000
},
"lightControl": {
"comingSoon": true,
"placeholders": [
{ "name": "Torchlight", "icon": "flame" },
{ "name": "Moon Glow", "icon": "moon" },
{ "name": "Arcane Pulse", "icon": "sparkles" },
{ "name": "Blood Red", "icon": "droplet" }
]
}
}

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
arcana-fx:
build: .
image: arcana-fx:latest
container_name: arcana-fx
restart: unless-stopped
env_file:
- .env
environment:
PORT: 8091
ports:
- "8091:8091"
volumes:
- ./config:/app/config:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8091/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

830
package-lock.json generated Normal file
View File

@@ -0,0 +1,830 @@
{
"name": "arcana-fx",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arcana-fx",
"version": "0.1.0",
"dependencies": {
"express": "^4.19.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "arcana-fx",
"version": "0.1.0",
"private": true,
"description": "Arcana FX — D&D effects soundboard with Home Assistant playback",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"check": "node --check src/server.js && node --check src/filestash.js && node --check src/effects.js && node --check src/homeassistant.js && node --check src/routes.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"express": "^4.19.2"
}
}

227
public/app.js Normal file
View File

@@ -0,0 +1,227 @@
(() => {
'use strict';
const els = {
grid: document.getElementById('grid'),
empty: document.getElementById('empty'),
search: document.getElementById('search'),
categories: document.getElementById('categories'),
status: document.getElementById('status'),
statusText: document.getElementById('status-text'),
refresh: document.getElementById('refresh-btn'),
stop: document.getElementById('stop-btn'),
entityLabel: document.getElementById('entity-label'),
countLabel: document.getElementById('count-label'),
lights: document.getElementById('lights'),
};
const state = {
effects: [],
categories: [],
activeCategory: 'all',
search: '',
playingPath: null,
busy: false,
};
const LIGHT_ICONS = {
flame: '\u{1F525}',
moon: '\u{1F319}',
sparkles: '\u{2728}',
droplet: '\u{1FA78}',
};
function setStatus(kind, text) {
els.status.classList.remove('ok', 'busy', 'err');
if (kind) els.status.classList.add(kind);
els.statusText.textContent = text;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderCategories() {
const all = ['all', ...state.categories];
els.categories.innerHTML = all
.map((c) => {
const label = c === 'all' ? 'All' : c;
const active = state.activeCategory === c ? ' active' : '';
return `<button class="chip${active}" data-cat="${escapeHtml(c)}" type="button">${escapeHtml(label)}</button>`;
})
.join('');
}
function renderGrid() {
const q = state.search.trim().toLowerCase();
const filtered = state.effects.filter((e) => {
if (state.activeCategory !== 'all' && e.category !== state.activeCategory) return false;
if (!q) return true;
return (
e.name.toLowerCase().includes(q) ||
e.category.toLowerCase().includes(q) ||
e.path.toLowerCase().includes(q)
);
});
els.empty.hidden = filtered.length !== 0;
els.countLabel.textContent = `${filtered.length} of ${state.effects.length} effects`;
els.grid.innerHTML = filtered
.map((e) => {
const playing = state.playingPath === e.path ? ' playing' : '';
return `
<button class="tile${playing}" type="button" data-path="${escapeHtml(e.path)}">
<div class="name">${escapeHtml(e.name)}</div>
<div class="meta">
<span class="tag">${escapeHtml(e.category)}</span>
<span class="tag">${escapeHtml(e.type.toUpperCase())}</span>
</div>
<div class="path" title="${escapeHtml(e.path)}">${escapeHtml(e.path)}</div>
</button>`;
})
.join('');
}
function renderLights(cfg) {
if (!cfg || !Array.isArray(cfg.placeholders)) {
els.lights.innerHTML = '';
return;
}
els.lights.innerHTML = cfg.placeholders
.map((p) => {
const icon = LIGHT_ICONS[p.icon] || '✨';
return `
<div class="light" aria-disabled="true">
<span class="soon">Soon</span>
<span class="icon">${icon}</span>
<span class="lbl">${escapeHtml(p.name)}</span>
</div>`;
})
.join('');
}
async function fetchJson(url, opts) {
const res = await fetch(url, opts);
const text = await res.text();
let body = null;
if (text) {
try { body = JSON.parse(text); } catch { body = { error: text }; }
}
if (!res.ok) {
const msg = (body && body.error) || `${res.status} ${res.statusText}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return body || {};
}
async function loadConfig() {
try {
const cfg = await fetchJson('/api/config');
if (cfg.mediaPlayer) {
els.entityLabel.textContent = cfg.mediaPlayer;
}
renderLights(cfg.lightControl);
} catch (err) {
console.warn('config load failed', err);
}
}
async function loadEffects({ force = false } = {}) {
setStatus('busy', force ? 'Rescanning the share…' : 'Summoning effects…');
try {
const url = force ? '/api/effects?refresh=1' : '/api/effects';
const data = await fetchJson(url);
state.effects = Array.isArray(data.effects) ? data.effects : [];
state.categories = Array.isArray(data.categories) ? data.categories : [];
if (!state.categories.includes(state.activeCategory) && state.activeCategory !== 'all') {
state.activeCategory = 'all';
}
renderCategories();
renderGrid();
setStatus('ok', `Ready — ${state.effects.length} effect${state.effects.length === 1 ? '' : 's'} loaded`);
} catch (err) {
console.error(err);
setStatus('err', `Failed to load effects: ${err.message}`);
}
}
async function play(effectPath) {
if (state.busy) return;
state.busy = true;
setStatus('busy', `Casting — ${effectPath.split('/').pop()}`);
try {
const res = await fetchJson('/api/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: effectPath }),
});
state.playingPath = effectPath;
renderGrid();
const name = (res.played && res.played.name) || effectPath;
setStatus('ok', `Playing — ${name}`);
} catch (err) {
console.error(err);
setStatus('err', `Play failed: ${err.message}`);
} finally {
state.busy = false;
}
}
async function stop() {
if (state.busy) return;
state.busy = true;
setStatus('busy', 'Silencing…');
try {
await fetchJson('/api/stop', { method: 'POST' });
state.playingPath = null;
renderGrid();
setStatus('ok', 'Stopped');
} catch (err) {
console.error(err);
setStatus('err', `Stop failed: ${err.message}`);
} finally {
state.busy = false;
}
}
// --- Event wiring ---
els.grid.addEventListener('click', (e) => {
const btn = e.target.closest('.tile');
if (!btn) return;
const path = btn.dataset.path;
if (path) play(path);
});
els.categories.addEventListener('click', (e) => {
const btn = e.target.closest('.chip');
if (!btn) return;
state.activeCategory = btn.dataset.cat || 'all';
renderCategories();
renderGrid();
});
let searchTimer = null;
els.search.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.search = els.search.value;
renderGrid();
}, 80);
});
els.refresh.addEventListener('click', () => loadEffects({ force: true }));
els.stop.addEventListener('click', stop);
// --- Boot ---
loadConfig();
loadEffects();
})();

70
public/index.html Normal file
View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arcana FX — Soundboard</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><text y='52' font-size='52'>%F0%9F%94%AE</text></svg>" />
</head>
<body>
<div class="aurora"></div>
<header class="topbar">
<div class="brand">
<span class="sigil">&#x1F52E;</span>
<div>
<h1>Arcana FX</h1>
<p class="tagline">Dungeon Master&rsquo;s Control Room</p>
</div>
</div>
<div class="status" id="status">
<span class="dot" id="status-dot"></span>
<span id="status-text">Summoning effects&hellip;</span>
</div>
<div class="actions">
<button id="refresh-btn" class="ghost" title="Rescan share">Rescan</button>
<button id="stop-btn" class="danger" title="Stop current playback">&#x25A0; Stop</button>
</div>
</header>
<main>
<section class="panel">
<div class="panel-head">
<h2>Sound Effects</h2>
<div class="panel-controls">
<input id="search" type="search" placeholder="Search the grimoire&hellip;" autocomplete="off" />
</div>
</div>
<div id="categories" class="categories" role="tablist" aria-label="Categories"></div>
<div id="grid" class="grid" aria-live="polite"></div>
<p id="empty" class="empty" hidden>No effects match your incantation.</p>
</section>
<section class="panel future">
<div class="panel-head">
<h2>Light Control</h2>
<span class="badge">Coming soon</span>
</div>
<p class="muted">
A scrying mirror for the lantern circuits. Coloured washes, flicker spells, and
power runes will appear here once the relays are bound.
</p>
<div id="lights" class="lights"></div>
</section>
</main>
<footer>
<span>Bound to <code id="entity-label">media_player.&hellip;</code></span>
<span class="sep">&middot;</span>
<span id="count-label">0 effects</span>
</footer>
<script src="/app.js"></script>
</body>
</html>

368
public/styles.css Normal file
View File

@@ -0,0 +1,368 @@
:root {
--bg: #0b0a14;
--bg-2: #131127;
--panel: rgba(20, 18, 40, 0.72);
--panel-border: rgba(140, 110, 220, 0.25);
--ink: #e8e3ff;
--ink-dim: #9c95c4;
--accent: #b388ff;
--accent-2: #6effc8;
--warn: #ffb454;
--danger: #ff5b6e;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
--radius: 14px;
--rune: "Cinzel", "Cormorant Garamond", "Trajan Pro", serif;
--body: "Inter", "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
min-height: 100vh;
background: radial-gradient(1200px 700px at 20% -10%, #1d1640 0%, transparent 60%),
radial-gradient(900px 600px at 90% 0%, #1a2a44 0%, transparent 55%),
linear-gradient(180deg, var(--bg) 0%, #07060f 100%);
color: var(--ink);
font-family: var(--body);
-webkit-font-smoothing: antialiased;
}
.aurora {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(600px 240px at 12% 30%, rgba(110, 255, 200, 0.10), transparent 70%),
radial-gradient(600px 240px at 88% 70%, rgba(179, 136, 255, 0.10), transparent 70%);
filter: blur(20px);
animation: drift 22s ease-in-out infinite alternate;
}
@keyframes drift {
from { transform: translate3d(-15px, -10px, 0); }
to { transform: translate3d(15px, 10px, 0); }
}
.topbar {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 18px;
padding: 18px 28px;
border-bottom: 1px solid var(--panel-border);
backdrop-filter: blur(8px);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
}
.brand .sigil {
font-size: 34px;
filter: drop-shadow(0 0 12px rgba(179, 136, 255, 0.55));
}
.brand h1 {
margin: 0;
font-family: var(--rune);
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 22px;
color: var(--ink);
}
.brand .tagline {
margin: 2px 0 0;
color: var(--ink-dim);
font-size: 12px;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--panel-border);
font-size: 13px;
color: var(--ink-dim);
max-width: 50%;
}
.status .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--ink-dim);
box-shadow: 0 0 0 0 currentColor;
}
.status.ok .dot { background: var(--accent-2); box-shadow: 0 0 10px var(--accent-2); }
.status.busy .dot { background: var(--warn); box-shadow: 0 0 10px var(--warn); animation: pulse 1.4s infinite; }
.status.err .dot { background: var(--danger); box-shadow: 0 0 10px var(--danger); }
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.95; }
50% { transform: scale(1.3); opacity: 0.6; }
}
.actions { display: flex; gap: 8px; }
button {
appearance: none;
border: 1px solid var(--panel-border);
background: rgba(255, 255, 255, 0.04);
color: var(--ink);
padding: 8px 14px;
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
letter-spacing: 0.04em;
transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease;
}
button:hover { background: rgba(179, 136, 255, 0.12); border-color: rgba(179, 136, 255, 0.55); }
button:active { transform: translateY(1px); }
button.ghost { color: var(--ink-dim); }
button.danger { color: var(--danger); border-color: rgba(255, 91, 110, 0.4); }
button.danger:hover { background: rgba(255, 91, 110, 0.10); border-color: rgba(255, 91, 110, 0.7); }
button:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(0.3);
}
main {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 28px 80px;
}
.panel {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 20px 22px;
backdrop-filter: blur(10px);
}
.panel-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.panel-head h2 {
margin: 0;
font-family: var(--rune);
font-size: 16px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink);
}
.panel-controls { margin-left: auto; }
input[type="search"] {
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--panel-border);
border-radius: 10px;
color: var(--ink);
padding: 8px 12px;
width: 280px;
font-family: inherit;
font-size: 13px;
outline: none;
}
input[type="search"]:focus { border-color: var(--accent); }
.badge {
margin-left: 10px;
padding: 3px 8px;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
border-radius: 999px;
background: rgba(255, 180, 84, 0.12);
color: var(--warn);
border: 1px solid rgba(255, 180, 84, 0.35);
}
.categories {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.chip {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--panel-border);
color: var(--ink-dim);
border-radius: 999px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
letter-spacing: 0.04em;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chip:hover { color: var(--ink); border-color: rgba(179, 136, 255, 0.6); }
.chip.active {
background: rgba(179, 136, 255, 0.15);
border-color: var(--accent);
color: var(--ink);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.tile {
position: relative;
background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 14px 14px 12px;
cursor: pointer;
overflow: hidden;
text-align: left;
color: var(--ink);
transition: transform 0.08s ease, border-color 0.15s ease, background 0.15s ease;
}
.tile::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(220px 80px at 30% 0%, rgba(179, 136, 255, 0.18), transparent 60%);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.tile:hover::before { opacity: 1; }
.tile:hover { border-color: rgba(179, 136, 255, 0.6); }
.tile:active { transform: translateY(1px); }
.tile.playing {
border-color: var(--accent-2);
box-shadow: 0 0 0 1px var(--accent-2), 0 0 22px rgba(110, 255, 200, 0.25);
}
.tile .name {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
margin-bottom: 6px;
word-break: break-word;
}
.tile .meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
font-size: 11px;
color: var(--ink-dim);
letter-spacing: 0.04em;
}
.tile .meta .tag {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 2px 8px;
text-transform: uppercase;
}
.tile .path {
margin-top: 8px;
font-size: 10.5px;
color: rgba(156, 149, 196, 0.7);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty {
margin: 20px 4px 0;
color: var(--ink-dim);
font-style: italic;
}
.future .muted {
margin: 0 0 14px;
color: var(--ink-dim);
font-size: 13px;
max-width: 60ch;
}
.lights {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.light {
border: 1px dashed rgba(140, 110, 220, 0.35);
border-radius: 12px;
padding: 16px;
text-align: center;
background: rgba(255, 255, 255, 0.02);
color: var(--ink-dim);
cursor: not-allowed;
position: relative;
user-select: none;
}
.light .icon { font-size: 22px; display: block; margin-bottom: 6px; }
.light .lbl { font-size: 13px; letter-spacing: 0.04em; }
.light .soon {
position: absolute; top: 8px; right: 10px;
font-size: 9.5px;
text-transform: uppercase;
color: var(--warn);
letter-spacing: 0.18em;
}
footer {
position: relative;
z-index: 1;
padding: 14px 28px 26px;
color: var(--ink-dim);
font-size: 12px;
display: flex;
gap: 10px;
align-items: center;
}
footer .sep { opacity: 0.5; }
footer code {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 2px 6px;
font-size: 11.5px;
}
@media (max-width: 760px) {
.topbar { flex-wrap: wrap; }
.status { max-width: 100%; order: 3; }
input[type="search"] { width: 100%; }
.panel-head { flex-direction: column; align-items: flex-start; gap: 10px; }
.panel-controls { margin-left: 0; width: 100%; }
}

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