Files
arcana-fx/README.md
gemma 3d4eb50d1c
All checks were successful
Publish container image / Build and push image (push) Successful in 2s
Add reusable deployment helper
2026-05-21 21:55:36 +02:00

6.9 KiB

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.yml pinned to 192.168.33.22:3300/gemma/arcana-fx:v0.1.0
  • .env copied from .env.example on first run
  • config/ copied from the repo via deploy/include-paths.txt
  • .deploy.meta with 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:

  1. checks out the tagged commit,
  2. builds the Docker image from Dockerfile,
  3. tags it as both the sanitized git tag and latest,
  4. 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:

  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.