commit 2474b42175ea23d0ec6bc4da2675070b2fb8ccea Author: Esa Kataja Date: Fri Jul 31 19:37:22 2026 +0300 Add project docs, glossary, and licence The Nuxt version is being rewritten in Go. This is the design that came out of it, split by how each part ages: - CONTEXT.md glossary, English identifiers and Finnish UI wording - docs/spec.md behaviour: rules, submission pipeline, routes, API contract, schema - docs/decisions.md why, append-only - docs/later.md deliberately not in v1 testdata/ytdlp-noose.json is a real yt-dlp dump used to test metadata prefill against a video with no track, artist or album tags. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ac9720 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/levyraati +/storage/ +/pgdata/ +.env diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..09f5ea5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,77 @@ +# Levyraati + +A private music review club for about ten friends. Members submit songs and review each other's +picks; other people's opinions of a song stay hidden until you've written your own. + +Identifiers, schema, and routes are English. Rendered text is Finnish only. Each term below carries +both, so the UI uses one word per concept everywhere. + +## Language + +**Member** — _jäsen_: +A person with an account. Every account is a member; there is no other kind. +_Avoid_: user, käyttäjä, account + +**Admin** — _ylläpitäjä_: +The operator of the installation. Not a member and not an account — a set of credentials on a +separate surface. Never submits, reviews, or appears in any list of people. +_Avoid_: admin user, superuser, role + +**Invite** — _kutsu_ / **invite code** — _kutsukoodi_: +A one-time code that permits one registration. Spent only by a registration that succeeds. + +**Song** — _kappale_: +A published piece of music with its converted audio, submitted by a member. A row in `songs` is a +real song with no qualifier. +_Avoid_: track, biisi, levy (that's a record, the wrong unit) + +**Submission** — _(no Finnish noun; the member sees only the verb_ lähettää _and a status)_: +A song in flight — uploaded or downloaded, converting, not yet published. It becomes a Song at +Publish and ceases to exist as a Submission. + +**Publish** — _julkaise_: +The submitter's explicit act of turning a converted Submission into a Song. Never automatic. + +**Review** — _arvostelu_: +One member's score and written verdict on one song. At most one per member per song, and never on +your own song. + +**Reviewer** — _arvostelija_: +The member who wrote a review. Reviews are never anonymous. + +**Score** — _pisteet_: +An integer 1–100 given in a review. +_Avoid_: rating, arvosana + +**Reveal**: +Gaining access to other members' opinions of a song by having written your own review of it — or by +being its submitter. Covers the reviews and the song's average score, on every surface that names a +song. Enforced in the query, never in the rendering. + +Per-song opinion is gated; whole-history aggregate is public. A member's profile shows counts and +averages over everything they have done, never how they reviewed any particular song. + +**Edit window**: +The 30 minutes after a review's last change, during which its author may edit or delete it. Then the +review is on the record permanently. + +**Locked** (of a song): +Having at least one review, which freezes its title, artist, and description and prevents the +submitter deleting it. A live state, not a one-way door: if the last review is deleted, the song is +editable again. + +**Report** — _palaute_: +A member's free-text note about something broken or annoying, carrying the page they were on. Open +until the admin resolves it. Deliberately has no category, priority, or severity. +_Avoid_: ticket, issue, bug, vikailmoitus + +**Genre** — _genre_: +One value from a fixed list, chosen by the submitter. +_Avoid_: tyylilaji, tag, category + +**Ban** — _estä_: +Ending a member's participation: login refused and live sessions dropped. Their songs and reviews +remain and continue to count. Reversible. + +**Stats** — _tilastot_: +The always-public leaderboards. The only place aggregate opinion is visible without having reviewed. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7f6b7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kessinen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bb8692 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# 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/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 | +|---|---|---| +| `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 | +| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions | +| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP | + +### Local development + +```sh +docker compose up -d postgres +export DATABASE_URL=postgres://levyraati:levyraati@localhost:5432/levyraati +export ADMIN_PASSWORD=dev SECURE_COOKIES=false +go run . +``` + +Requires Go 1.22+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. + +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 is installed with +`pip install -U yt-dlp` at image build time, 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 +- `./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). diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..7deeca5 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,160 @@ +# Decisions + +Append-only. Entries are never rewritten — if one is reversed, the reversal is a new entry that says +so. What the app does today is in [spec.md](./spec.md); this is why. + +--- + +## 2026-07-31 — the rewrite + +Levyraati existed as a Nuxt 4 app that reached v1.0-rc.3 and never launched. It is being rebuilt in +Go and the Nuxt repo archived. The reason was proportion: no SSR or SEO requirement for a +login-walled app for ten friends, no client state worth a reactive framework, and a dependency +surface larger than the feature set. There is no data to migrate — everything in the old `pgdata` +and `storage` was test data, so **the schema has no legacy to respect.** + +1. **Go, `net/http`, `html/template`, `pgx`, plain SQL.** Dependency budget is `pgx` and + `x/crypto`; anything else needs a reason. Go 1.22 routing patterns cover every route, so no + router. Seven tables, so no ORM. +2. **Migrations run at startup**, before serving. One less deploy step. Carried over from the old app, + which got this right. +3. **Docker Compose shape carried over**: app + postgres, `./storage` and `./pgdata` bind mounts, + healthcheck-gated `depends_on`. +4. **No Tailwind, no bundler, no npm.** One hand-written stylesheet with CSS custom properties. The + dark rock/metal theme — Oswald headings, orange/red accents — survives dropping Tailwind + unchanged, as theme tokens rather than utility soup. +5. **Finnish only.** No locale JSON, no `T()`, no language cookie, no switcher. Strings go directly + in templates. The audience is ten Finnish speakers. +6. **`bigserial` ids, not UUID.** No id-generation code, no `google/uuid` in a two-dependency budget, + smaller indexes, and `/audio/{id}` validates by parsing an integer. Enumerable ids are not a + threat model here. Session tokens stay random — those are secrets. +7. **stdout logging only.** The rejected alternative was mirroring every entry into a `logs` table for + an in-app viewer: a custom handler, a buffered channel, a drain goroutine, drop accounting, a + retention `DELETE`, a table and a filtered page — ~150 lines to avoid `docker compose logs`. If + in-app visibility is ever wanted, build an *audit* view of domain events instead; those are + queries over tables that already exist. +8. **The admin is not a user.** Env credentials, Basic Auth, its own loopback listener. This deletes + the `role` column, first-launch seeding, admin sessions, the "cannot ban the last admin" rules, + and every "exclude the admin" clause that would otherwise appear in user and stats queries. +9. **Same process, two listeners** — not a second binary. A management binary would need its own + deploy and would race the startup migrations. Two listeners give the network isolation, which was + the only real benefit. +10. **The admin recovery endpoint is dropped.** The old app had a key-gated credential reset with a + `qwerty123` default in `docker-compose.yml`. The password is an env var now, so recovery is + editing it and restarting. No route, no key, no default. +11. **Conversion runs in the background; nothing enters `songs` until it succeeds and the submitter + confirms.** Costs a `submissions` table, buys a `songs` table where every row is a real song and + no query filters on readiness. +12. **Metadata is read synchronously at submit, conversion asynchronously.** Otherwise prefill races + the submitter's typing. This also revived the YouTube prefill cut earlier — the objection was the + extra round trip, and the redesign removed it. +13. **Publish is an explicit click**, never automatic on conversion success. +14. **Native `