Verifying a change meant four invocations — gofmt, go vet, go build, go test — and remembering the order. `make` is all of them with one exit code. gofmt needs the wrapper: it reports offending files on stdout and still exits 0, so a bare `gofmt -l .` in a target would never fail the build.
175 lines
7.1 KiB
Markdown
175 lines
7.1 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 |
|
||
| [docs/deployment.md](docs/deployment.md) | Running it on a server: the compose file, releases, upgrades, backups |
|
||
|
||
## 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 — set ADMIN_EMAIL and ADMIN_PASSWORD before the first start
|
||
docker compose up -d
|
||
```
|
||
|
||
Migrations apply themselves at startup, before the server accepts connections. On an empty database
|
||
the first launch creates one account from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and marks it admin; log
|
||
in as that account and mint invites for everyone else. The two variables are read only while the
|
||
`users` table is empty, so once that account exists they do nothing and can leave the environment.
|
||
|
||
### Configuration
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
|
||
| `ADMIN_EMAIL` | — | Login address of the first account. Required on an empty database, ignored afterwards |
|
||
| `ADMIN_PASSWORD` | — | Password for that account. Required on an empty database, ignored afterwards |
|
||
| `ADMIN_NAME` | `Ylläpito` | Display name for that account |
|
||
| `ADDR` | `:8080` | The only listener |
|
||
| `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 site, e.g. `https://levyraati.example.com`. Used to build invite links on the admin page; unset gives relative links |
|
||
|
||
### Local development
|
||
|
||
```sh
|
||
export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||
go run .
|
||
```
|
||
|
||
Requires Go 1.27+, 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.
|
||
`make` is everything that has to pass before a commit — formatting, `go vet`, and the tests:
|
||
|
||
```sh
|
||
make # gofmt -l, go vet, go test
|
||
make test # just the tests
|
||
```
|
||
|
||
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
||
template changes. `make run` is the loop.
|
||
|
||
## Admin page
|
||
|
||
An admin is **an ordinary member with `is_admin` set** — the same account, the same login, the same
|
||
session cookie. Admins submit and review like anyone else; the flag adds a Ylläpito link to the nav
|
||
and unlocks `/admin` on the normal listener. A signed-in member without the flag gets a 404 there.
|
||
|
||
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
||
|
||
An admin cannot ban themselves, since banning drops every session for the target and nothing would
|
||
be left to undo it.
|
||
|
||
**Lost the admin password?** There is no recovery endpoint and no recovery key. Reset the hash
|
||
directly in the SQLite file, the same as for any locked-out member.
|
||
|
||
## 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).
|