Files
Levyraati26_go/README.md
T
Esa Kataja fd5b4d212c Give a failed submission a code, and put the cause in the log
A download that died on an HTTP 403 told the submitter "HTTP Error 403:
Forbidden" and told the log "error: exit status 1". The tool's stderr went
into status_msg and nowhere else, so the one person who could act on it saw
nothing. Exactly backwards.

Failures now go through a.fail: the submitter gets a sentence and an eight
character code, the log gets that code, the stage, the submission, the
source URL and the stderr tail. Quote the code, grep the log, find the line.

Every record carries file:line now, and LOG_LEVEL sets the threshold —
failures are logged at error, so no level hides them.
2026-09-05 14:56:12 +03:00

8.2 KiB
Raw Blame History

Levyraati

A private music review club. Members submit songs — by file upload or YouTube link — and review each other's picks on a 1100 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; commands here are the intended interface, not a record of anything that runs today.

Docs

File What's in it
CONTEXT.md The glossary — every domain term, in English and Finnish
docs/spec.md What the app does: rules, pipeline, routes, API contract, schema
docs/decisions.md Why it is that way. Append-only
docs/theme.md The visual language: tokens, type, and what differs from the theme handoff
docs/later.md Deliberately not in v1, with the reasoning kept
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:

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

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
LOG_LEVEL info debug, info, warn or error. An unparseable value falls back to info
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

export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run ./src

The package lives in src/, together with the templates/, static/ and migrations/ it embeds — //go:embed cannot reach outside its own directory, so the assets live beside the code that reads them. storage/ stays at the root, since it is runtime data rather than source.

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:

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:

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

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:

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

docker compose exec app sqlite3 /storage/levyraati.db

Layout

src/ is the whole program: one flat package main, with the assets it embeds beside it, because //go:embed cannot reach outside its own directory. templates/ and static/ are those 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.

The root keeps what is not source: docs/, the container and compose files, the Makefile, and storage/ once the app has run.

Command Does
make gofmt, go vet, go test — everything that must pass before a commit
make run Build and start on 127.0.0.1:8080 with development defaults
make fix Show go fix modernizer suggestions as a diff, without applying them
make image IMAGE=… Build a release image tagged from git describe; refuses an untagged HEAD
make db / make backup SQLite shell, and a WAL-safe snapshot, against the running container

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.