Initial Arcana FX soundboard
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
17
.env.example
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
dist/
|
||||
.cache/
|
||||
41
BUILD_BRIEF.md
Normal file
41
BUILD_BRIEF.md
Normal 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
18
Dockerfile
Normal 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
114
README.md
Normal 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
26
config/config.json
Normal 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
20
docker-compose.yml
Normal 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
830
package-lock.json
generated
Normal 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
18
package.json
Normal 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
227
public/app.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
70
public/index.html
Normal 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">🔮</span>
|
||||
<div>
|
||||
<h1>Arcana FX</h1>
|
||||
<p class="tagline">Dungeon Master’s Control Room</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">
|
||||
<span class="dot" id="status-dot"></span>
|
||||
<span id="status-text">Summoning effects…</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">■ 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…" 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.…</code></span>
|
||||
<span class="sep">·</span>
|
||||
<span id="count-label">0 effects</span>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
368
public/styles.css
Normal file
368
public/styles.css
Normal 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
30
src/config.js
Normal 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
149
src/effects.js
vendored
Normal 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
188
src/filestash.js
Normal 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
108
src/homeassistant.js
Normal 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
152
src/routes.js
Normal 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
125
src/server.js
Normal 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 };
|
||||
Reference in New Issue
Block a user