Files
arcana-fx/README.md
2026-05-20 23:51:18 +02:00

115 lines
4.7 KiB
Markdown

# 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.