Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.
The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:
- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
declared type is what makes the driver return time.Time, and the
fixed-width UTC string is what makes ordering and comparison against
datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
formula out, guarded with max(0.0, ...) because cancellation returns a
tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
pragma is set on every connection.
Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
172 lines
6.6 KiB
Markdown
172 lines
6.6 KiB
Markdown
# Levyraati
|
||
|
||
A private music review club. Members submit songs — by file upload or YouTube link — and review each
|
||
other's picks on a 1–100 scale. Other people's reviews of a song stay hidden until you've written
|
||
your own.
|
||
|
||
Invite-only, no public registration. Built for about ten friends.
|
||
|
||
> **Status:** not built yet. This README describes the app defined in [docs/spec.md](docs/spec.md);
|
||
> commands here are the intended interface, not a record of anything that runs today.
|
||
|
||
## Docs
|
||
|
||
| File | What's in it |
|
||
|---|---|
|
||
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
|
||
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
|
||
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
|
||
| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff |
|
||
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
|
||
|
||
## Branches and releases
|
||
|
||
- **`main`** — released code only. Every commit on it is something that ran in production, or is
|
||
meant to. Tagged at each release.
|
||
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and
|
||
reaches `main` by merge at release time.
|
||
|
||
Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1.
|
||
The version is injected at build time, so no file in the repo carries it:
|
||
|
||
```sh
|
||
git switch main && git merge --no-ff dev
|
||
git tag 2026.07.31-1
|
||
VERSION=$(git describe --tags --exact-match) docker compose build app
|
||
docker compose up -d app
|
||
```
|
||
|
||
A plain `go build` reports `dev`, which is the honest answer for a local binary. The running
|
||
version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is
|
||
actually deployed" is answerable without an SSH session.
|
||
|
||
The app never sends email — there is no verification, no password reset link, and no notifications.
|
||
Members have an address because it is their login and because mail is a planned feature.
|
||
|
||
## Stack
|
||
|
||
Go, SQLite, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
|
||
yt-dlp. One binary, one origin, one container — there is no separate frontend and no database server
|
||
to deploy.
|
||
|
||
Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the
|
||
build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
|
||
|
||
## Running it
|
||
|
||
```sh
|
||
cp .env.example .env # then edit — ADMIN_PASSWORD has no default and the app won't start without it
|
||
docker compose up -d
|
||
```
|
||
|
||
Migrations apply themselves at startup, before the server accepts connections. The first launch
|
||
creates no users: log into the admin panel and mint an invite.
|
||
|
||
### Configuration
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
|
||
| `ADMIN_USER` | `admin` | Admin panel username |
|
||
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
||
| `ADDR` | `:8080` | Member-facing listener |
|
||
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
|
||
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
||
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
|
||
|
||
### Local development
|
||
|
||
```sh
|
||
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||
go run .
|
||
```
|
||
|
||
Requires Go 1.25+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
|
||
the database is a file under `./storage`, created on the first run.
|
||
|
||
Tests get a fresh database file in a temp directory each, so they need no setup and touch nothing:
|
||
|
||
```sh
|
||
go test ./...
|
||
```
|
||
|
||
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
||
template changes. `go build && ./levyraati` is the loop.
|
||
|
||
## Admin panel
|
||
|
||
The admin is **not a user account**. It exists only as `ADMIN_USER` / `ADMIN_PASSWORD`, authenticates
|
||
with HTTP Basic Auth, and is bound to loopback so it is not reachable from the internet. Reach it
|
||
through an SSH tunnel:
|
||
|
||
```sh
|
||
ssh -L 8081:127.0.0.1:8081 you@server
|
||
# then open http://localhost:8081
|
||
```
|
||
|
||
From there: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
||
|
||
**Lost the admin password?** Edit `.env` and `docker compose restart app`. There is no recovery
|
||
endpoint and no recovery key — the credentials are the environment.
|
||
|
||
## Operations
|
||
|
||
### yt-dlp goes stale
|
||
|
||
yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community
|
||
repository, whose active branch tracks upstream closely, so rebuilding is how you update it:
|
||
|
||
```sh
|
||
docker compose build --no-cache app && docker compose up -d app
|
||
```
|
||
|
||
**Rebuild monthly.** When submissions start failing with download errors, this is the first thing to
|
||
try. Failures are shown to the submitter with the yt-dlp error attached, so they're visible without
|
||
reading logs.
|
||
|
||
### Logs
|
||
|
||
```sh
|
||
docker compose logs -f app
|
||
```
|
||
|
||
JSON to stdout, nothing else. There is no log table and no log viewer in the app.
|
||
|
||
### Backups
|
||
|
||
`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy
|
||
of that one directory is the whole backup. `storage/tmp/` is in-flight conversions and is safe to
|
||
skip; it's cleared on startup anyway.
|
||
|
||
Copying the file while the app is running is not a backup — WAL means the latest writes live in a
|
||
sidecar file. Ask SQLite for a consistent snapshot instead:
|
||
|
||
```sh
|
||
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
|
||
gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db
|
||
```
|
||
|
||
### Database shell
|
||
|
||
```sh
|
||
docker compose exec app sqlite3 /storage/levyraati.db
|
||
```
|
||
|
||
## Layout
|
||
|
||
Go source is flat at the repository root, one package. Beyond that: `templates/` and `static/` are
|
||
embedded assets, `migrations/` holds numbered `.sql` files applied in order at startup, and
|
||
`testdata/` holds the golden JSON files that guard the API contract, plus `ytdlp-noose.json` — a real
|
||
`yt-dlp -J` dump of an ordinary upload, used to test metadata prefill against a video that has no
|
||
`track`, `artist` or `album` at all.
|
||
|
||
## Notes
|
||
|
||
Downloading audio from YouTube is against YouTube's terms of service. This is a private app among a
|
||
handful of friends; the decision is deliberate rather than accidental.
|
||
|
||
## Licence
|
||
|
||
MIT — see [LICENSE](LICENSE).
|