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
cp .env.example .env
# edit .env: set HA_TOKEN and PUBLIC_BASE_URL
docker compose up -d --build
Then open http://<host>:8091.
Deployment helper for published images
For host deployments on this machine, use deploy/deploy.sh instead of editing
compose files by hand. The script is meant to be copied into other repos too: it
infers the repo name from origin, looks up the numeric Gitea repo id via the
API, computes the host port as 5000 + 10*repo_id + service_number, copies the
repo's deployment assets, and writes a ready-to-run deployment directory.
For Arcana FX the Gitea repo id is 4, so the default deployment port is:
5000 + 10*4 + 0 = 5040
Generate a deployment directory for a tagged image:
./deploy/deploy.sh v0.1.0
That creates ~/deployments/arcana-fx-0/ containing:
docker-compose.ymlpinned to192.168.33.22:3300/gemma/arcana-fx:v0.1.0.envcopied from.env.exampleon first runconfig/copied from the repo viadeploy/include-paths.txt.deploy.metawith the resolved repo id, version, and host port
Then edit the generated .env and start it:
cd ~/deployments/arcana-fx-0
$EDITOR .env
docker compose pull
docker compose up -d
A second service instance can use another service number, which shifts the port by 1 while keeping the same repo-id block:
./deploy/deploy.sh v0.1.0 1
# host port becomes 5041
When copying this deployment helper to another repo, update
deploy/docker-compose.template.yml for that app's internal port and mounts,
and deploy/include-paths.txt for any extra files/directories that must travel
with the generated compose file.
Container publishing pipeline
A Gitea Actions workflow lives at .gitea/workflows/release-container.yml.
Whenever a git tag is pushed, the self-hosted runner:
- checks out the tagged commit,
- builds the Docker image from
Dockerfile, - tags it as both the sanitized git tag and
latest, - pushes both tags to the Gitea container registry at
192.168.33.22:3300/gemma/arcana-fx.
The workflow authenticates with the repository Actions secret PACKAGES_TOKEN.
Example release flow:
git tag v0.1.0
git push origin v0.1.0
After the workflow finishes, the image can be pulled with:
docker pull 192.168.33.22:3300/gemma/arcana-fx:v0.1.0
docker pull 192.168.33.22:3300/gemma/arcana-fx:latest
Run locally (no Docker)
Requires Node.js 20+.
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:
POST /api/share/<id>/proofwithX-Requested-With: XmlHttpRequest. Response sets the proof cookie and tells us the share's base path (e.g./DnD/Audio/).GET /api/files/ls?path=<absolute>&share=<id>to walk directories.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_playerentity 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. .envis git-ignored; only.env.exampleis 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.