Files
Levyraati26_go/docs/decisions.md
T
Esa Kataja 2474b42175 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.
2026-07-31 19:37:22 +03:00

12 KiB
Raw Blame History

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; 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. The feed is a queue: unreviewed only, oldest first. It drains, and the oldest unreviewed song is the one most likely waiting on someone.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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) — v1 cannot verify addresses, so that feature will need the admin as a fallback for typos.
  12. 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.
  13. 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.
  14. 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.
  15. 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.
  16. 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.
  17. 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.
  18. 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.
  19. 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.
  20. 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.