Files
Levyraati26_go/docs/spec.md
T
Esa Kataja 992caa4eb1 Write the deployment manual, and keep the build context clean
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.
2026-08-02 23:18:44 +03:00

36 KiB
Raw Blame History

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, decisions and their reasons in decisions.md, and anything explicitly not in v1 in later.md.

Stack and configuration are in the README; running it on a server is in deployment.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.
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. There is no minimum length — only non-empty. Invite-only registration, ten members, and an admin-only reset path leave a length policy nothing to protect.
  • Login attempts are rate limited: 10 failures for one email address within 15 minutes lock that address's login for 15 minutes, and a correct password clears the counter. Keyed by email rather than IP, because behind a proxy the address requires trusting X-Forwarded-For. Held in memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
  • 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. The stored value is the English code and the Finnish label is display only — the same split the statuses use, so rewording a genre never touches a song row:

Code Label Code Label
Rock Rock Soundtrack Elokuvamusiikki
Metal Metal Experimental Kokeellinen
Punk Punk Classical Klassinen
Blues Blues Electronic Elektroninen
Jazz Jazz Hip Hop Hip hop
Pop Pop Finnish Kotimainen
Folk / Country Folk / Country Just Plain Weird Ihan outoa
Other Muu

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 artistalbum_artist
YouTube (yt-dlp -J) tracktitle artistcreatoruploader
  • 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

// 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) → convertingready | 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.

One button, at the bottom of the form: Julkaise, disabled until status = 'ready'. Publishing is always an explicit click — firing it automatically would race the submitter mid-sentence.

There is no separate save button: two buttons made it unclear which one committed the text.

  • The metadata form autosaveshx-post on input changed delay:1.2s and on change, answering with a quiet "Tallennettu 21.37" line and nothing else.
  • Julkaise lives outside the form and is bound to it with the HTML form= attribute, so pressing it submits the metadata and publishes in one request. The last keystrokes therefore arrive with the click even if the autosave never fired — which is also what makes the page work with no JS at all.

The live part is HTMX polling a fragment:

<!-- {{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 type="submit" form="meta" {{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.

// 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:

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. Installed with apk add yt-dlp from Alpine's active branch, which tracks upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment, pip install -U yt-dlp is the fallback — at the cost of python3 and pip in the image.
  • 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 score spread), Most Unified (lowest), Most Reviewed.
  • Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific Submitter.
  • SQL does all of it: avg(), count(), HAVING count(*) >= 3. Order and limit in SQL, never in Go. SQLite has no stddev_pop, so the divisive/unified boards spell the population formula out — see stddevPop in stats.go.
  • Every leaderboard needs a deterministic tie-breakORDER 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.

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

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

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

Not built. Nothing consumes /api — the browser talks HTML to the page surface — so this section is a design, not a description of running code (decision 43). It stays here because the shape is the expensive thing to change once a client is installed somewhere you cannot update, and the first endpoint is one line over a data function that already exists.

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 (SQLite rowids, 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

// 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:

{ "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:

{ "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 integer primary key autoincrement — never reused, because storage/audio/<song_id>.ogg is named after one. 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.

Timestamps are declared timestamp and hold 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. Booleans are integer, 0 or 1.

users       (id pk, name, email unique, password_hash, avatar, banned, created_at)
sessions    (token pk, user_id fk not null, idle_ttl integer not null,   -- seconds
             expires_at, created_at)          -- token: 32 random bytes, hex
songs       (id pk, title, artist, genre, description, lyrics, audio_file,
             duration_seconds integer,
             source_url,          -- nullable, for YouTube submissions
             submitted_by fk users, created_at)
submissions (id 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, lyrics,
             created_at)
reviews     (id pk, song_id fk on delete cascade, reviewer_id fk users,
             score integer, text, created_at, updated_at,
             unique (song_id, reviewer_id))
invites     (id pk, code unique, is_valid integer, created_at)
reports     (id pk, user_id fk not null, body text not null,
             page text, user_agent text,
             resolved_at timestamp,   -- null = open
             created_at)

foreign_keys is off by default in SQLite, so the cascades above only exist because the pragma is set on every connection — see openDB in main.go.

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 INSERTs. 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. Skeletonmain.go, embedded migrations at startup, database/sql, 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 — deferred until something wants them (decision 43).