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