The theme handoff encoded as CSS custom properties rather than a Tailwind config, since there is no Tailwind here. Palette, spacing, radii, shadows and motion follow it as written; docs/theme.md lists what differs and why. - Oswald vendored as a 21KB variable woff2, latin subset, no CDN. Its phantom weight 900 resolved to 700 — loading a weight you don't have is what made the brand render differently per platform - color-scheme: dark makes the native audio element fit the palette, which was the handoff's complaint about it - Songs are text cards rather than artwork tiles, because there is no artwork. The unreviewed state keeps its red-brown border and gains a badge, so it is never carried by colour alone - Nav is the three-column grid; the mobile menu is <details>, no JS - Flash messages became bottom-right toasts Favicon carried over from the Nuxt project.
160 lines
6.0 KiB
Markdown
160 lines
6.0 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
|
||
|
||
- **`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.
|
||
|
||
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, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
|
||
yt-dlp. One binary, one origin — there is no separate frontend to deploy.
|
||
|
||
Go dependencies: `pgx/v5` and `golang.org/x/crypto`. 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 |
|
||
|---|---|---|
|
||
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
|
||
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
||
| `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
|
||
docker compose up -d postgres
|
||
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
|
||
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||
go run .
|
||
```
|
||
|
||
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
|
||
|
||
Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the
|
||
migration test drops and recreates the `public` schema, so never point it at anything you care about.
|
||
|
||
```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
|
||
|
||
Two paths hold everything:
|
||
|
||
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
|
||
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
|
||
- `./storage` — audio files and avatars
|
||
|
||
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
|
||
startup anyway.
|
||
|
||
```sh
|
||
docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz
|
||
```
|
||
|
||
### Database shell
|
||
|
||
```sh
|
||
docker compose exec postgres psql -U levyraati levyraati
|
||
```
|
||
|
||
## 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).
|