- Rust 99.1%
- Dockerfile 0.5%
- HTML 0.4%
Replace the Python/Vue application with a Cargo workspace: an Axum REST API that owns commands and durable queue state, and a Leptos CSR frontend that turns an open browser into the playback device. A REST call can never produce sound on its own, and a play is only counted once the browser acknowledges that audio started. The website and the future MCP adapter speak the same /api/v1 contract, so core operations stay explicit Axum handlers rather than Leptos server functions. Shared DTOs live in crates/contracts and compile into both sides. Authentication -------------- Argon2id passwords, HttpOnly SameSite=Lax session cookies for the browser, and "Authorization: Bearer sb_..." tokens for scripts. Every credential is a random hex secret, disclosed once, stored only as its SHA-256. Accounts come solely from single-use invite links; passwords are reset the same way, since there are no email addresses. Protection is carried by CurrentUser/AdminUser extractors on each handler rather than a path list that can drift from the router. Ownership is always the authenticated caller: created_by and user_id are gone from request bodies, and POST /users is gone entirely. Legacy adoption --------------- On startup the server detects a previous library and queues one import job per file, so a 319-file folder never delays boot. A folder beside the old Python soundboard.db carries titles, tags, enabled flags, timestamps and play counts across; a bare folder falls back to filenames. The originals are never moved or deleted, adoption is recorded so it cannot run twice, an interrupted run resumes, and a file whose normalized audio already exists is folded into that sound. Historic plays land in sounds.legacy_play_count, apart from play_events, so acknowledged-play statistics stay honest. Notable fixes found while building this --------------------------------------- - wasm-bindgen strips the target_features section, so wasm-opt rejected the bulk-memory ops rustc emits; trunk build --release had never succeeded. - The board was empty on first paint: refresh() was only wired to the WebSocket handler, so nothing fetched sounds until a server event arrived. - ServeDir::not_found_service forces a 404 onto the response, so the SPA shell and every unknown /api/v1 path returned HTML with a 404. - REST stop left the session stuck in "playing"; the browser acknowledges nothing for a stop. - A late "completed" ack could revive a cancelled queue item and inflate statistics, because the ack matched on command_id with no status guard. - request_play credited the play to the session owner, not the requester. - Uploads were titled after their temp path, leaking a ULID into the title, and a duplicate upload stranded its temp file. - The import worker slept 500ms between every job. 23 integration tests cover the playback state machine over a real WebSocket, the auth surface, and legacy detection, metadata carry-over and idempotency. The vertical slice and the full auth flow were verified in real Chrome. Known gaps are recorded in status.md. The largest are: no rate limiting on login, the Safari audio-unlock design, and an unbuilt Docker image. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| crates | ||
| data | ||
| migrations | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| status.md | ||
| Trunk.toml | ||
Soundboard
A clean-sheet, browser-native soundboard written in Rust. Axum exposes the public REST API and coordinates durable playback queues; a Leptos WebAssembly application turns an open browser into an audio playback session.
The website uses the same /api/v1 contract intended for automation and a future MCP adapter. There are no native clients, hardware agents, daemon modes, or platform-specific sound controls.
Current foundation
- Leptos CSR dashboard built from scratch
- Axum REST API with generated OpenAPI 3.1 document
- browser playback sessions over WebSocket
- immediate, enqueue, play-next, stop, remove, reorder, and clear queue semantics
- playback acknowledgements; statistics count audio only after the browser reports
started - SQLite persistence through SQLx migrations
- password login with HttpOnly browser sessions and bearer API tokens
- invite-only signup, admin user management, and single-use password reset links
- automatic adoption of a previously installed soundboard library
- upload, import-directory, MyInstants, and YouTube import jobs
- ffmpeg normalization to browser-compatible MP3
- YouTube start/end trimming
- sound search, metadata editing, and deletion endpoints
- single-container deployment
Every /api/v1 endpoint except health, openapi.json, and the login/invite/reset routes requires authentication. Scoped API-token permissions and rate limiting are the next security milestones; run behind TLS and set SOUNDBOARD_COOKIE_SECURE=1 before exposing it.
Requirements
- Rust 1.93+
wasm32-unknown-unknownRust target- Trunk
ffmpegandffprobeyt-dlpfor YouTube imports
rustup target add wasm32-unknown-unknown
cargo install --locked trunk
First run
Nobody can sign in until an administrator exists. Seed one from the environment:
SOUNDBOARD_ADMIN_USERNAME=admin \
SOUNDBOARD_ADMIN_PASSWORD='a-long-password' \
cargo run -p soundboard-server
The password is applied when the account is created, or when it has none. It is not reapplied on every boot, so a password changed in the UI survives a restart. Set SOUNDBOARD_ADMIN_PASSWORD_FORCE_RESET=1 to force it back — the recovery path when the password is lost.
Everyone else joins through a single-use invite link that an administrator generates under Admin → Invite someone. Passwords are reset the same way.
Configuration
| Variable | Default | Purpose |
|---|---|---|
SOUNDBOARD_ADDRESS |
127.0.0.1:8420 |
Listen address |
SOUNDBOARD_DATA_DIR |
./data |
Database, media, imports, temp |
SOUNDBOARD_WEB_DIR |
./dist |
Built frontend |
SOUNDBOARD_ADMIN_USERNAME |
admin |
Seeded administrator |
SOUNDBOARD_ADMIN_PASSWORD |
— | Required for anyone to sign in |
SOUNDBOARD_ADMIN_PASSWORD_FORCE_RESET |
0 |
Reapply the password on boot |
SOUNDBOARD_SESSION_TTL_DAYS |
30 |
Browser session lifetime |
SOUNDBOARD_COOKIE_SECURE |
0 |
Send the session cookie over HTTPS only |
SOUNDBOARD_LEGACY_DIR |
$DATA/sounds |
Previous library to adopt |
SOUNDBOARD_LEGACY_DB |
$DATA/soundboard.db |
Its metadata database, if any |
SOUNDBOARD_LEGACY_MIGRATION |
1 |
Set to 0 to never adopt anything |
Migrating a previous soundboard
On startup the server looks for a legacy folder. If it finds audio files it queues one import job per file and lets the normal worker convert them in the background, so a large library never delays boot and progress is visible under Imports.
Two shapes are detected automatically:
legacy_v2— the folder sits beside the old Python soundboard'ssoundboard.db. Titles, tags, enabled flags, timestamps, and play counts are carried across. Historic plays land in a separatelegacy_play_countso that acknowledged-play statistics stay honest.plain_directory— a bare folder of audio files. Titles come from filenames.
The originals are never moved or deleted. Adoption is recorded, so it never runs twice, and an interrupted run resumes where it stopped. A file whose normalized audio already exists is folded into the existing sound rather than duplicated.
Development
Run the API on port 8420:
cargo run -p soundboard-server
In another terminal, run the Leptos development server. Trunk.toml proxies REST and WebSocket traffic to Axum:
trunk serve --open
For a production-style local run:
trunk build --release
cargo run --release -p soundboard-server
The application is available at http://127.0.0.1:8420. The OpenAPI document is available at http://127.0.0.1:8420/api/v1/openapi.json.
REST API
Important endpoints:
GET /api/v1/health (public)
GET /api/v1/openapi.json (public)
POST /api/v1/auth/login (public)
POST /api/v1/auth/logout
GET /api/v1/auth/me
POST /api/v1/auth/password
GET /api/v1/auth/invite/{token} (public)
POST /api/v1/auth/signup (public)
GET /api/v1/auth/reset/{token} (public)
POST /api/v1/auth/reset (public)
GET /api/v1/auth/tokens
POST /api/v1/auth/tokens
DELETE /api/v1/auth/tokens/{token_id}
GET /api/v1/users (admin)
PATCH /api/v1/users/{user_id} (admin)
DELETE /api/v1/users/{user_id} (admin)
POST /api/v1/users/{user_id}/reset-link (admin)
GET /api/v1/invites (admin)
POST /api/v1/invites (admin)
DELETE /api/v1/invites/{invite_id} (admin)
GET /api/v1/sounds
GET /api/v1/sounds/{sound_id}
PATCH /api/v1/sounds/{sound_id}
DELETE /api/v1/sounds/{sound_id}
GET /api/v1/sounds/{sound_id}/audio
GET /api/v1/sessions
POST /api/v1/sessions
GET /api/v1/sessions/{session_id}
POST /api/v1/sessions/{session_id}/play
POST /api/v1/sessions/{session_id}/stop
GET /api/v1/sessions/{session_id}/queue
DELETE /api/v1/sessions/{session_id}/queue
PUT /api/v1/sessions/{session_id}/queue/reorder
DELETE /api/v1/sessions/{session_id}/queue/{item_id}
GET /api/v1/imports
GET /api/v1/imports/{import_id}
POST /api/v1/imports/upload
POST /api/v1/imports/myinstants
POST /api/v1/imports/youtube
POST /api/v1/imports/scan
GET /api/v1/statistics/overview
GET /api/v1/statistics/sounds
WS /api/v1/ws?session_id={session_id}
Calling the API
The website authenticates with an HttpOnly cookie. Scripts and the future MCP adapter use a bearer token, created under Account → API tokens. The secret is shown once.
curl -X POST http://127.0.0.1:8420/api/v1/sessions/SESSION_ID/play \
-H "authorization: Bearer $SOUNDBOARD_TOKEN" \
-H 'content-type: application/json' \
-d '{"sound_id":"SOUND_ID","mode":"interrupt"}'
Queue instead by changing mode to enqueue or play_next.
A play still only counts once the browser acknowledges that audio started; a REST call alone never moves the statistics.
Import directory
Files copied into data/imports/ remain available as an explicit intake path. Queue them with:
curl -X POST http://127.0.0.1:8420/api/v1/imports/scan \
-H "authorization: Bearer $SOUNDBOARD_TOKEN"
Imports are durable jobs. The in-process worker claims pending jobs, runs the media tools, stores normalized files under data/media/, and records success or failure in SQLite.
Quality checks
cargo fmt --all -- --check
cargo test
cargo clippy --workspace --all-targets -- -D warnings
cargo check -p soundboard-web --target wasm32-unknown-unknown
cargo clippy -p soundboard-web --target wasm32-unknown-unknown -- -D warnings
trunk build --release
Docker
cp .env.example .env # then set SOUNDBOARD_ADMIN_PASSWORD
docker compose up --build
The container listens on 8420 and is published on 40888. The ./data bind mount holds soundboard-v3.db, media/, imports/, temp/, and — on first boot — the legacy sounds/ and soundboard.db that get adopted in the background.
SOUNDBOARD_ADMIN_PASSWORD is required; compose refuses to start without it. Put it in .env, which is gitignored — never in docker-compose.yml. Set SOUNDBOARD_COOKIE_SECURE=1 once the service is behind an HTTPS reverse proxy.
Without compose:
docker build -t soundboard .
docker run --rm -p 40888:8420 -v "$PWD/data:/app/data" \
-e SOUNDBOARD_ADMIN_PASSWORD='a-long-password' soundboard