Files
Esa Kataja 0f15ae0bfc Release 2026.08.02-1
SQLite replaces Postgres, and two fixes from using the thing.

- The database is a file under ./storage instead of a second container. Ten
  members never needed a database server, and the driver is pure Go, so the
  build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind
  mount is now the whole backup: no pgdata, no healthcheck-gated depends_on,
  no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the
  divisive/unified boards carry their own stddev, and foreign keys are on by
  pragma. Tests get a database file each and run without any setup
- Invites are copied, not clicked. An invite is something to send, and the
  anchor opened the join form in the admin's own browser
- Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita?
  Palautetta?" and the page behind it invites ideas rather than only bugs
- Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
2026-08-02 20:58:52 +03:00

172 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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).