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.
This commit is contained in:
Esa Kataja
2026-07-31 19:37:22 +03:00
commit 2474b42175
7 changed files with 1252 additions and 0 deletions
+160
View File
@@ -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 `<audio controls>` for now.** Swapping in a custom player later is client-side and edits
one template partial; the element and `http.ServeContent` keep doing the real work.
15. **The API is JSON-only, and its contract is fixed now.** Pages return HTML, `/api/…` returns
JSON, nothing returns HTML fragments as data. Both surfaces are thin adapters over one data
function, so the domain rules cannot diverge. Explicit routes rather than `Accept` negotiation: a
client should not depend on a header being honored, and negotiation drags `Vary: Accept` along.
16. **HTMX for server round trips, Alpine for local state.** Reversed twice, so the reasoning matters
more than the outcome: the first pass rejected a JS framework outright; the API decision made
JSON-to-DOM plumbing necessary, which argued for Alpine; HTMX then removed that plumbing entirely
by swapping rendered partials. Fragments are a page-surface concern and never come from `/api`,
so this does not compromise 15.
17. **The contract is fixed now, the endpoints are built on demand.** Nothing consumes `/api` while
the browser talks HTML, so building all of it up front would be handlers with no callers. The
contract prevents breakage; each handler is one line whenever a consumer appears.
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
tracking.
19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the
breakage for a moment you did not choose.
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
unbuilt-stats list ships in v1.
---
## 2026-07-31 — design session
Decisions 2137 came out of grilling the handoff. Several reverse entries above; where they do, it
says so.
21. **English identifiers, Finnish rendered text.** Schema, structs, and routes are English; every
Finnish word is pinned per term in `CONTEXT.md` so the UI cannot drift between *kappale* and
*biisi*. Finnish identifiers would mean ASCII-folding (`lahettaja`) and hand-diffing the spec
against the schema forever. The admin panel is Finnish too — one vocabulary, not two.
22. **The reveal rule covers the average score.** The earlier design hid the review text but printed
`average_score: 71.4` in the feed — the number is the part that actually anchors a reviewer, so
hiding the sentences alone was decorative. `/stats` stays fully public: it is a page you walk to
deliberately, and gating it would make Top 10 per-viewer, which stops rankings being something
the club can talk about. Costs a nullable `average_score` and a `reviews_revealed` flag on
`song_summary`.
23. **A reviewer may edit or delete their own review within 30 minutes.** One rule, two verbs: for
half an hour your review is yours to change or withdraw, after that it is on the record. Delete
is bounded by the same window because an unbounded one is an unlimited edit window with extra
steps — review, unlock the reveal, read everyone, delete, repost recalibrated. Nobody else ever
deletes a review; the admin's remedy is deleting the song or talking to the person.
24. **Locked is a live state.** A song freezes when it has any review and unfreezes if it drops back
to zero. Since delete is confined to 30 minutes (23), the unfreeze can only happen shortly after
a song's only review.
25. **A ban drops live sessions, keeps all content, and is reversible.** Checking `banned` only at
login would leave a banned member browsing until their session expired. Purging their content
would cascade *other people's* reviews and retroactively rewrite the leaderboards; a ban ends
participation, it does not rewrite history.
26. **The feed is a queue: unreviewed only, oldest first.** It drains, and the oldest unreviewed song
is the one most likely waiting on someone.
27. **Your own songs are excluded from the queue** — reverses the earlier decision to include them
marked "yours, can't review". Under a queue they can never be actioned, so they would sit
permanently at the front of your own list, oldest-first putting your least actionable item first
every visit. The original complaint — submitters thinking their song vanished — is answered by
listing your songs on your profile and in the browse list.
28. **A browse list at `/songs`.** The queue is a worklist, so a reviewed song leaves it forever;
without a browse list a song ranked 11th is unreachable even by its own submitter, including for
re-listening, which is the app's core loop. Nav is four items: Jono, Kappaleet, Tilastot, Oma
profiili.
29. **The queue is `GET /api/songs?unreviewed=1&order=oldest`**, not `/api/queue`. The queue is a
*view* of songs, and a future filter is another parameter rather than another route with another
shape.
30. **Profiles show counts and history-wide averages, never per-song opinions.** Listing a member's
reviews would walk straight through 22. The resulting principle covers every future page:
*per-song opinion is gated; whole-history aggregate is public.*
31. **Email stays as the login identifier**, even though v1 never sends mail. If the display name
were the login, renaming yourself would break your own credentials. Password reset and
announcements over SMTP are wanted and deferred (see [later.md](./later.md)) — v1 cannot verify
addresses, so that feature will need the admin as a fallback for typos.
32. **Sessions are idle timeouts: 24 hours, or 30 days with "remember me".** Absolute expiry was
rejected in favour of "N with no requests", so an everyday member is never logged out and a
borrowed machine forgets quickly. The TTL lives on the session row, so extending is one
assignment with no branch.
33. **Max 5 submissions per member per rolling 24 hours.** A queue turns one member's enthusiasm into
everyone else's workload — forty songs in a weekend is forty items at the front of nine other
queues. A rate limit was chosen over round-robin queue ordering (more code, harder to explain)
and over a cap on in-flight submissions (punishes the most engaged member, no non-arbitrary
value). Rolling rather than calendar-day: no midnight hovering and no timezone question.
34. **Failed and discarded submissions never count against the quota.** No audio, no submission.
yt-dlp rot is expected and periodic (19), and burning someone's day on your own infrastructure's
failure is the wrong lesson.
35. **All nine leaderboards stay, with deterministic tie-breaks.** The count-based ones (Most
Reviewed, Most Active) go stale once everyone drains the queue and everything ties, but they are
a query each and genuinely differ in the early months when people actually look. Prune them when
they are proven useless. The tie-break is not optional: ties plus `LIMIT 10` gives unstable
ordering, and a leaderboard that reshuffles between reloads reads as a bug.
36. **Title, artist and genre are required to publish; genre joins the editable-while-unlocked set.**
Prefill can legitimately produce nothing (a real yt-dlp dump of an ordinary upload has no
`track`, `artist` or `album`), so publishing has to validate or a nameless song reaches the feed.
Validation happens at publish, not at submit, because the form is meant to be filled during the
conversion wait. Genre is editable because it is the one field with no prefill at all — picked in
a hurry, most likely wrong.
37. **No avatar renders as initials in a circle, in CSS.** `avatar_url` is `null` and
`/avatars/{id}` 404s. No default image on disk, no identicon generator, and ten initials
distinguish ten people better than ten grey silhouettes.
38. **Build order is admin → pipeline → product loop → YouTube → surroundings.** Registration needs
an invite and invites come from the admin panel, so the admin surface exists before any member
can. The upload path is built and proven before yt-dlp is added, so the worker's failure modes
are not debugged alongside the network's. The app is usable by ten people at step 4.
39. **`HANDOFF.md` is deleted.** It was a relay document from the Nuxt project. Its lasting content
lives in `CONTEXT.md` (language), `docs/spec.md` (behaviour), this file (why), and
`docs/later.md` (deferred). Its case for the rewrite and its notes on the old app were
archaeology once the decision was made.
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
`dev`.
+137
View File
@@ -0,0 +1,137 @@
# Later
Ideas with their reasoning, kept so the thinking isn't redone from scratch. **None of this is v1
work**, and none of it should shape v1 code beyond what the API contract already allows.
---
## Email: password reset and announcements
Wanted, blocked on not having a reliable SMTP server. Members already have addresses in their profile
(decisions 31), so the data is there when the transport is.
Two things to remember when it happens:
- **v1 cannot verify addresses.** Some of them will be typos, and a password reset to a typo'd
address is a locked-out member. The admin already resets passwords, so that is the fallback — but
the reset flow should not assume the address works.
- Announcements need an opt-out, or they become the reason someone stops reading them.
---
## Lyrics with scaled autoscroll
Fetch lyrics and scroll them in time with the audio.
- **LRCLIB** (`lrclib.net`) is a community database with no API key, and its responses include
`syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by
track, artist and duration, all of which are already on the song row. So a decent share of songs
need no faked timing at all.
- **Synced hit** → highlight the current line properly. **Plain hit or manual paste** → distribute
lines evenly across `duration_seconds` and scroll the block *continuously without highlighting a
line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and
outros alone break a uniform mapping.
- The Web Animations API does the whole thing including seeking: build the scroll animation with
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
no drift accumulation.
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
- Storage: one nullable `lyrics text` column. LRC or plain — tell them apart by whether the first
line starts with `[`, so no second column and no flag. Fetched best-effort in the publish worker.
- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*,
*Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the
textarea is the entire feature.
- Copyright posture is the same as the YouTube note: private app, ten people, written down
deliberately.
---
## AI features (Gemma-class local model)
The constraint that shapes every idea: **E2B-class multimodal models are speech models.** The audio
encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near
coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst
case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU
beside Postgres and ffmpeg that is minutes per submission.
So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be
*entertaining* is the strong one:
- **A bot reviewer** — a member account that listens to each published song and posts a score and a
few sentences in character. The model's weaknesses become the feature; nobody files a bug because
the robot was wrong about a friend's doom metal demo. It is a `users` row and a `reviews` row, no
new domain concepts, and the reveal rule already makes its take something you unlock by reviewing —
which quietly rewards the behaviour the whole app exists for. Needs: a sharp persona, a
~3-sentence cap, exclusion from the reviewer leaderboards and from the ≥3-review threshold.
- Weak: genre suggestion (buys one click, and a confident wrong default is worse than an empty
dropdown), auto-drafted introductions (same objection as YouTube descriptions), "is this actually
music" screening (ffmpeg already proves it is audio, and the admin can delete).
- If **duplicate detection** is ever the real want, that is Chromaprint/AcoustID, not an LLM.
- Architecture, when it happens: an Ollama or llama.cpp sidecar called over HTTP from the worker with
a timeout — treated exactly like ffmpeg and yt-dlp, an external process allowed to fail. Go's side
is `net/http` and `encoding/json`, so the dependency budget survives. Never block publish on
inference.
---
## Native client
Deferred; the shape depends entirely on what it is *for*.
- **Launcher icon and media keys** → a PWA. `manifest.json` plus ~10 lines of
`navigator.mediaSession` gets media-key control, and on Linux the browser exposes it over MPRIS, so
the song appears in the GNOME/KDE media widget with artwork. Zero packaging.
- **Playback surviving navigation** → a webview shell, and this is *not* free even then: a webview is
one browser context, so in-app navigation tears down `<audio>` exactly like a tab does. The fixes
are an outer shell page with the app in a same-origin `<iframe>`, fetch-based navigation, or audio
played by the native layer with the webview as UI only.
- **Offline listening** → the only motivation nothing else covers, and a genuinely different project:
download endpoint, local cache management, sync.
- **Not Electron.** It bundles Chromium and Node — ~150 MB on disk, 200400 MB idle RAM, and Node
back in the build, which is the exact dependency surface the rewrite exists to escape. The
system-webview equivalents are ~510 MB: **Wails** (Go, same language as the server) is the pick,
with Tauri as the Rust alternative.
- Packaging inverts with that choice: a plain Go binary → **AppImage** (one `appimagetool` step, one
file to hand out). A webview shell links WebKitGTK, which AppImage bundles badly and the GNOME
**Flatpak** runtime provides for free.
- Worth settling first: the usage pattern this app implies is opening a song, listening, and
reviewing it right there — one page, playback never interrupted. If that is the real behaviour, the
entire rationale reduces to wanting a launcher icon, and that is a `.desktop` file with
`chromium --app=…`.
---
## Custom audio player
Native `<audio controls>` is plain, but it is also correct — keyboard-operable, screen-reader
labelled, media-key aware. Replacing it is cheap and purely client-side: drop the `controls`
attribute, keep the same element, and drive it through `play()`, `pause()`, `currentTime`,
`duration`, and `timeupdate`. Playback, buffering, seeking and Range requests keep working untouched,
and the server changes not at all.
Two things keep it cheap and both are already in v1: the player lives in one template partial, and
`duration_seconds` is on the song row. Skip precomputed waveform peaks — if a SoundCloud-style
waveform is ever wanted, backfilling a couple hundred `.ogg` files is a ten-line script.
When building it, re-earn what the native controls gave away for free: keyboard operation, visible
focus states, and `aria-label`s on the buttons. That is the part custom players routinely drop, and
it is not optional.
---
## Stats not built
Wanted at some point, deliberately out of v1: Top 10 by genre, average given versus received per
member, a reviewer agreement matrix, genre distribution, activity over time. The agreement matrix in
particular is an O(n²) query and a UI nobody has asked for twice.
Also here: pruning the count-based leaderboards once the queue has drained and they are all ties
(decisions 35).
---
## Filters on the browse list
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
haven't I reviewed" and "show me the metal" become real questions — one `WHERE` each, additive to the
API contract as query parameters. Worth doing when the scrolling annoys someone, not before.
+705
View File
@@ -0,0 +1,705 @@
# Levyraati — specification
What the app does. This file and the code must never disagree; when behaviour changes, this changes
with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in
[decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md).
Stack, configuration, and operations are in the [README](../README.md).
---
## 1. Access
- **Invite-only.** No public registration, no self-service password reset. The admin resets passwords.
- Registration requires a valid, unused invite code. Codes are one-time and are spent **only after a
registration actually succeeds** — a failed signup must leave the code usable.
- **No roles.** Every account is a member. The admin is not an account (§5).
- **Email is the login identifier.** The app never sends mail in v1 and cannot verify addresses.
- **Names are not unique** — they are display only, and changing yours must never affect logging in.
- Banned members are rejected at login.
### 1.1 Sessions
- Random token (32 bytes, hex) in `sessions`, presented in an `HttpOnly; Secure; SameSite=Lax`
cookie **or** an `Authorization: Bearer` header.
- **Idle timeout.** A session dies after a period with no requests: **24 hours**, or **30 days** if
"remember me" was ticked at login. The per-session `idle_ttl` is stored on the row, so extending is
`expires_at = now() + idle_ttl` with no branch. Skip the write unless at least a minute has passed,
so this is not a write per request.
- Logging out deletes the row. Changing your password deletes your other sessions. Banning deletes
all of a member's sessions.
- Expired rows are swept at startup (§4.6). They are harmless until then — `expires_at` is checked on
every read.
```go
func sessionToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
if c, err := r.Cookie("session"); err == nil {
return c.Value
}
return ""
}
```
Reading the token from a header does not weaken CSRF: `SameSite=Lax` still guards the cookie path,
and a cross-origin page cannot set `Authorization` without CORS, which is not enabled.
### 1.2 Security behaviours
- Changing your own password requires the current password.
- Passwords are bcrypt.
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
- Audio is auth-gated. Avatars are public — they are not secret, and gating them buys nothing.
- Never log secrets: no passwords, no session tokens, no invite codes, no admin password.
---
## 2. Songs
A song is a published piece of music with converted audio. A row in `songs` is a real song with no
qualifier — in-flight work lives in `submissions` (§4).
- Any member may submit: title (≤100), artist (≤100), genre (fixed list), optional description
("short introduction"), and either an audio file or a YouTube URL.
- **Max 5 submissions per rolling 24 hours** per member. Failed and discarded submissions never
count — no audio, no submission — so a yt-dlp outage costs nobody their quota. Checked before
anything is written to disk.
- Audio is Opus, 96 kbps, stereo, `.ogg`:
`ffmpeg -i IN -c:a libopus -b:a 96k -ac 2 -vn OUT.ogg`. The original is discarded.
- Stored at `storage/audio/<song_id>.ogg`; the DB stores the filename.
- `duration_seconds` is captured from `ffprobe` during conversion and stored on the row.
- **Tracks longer than 15 minutes are rejected** at submit, from metadata, before any download.
- `source_url` is kept on the song row for YouTube submissions — "listen on YouTube", and
re-downloading if a conversion turns out bad.
### 2.1 Locked
A song with at least one review is **locked**. While it is unlocked, its submitter may change
**title, artist, genre, and description**, and may delete it. Audio is never editable.
Locked is a live state, not a one-way door: if a review is deleted (§3) and the song drops back to
zero reviews, it is editable again.
- Submitter deletes: `POST /songs/{id}/delete`, unlocked only.
- Admin deletes: `POST /admin/songs/{id}/delete`, unconditional.
- Two routes rather than one route with a branch.
- **Deleting a song deletes its `.ogg`.** The row and the file always go together.
### 2.2 Genres
Fixed list, `text` column, validated app-side:
Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack,
Experimental, Finnish, Just Plain Weird, Other
---
## 3. Reviews
- Score: integer 1100. Text: required, ≤5000 chars.
- One review per member per song. Rely on the DB unique constraint on `(song_id, reviewer_id)` and
map error `23505` to a 409.
- You cannot review your own song.
- Reviews are never anonymous.
- **30-minute window**, measured from `updated_at`, during which the reviewer may **edit or delete**
their own review. An edit extends the window. After it closes the review is permanent.
- Nobody else deletes a review. The admin's remedy for a bad one is deleting the song or talking to
the person.
### 3.1 Reveal
Other members' opinions of a song are hidden until you have reviewed it, or you are its submitter.
- Covers **the reviews and the song's average score**, everywhere a song is named: the queue, the
browse list, the song page, the API.
- Enforced **server-side in the query**. Hidden reviews are never sent and hidden with CSS.
- Does **not** apply to `/stats` — leaderboards are always public.
- Profiles show counts and history-wide averages, never how someone reviewed a particular song.
The principle, which settles future "can this page show X" questions: *per-song opinion is gated;
whole-history aggregate is public.*
---
## 4. Submission pipeline
Both paths — file upload and YouTube URL — converge on one pipeline. A YouTube song is not a
different kind of song; it has an extra download step and a `source_url`.
Two principles, and the rest follows:
1. **Nothing enters `songs` until the audio converts and the submitter confirms it.** So `songs` has
no `status` column and no query anywhere filters on readiness.
2. **Metadata is read synchronously; conversion runs in the background.** Metadata arriving later
would land in a form the submitter is already typing into, and every prefill would race their
keystrokes.
### 4.1 Submit — `POST /submit`
- **File upload:** cap the request body at 50 MB, write to `storage/tmp/<submission_id>`, then read
tags with `ffprobe` (local, effectively instant).
- **URL:** validate the host against an allowlist (`youtube.com`, `youtu.be`, `music.youtube.com`)
*before* touching yt-dlp. Never pass an unvalidated string into a subprocess argument list, and
never through a shell. Then `yt-dlp -J` for metadata only — no download yet.
- Reject over-long tracks here, from `duration`.
- Insert the `submissions` row with any prefilled title/artist, then redirect to `GET /submit/{id}`.
`yt-dlp -J` is a 13 s network call, acceptable in the handler with a 15 s timeout. On timeout,
render blank fields rather than failing the submission.
**Prefill is a suggestion, not an answer:**
| Source | Title | Artist |
|---|---|---|
| Uploaded file (`ffprobe`) | `title` tag | `artist``album_artist` |
| YouTube (`yt-dlp -J`) | `track``title` | `artist``creator``uploader` |
- `ffprobe` tag keys vary in case by container (`title`, `TITLE`, `Title`) — lowercase the map first.
- YouTube's `track`/`artist` exist only for Topic channels, YouTube Music entries, and videos with a
"Music in this video" panel. `testdata/ytdlp-noose.json` is a real dump of an ordinary upload:
`track`, `artist`, `creator` and `album` are all null, leaving `uploader` and a title with double
spaces in it.
- If a field resolves to empty, **leave it blank.** A blank field prompts the submitter; a plausible
`"Unknown"` does not.
- **Never prefill the description from YouTube's `description`** — it is tracklists, socials, and
affiliate links. The introduction wants the submitter's own words.
- Tag text is attacker-controlled. `html/template` escapes on render, but truncate to the column
limit and strip control characters and newlines before the insert: a title with an embedded
newline wrecks every list layout it appears in.
### 4.2 Convert — background worker
```go
// ponytail: in-process goroutines, 2 at a time. A real queue is the upgrade if this ever
// needs to survive a restart mid-conversion or run on another box.
var slots = make(chan struct{}, 2)
```
Unbounded goroutines shelling out to ffmpeg is how one enthusiastic evening fork-bombs a small VPS.
Everything past the cap waits in `queued`.
States: `queued` → (`downloading`, URL only) → `converting``ready` | `failed`.
- Download: `yt-dlp -f bestaudio --no-playlist --max-filesize 100M -o storage/tmp/<id>.%(ext)s <url>`
- Convert: the ffmpeg command in §2. Its success **is** the validation — no container sniffing, no
magic-byte library. If ffmpeg produced an Opus stream, it was audio.
- On failure, store the stderr tail in `status_msg`. "Video unavailable in this region" is worth
showing; "submission failed" is not.
- Output lands at `storage/tmp/<submission_id>.ogg` — still not in `storage/audio/`.
### 4.3 The waiting page — `GET /submit/{id}`
Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the
introduction rather than watching a spinner.
The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes
**Julkaise**. Publishing is always an explicit click — firing it automatically would race the
submitter mid-sentence.
The live part is HTMX polling a fragment:
```html
<!-- {{define "submission-status"}} — included by the page, returned alone by the poll -->
<div hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML">
<p>{{.Label}}</p>
<button {{if not .Ready}}disabled{{end}}>Julkaise</button>
</div>
```
The page includes the partial on first paint and `GET /submit/{id}/status` returns the same partial
alone, so the markup exists once and arrives populated — no flash, no empty state, no client-side
templating. HTMX stops polling when the fragment drops `hx-trigger`, which it does on a terminal
status. Finnish strings never leave Go.
With JS disabled the page degrades to "saving the form shows the current status", which is what a
`<meta http-equiv="refresh">` would have given at the cost of wiping whatever is being typed.
### 4.4 Publish — `POST /submit/{id}/publish`
**Title, artist and genre are required to publish**; description is optional. Validated here, not at
submit time — the form is meant to be filled during the wait. A blank field comes back as a field
error on the same page with the audio still converted and waiting.
```go
// Rename inside the transaction: if the file move fails, the song row never existed.
// A crash between rename and commit leaves an orphan .ogg — the startup sweep gets it.
tx.QueryRow(ctx, `INSERT INTO songs (...) VALUES (...) RETURNING id`).Scan(&songID)
os.Rename(sub.TmpPath, filepath.Join("storage/audio", fmt.Sprint(songID)+".ogg"))
tx.Exec(ctx, `DELETE FROM submissions WHERE id = $1`, sub.ID)
tx.Commit(ctx)
```
### 4.5 Failure means denied
A failed submission is invisible to everyone but its submitter and never reaches `songs`. The row
survives only to carry the error message and offer:
- **Discard** — delete the row and unlink the temp file.
- **Retry** — URL submissions only, re-queued with the title and introduction already typed. An
upload cannot retry, its temp file is gone; offer re-upload.
Neither costs quota (§2).
### 4.6 Startup sweeps
Next to the migrations, before serving:
```sql
UPDATE submissions SET status='failed', status_msg='interrupted by restart'
WHERE status IN ('queued','downloading','converting');
DELETE FROM submissions WHERE created_at < now() - interval '7 days';
DELETE FROM sessions WHERE expires_at < now();
```
An in-process goroutine dies with the process; without the first statement those rows say
`converting` forever. The second covers failures *and* conversions nobody came back to publish —
unlink `tmp_path` as you delete. Also unlink any `.ogg` in `storage/audio/` with no matching song
row. No cron, no partitioning.
Log a count of `failed` submissions at startup: an unpublished submission is invisible to everyone
but its submitter, so a broken pipeline has no other way of announcing itself.
### 4.7 Operational notes
- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly.
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
decision is deliberate rather than accidental.
---
## 5. Pages
Nav: **Jono** (`/`), **Kappaleet** (`/songs`), **Tilastot** (`/stats`), **Oma profiili**
(`/profile`).
### 5.1 Jono — the queue, `GET /`
The work list, not a catalogue:
- Songs you have **not** reviewed, **oldest first**.
- **Excludes your own songs** — you can never review them, so they would sit at the front forever.
- No average score, no review text (§3.1). Review count is fine.
- Drains to empty, and the empty state is a real state worth rendering well.
- Cursor pagination, 20 per page.
### 5.2 Kappaleet — browse, `GET /songs`
Everything, newest first, cursor-paginated, with badges (`arvosteltu`, `oma kappale`).
`average_score` shows only for songs you have revealed. This is where a song lives after it leaves
the queue, and the only way back to something you have already reviewed.
### 5.3 Song — `GET /songs/{id}`
Player, metadata, your own review or the review form, and — once revealed — everyone else's reviews
and the average.
### 5.4 Tilastot — `GET /stats`
Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking;
`min_reviews` is published, not hardcoded in a client.
- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest `stddev_pop`), Most Unified (lowest),
Most Reviewed.
- Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific
Submitter.
- Postgres does all of it: `avg()`, `count()`, `stddev_pop()`, `HAVING count(*) >= 3`. Order and
limit in SQL, never in Go.
- **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC,
id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads
for no reason.
- The count-based lists (Most Reviewed, Most Active) go stale once everyone has worked the queue.
Prune them when they stop being interesting.
### 5.5 Profiles — `GET /profile`, `GET /profile/{id}`
The member's songs, and counts and averages over their whole history: songs submitted, reviews
written, average score given, average score received. **Never a list of their reviews** (§3.1).
`POST /profile` edits name, email, password, avatar. No avatar → `avatar_url` is `null` and the
template renders a circle with the member's initials, in CSS. No default image on disk.
### 5.6 Palaute — `GET /report?from=…`, `POST /report`
- Any member, free text (≤2000 chars), nothing else. No category, no priority, no severity — with
ten users a sentence and a page URL beat a taxonomy nobody fills in honestly.
- A footer link on every page carries the current path into a hidden field. The server already knows
where they were, so no JS.
- `user_agent` is captured from the header. "Only on my phone" is the most common bug report and
this answers it without asking.
- Submitting redirects back to `from` with a flash toast. The reporter sees their own past reports
listed, which is what stops the same bug arriving four times.
---
## 6. Admin
**The admin is not a user.** They never submit, never review, and never see the member-facing site.
No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and
no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI
is Finnish, like everything else.
```go
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
// (close the browser). Add a cookie session if a second admin ever needs one.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
```
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in
the env file next to the Postgres password already. The constant-time compare is the part that
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
worse than one that will not boot.
```go
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
// or the reverse proxy. A separate binary would need its own deploy and would race the
// startup migrations; it buys nothing else.
go func() { log.Fatal(http.ListenAndServe("127.0.0.1:8081", requireAdmin(adminMux))) }()
log.Fatal(http.ListenAndServe(":8080", memberMux))
```
**Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the
entire first-launch story. Losing the password is an edit to `.env` and a restart.
**Routes** (all on the loopback listener): `GET /admin` dashboard, `POST /admin/invites`,
`POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`,
`GET /admin/reports`, `POST /admin/reports/{id}/resolve`, and `GET /admin/audio/{id}` — moderating a
complaint means listening to the song, and a separate audio route avoids branching auth inside the
member handler.
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends
participation, it does not rewrite history.
**The admin surface has no API.** Basic Auth on loopback with no client but a browser — JSON would
be contract surface with no consumer.
---
## 7. Two surfaces
**One binary, one origin.** HTML at `/`, JSON at `/api/…`. No separate frontend, no static bundle,
no second domain, and therefore no CORS. A reverse proxy in front is TLS termination, not a second
application.
**The page surface returns HTML, including fragments for HTMX. The API surface returns JSON and
never HTML.** Which you get is decided by the route, not by an `Accept` header — one URL returning
two shapes is a contract a client has to hope is honored, and it drags `Vary: Accept` along to stay
cache-correct.
```go
mux.HandleFunc("GET /songs/{id}", page(a.songData, "song.html")) // HTML, browser
mux.HandleFunc("GET /api/songs/{id}", jsonOf(a.songData)) // JSON, always
```
**Every route is two thin adapters over one data function.** `songData` owns the reveal rule, the
edit window, the locked rule — everything. The surfaces are physically incapable of diverging, which
is the only real cost an API has. Templates receive the same struct the API marshals.
The discipline that keeps it honest:
- `json` tags on everything exposed; the struct **is** the contract.
- **No display formatting in the data.** Dates stay timestamps, statuses stay codes with a `label`
alongside. Formatting is a template func.
- **Additive changes only** once a client is installed somewhere you cannot update.
- **CORS** is a one-line middleware added the day a client fetches from another origin. Not before.
- **Design the contract now; implement each endpoint when something calls it.** Nothing consumes
`/api` while the browser talks HTML, and each route is one line over a data function that already
exists.
`GET /audio/{id}` stays a plain, Range-capable, auth-gated URL — not under `/api`, because it serves
bytes rather than JSON. `http.ServeContent` handles 206/416/If-Range correctly for free. Parse
`{id}` as an integer before it touches a path; that **is** the traversal check.
### 7.1 Page routes
| Route | Purpose |
|---|---|
| `POST /login`, `POST /register`, `POST /logout` | Form posts, redirect on success |
| `GET /` | Jono — the queue (§5.1) |
| `GET /songs` | Kappaleet — browse (§5.2) |
| `GET /songs/{id}` | Song detail, reveal rule applied |
| `POST /songs/{id}` | Submitter edits title/artist/genre/description, unlocked only |
| `POST /songs/{id}/delete` | Submitter deletes, unlocked only |
| `POST /submit` | Upload or URL (§4.1) |
| `GET /submit/{id}` | Waiting page, submitter-only |
| `GET /submit/{id}/status` | HTML partial for the HTMX poll |
| `POST /submit/{id}/publish`, `/retry`, `/discard` | (§4.4, §4.5) |
| `POST /songs/{id}/review` | Create a review |
| `POST /reviews/{id}`, `POST /reviews/{id}/delete` | Edit or delete, inside the 30-minute window |
| `GET /stats` | Leaderboards |
| `GET /profile`, `GET /profile/{id}`, `POST /profile` | Profiles |
| `GET /audio/{id}` | Auth-gated, Range-capable |
| `GET /avatars/{id}` | Public; 404 when there is no upload |
| `GET /report`, `POST /report` | Palaute (§5.6) |
HTML forms cannot send `PATCH` or `DELETE`, which is exactly why the browser uses `POST` here and
the API uses the proper methods. The two-surface split earns its keep rather than contorting one
surface to serve both.
### 7.2 Frontend
Hand-written `style.css` (~200 lines, CSS custom properties at the top for the dark rock/metal
theme, Oswald headings, orange/red accents) plus **vendored** HTMX and Alpine — downloaded files, not
a CDN, ~30 KB together. Two script tags, no build step, no npm.
- **HTMX** when the answer must come from the server: it swaps a rendered partial in, so there is no
JSON tier and no duplicated markup.
- **Alpine** when the page reacts only to itself.
- **Neither** when a native element does the job:
| Need | Use |
|---|---|
| Audio player | `<audio controls>` |
| Mobile nav toggle | `<details>` |
| Toasts | flash cookie, rendered server-side, cleared on read |
| Score slider | `<input type=range>` + `<output>` |
Today HTMX drives exactly one thing (the status poll) and Alpine has no job at all. Both are present
so the next dynamic feature needs no decision, not because v1 leans on them. **A page with no server
round trip and no local state gets neither `hx-` nor `x-` attributes.**
The ceiling: this pairing is for server-owned state. An interface where a lot of state lives on the
client and changes constantly is what a real client framework is for; nothing here is close.
**The audio player lives in one template partial** (`{{template "player" .}}`) — not an abstraction,
just not copy-pasting five lines into four pages. Replacing the native controls later then edits one
file.
---
## 8. API contract
Fixed before implementation, because the shape is the expensive thing to change once a client is
installed somewhere you cannot update.
**Conventions**
- `snake_case` field names, matching the SQL columns.
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
string, never a unix int.
- Ids are JSON numbers (`bigserial`, safely under 2⁵³).
- Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a
few bytes, and "missing" versus "null" is a distinction clients get wrong.
- Scores are integers, averages are floats.
- **Every enum ships with a `label`**: `{"status":"converting","label":"Muunnetaan…"}`. Clients
render the label and never map codes to strings, so adding a status later breaks nothing and the
Finnish stays in Go.
- **Derived booleans accompany state**: `done`, `ready`, `failed`, `can_edit`, `can_delete`. A client
asking "can I edit this?" must never reimplement the 30-minute rule.
- Collections are `{"items": [...], "next_cursor": "…"|null}`. The cursor is **opaque** — today it is
the last id, but naming it a cursor means changing that is not a breaking change.
**Client contract:** ignore unknown fields, tolerate unknown enum values by falling back to `label`,
never derive permissions locally.
**Object shapes**
```jsonc
// song_summary — queue, browse, stats entries
{ "id": 42, "title": "…", "artist": "…", "genre": "Metal", "duration_seconds": 213,
"audio_url": "/audio/42", "submitted_by": <user_summary>, "created_at": "…",
"review_count": 5,
"reviews_revealed": false, // whether the viewer has unlocked this song
"average_score": null, // null when not revealed — see below
"own": false, "viewer_reviewed": false }
// song — detail, adds:
{ …song_summary,
"description": "…"|null, "source_url": null,
"reviews": [<review>]|null, // null when not revealed — never a lie-by-empty-array
"viewer_review": <review>|null,
"can_review": true, "can_edit": false, "can_delete": false }
// user_summary
{ "id": 3, "name": "Esa", "avatar_url": null } // null → client renders initials
// user — profile, adds created_at and:
{ "stats": { "songs_submitted": 4, "reviews_written": 12,
"average_score_given": 63.2, "average_score_received": 70.1 } }
// review
{ "id": 9, "song_id": 42, "reviewer": <user_summary>, "score": 88, "text": "…",
"created_at": "…", "updated_at": "…",
"editable_until": "…"|null, "can_edit": true, "can_delete": true }
// submission
{ "id": 7, "status": "converting", "label": "Muunnetaan…",
"done": false, "ready": false, "failed": false, "status_msg": null,
"title": "…"|null, "artist": "…"|null, "genre": null, "description": null,
"source_url": null, "can_retry": false, "created_at": "…" }
// report
{ "id": 2, "body": "…", "page": "/songs/42", "created_at": "…", "resolved_at": null }
```
`reviews: null` versus `[]` is deliberate: null means the reveal rule is withholding them, `[]` means
the song genuinely has none. Collapsing them would show "no reviews yet" on a song with twenty.
`average_score` is `null` for both reasons — `reviews_revealed` and `review_count` disambiguate, and
a client must not infer a score from either.
**Endpoints**
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| `POST` | `/api/login` | `{email, password, remember?}` | `{token, expires_at, user}` |
| `POST` | `/api/logout` | — | `204` |
| `GET` | `/api/me` | — | `user` + `email` |
| `PATCH` | `/api/me` | `{name?, email?, current_password?, new_password?}` | `user` |
| `POST` | `/api/me/avatar` | multipart | `user` |
| `GET` | `/api/songs` | `?cursor=&limit=20&unreviewed=&order=` | `{items: [song_summary], next_cursor}` |
| `GET` | `/api/songs/{id}` | — | `song` |
| `PATCH` | `/api/songs/{id}` | `{title?, artist?, genre?, description?}` | `song` |
| `DELETE` | `/api/songs/{id}` | — | `204` |
| `POST` | `/api/songs/{id}/reviews` | `{score, text}` | `review` (`409` if one exists) |
| `PATCH` | `/api/reviews/{id}` | `{score?, text?}` | `review` (`403` past the window) |
| `DELETE` | `/api/reviews/{id}` | — | `204` (`403` past the window) |
| `POST` | `/api/submissions` | multipart, or `{source_url}` | `submission` (`429` over quota) |
| `GET` | `/api/submissions/{id}` | — | `submission` |
| `PATCH` | `/api/submissions/{id}` | `{title?, artist?, genre?, description?}` | `submission` |
| `POST` | `/api/submissions/{id}/publish` | — | `song` |
| `POST` | `/api/submissions/{id}/retry` | — | `submission` |
| `DELETE` | `/api/submissions/{id}` | — | `204` |
| `GET` | `/api/users/{id}` | — | `user` |
| `GET` | `/api/stats` | — | see below |
| `POST` | `/api/reports` | `{body, page?}` | `report` |
| `GET` | `/api/reports` | — | `{items: [report]}` (own only) |
`GET /api/songs` is the browse list by default: all songs, newest first. The queue is the same
endpoint with `?unreviewed=1&order=oldest`. Parameters rather than a second route, because the queue
is a *view* of songs and a future filter is another param, not another shape.
`GET /api/stats` returns one object of named leaderboards, every entry the same shape so a client
renders them all with one component:
```jsonc
{ "min_reviews": 3,
"top_songs": [{ "song": <song_summary>, "value": 88.2, "review_count": 6 }],
"bottom_songs": [ … ], "most_divisive": [ … ], "most_unified": [ … ],
"most_reviewed": [ … ],
"harshest_critics": [{ "user": <user_summary>, "value": 41.3, "review_count": 9 }],
"most_generous": [ … ], "most_active": [ … ], "most_prolific": [ … ] }
```
Adding a leaderboard later is a new key, which is additive. Removing one is not, which is one more
reason the count-based lists stay until they are proven useless.
**Errors** — one shape, always:
```jsonc
{ "error": { "code": "already_reviewed",
"message": "Olet jo arvostellut tämän kappaleen.",
"fields": { "score": "Pisteiden tulee olla 1100." } } }
```
`code` is machine-stable and never changes; `message` is Finnish and may. `fields` appears only for
`validation_failed`. Codes: `unauthorized` (401), `forbidden` (403), `not_found` (404),
`validation_failed` (422), `already_reviewed` (409), `edit_window_expired` (403), `invite_invalid`
(422), `banned` (403), `payload_too_large` (413), `quota_exceeded` (429).
---
## 9. Data model
Ids are `bigserial`. Session tokens stay random — those are secrets, ids are not, and enumerable ids
are not a threat model for a login-walled app for ten friends.
```sql
users (id bigserial pk, name, email unique, password_hash, avatar, banned, created_at)
sessions (token pk, user_id fk not null, idle_ttl interval not null,
expires_at, created_at) -- token: 32 random bytes, hex
songs (id bigserial pk, title, artist, genre, description, audio_file,
duration_seconds int,
source_url, -- nullable, for YouTube submissions
submitted_by fk users, created_at)
submissions (id bigserial pk, user_id fk not null,
status text not null default 'queued', -- queued|downloading|converting|ready|failed
status_msg text,
source_url, tmp_path,
title, artist, genre, description,
created_at)
reviews (id bigserial pk, song_id fk on delete cascade, reviewer_id fk users,
score int, text, created_at, updated_at,
unique (song_id, reviewer_id))
invites (id bigserial pk, code unique, is_valid bool, created_at)
reports (id bigserial pk, user_id fk not null, body text not null,
page text, user_agent text,
resolved_at timestamptz, -- null = open
created_at)
```
No `role` column (§6). No `status` on `songs` (§4).
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
`reviews(song_id)`, and an index supporting the queue's "songs this member has not reviewed".
Files on disk: `storage/tmp/<submission_id>[.ogg]` in flight, `storage/audio/<song_id>.ogg` once
published. A row and its file are always created and deleted together.
Migrations are numbered `.sql` files in an `embed.FS`, applied at startup before serving.
---
## 10. Logging
`slog.NewJSONHandler(os.Stdout, …)` and nothing else. `docker compose logs` is the log viewer — there
is no log table and no in-app viewer.
Group with `slog.With("ctx", …)`: `songs`, `reviews`, `auth`, `invites`, `submissions`, `startup`.
---
## 11. Tests
Each of these is a place where a plausible-looking implementation is silently wrong:
- **Invite burning** — a failed registration leaves the code usable; a successful one does not.
- **The 30-minute window** — measured from `updated_at`, so an edit extends it. Check the boundary,
the expired case, and that it gates delete as well as edit.
- **The reveal query** — a member who has not reviewed a song must not receive other reviews *in the
result set*, and must not receive the average. Assert on the JSON: `reviews` is `null`, not `[]`,
and `average_score` is `null`.
- **The queue** — excludes your own songs and songs you have reviewed, orders oldest first, and
re-includes nothing when a review is deleted by someone else.
- **Unlock** — deleting the only review makes the song editable by its submitter again.
- **The submission quota** — five in 24 hours, and a failed submission does not consume one.
- **Publish** — the song row and its `.ogg` appear together, or neither does. Force the rename to
fail and assert no orphan row.
- **Restart recovery** — a submission left `converting` becomes `failed` at the next startup.
- **The API contract** — one golden file per object shape. It fails the moment a field is renamed or
dropped, which is the breakage that is expensive later and invisible in review.
Everything else is forms and `INSERT`s. No framework, no fixtures beyond a test database.
---
## 12. Build order
Each step leaves something runnable. Registration needs an invite and invites come from the admin
panel, so the admin surface comes first — before a single member can exist.
1. **Skeleton** — `main.go`, embedded migrations at startup, pgxpool, slog, Docker Compose, the two
listeners.
2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban.
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
dependency.
4. **Queue, song page, reviews, reveal** — the product loop. **Usable by ten people at the end of
this step.**
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
6. **Stats, profiles, avatars, palaute.**
7. **API endpoints and golden tests**, once something wants them.