The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.
is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.
Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.
An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.
This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
A copy pass over the Finnish UI. The em-dash aside was doing the work of a
second sentence in ten places, so it is a second sentence now, or gone.
The sealed-score wording was wrong twice over: it promised "pisteet" when
only other people's are hidden, and said "kirjoitat" when the reveal
actually happens on save. Both fixed on the queue, the song page and the
card badge.
Initials() capped the loop with len(out) == 2, which counts bytes: a name
starting with Ä, Ö or Å filled the budget on its own and returned a single
letter. Count the initials taken instead.
go fix also wanted a strings.Builder here, but that allocates a string per
iteration to measure a two-character result. Took its SplitSeq suggestion
in lyrics.go, which drops an intermediate slice.
docs/deployment.md is the server-side procedures: the compose file a server
runs, building and publishing a release, first deployment, reverse proxy,
upgrades and rollback, backups and restore, and a troubleshooting table.
The compose file lives in the manual rather than in the repository, because
the one at the root builds from source and is what development wants. The
server's pulls a published image, pins a release tag, and publishes the
admin port on the host's loopback instead of every interface — the panel is
Basic Auth and nothing else, so where that port is bound is the whole of its
security.
.dockerignore keeps the image build off storage/ (the live database and the
audio), .env (the admin password) and the leftover pgdata, which the build
cannot read anyway and which fails it outright.
Three fixes from using the admin panel and the site:
- The invite link was an anchor, but an invite is something to send, not to
follow — clicking it opened the join form in the admin's own browser. It
is now the URL beside a Kopioi button. The handler reads the text out of
the sibling element rather than interpolating the URL into JS, so there is
nothing to escape, and where the clipboard API is missing (it needs a
secure context, which the documented SSH tunnel to localhost provides) it
selects the text instead of leaving a button that does nothing.
- "Ilmoita ongelmasta" framed the feedback form as a bug tracker when it is
meant to take ideas and general feedback too. The footer now asks
"Ongelmia? Ideoita? Palautetta?", and the page it leads to answers all
three: the ingress covers ideas explicitly and the placeholder suggests a
feature rather than a fault.
- "Kuuntele YouTubessa" opens in a new tab. Leaving the page mid-review
would lose whatever is already typed into the review form.
Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.
The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:
- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
declared type is what makes the driver return time.Time, and the
fixed-width UTC string is what makes ordering and comparison against
datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
formula out, guarded with max(0.0, ...) because cancellation returns a
tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
pragma is set on every connection.
Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
Synced LRC highlights the playing line, keeps it centred and seeks on click.
Plain text scrolls continuously with a nudge knob instead — a highlight on
guessed timings turns guaranteed drift into what looks like a bug. Seuraa
kappaletta turns following off without losing the highlight, and scrolling by
hand turns it off too.
Fixes the scroll landing in the wrong place (offsetTop measured from a
different coordinate space than the box it was applied to) and the fader
shifting the deck sideways at score 100 (auto-sized grid columns plus a
readout spanning both).
Lyrics are suggested at submission and never imposed. The conversion worker
makes one LRCLIB lookup with whatever metadata exists, and the waiting page
has a Hae sanoitukset button that re-queries with whatever title and artist
are currently typed — which is the case that matters, since our metadata comes
from ID3 tags and YouTube uploaders. Neither path overwrites typed text.
- lyrics text on both submissions and songs, copied across at publish. Nothing
has launched, so the column goes into 001_init.sql rather than a migration
- The lock does not cover lyrics: it freezes what the song claims to be, and
nobody reviewed the lyrics. So the submitter can still fix them afterwards,
or paste them for an old song a year later
- The review strip gained a second pane: lyrics on the left, review on the
right, so following the words costs no scrolling. No lyrics means no pane,
not an empty one. Below 1024px the panes stack
- LRC timestamps are stored but stripped for reading — they belong to the
player, not the reader
- The lyrics box is capped and scrolls inside itself, so a long song cannot
stretch the strip past the screen
Fixes a real bug found on the way: saveMetadata cleared any field the request
did not carry, so publishing wiped the lyrics the worker had just fetched.
Fields absent from a request now keep their stored value.
The client identifies itself to LRCLIB as "levyraati" and nothing more.
Tests cover cleanLyrics keeping line breaks, and fetchLyrics against a local
server: synced beats plain, instrumentals and wrong-length takes are skipped,
and a miss is empty with no error.
Versions are YYYY.MM.DD-N, injected with -ldflags from a git tag, so no file
in the repo carries the number and a local build honestly says dev. The
version shows in the footer, the startup log and /healthz.
The song page's metadata was five different kinds of fact — artist, genre,
duration, provenance and a badge about the viewer — in one flat run with two
competing pills. Now the artist has its own line in the display face, and the
facts sit in four labelled cells like the spine of a cassette insert. The
submitter links to their profile, "oma kappale" became "lähetti: sinä", and
the clock time is gone: nobody needs the minute a song was published.
The app is about operating something — playing a track and setting a level on
it — but every screen looked like a form. One metaphor now does three jobs.
- Reviewing: a vertical fader beside the text, so the two things you do at
once stop being a screen apart. Native range input, so keyboard, focus and
form submission are unchanged; on mobile it lies down and the ticks reverse
- The reveal: everyone's scores as a row of channels. The silhouette of that
row is the spread, which the stats page can only tell you as a number
- Profiles: given versus received as two faders, the one comparison that says
something about a person
The player is now a transport: play/pause, a range input for seeking so arrow
keys come free, and a stereo level meter driven by a real AnalyserNode. It is
progressive enhancement — the page ships native audio controls and the script
takes over, so no JS means the browser's own player. The meter is dark until
audio actually plays and stops when it does; reduced motion skips it entirely.
Also: hidden scores are hatched rather than blank, the nav carries the queue
count, "Seuraava jonossa" keeps the loop going after a review, leaderboards
gained level bars and a range bar where divisive is the point, durations read
3:54, both lists can get back to the start, and the admin invite table lists
unused codes instead of silently truncating at 50.
Slogan restored from the original app, three decades on.
Supersedes decision 17, which expected endpoints to appear one at a time.
Nothing calls /api at all, so even that would be handlers with no callers and
golden tests guarding shapes nothing reads.
The contract stays in the spec as a design — it is what stops the shape
changing under a future client — marked as not built so the spec doesn't
claim behaviour the code lacks. later.md records what to build first when a
consumer appears.
Step 6. The surfaces around the review loop.
- Nine leaderboards, ordered and limited in SQL, each with a deterministic
tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews
to qualify, for reviewer boards too
- Profiles show counts and history-wide averages and the member's songs,
never a list of their reviews — per-song opinion stays gated
- Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation.
No upload still means initials, and avatars are public
- Changing your own password requires the current one and drops your other
sessions
- Palaute: free text plus the page you were on, carried in a footer link, and
the user agent from the header. Reporters see their own; the admin resolves
them with a timestamp rather than a status enum
- Admin gained the song list with delete, the reports page, and an open-report
count on the dashboard
Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter
because display:grid suppresses list markers, and count-based boards were
printing 3.0 where they mean 3.
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.
Step 5. A URL goes through the same pipeline as an upload — it just gains a
download step and a source_url.
- The host allowlist is checked on the parsed hostname before yt-dlp is
invoked, so lookalikes and userinfo tricks are refused too
- yt-dlp -J reads metadata synchronously with a 15s timeout; a timeout leaves
the fields blank rather than failing the submission
- Over-long tracks are refused from that metadata, before a byte is downloaded
- Failed URL submissions offer Yritä uudelleen with the typed text intact;
uploads cannot retry, so they offer re-upload
Prefill takes track then title, and artist then creator then uploader, and
leaves a field blank rather than inventing one. testdata/ytdlp-noose.json is a
real dump of an ordinary upload, which has none of the music fields.
Also fixes a URL-only submit being blocked by the file input's required
attribute — HTML cannot express "one of these two", so the server says it.
The image now takes yt-dlp from Alpine 3.24 instead of pip, which drops
python3 and pip entirely; see decision 19.
Steps 3 and 4 of the build order. A member can now upload a song, watch it
convert, publish it, and review what everyone else has published.
Pipeline:
- ffprobe reads tags synchronously at submit so prefill never races typing;
ffmpeg converts to Opus in the background, two at a time
- ffmpeg succeeding is the validation — no container sniffing
- publish moves the file inside the transaction, so a song row and its .ogg
appear together or neither does
- five submissions per rolling 24h, failures excluded
Reviews and the reveal rule:
- the queue is unreviewed songs only, oldest first, never your own
- other people's reviews and the average are withheld in the query, not the
template — a hidden average is never sent
- 30 minutes to edit or delete your own review, enforced in the WHERE clause
- deleting the last review unlocks the song for its submitter again
The waiting page has one button: the metadata form autosaves after a pause in
typing, and Julkaise submits it and publishes in the same request, so nothing
is lost without JS.
Genres store an English code and render a Finnish label.
Step 2 of the build order. The admin mints an invite link, the recipient
registers with it, and from then on has a session.
- The invite is spent in the same transaction that creates the account, so a
failed signup leaves the code usable
- Sessions are idle timeouts, 24h or 30 days with remember me, read from a
cookie or a bearer header, extended at most once a minute
- Ban is a reversible toggle that drops the member's live sessions
- No password minimum; login is rate limited instead, 10 failures per email
in 15 minutes, cleared by a correct password
- Invite codes render as links carrying ?code=, which the register form
prefills; PUBLIC_URL makes them pasteable from the loopback admin panel
Tests cover invite spending, the idle timeout, ban, and the rate limiter.
Step 1 of the build order in docs/decisions.md. Boots, applies migrations
before serving, and serves a health check and an empty admin page.
- 001_init.sql is the full schema from docs/spec.md, including the check
constraints and indexes the old app lacked
- The startup sweep fails submissions left mid-conversion by a restart; an
in-process goroutine dies with the process and those rows would otherwise
say converting forever
- Admin is Basic Auth from env on its own listener, fatal at startup when
ADMIN_PASSWORD is unset
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.