Author SHA1 Message Date
Esa Kataja 400b5d3833 Release 2026.08.01-1
Lyrics, versioning, and the song page's fact line.

- Lyrics are suggested at submission and never imposed: the worker makes one
  LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist
  are typed. Neither overwrites what the submitter wrote. They live on the
  submission, travel to the song at publish, and stay editable after the song
  locks — the lock freezes what a song claims to be, and nobody reviewed the
  lyrics
- The review strip reads and writes side by side: lyrics left, review right.
  Synced LRC highlights the playing line and seeks on click; plain text scrolls
  continuously with a nudge knob. Following can be turned off
- CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the
  footer, the startup log and /healthz
- The song page's metadata became four labelled cells instead of one flat run
  of five different kinds of fact

Fixes: publishing wiped lyrics the worker had just fetched (a request that
omitted a field cleared it), lyric auto-scroll landed in the wrong place, and
the fader shifted the deck sideways at score 100.
2026-08-01 00:45:55 +03:00
Esa Kataja 1b3bbbbd7b Release 2026.07.31-1
First release of the Go rewrite: invite-only membership, the submission
pipeline for uploads and YouTube, the queue and review loop with the reveal
rule, stats, profiles, avatars and palaute.
2026-07-31 23:39:03 +03:00
Esa Kataja ac2cfaebac Add CalVer versioning and rebuild the song page's fact line
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.
2026-07-31 23:38:54 +03:00
Esa Kataja 2e68feedfe Merge the channel-strip UI work
Reviewing, the reveal, profiles and the player now share one metaphor. See
f51dcd7 for the reasoning.
2026-07-31 23:21:11 +03:00
Esa Kataja cbae2448f9 Add the copyright line to the footer
No year: a bare mark never goes stale, and a hardcoded one quietly says
abandoned.
2026-07-31 23:20:51 +03:00
Esa Kataja f51dcd743e Rebuild the review page as a channel strip
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.
2026-07-31 23:17:40 +03:00
Esa Kataja 69eea8d707 Defer the JSON API until something consumes it
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.
2026-07-31 22:38:03 +03:00
Esa Kataja f1e907bac3 Add stats, profiles, avatars and palaute
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.
2026-07-31 22:34:04 +03:00
Esa Kataja f33f4fa4d6 Apply the theme: tokens, Oswald, song cards, toasts
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.
2026-07-31 22:16:29 +03:00
38 changed files with 3400 additions and 349 deletions
+3 -1
View File
@@ -1,9 +1,11 @@
FROM golang:1.26-alpine AS build FROM golang:1.26-alpine AS build
# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
ARG VERSION=dev
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -o /levyraati . RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati .
FROM alpine:3.24 FROM alpine:3.24
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current # yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
+16 -1
View File
@@ -16,15 +16,30 @@ Invite-only, no public registration. Built for about ten friends.
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish | | [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema | | [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only | | [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff |
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
## Branches ## Branches and releases
- **`main`** — released code only. Every commit on it is something that ran in production, or is - **`main`** — released code only. Every commit on it is something that ran in production, or is
meant to. Tagged at each release. meant to. Tagged at each release.
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and - **`dev`** — current development, and whatever nightly builds get made. Work happens here and
reaches `main` by merge at release time. reaches `main` by merge at release time.
Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1.
The version is injected at build time, so no file in the repo carries it:
```sh
git switch main && git merge --no-ff dev
git tag 2026.07.31-1
VERSION=$(git describe --tags --exact-match) docker compose build app
docker compose up -d app
```
A plain `go build` reports `dev`, which is the honest answer for a local binary. The running
version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is
actually deployed" is answerable without an SSH session.
The app never sends email — there is no verification, no password reset link, and no notifications. The app never sends email — there is no verification, no password reset link, and no notifications.
Members have an address because it is their login and because mail is a planned feature. Members have an address because it is their login and because mail is a planned feature.
+21 -1
View File
@@ -30,14 +30,24 @@ type adminMember struct {
type dashboard struct { type dashboard struct {
Invites []adminInvite Invites []adminInvite
SpentCount int
Members []adminMember Members []adminMember
Songs []adminSong
OpenCount int
} }
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
var d dashboard var d dashboard
// Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating
// a list silently reads as "that's all of them".
if err := a.pool.QueryRow(r.Context(),
`select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
adminError(w, "invites", err)
return
}
rows, err := a.pool.Query(r.Context(), rows, err := a.pool.Query(r.Context(),
`select id, code, is_valid, created_at from invites order by created_at desc limit 50`) `select id, code, is_valid, created_at from invites where is_valid order by created_at desc`)
if err != nil { if err != nil {
adminError(w, "invites", err) adminError(w, "invites", err)
return return
@@ -77,6 +87,16 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
adminError(w, "songs", err)
return
}
if err := a.pool.QueryRow(r.Context(),
`select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err)
return
}
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d}) a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
} }
+8 -8
View File
@@ -160,7 +160,7 @@ type authForm struct {
} }
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}}) a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}})
} }
func (a *app) login(w http.ResponseWriter, r *http.Request) { func (a *app) login(w http.ResponseWriter, r *http.Request) {
@@ -169,7 +169,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
if a.logins.locked(email) { if a.logins.locked(email) {
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen." form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form}) a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return return
} }
@@ -185,12 +185,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
// One message for both cases: a distinct "no such account" tells anyone who asks which // One message for both cases: a distinct "no such account" tells anyone who asks which
// addresses are members. // addresses are members.
form.Errors["form"] = "Sähköposti tai salasana ei täsmää." form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form}) a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return return
} }
if banned { if banned {
form.Errors["form"] = "Tunnus on estetty." form.Errors["form"] = "Tunnus on estetty."
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form}) a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return return
} }
@@ -219,7 +219,7 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request) {
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) { func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "register.html", a.render(w, r, http.StatusOK, "register.html",
page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}}) page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}})
} }
// register spends the invite only when the account is actually created: both statements are in one // register spends the invite only when the account is actually created: both statements are in one
@@ -248,7 +248,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
form.Errors["code"] = "Kutsukoodi on pakollinen." form.Errors["code"] = "Kutsukoodi on pakollinen."
} }
if len(form.Errors) > 0 { if len(form.Errors) > 0 {
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return return
} }
@@ -273,7 +273,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
form.Code).Scan(&inviteID) form.Code).Scan(&inviteID)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa." form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return return
} else if err != nil { } else if err != nil {
slog.Error("burn invite", "ctx", "invites", "error", err) slog.Error("burn invite", "ctx", "invites", "error", err)
@@ -288,7 +288,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
if isUnique(err) { if isUnique(err) {
// Rolls back, so the invite is still valid. // Rolls back, so the invite is still valid.
form.Errors["email"] = "Sähköpostiosoite on jo käytössä." form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return return
} else if err != nil { } else if err != nil {
slog.Error("create user", "ctx", "auth", "error", err) slog.Error("create user", "ctx", "auth", "error", err)
+4 -1
View File
@@ -17,7 +17,10 @@ services:
restart: unless-stopped restart: unless-stopped
app: app:
build: . build:
context: .
args:
VERSION: ${VERSION:-dev}
environment: environment:
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_USER: ${ADMIN_USER:-admin}
+38
View File
@@ -162,6 +162,13 @@ says so.
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge 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 at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
`dev`. `dev`.
42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its
palette, spacing, shadows, motion and component shapes are followed as written; the parts that
assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is
listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight
you do not have is what made the brand render differently per platform. The custom audio player
stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the
actual complaint.
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing, 41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
lockout, cleared by a correct password. The floor was rejected because typing an 8-character lockout, cleared by a correct password. The floor was rejected because typing an 8-character
@@ -171,3 +178,34 @@ says so.
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone `X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
else out by trying. else out by trying.
43. **The JSON API is deferred entirely, not built on demand.** Decision 17 kept the contract fixed
and expected handlers to appear one at a time; in practice nothing consumes `/api` at all, so
even that trickle would be handlers with no callers, plus golden tests guarding shapes nothing
reads. The contract in [spec.md](./spec.md) stays as the design — it is what stops the shape
changing under a future client — and the first endpoint gets built the day something actually
calls it. Both surfaces being thin adapters over one data function is already true of the page
handlers, so adding the JSON side later stays a one-line-per-route job.
44. **CalVer, `YYYY.MM.DD-N`, injected at build time.** The date is the useful part: this app ships
when there is something to ship and gets rebuilt monthly for yt-dlp anyway, so a semantic
version would communicate nothing a date does not. `N` is the build number within that day,
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
45. **Lyrics are suggested at submission, and stay editable forever.** Four decisions in one, taken
2026-07-31 while scoping the feature in [later.md](./later.md):
- **Suggested, never imposed.** The worker attempts one LRCLIB lookup after conversion, and the
waiting page carries a *Hae sanoitukset* button that re-queries with whatever title and artist
are currently typed. The button exists because our metadata comes from ID3 tags and YouTube
uploaders, so the automatic attempt misses exactly the songs with messy names — and would look
broken rather than absent. Neither path overwrites text the submitter has typed.
- **Lyrics live on the submission, not just the song**, and are copied across at publish, because
they are part of preparing a song rather than something bolted on afterwards.
- **No migration 002.** Nothing has launched, so the column goes into `001_init.sql` and the
database is recreated. The schema has no legacy to respect until there is data worth keeping.
- **The lock does not cover lyrics.** It exists so the thing people reviewed stops changing under
them, and nobody reviewed the lyrics — the rule is now *the lock freezes what the song claims
to be; lyrics are an attachment to it.* This also allows pasting lyrics for an old song, which
is when the feature is worth most.
The coverage assumption that shaped the earlier sketch was wrong and is corrected in `later.md`:
LRCLIB has synced lyrics for a good share of Finnish rock, not almost none.
+85 -15
View File
@@ -21,27 +21,59 @@ Two things to remember when it happens:
## Lyrics with scaled autoscroll ## Lyrics with scaled autoscroll
Fetch lyrics and scroll them in time with the audio. Fetch lyrics and scroll them in time with the audio. Designed and decided (decisions 45), not built.
- **LRCLIB** (`lrclib.net`) is a community database with no API key, and its responses include **Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
`syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by LRCLIB would miss nearly all Finnish music. It does not:
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. | Search | Results | With `syncedLyrics` |
- **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 | Nightwish | 20 | 20 |
line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and | Eppu Normaali | 20 | 13 |
outros alone break a uniform mapping. | CMX | 15 | 12 |
| Popeda | 20 | 8 |
**LRCLIB** (`lrclib.net`) needs no API key. `/api/get` matches on artist, track and duration within
±2 s and returns `syncedLyrics` — real LRC with `[mm:ss.xx]` per line — alongside `plainLyrics`;
`/api/search?q=` is the looser fallback. Go's side is `net/http` and `encoding/json`, so the
dependency budget survives, and it is treated exactly like ffmpeg and yt-dlp: a timeout, allowed to
fail, never blocking anything.
**Where it happens: at submission, as a suggestion.**
- The worker attempts one automatic lookup after conversion, using whatever metadata exists.
- The waiting page has a **Hae sanoitukset** button that re-queries with whatever is currently typed
in the title and artist fields. That is the answer for messy tags — `Sentenced Noose` from a
YouTube upload will not match until the submitter fixes it, and the automatic attempt would
otherwise just look broken.
- Neither ever overwrites text the submitter has typed. They can accept the suggestion, edit it, or
leave the field empty.
**Storage:** one nullable `lyrics text` column on both `submissions` and `songs`, copied across at
publish. LRC or plain is told apart by whether the first line starts with `[`, so no second column
and no flag. **Nothing has launched, so this goes into `001_init.sql` rather than a migration 002.**
**Lyrics stay editable after the song locks** — the lock exists so the thing people reviewed stops
changing, and nobody reviewed the lyrics. It also means someone can paste them for an old song a
year later, which is when this feature is most useful.
**Playback:**
- **Synced hit** → highlight the current line properly, driven by the transport's `timeupdate`.
- **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 - 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, `duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
no drift accumulation. no drift accumulation.
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform - **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. 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. **Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*, and a scrolling lyrics panel competes with the review textarea for both space and attention — a
*Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the collapsed panel under the player is the starting guess, not a decision.
textarea is the entire feature.
- Copyright posture is the same as the YouTube note: private app, ten people, written down Copyright posture is the same as the YouTube note: private app, ten people, written down
deliberately. deliberately.
--- ---
@@ -130,6 +162,44 @@ Also here: pruning the count-based leaderboards once the queue has drained and t
--- ---
## Review form as a mixer channel
Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**,
with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form
field and becomes the instrument the app is actually about, and the two things you do at once —
decide a number, write why — stop being stacked a screenful apart.
Notes for whoever builds it:
- A vertical `<input type="range">` is native now: `writing-mode: vertical-lr; direction: rtl` gives
bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free.
- Keep the live `<output>` — on a fader it wants to sit at the top of the track, reading like a
channel's gain display.
- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside
it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal
one, so measure before choosing.
- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are
what make it read as equipment rather than decoration.
---
## The JSON API
Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes,
pagination — and deliberately not implemented, because nothing calls it (decision 43).
When something does:
- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data
functions the pages already use, so the domain rules cannot diverge between the surfaces.
- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed
field a test failure rather than a silent break in a client you cannot update.
- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before.
- The most likely first consumer is a native client (see above), and the endpoints it needs are
login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one.
---
## Filters on the browse list ## Filters on the browse list
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones `/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
+5 -3
View File
@@ -509,8 +509,10 @@ file.
## 8. API contract ## 8. API contract
Fixed before implementation, because the shape is the expensive thing to change once a client is **Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this
installed somewhere you cannot update. 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** **Conventions**
@@ -727,4 +729,4 @@ panel, so the admin surface comes first — before a single member can exist.
this step.** this step.**
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works. 5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
6. **Stats, profiles, avatars, palaute.** 6. **Stats, profiles, avatars, palaute.**
7. **API endpoints and golden tests**, once something wants them. 7. **API endpoints and golden tests** — deferred until something wants them (decision 43).
+53
View File
@@ -0,0 +1,53 @@
# Theme
Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber
accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere.
Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is
wanted.**
The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and
that file is the source of truth. This page records the decisions behind them and the places the
implementation deliberately differs from the theme handoff it came from.
## Rules
- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in
a rule unless it is declared as a token first. Six spacing steps (432 px), one radius (4 px, plus
6 px for toasts and a pill), one duration, one curve.
- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze.
- **Status colours are desaturated on purpose.** A pure red error would break the palette.
- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `<audio>` element,
checkboxes, range inputs and scrollbars from rendering as white slabs.
- **One focus treatment, everywhere:** a soft gold ring via `box-shadow` on `:focus-visible` only.
Not optional — keyboard navigation is the only way through some admin tables.
- **`prefers-reduced-motion`** drops the card lift and the panel slide, and keeps the fades.
## Type
Body is a system stack; no webfont for body text. Display is **Oswald**, vendored as a variable
`.woff2` (latin subset, 21 KB) in `static/fonts/` — no CDN, matching the no-npm rule for HTMX.
The handoff asked for weight 900 in five places while loading only 400/600/700, so browsers were
synthesising a fake bold that differed per platform. **Resolved as 700 being the top weight.** The
variable font covers 400700 and nothing asks for more.
## The unreviewed state
A song the viewer has not reviewed gets a red-brown border — it is the single most important state
in the app, since it is how you see what still needs a review. It is **never carried by colour
alone**: the card also shows an `arvostelematta` badge.
## Deviations from the handoff
| Handoff said | Here | Why |
|---|---|---|
| Tailwind theme config, utility classes | CSS custom properties, semantic classes | Decisions 4 and 8 — no Tailwind, no bundler, no npm |
| Song cards are 16:9 tiles with cover artwork and a gradient scrim | Text cards, same borders, badges, hover glow and score badge | Songs have no artwork. There is no upload for one and nothing to derive it from |
| Fixed bottom audio player bar | Player inline on the song page | Nothing plays across navigation, so a persistent bar would be an empty bar on every other page |
| A styleguide page rendering every variant | Not built | The real pages cover every component; a second copy of them would drift |
| Custom audio player skin | Native `<audio controls>` | Decision 14. It is keyboard-operable, screen-reader labelled and media-key aware for free, and replacing it later is one template partial — see [later.md](./later.md) |
| Nav dropdown for the user block, hamburger with animated bars | User block is a plain row; mobile menu is `<details>` | No JS for either. The app has no dropdown-worthy menu yet: logout is one button |
Everything else — the palette, spacing, radii, shadows, motion, the 1450 px content width, the
420 px auth column, badges, review cards, the admin section cards, bottom-right toasts — follows the
handoff as written.
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// LRCLIB is a community lyrics database with no API key. It is treated exactly like ffmpeg and
// yt-dlp: an outside thing with a timeout, allowed to fail, never blocking anything.
// A var rather than a const so tests can point it at a local server instead of the real service.
var lrclibBase = "https://lrclib.net/api"
const (
// Identifies the client and nothing else. No URL, no host, no version: this app is private,
// and a third party's logs are not the place to learn where it lives.
lrclibAgent = "levyraati"
lyricsTimout = 10 * time.Second
)
type lrclibResult struct {
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"`
}
// best returns the synced version when there is one — timestamps are what make the scroll possible
// later, and plain text is the fallback rather than the goal.
func (r lrclibResult) best() string {
if r.SyncedLyrics != "" {
return r.SyncedLyrics
}
return r.PlainLyrics
}
var lyricsClient = &http.Client{Timeout: lyricsTimout}
// LRC timestamps are stored, because the highlight needs them, and stripped for reading, because
// nobody wants to read [00:11.74] at the start of every line.
var lrcStamp = regexp.MustCompile(`^(\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]\s*)+`)
// A line of synced lyrics: the seconds it starts at, and the words.
type lyricLine struct {
At float64
Text string
}
var lrcOne = regexp.MustCompile(`\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]`)
// parseLRC returns nil for plain text, which is the signal to scroll continuously instead of
// highlighting: a line-by-line highlight on guessed timings makes every second of drift read as a
// bug.
func parseLRC(s string) []lyricLine {
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return nil
}
var out []lyricLine
for _, raw := range strings.Split(s, "\n") {
stamps := lrcOne.FindAllStringSubmatch(raw, -1)
if len(stamps) == 0 {
continue
}
text := strings.TrimSpace(lrcStamp.ReplaceAllString(raw, ""))
// One line can carry several timestamps when a refrain repeats.
for _, m := range stamps {
min, _ := strconv.Atoi(m[1])
sec, _ := strconv.Atoi(m[2])
at := float64(min*60 + sec)
if m[3] != "" {
frac, _ := strconv.Atoi(m[3])
switch len(m[3]) {
case 1:
at += float64(frac) / 10
case 2:
at += float64(frac) / 100
default:
at += float64(frac) / 1000
}
}
out = append(out, lyricLine{At: at, Text: text})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
return out
}
func stripLRC(s string) string {
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return s
}
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(lrcStamp.ReplaceAllString(line, ""), " ")
}
return strings.Join(lines, "\n")
}
func lrclibGet(ctx context.Context, path string, q url.Values) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, lyricsTimout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", lrclibBase+path+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", lrclibAgent)
resp, err := lyricsClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("lrclib %s: %s", path, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}
// fetchLyrics tries the exact match first — artist, track and duration within LRCLIB's ±2 s — and
// falls back to a search, which is what saves songs whose tags are close but not exact. Returns an
// empty string when nothing matches, which is a normal outcome rather than an error.
func fetchLyrics(ctx context.Context, title, artist string, seconds int) (string, error) {
title, artist = strings.TrimSpace(title), strings.TrimSpace(artist)
if title == "" {
return "", nil // nothing to match on; the submitter has not named it yet
}
if artist != "" && seconds > 0 {
body, err := lrclibGet(ctx, "/get", url.Values{
"track_name": {title},
"artist_name": {artist},
"duration": {fmt.Sprint(seconds)},
})
if err == nil {
var res lrclibResult
if json.Unmarshal(body, &res) == nil && !res.Instrumental {
if l := res.best(); l != "" {
return l, nil
}
}
}
}
// Looser: let LRCLIB do the matching on a free-text query.
q := title
if artist != "" {
q = artist + " " + title
}
body, err := lrclibGet(ctx, "/search", url.Values{"q": {q}})
if err != nil {
return "", err
}
var results []lrclibResult
if err := json.Unmarshal(body, &results); err != nil {
return "", err
}
for _, res := range results {
if res.Instrumental {
continue
}
// A duration within 5 s is the strongest signal we have that it is the same recording.
if seconds > 0 && res.Duration > 0 && abs(int(res.Duration)-seconds) > 5 {
continue
}
if l := res.best(); l != "" {
return l, nil
}
}
return "", nil
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// --- the button on the waiting page ---
// Suggests lyrics for whatever title and artist are currently typed, and never overwrites what the
// submitter has already put in the field — the response fills the textarea, and they can accept it,
// edit it or clear it.
func (a *app) suggestLyrics(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
title := clean(r.FormValue("title"), maxTitle)
artist := clean(r.FormValue("artist"), maxArtist)
if title == "" {
title, artist = s.Title, s.Artist
}
seconds := 0
if meta, err := probe(r.Context(), s.TmpPath); err == nil {
seconds = int(meta.Duration.Seconds())
}
lyrics, err := fetchLyrics(r.Context(), title, artist, seconds)
if err != nil {
slog.Warn("lyrics lookup", "ctx", "submissions", "error", err, "submission", s.ID)
}
// Keep whatever the submitter already typed: a suggestion never overwrites their own words.
if existing := cleanLyrics(r.FormValue("lyrics")); existing != "" {
lyrics = existing
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := map[string]any{
"ID": s.ID, "Lyrics": lyrics, "Found": lyrics != "", "Searched": true,
}
if err := pages["submission.html"].ExecuteTemplate(w, "lyricsfield", data); err != nil {
slog.Error("render lyrics field", "ctx", "submissions", "error", err)
}
}
// Called from the conversion worker: one automatic attempt, best effort, and only when the
// submitter has not already pasted something.
func (a *app) autoFetchLyrics(ctx context.Context, subID int64, title, artist string, seconds int) {
if title == "" {
return
}
lyrics, err := fetchLyrics(ctx, title, artist, seconds)
if err != nil {
slog.Warn("lyrics lookup", "ctx", "submissions", "error", err, "submission", subID)
return
}
if lyrics == "" {
return
}
tag, err := a.pool.Exec(ctx,
`update submissions set lyrics = $2 where id = $1 and lyrics is null`,
subID, cleanLyrics(lyrics))
if err != nil {
slog.Error("save lyrics", "ctx", "submissions", "error", err, "submission", subID)
return
}
if tag.RowsAffected() > 0 {
slog.Info("lyrics found", "ctx", "submissions", "submission", subID)
}
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Line breaks are the content here — LRC timestamps are per line — so cleanLyrics must not do what
// clean() does to a title.
func TestCleanLyrics(t *testing.T) {
got := cleanLyrics(" [00:11.74] Rivi yksi\r\n[00:13.99] Rivi\x07 kaksi\r\n\n")
want := "[00:11.74] Rivi yksi\n[00:13.99] Rivi kaksi"
if got != want {
t.Fatalf("cleanLyrics gave %q, want %q", got, want)
}
if n := len([]rune(cleanLyrics(strings.Repeat("a", maxLyrics+500)))); n != maxLyrics {
t.Fatalf("truncated to %d runes, want %d", n, maxLyrics)
}
}
// Plain text must parse to nil: that is the signal to scroll continuously rather than highlight
// lines on timings nobody measured.
func TestParseLRC(t *testing.T) {
if got := parseLRC("Ihan tavallista tekstiä\ntoinen rivi"); got != nil {
t.Fatalf("plain text parsed as synced: %v", got)
}
lines := parseLRC("[00:11.74] Ensimmäinen\n[01:02] Toinen\n[00:05.5] Aikaisempi\nrivi ilman aikaa")
if len(lines) != 3 {
t.Fatalf("got %d lines, want 3 — untimed lines are dropped", len(lines))
}
// Sorted by time, whatever order the file had.
if lines[0].At != 5.5 || lines[0].Text != "Aikaisempi" {
t.Fatalf("first line is %+v, want 5.5s Aikaisempi", lines[0])
}
if lines[1].At != 11.74 || lines[2].At != 62 {
t.Fatalf("timestamps parsed as %v and %v, want 11.74 and 62", lines[1].At, lines[2].At)
}
// A refrain can carry several timestamps on one line, and each is its own occurrence.
rep := parseLRC("[00:10.00][01:10.00] Kertosäe")
if len(rep) != 2 || rep[0].At != 10 || rep[1].At != 70 {
t.Fatalf("repeated stamps gave %+v, want two occurrences", rep)
}
}
// The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a
// synced hit always beats a plain one.
func TestFetchLyrics(t *testing.T) {
var lastPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/get" && r.URL.Query().Get("track_name") == "Paranoid":
w.Write([]byte(`{"trackName":"Paranoid","artistName":"Black Sabbath","duration":168,
"plainLyrics":"plain version","syncedLyrics":"[00:11.74] synced version"}`))
case r.URL.Path == "/get":
http.Error(w, `{"code":404}`, http.StatusNotFound)
case r.URL.Path == "/search" && strings.Contains(r.URL.Query().Get("q"), "Soittorasia"):
// An instrumental and a wrong-length take come first: both must be skipped.
w.Write([]byte(`[{"trackName":"Soittorasia","duration":200,"instrumental":true,
"plainLyrics":"","syncedLyrics":"[00:01.00] should be skipped"},
{"trackName":"Soittorasia","duration":600,
"plainLyrics":"wrong length take"},
{"trackName":"Soittorasia","duration":201,
"plainLyrics":"right one"}]`))
default:
w.Write([]byte(`[]`))
}
}))
defer srv.Close()
old := lrclibBase
lrclibBase = srv.URL
defer func() { lrclibBase = old }()
ctx := context.Background()
got, err := fetchLyrics(ctx, "Paranoid", "Black Sabbath", 168)
if err != nil {
t.Fatal(err)
}
if got != "[00:11.74] synced version" {
t.Fatalf("exact match returned %q, want the synced version", got)
}
got, err = fetchLyrics(ctx, "Soittorasia", "Joku", 200)
if err != nil {
t.Fatal(err)
}
if got != "right one" {
t.Fatalf("search fallback returned %q — instrumental and wrong-length takes must be skipped", got)
}
if lastPath != "/search" {
t.Fatalf("last request was %s, want the search fallback", lastPath)
}
// Nothing found is not an error: the submitter simply types their own.
got, err = fetchLyrics(ctx, "Ei olemassa", "Kukaan", 100)
if err != nil || got != "" {
t.Fatalf("miss returned %q, %v — want empty and no error", got, err)
}
// No title means nothing to match on, and no request at all.
if got, err := fetchLyrics(ctx, "", "Artisti", 100); err != nil || got != "" {
t.Fatalf("empty title returned %q, %v", got, err)
}
}
+23 -2
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -13,6 +14,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
var version = "dev"
type config struct { type config struct {
databaseURL string databaseURL string
adminUser string adminUser string
@@ -67,6 +71,7 @@ type app struct {
func main() { func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig() cfg := loadConfig()
ctx := context.Background() ctx := context.Background()
@@ -96,7 +101,7 @@ func main() {
if err := sweep(ctx, pool); err != nil { if err := sweep(ctx, pool); err != nil {
fatal("startup sweep", "error", err) fatal("startup sweep", "error", err)
} }
for _, dir := range []string{"audio", "tmp"} { for _, dir := range []string{"audio", "tmp", "avatars"} {
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
fatal("storage dir", "error", err, "dir", dir) fatal("storage dir", "error", err, "dir", dir)
} }
@@ -126,7 +131,8 @@ func (a *app) memberMux() *http.ServeMux {
http.Error(w, "db down", http.StatusServiceUnavailable) http.Error(w, "db down", http.StatusServiceUnavailable)
return return
} }
w.Write([]byte("ok")) // The version answers "what is actually running out there" without an SSH session.
fmt.Fprintf(w, "ok %s\n", version)
}) })
mux.HandleFunc("GET /login", a.loginPage) mux.HandleFunc("GET /login", a.loginPage)
@@ -140,7 +146,17 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage)) mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong)) mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong)) mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
mux.HandleFunc("POST /songs/{id}/lyrics", a.requireMember(a.editLyrics))
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio)) mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview)) mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview)) mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
@@ -152,6 +168,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus)) mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission)) mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish)) mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/lyrics", a.requireMember(a.suggestLyrics))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry)) mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard)) mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
return mux return mux
@@ -164,6 +181,10 @@ func (a *app) adminMux() *http.ServeMux {
mux.HandleFunc("POST /admin/invites", a.createInvite) mux.HandleFunc("POST /admin/invites", a.createInvite)
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan) mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword) mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
mux.HandleFunc("GET /admin/reports", a.adminReports)
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
}) })
+34
View File
@@ -96,6 +96,29 @@ func clean(s string, max int) string {
return s return s
} }
const maxLyrics = 20000
// Lyrics are the one field where line breaks carry meaning — LRC timestamps are per line — so they
// survive, and only the other control characters go.
func cleanLyrics(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return r
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > maxLyrics {
s = strings.TrimSpace(string(r[:maxLyrics]))
}
return s
}
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess // Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
// argument list — and it never goes through a shell. // argument list — and it never goes through a shell.
var allowedHosts = map[string]bool{ var allowedHosts = map[string]bool{
@@ -176,6 +199,17 @@ func downloadYouTube(ctx context.Context, url, outTemplate string) (string, erro
return "", nil return "", nil
} }
// toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is
// the validation and the size cap in one — webp and avif included, which stdlib image cannot read.
func toAvatarJPEG(ctx context.Context, in, out string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in,
"-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256",
"-frames:v", "1", "-q:v", "3", out).Run()
}
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio. // convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth // No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
// showing — "Invalid data found when processing input" beats "submission failed". // showing — "Invalid data found when processing input" beats "submission failed".
+4
View File
@@ -31,6 +31,9 @@ create table songs (
artist text not null, artist text not null,
genre text not null, genre text not null,
description text, description text,
-- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the
-- lock: nobody reviewed the lyrics.
lyrics text,
audio_file text not null, audio_file text not null,
duration_seconds integer not null, duration_seconds integer not null,
source_url text, source_url text,
@@ -51,6 +54,7 @@ create table submissions (
artist text, artist text,
genre text, genre text,
description text, description text,
lyrics text,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
constraint submissions_status check ( constraint submissions_status check (
status in ('queued', 'downloading', 'converting', 'ready', 'failed') status in ('queued', 'downloading', 'converting', 'ready', 'failed')
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const maxAvatarBytes = 5 << 20
type profileStats struct {
SongsSubmitted int
ReviewsWritten int
AverageGiven *float64
AverageReceived *float64
}
type profileView struct {
ID int64
Name string
Email string // only filled for your own profile
Avatar *string
CreatedAt time.Time
Own bool
Stats profileStats
Songs []*songSummary
Errors map[string]string
}
func (p *profileView) Initials() string { m := member{Name: p.Name}; return m.Initials() }
func (a *app) avatarPath(userID int64) string {
return filepath.Join(a.cfg.storageDir, "avatars", strconv.FormatInt(userID, 10)+".jpg")
}
// Counts and history-wide averages only. A member's per-song opinions stay on the song pages —
// per-song opinion is gated, whole-history aggregate is public.
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
var p profileView
err := a.pool.QueryRow(ctx, `
select u.id, u.name, u.email, u.avatar, u.created_at,
(select count(*) from songs s where s.submitted_by = u.id),
(select count(*) from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r
join songs s on s.id = r.song_id where s.submitted_by = u.id)
from users u where u.id = $1`, userID).
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
&p.Stats.SongsSubmitted, &p.Stats.ReviewsWritten,
&p.Stats.AverageGiven, &p.Stats.AverageReceived)
if err != nil {
return nil, err
}
p.Own = viewerID == userID
if !p.Own {
p.Email = ""
}
// Their songs, with the viewer's own reveal rule applied to each average.
rows, err := a.pool.Query(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by
where s.submitted_by = $2
order by s.created_at desc`, viewerID, userID)
if err != nil {
return nil, err
}
p.Songs, err = scanSongs(rows)
return &p, err
}
func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
me := memberFrom(r.Context())
id := me.ID
if raw := r.PathValue("id"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
id = parsed
}
p, err := a.profile(r.Context(), me.ID, id)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
slog.Error("profile", "ctx", "auth", "error", err, "user", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.render(w, r, http.StatusOK, "profile.html", page{Title: p.Name, Data: p})
}
// Editing your own profile: name, email, password, avatar. Changing the password requires the
// current one and drops your other sessions.
func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
me := memberFrom(r.Context())
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil && !errors.Is(err, http.ErrNotMultipart) {
a.flash(w, "Kuva on liian suuri. Enintään 5 MB.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
name := clean(r.FormValue("name"), 50)
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
if name == "" || !strings.Contains(email, "@") {
a.flash(w, "Tarkista nimi ja sähköpostiosoite.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
if _, err := a.pool.Exec(r.Context(),
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
a.flash(w, "Sähköpostiosoite on jo käytössä.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
} else if err != nil {
slog.Error("edit profile", "ctx", "auth", "error", err, "user", me.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if newPassword := r.FormValue("new_password"); newPassword != "" {
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
return
}
}
if file, _, err := r.FormFile("avatar"); err == nil {
defer file.Close()
if err := a.saveAvatar(r, me.ID, file); err != nil {
slog.Error("avatar", "ctx", "auth", "error", err, "user", me.ID)
a.flash(w, "Kuvaa ei voitu käsitellä. Onko se varmasti kuva?")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
}
a.flash(w, "Tiedot tallennettu.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
var hash string
if err := a.pool.QueryRow(r.Context(),
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(current)) != nil {
a.flash(w, "Nykyinen salasana ei täsmää.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return false
}
newHash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
if _, err := a.pool.Exec(r.Context(),
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
// Every other session dies; this browser keeps its own.
if _, err := a.pool.Exec(r.Context(),
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
}
slog.Info("password changed", "ctx", "auth", "user", userID)
return true
}
// The re-encode through ffmpeg is the validation, the same trick as audio: it handles webp and
// avif (stdlib image does not), and it caps what ends up on disk.
func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
tmp := filepath.Join(a.cfg.storageDir, "tmp", "avatar-"+strconv.FormatInt(userID, 10))
dst, err := os.Create(tmp)
if err != nil {
return err
}
_, err = io.Copy(dst, io.LimitReader(file, maxAvatarBytes))
dst.Close()
if err != nil {
os.Remove(tmp)
return err
}
defer os.Remove(tmp)
out := a.avatarPath(userID)
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
return err
}
_, err = a.pool.Exec(r.Context(),
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
return err
}
// Avatars are public: they are not secret, and gating them buys nothing.
func (a *app) avatar(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(a.avatarPath(id))
if err != nil {
// No upload: the template renders initials instead, and a client sees avatar_url null.
http.NotFound(w, r)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=300")
http.ServeContent(w, r, "avatar.jpg", info.ModTime(), f)
}
+27
View File
@@ -16,7 +16,21 @@ var assetFS embed.FS
var funcs = template.FuncMap{ var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") }, "fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
// Date without the clock: the minute a song was published is noise.
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
// Lyrics as they are meant to be read: LRC timestamps belong to the player, not the reader.
"lyricstext": stripLRC,
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) }, "score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
// Lets one board partial be called with a title and a list, instead of two near-identical
// partials per leaderboard.
"dict": func(pairs ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(pairs); i += 2 {
m[pairs[i].(string)] = pairs[i+1]
}
return m
},
} }
// Each page is parsed with the layout into its own set, so two pages may both define "content". // Each page is parsed with the layout into its own set, so two pages may both define "content".
@@ -46,6 +60,9 @@ type page struct {
Admin bool Admin bool
Flash string Flash string
Path string Path string
Narrow bool // auth pages are a 420px column
Queued int // songs still owed a review, shown in the nav
Version string
Data any Data any
} }
@@ -58,6 +75,16 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st
} }
p.Member = memberFrom(r.Context()) p.Member = memberFrom(r.Context())
p.Path = r.URL.Path p.Path = r.URL.Path
p.Version = version
if p.Member != nil {
// The queue is a worklist, so its size belongs in the nav.
a.pool.QueryRow(r.Context(), `
select count(*)::int from songs s
where s.submitted_by <> $1
and not exists (select 1 from reviews r
where r.song_id = s.id and r.reviewer_id = $1)`,
p.Member.ID).Scan(&p.Queued)
}
p.Flash = a.takeFlash(w, r) p.Flash = a.takeFlash(w, r)
// Render to memory first: a template that fails halfway must not leave a half-written 200. // Render to memory first: a template that fails halfway must not leave a half-written 200.
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
const maxReportBody = 2000
type report struct {
ID int64
Body string
Page string
UserAgent string
Reporter string
ResolvedAt *time.Time
CreatedAt time.Time
}
func (r *report) Open() bool { return r.ResolvedAt == nil }
type reportPage struct {
From string
Mine []*report
}
// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and
// a page URL beat a taxonomy nobody fills in honestly.
func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID)
if err != nil {
slog.Error("list reports", "ctx", "reports", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
from := r.URL.Query().Get("from")
if !strings.HasPrefix(from, "/") {
from = "/" // never redirect off-site on the strength of a query parameter
}
a.render(w, r, http.StatusOK, "report.html",
page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}})
}
// Seeing your own past reports is what stops the same bug arriving four times.
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
rows, err := a.pool.Query(ctx, `
select id, body, coalesce(page, ''), resolved_at, created_at
from reports where user_id = $1 order by created_at desc`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*report
for rows.Next() {
var rep report
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil {
return nil, err
}
out = append(out, &rep)
}
return out, rows.Err()
}
func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
me := memberFrom(r.Context())
body := clean(r.FormValue("body"), maxReportBody)
from := r.FormValue("from")
if !strings.HasPrefix(from, "/") {
from = "/"
}
if body == "" {
a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.")
http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther)
return
}
// "Only on my phone" is the most common bug report and this answers it without asking.
_, err := a.pool.Exec(r.Context(), `
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
if err != nil {
slog.Error("create report", "ctx", "reports", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("report filed", "ctx", "reports", "user", me.ID)
a.flash(w, "Kiitos! Palaute on perillä.")
http.Redirect(w, r, from, http.StatusSeeOther)
}
// --- admin ---
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
rows, err := a.pool.Query(r.Context(), `
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
u.name, rep.resolved_at, rep.created_at
from reports rep join users u on u.id = rep.user_id
order by rep.resolved_at nulls first, rep.created_at desc`)
if err != nil {
adminError(w, "reports", err)
return
}
defer rows.Close()
var out []*report
for rows.Next() {
var rep report
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter,
&rep.ResolvedAt, &rep.CreatedAt); err != nil {
adminError(w, "reports", err)
return
}
out = append(out, &rep)
}
if err := rows.Err(); err != nil {
adminError(w, "reports", err)
return
}
a.render(w, r, http.StatusOK, "admin_reports.html",
page{Title: "Palautteet", Admin: true, Data: out})
}
// A nullable timestamp rather than a status enum: smaller, and it tells you *when*.
func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if _, err := a.pool.Exec(r.Context(),
`update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
id); err != nil {
adminError(w, "reports", err)
return
}
http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
}
// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one
// route with a branch. The row and the file go together here too.
func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id)
if err != nil {
adminError(w, "songs", err)
return
}
if tag.RowsAffected() > 0 {
removeFile(a.audioPath(id))
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
a.flash(w, "Kappale poistettu.")
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
type adminSong struct {
ID int64
Title string
Artist string
Submitter string
Reviews int
CreatedAt time.Time
}
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, u.name,
(select count(*) from reviews r where r.song_id = s.id)::int, s.created_at
from songs s join users u on u.id = s.submitted_by
order by s.created_at desc`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []adminSong
for rows.Next() {
var s adminSong
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
// rather than branching auth inside the member handler.
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
a.audio(w, r)
}
+70 -8
View File
@@ -36,11 +36,12 @@ func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) }
func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed } func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed }
func (s *songSummary) Length() string { func (s *songSummary) Length() string {
return fmt.Sprintf("%d.%02d", s.Duration/60, s.Duration%60) return fmt.Sprintf("%d:%02d", s.Duration/60, s.Duration%60)
} }
type songList struct { type songList struct {
Items []*songSummary Items []*songSummary
Cursor int64 // the cursor this page was fetched with; 0 means the first page
NextCursor int64 // 0 when there is no next page NextCursor int64 // 0 when there is no next page
Queue bool Queue bool
} }
@@ -87,7 +88,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err
if err != nil { if err != nil {
return nil, err return nil, err
} }
return paginate(items, true), nil return paginate(items, cursor, true), nil
} }
// Everything, newest first. This is where a song lives once it has left the queue. // Everything, newest first. This is where a song lives once it has left the queue.
@@ -104,12 +105,12 @@ func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
return paginate(items, false), nil return paginate(items, cursor, false), nil
} }
// One row over the page size is fetched so "is there more" needs no second count query. // One row over the page size is fetched so "is there more" needs no second count query.
func paginate(items []*songSummary, isQueue bool) *songList { func paginate(items []*songSummary, cursor int64, isQueue bool) *songList {
l := &songList{Items: items, Queue: isQueue} l := &songList{Items: items, Cursor: cursor, Queue: isQueue}
if len(items) > pageSize { if len(items) > pageSize {
l.Items = items[:pageSize] l.Items = items[:pageSize]
l.NextCursor = l.Items[pageSize-1].ID l.NextCursor = l.Items[pageSize-1].ID
@@ -147,24 +148,31 @@ func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
type songDetail struct { type songDetail struct {
songSummary songSummary
Description string Description string
Lyrics string
SourceURL *string SourceURL *string
Reviews []*review // nil when the reveal rule is withholding them Reviews []*review // nil when the reveal rule is withholding them
ViewerReview *review ViewerReview *review
CanReview bool CanReview bool
CanEdit bool // submitter, and the song is unlocked CanEdit bool // submitter, and the song is unlocked
NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review
Genres []genre Genres []genre
} }
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 } func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
// Synced lyrics get a line-by-line highlight; plain text scrolls continuously instead, because a
// highlight on guessed timings makes every second of drift look like a bug.
func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) { func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
var d songDetail var d songDetail
err := a.pool.QueryRow(ctx, `select`+songColumns+`, coalesce(s.description, ''), s.source_url err := a.pool.QueryRow(ctx, `select`+songColumns+`,
coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url
from songs s join users u on u.id = s.submitted_by from songs s join users u on u.id = s.submitted_by
where s.id = $2`, viewerID, songID). where s.id = $2`, viewerID, songID).
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt, Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
&d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed, &d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
&d.Description, &d.SourceURL) &d.Description, &d.Lyrics, &d.SourceURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -186,9 +194,31 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er
return nil, err return nil, err
} }
} }
if !d.CanReview {
d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID)
if err != nil {
return nil, err
}
}
return &d, nil return &d, nil
} }
// The oldest song the viewer still owes a review on. Offered right after they finish one, so
// draining the queue never means navigating back to it.
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
var id int64
err := a.pool.QueryRow(ctx, `
select s.id from songs s
where s.submitted_by <> $1 and s.id <> $2
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
order by s.created_at, s.id
limit 1`, viewerID, exceptID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return id, err
}
func (a *app) songPage(w http.ResponseWriter, r *http.Request) { func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil { if err != nil {
@@ -207,6 +237,31 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d}) a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
} }
// Lyrics are not covered by the lock: it freezes what the song claims to be, and nobody reviewed
// the lyrics. So this checks the submitter and nothing else, which also lets someone paste them for
// an old song a year later.
func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
tag, err := a.pool.Exec(r.Context(),
`update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`,
id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics")))
if err != nil {
slog.Error("edit lyrics", "ctx", "songs", "error", err, "song", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if tag.RowsAffected() == 0 {
http.NotFound(w, r)
return
}
a.flash(w, "Sanoitukset tallennettu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
}
// --- edit and delete --- // --- edit and delete ---
// The submitter may change the four text fields while the song is unlocked. Once people have // The submitter may change the four text fields while the song is unlocked. Once people have
@@ -269,12 +324,19 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
return return
} }
os.Remove(a.audioPath(id)) removeFile(a.audioPath(id))
slog.Info("song deleted", "ctx", "songs", "song", id) slog.Info("song deleted", "ctx", "songs", "song", id)
a.flash(w, "Kappale poistettu.") a.flash(w, "Kappale poistettu.")
http.Redirect(w, r, "/songs", http.StatusSeeOther) http.Redirect(w, r, "/songs", http.StatusSeeOther)
} }
// A missing file is fine — the row is gone either way — but anything else is worth knowing about.
func removeFile(path string) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
slog.Error("remove file", "ctx", "songs", "error", err, "path", path)
}
}
// --- audio --- // --- audio ---
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON. // Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<linearGradient
id="linearGradient1">
<stop
style="stop-color:#131313;stop-opacity:1;"
offset="0"
id="stop1" />
<stop
style="stop-color:#1e1e1e;stop-opacity:1;"
offset="1"
id="stop2" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient1"
id="linearGradient2"
x1="-126.14488"
y1="1023.127"
x2="-1038.4041"
y2="94.281311"
gradientUnits="userSpaceOnUse" />
</defs>
<g
id="g1"
transform="translate(1112.6069,-41.466324)">
<rect
style="font-variation-settings:'wght' 400;opacity:1;fill:url(#linearGradient2);stroke:none;stroke-width:1.50385"
id="rect1"
width="1024"
height="1024"
x="-1112.6069"
y="41.466324" />
<path
style="fill:#a96832;fill-opacity:1"
d="m -256.32286,859.80613 c -0.5587,-1.0439 -5.998,-14.34542 -12.0874,-29.55893 -6.0894,-15.21351 -12.6981,-31.36028 -14.6861,-35.88171 -1.9879,-4.52143 -6.2446,-15.32745 -9.4591,-24.01338 -9.8775,-26.68952 -17.0559,-44.11196 -19.469,-47.25279 -1.2676,-1.65 -6.3824,-7.275 -11.3661,-12.5 -4.9837,-5.225 -9.2794,-10.4 -9.546,-11.5 -0.2667,-1.1 -1.0926,-9.875 -1.8354,-19.5 l -1.3507,-17.5 -8.7081,-21 c -19.3876,-46.75383 -39.6764,-97.57184 -39.2382,-98.28098 0.2562,-0.41453 9.0751,-6.5959 19.5975,-13.73636 10.5224,-7.14046 25.298,-17.36069 32.8348,-22.71161 7.5367,-5.35093 16.5609,-11.49239 20.0538,-13.64769 l 6.3507,-3.91873 -0.138,-14.35231 c -0.088,-9.11047 0.4034,-17.17033 1.3445,-22.06808 1.2363,-6.43377 1.4019,-16.5691 0.9969,-61 -0.2672,-29.30633 -0.7305,-55.53424 -1.0295,-58.28424 -0.6352,-5.84093 9.315,3.05716 -80.2267,-71.74374 l -20.3514,-17.00104 -159.3986,-0.43563 c -93.9669,-0.2568 -159.3986,-0.0666 -159.3986,0.46325 0,0.79387 14.1728,39.5009 32.3012,88.21716 l 6.14,16.5 -0.4706,20 c -0.5906,25.10141 -0.61,106.08069 -0.035,147 0.2396,17.05 -0.058,42.25 -0.6606,56 -0.6029,13.75 -1.4093,49.4125 -1.7922,79.25 l -0.696,54.25 h 34.4369 34.4369 l 17.6574,-12.25 c 9.7116,-6.7375 23.2724,-16.075 30.1351,-20.75 l 12.4776,-8.5 0.045,-28 0.045,-28 23.7399,-10.46084 c 13.0569,-5.75347 28.8714,-12.84696 35.1432,-15.76332 6.2719,-2.91637 11.4981,-5.18399 11.6139,-5.03916 0.1159,0.14483 1.3683,3.86332 2.7832,8.26332 1.415,4.4 8.1816,25.1 15.0368,46 11.4894,35.02826 12.867,38.58653 17.6148,45.5 2.8328,4.125 7.6915,11.24468 10.797,15.8215 l 5.6464,8.3215 0.8322,11.6785 c 2.1376,29.99811 3.0902,34.82716 12.5368,63.55422 l 8.8379,26.87572 5.9488,2.32255 c 3.2719,1.27739 12.0239,4.62345 19.4489,7.43567 7.425,2.81223 18.4886,7.11051 24.5858,9.55174 6.0972,2.44123 11.7197,4.4386 12.4945,4.4386 2.6086,0 23.1976,8.18948 27.9673,11.12431 2.5995,1.59952 12.5398,5.78582 22.0894,9.30289 9.5496,3.51707 23.888,8.84253 31.863,11.83435 14.6625,5.50065 16.6188,5.6317 14.1107,0.94526 z m -303.1107,-418.51779 v -57.34908 l -23.4418,-17.91997 c -12.893,-9.85598 -23.468,-18.20969 -23.5,-18.56379 -0.032,-0.3541 34.315,-0.53781 76.3268,-0.40824 l 76.3849,0.23557 7.3651,5.99943 c 4.0507,3.29968 10.74,8.39168 14.865,11.31555 4.125,2.92387 9.2498,6.8043 11.3884,8.62319 l 3.8884,3.30707 -0.1932,29.78562 c -0.1063,16.3821 -0.4246,30.68563 -0.7073,31.78563 -0.2828,1.1 -4.6655,5.22906 -9.7393,9.17568 -5.0739,3.94663 -12.9359,10.38223 -17.4712,14.30134 l -8.246,7.12566 -37.2099,10.49833 c -20.4655,5.77409 -43.2849,12.21526 -50.7099,14.31371 -7.425,2.09845 -14.7375,4.10967 -16.25,4.46938 l -2.75,0.654 z m 45.446,417.1983 c 0.2839,-0.28388 1.4247,-7.59187 2.5351,-16.23998 1.8573,-14.46552 2.0189,-21.36257 2.0189,-86.18559 0,-40.49991 -0.3784,-70.46175 -0.89,-70.46175 -0.4894,0 -6.9019,4.40065 -14.25,9.77923 -11.1102,8.13247 -58.8721,42.62513 -78.1649,56.44912 l -5.2033,3.72836 -111.246,0.27164 c -61.1854,0.14941 -111.246,0.21437 -111.246,0.14437 10e-5,-0.07 4.1627,-3.4641 9.2502,-7.54242 11.3961,-9.13555 33.5431,-26.93368 35.3917,-28.44206 1.1443,-0.93375 1.3221,-39.12151 1.1104,-238.5 l -0.2521,-237.38824 -79.75,-0.25599 c -65.2629,-0.20948 -79.75,-0.0177 -79.75,1.05568 0,0.72141 1.6361,5.33661 3.6358,10.25598 1.9997,4.91938 6.4668,16.14433 9.9269,24.94433 3.4601,8.8 10.0502,25.225 14.6447,36.5 l 8.3537,20.5 -0.1377,207.42507 -0.1377,207.42508 -18.8328,19.343 -18.8327,19.34299 0.206,33.73193 c 0.1133,18.55256 0.1987,34.00382 0.1899,34.33613 -0.02,0.73234 430.6972,0.51582 431.4299,-0.21688 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.
+135
View File
@@ -0,0 +1,135 @@
// Lyrics that follow the audio. Two behaviours, because the two kinds of lyrics deserve different
// treatment: real LRC timestamps get a line highlight, guessed timings get a continuous scroll and
// a nudge knob. Progressive enhancement — without this file the lyrics are still readable text.
(function () {
'use strict'
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// The audio element belonging to the same strip, falling back to the only one on the page.
const audioFor = (box) => {
const scope = box.closest('.strip') || box.closest('section') || document
return scope.querySelector('audio') || document.querySelector('audio')
}
// --- synced: highlight the line that is playing ---
function enhanceSynced(box) {
const audio = audioFor(box)
if (!audio) return
const lines = [...box.querySelectorAll('.lline')]
if (!lines.length) return
const times = lines.map((l) => Number(l.dataset.t))
let current = -1
// Following is what moves the box. Timestamps are somebody else's guess at where a line
// starts, so when they are off, the scrolling is the part that fights you — the highlight can
// stay. Remembered per song.
const follow = box.parentElement.querySelector('.follow input')
const key = 'lyricsfollow:' + box.dataset.song
if (follow && localStorage.getItem(key) === 'off') follow.checked = false
if (follow) {
follow.addEventListener('change', () => {
localStorage.setItem(key, follow.checked ? 'on' : 'off')
})
}
// Scrolling the box by hand turns following off: reading somewhere else is a clear statement
// that you do not want to be dragged back.
let selfScroll = false
box.addEventListener('scroll', () => {
if (selfScroll || !follow || !follow.checked) return
follow.checked = false
localStorage.setItem(key, 'off')
})
const show = (i) => {
if (i === current) return
if (lines[current]) lines[current].classList.remove('on')
current = i
const line = lines[i]
if (!line) return
line.classList.add('on')
if (follow && !follow.checked) return
// Measured against the box itself. offsetTop is relative to the nearest positioned ancestor,
// which is not this box, so using it scrolls to a position from a different coordinate space.
const boxRect = box.getBoundingClientRect()
const lineRect = line.getBoundingClientRect()
const target = box.scrollTop + (lineRect.top - boxRect.top)
- box.clientHeight / 2 + lineRect.height / 2
selfScroll = true
box.scrollTo({ top: target, behavior: quiet ? 'auto' : 'smooth' })
// Long enough for the smooth scroll to finish, so our own movement is not mistaken for the
// reader's.
setTimeout(() => { selfScroll = false }, 700)
}
audio.addEventListener('timeupdate', () => {
const t = audio.currentTime
let i = current
// Usually one step forward; a seek walks from wherever it lands.
if (i < 0 || times[i] > t) i = 0
while (i + 1 < times.length && times[i + 1] <= t) i++
if (times[i] <= t) show(i)
})
audio.addEventListener('seeked', () => {
current = -1
})
// Clicking a line seeks to it: the lyrics become a way to navigate the song.
lines.forEach((line, i) => {
line.addEventListener('click', () => {
audio.currentTime = times[i]
if (audio.paused) audio.play()
})
})
}
// --- plain: scroll the block in step with the audio ---
function enhancePlain(box) {
const audio = audioFor(box)
const inner = box.querySelector('.lscroll')
if (!audio || !inner) return
const nudge = box.parentElement.querySelector('.nudge input')
const readout = box.parentElement.querySelector('.nudge output')
const key = 'lyricsoffset:' + box.dataset.song
let offset = Number(localStorage.getItem(key) || 0)
if (nudge) {
nudge.value = offset
readout.value = offset + ' s'
}
const duration = () => Number(audio.duration) || Number(box.dataset.duration) || 0
// Position is a pure function of time, so a seek needs no bookkeeping and drift cannot
// accumulate the way it would with a timer.
const place = () => {
const total = duration()
const travel = inner.scrollHeight - box.clientHeight
if (total <= 0 || travel <= 0) return
const at = (audio.currentTime + offset) / total
box.scrollTop = Math.max(0, Math.min(travel, at * travel))
}
audio.addEventListener('timeupdate', place)
audio.addEventListener('seeked', place)
audio.addEventListener('loadedmetadata', place)
if (nudge) {
nudge.addEventListener('input', () => {
offset = Number(nudge.value)
readout.value = (offset > 0 ? '+' : '') + offset + ' s'
localStorage.setItem(key, offset)
place()
})
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.lyricsbox.synced').forEach(enhanceSynced)
document.querySelectorAll('.lyricsbox.plain').forEach(enhancePlain)
})
})()
+143
View File
@@ -0,0 +1,143 @@
// Progressive enhancement: the page ships <audio controls>. If this script runs, it takes the
// controls off and drives the same element itself — playback, buffering, seeking and Range
// requests are untouched, because the element never changes.
(function () {
'use strict'
const fmt = (s) => {
if (!isFinite(s)) return ':––'
const m = Math.floor(s / 60)
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0')
}
const SEGMENTS = 18
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function enhance(wrap) {
const audio = wrap.querySelector('audio')
if (!audio) return
audio.removeAttribute('controls')
const total = Number(wrap.dataset.duration) || 0
wrap.insertAdjacentHTML('beforeend', `
<div class="transport">
<button type="button" class="tp-play" aria-label="Toista">
<span class="tp-icon" aria-hidden="true"></span>
</button>
<div class="tp-mid">
<input type="range" class="tp-seek" min="0" max="${total || 100}" step="0.1" value="0"
aria-label="Kelaus">
<div class="meter" aria-hidden="true">
<div class="meter-row"><span class="lbl">L</span><span class="segs"></span></div>
<div class="meter-row"><span class="lbl">R</span><span class="segs"></span></div>
</div>
</div>
<span class="tp-time"><b>0:00</b> / ${fmt(total)}</span>
</div>`)
const play = wrap.querySelector('.tp-play')
const seek = wrap.querySelector('.tp-seek')
const time = wrap.querySelector('.tp-time b')
const rows = wrap.querySelectorAll('.meter .segs')
for (const row of rows) {
row.innerHTML = '<span class="seg"></span>'.repeat(SEGMENTS)
}
const segs = [...rows].map((r) => [...r.children])
// --- transport ---
play.addEventListener('click', () => (audio.paused ? audio.play() : audio.pause()))
const setPlaying = (playing) => {
wrap.classList.toggle('playing', playing)
play.setAttribute('aria-label', playing ? 'Tauko' : 'Toista')
}
audio.addEventListener('play', () => { setPlaying(true); startMeter() })
audio.addEventListener('pause', () => setPlaying(false))
audio.addEventListener('ended', () => setPlaying(false))
audio.addEventListener('loadedmetadata', () => {
if (isFinite(audio.duration)) {
seek.max = audio.duration
wrap.querySelector('.tp-time').lastChild.textContent = ' / ' + fmt(audio.duration)
}
})
let scrubbing = false
seek.addEventListener('input', () => {
scrubbing = true
time.textContent = fmt(Number(seek.value))
})
seek.addEventListener('change', () => {
audio.currentTime = Number(seek.value)
scrubbing = false
})
audio.addEventListener('timeupdate', () => {
if (scrubbing) return
seek.value = audio.currentTime
time.textContent = fmt(audio.currentTime)
seek.style.setProperty('--pct', (audio.currentTime / (Number(seek.max) || 1)) * 100 + '%')
})
// --- meter ---
//
// A real analyser, not a decorative loop: it is dark until the audio actually plays, and it
// stops the moment playback does. The AudioContext can only start from a gesture, so it is
// created on first play. MediaElementSource reroutes the audio, so the graph must reach the
// destination or the sound stops.
let ctx, analysers, raf
function startMeter() {
if (quiet || raf) return
if (!ctx) {
try {
ctx = new (window.AudioContext || window.webkitAudioContext)()
const src = ctx.createMediaElementSource(audio)
const split = ctx.createChannelSplitter(2)
analysers = [ctx.createAnalyser(), ctx.createAnalyser()]
analysers.forEach((a, i) => {
a.fftSize = 256
split.connect(a, i)
})
src.connect(split)
src.connect(ctx.destination)
} catch (e) {
return // no Web Audio: the transport still works, the meter simply never lights
}
}
ctx.resume()
const buf = new Uint8Array(analysers[0].fftSize)
const held = [0, 0]
const draw = () => {
if (audio.paused) {
segs.forEach((row) => row.forEach((s) => (s.className = 'seg')))
raf = null
return
}
analysers.forEach((a, ch) => {
a.getByteTimeDomainData(buf)
let peak = 0
for (let i = 0; i < buf.length; i++) {
const v = Math.abs(buf[i] - 128) / 128
if (v > peak) peak = v
}
// Fall slower than it rises, the way a real meter behaves.
held[ch] = peak > held[ch] ? peak : held[ch] * 0.88
const lit = Math.round(held[ch] * SEGMENTS)
segs[ch].forEach((s, i) => {
s.className = 'seg' +
(i < lit ? ' on' + (i >= SEGMENTS - 3 ? ' peak' : i >= SEGMENTS - 7 ? ' hot' : '') : '')
})
})
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.playerwrap').forEach(enhance)
})
})()
+1015 -162
View File
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"context"
"log/slog"
"net/http"
)
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
// the club has weighed in, which is a real threshold rather than a formality.
const minReviews = 3
type songStat struct {
ID int64
Title string
Artist string
Value float64
ReviewCount int
Min int // the spread, which is what "divisive" actually means
Max int
}
// Percentages for the bars, so the templates hold no arithmetic.
func (s songStat) Pct() float64 { return s.Value }
func (s songStat) MinPct() float64 { return float64(s.Min) }
func (s songStat) SpanPct() float64 {
if s.Max <= s.Min {
return 1
}
return float64(s.Max - s.Min)
}
type userStat struct {
ID int64
Name string
Value float64
Count int
Avatar *string
}
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
type stats struct {
MinReviews int
TopSongs []songStat
BottomSongs []songStat
MostDivisive []songStat
MostUnified []songStat
MostReviewed []songStat
Harshest []userStat
MostGenerous []userStat
MostActive []userStat
MostProlific []userStat
}
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
// ties are common in a ten-person club, and without one Postgres may return a different ten each
// time, so the page visibly reshuffles between reloads for no reason.
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
min(r.score)::int, max(r.score)::int
from songs s join reviews r on r.song_id = s.id
group by s.id
having count(r.id) >= $1
order by value `+direction+`, reviews desc, s.id asc
limit 10`, minReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var out []songStat
for rows.Next() {
var s songStat
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount,
&s.Min, &s.Max); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
// member in the club forever.
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
from users u join reviews r on r.reviewer_id = u.id
group by u.id
having count(r.id) >= $1
order by value `+direction+`, n desc, u.id asc
limit 10`, minReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var out []userStat
for rows.Next() {
var u userStat
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
from users u join songs s on s.submitted_by = u.id
group by u.id
order by count(s.id) desc, u.id asc
limit 10`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []userStat
for rows.Next() {
var u userStat
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
// /stats being a page you walk to deliberately.
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s := stats{MinReviews: minReviews}
var err error
for _, load := range []func() error{
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) {
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
return
},
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
} {
if err = load(); err != nil {
slog.Error("stats", "ctx", "songs", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
}
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"context"
"testing"
)
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
// same ten come back in the same order every time.
func TestLeaderboardThresholdAndOrder(t *testing.T) {
a := testApp(t)
ctx := context.Background()
submitter := a.seedMember(t, "[email protected]")
var reviewers []int64
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
reviewers = append(reviewers, a.seedMember(t, e))
}
loved := a.seedSong(t, submitter, "Rakastettu")
hated := a.seedSong(t, submitter, "Vihattu")
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
for _, r := range reviewers {
a.seedReview(t, loved, r, 90)
a.seedReview(t, hated, r, 20)
}
// One short of the threshold.
a.seedReview(t, ignored, reviewers[0], 100)
a.seedReview(t, ignored, reviewers[1], 100)
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
if err != nil {
t.Fatal(err)
}
if len(top) != 2 {
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
}
if top[0].ID != loved || top[1].ID != hated {
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
}
if top[0].Value != 90 || top[0].ReviewCount != 3 {
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
}
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if bottom[0].ID != hated {
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
}
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if unified[0].Value != 0 {
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
}
// Ties are the common case in a ten-person club: the same query must return the same order.
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
for i := range first {
if first[i].ID != second[i].ID {
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
}
}
}
// Profiles are counts and history-wide averages. They never carry per-song opinions.
func TestProfileStats(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
song := a.seedSong(t, aino, "Testikappale")
a.seedReview(t, song, bertta, 80)
other := a.seedSong(t, bertta, "Berttan kappale")
a.seedReview(t, other, aino, 40)
p, err := a.profile(ctx, bertta, aino)
if err != nil {
t.Fatal(err)
}
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
}
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
}
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
}
// Viewing someone else's profile does not expose their email.
if p.Email != "" {
t.Fatalf("another member's email leaked: %q", p.Email)
}
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
// its average here.
if len(p.Songs) != 1 {
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
}
if p.Songs[0].Average == nil {
t.Fatal("a reviewer cannot see the average of a song they reviewed")
}
// A third member who has reviewed nothing must not learn it from the profile page.
cecilia := a.seedMember(t, "[email protected]")
p, err = a.profile(ctx, cecilia, aino)
if err != nil {
t.Fatal(err)
}
if p.Songs[0].Average != nil {
t.Fatal("profile leaked a song average to someone who has not reviewed it")
}
}
+42 -10
View File
@@ -84,6 +84,7 @@ type submission struct {
Artist string Artist string
Genre string Genre string
Description string Description string
Lyrics string
CreatedAt time.Time CreatedAt time.Time
} }
@@ -376,6 +377,21 @@ func (a *app) process(subID int64, sourceURL, src string) {
return return
} }
slog.Info("conversion ready", "ctx", "submissions", "submission", subID) slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
// One automatic lyrics attempt, after the audio is safe. It runs on whatever metadata exists,
// so it covers well-tagged music; the Hae sanoitukset button on the waiting page is what
// covers everything else, once the submitter has fixed the title and artist.
var title, artist string
if err := a.pool.QueryRow(ctx,
`select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`,
subID).Scan(&title, &artist); err != nil {
return
}
seconds := 0
if meta, err := probe(ctx, out); err == nil {
seconds = int(meta.Duration.Seconds())
}
a.autoFetchLyrics(ctx, subID, title, artist, seconds)
} }
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) { func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
@@ -399,10 +415,10 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission
err = a.pool.QueryRow(r.Context(), ` err = a.pool.QueryRow(r.Context(), `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''), select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''), coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), created_at coalesce(description, ''), coalesce(lyrics, ''), created_at
from submissions where id = $1`, id). from submissions where id = $1`, id).
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath, Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt) &s.Title, &s.Artist, &s.Genre, &s.Description, &s.Lyrics, &s.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
return nil return nil
@@ -444,19 +460,34 @@ func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) {
// no save button: HTMX posts here after a pause in typing, and pressing Julkaise posts the same // no save button: HTMX posts here after a pause in typing, and pressing Julkaise posts the same
// fields to publish, so a browser without JS loses nothing. // fields to publish, so a browser without JS loses nothing.
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error { func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
// r.Form is only populated once the body has been parsed, and the check below reads it.
if err := r.ParseForm(); err != nil {
return err
}
genre := r.FormValue("genre") genre := r.FormValue("genre")
if genre != "" && !validGenre(genre) { if genre != "" && !validGenre(genre) {
return fmt.Errorf("unknown genre %q", genre) return fmt.Errorf("unknown genre %q", genre)
} }
// A field the request does not carry keeps its stored value. Without this, any post that omits
// a field silently clears it — which is exactly how a publish request wiped lyrics that the
// worker had just fetched.
has := func(field string) bool { _, ok := r.Form[field]; return ok }
_, err := a.pool.Exec(ctx, ` _, err := a.pool.Exec(ctx, `
update submissions set title = nullif($2, ''), artist = nullif($3, ''), update submissions set
genre = nullif($4, ''), description = nullif($5, '') title = case when $2 then nullif($3, '') else title end,
artist = case when $4 then nullif($5, '') else artist end,
genre = case when $6 then nullif($7, '') else genre end,
description = case when $8 then nullif($9, '') else description end,
lyrics = case when $10 then nullif($11, '') else lyrics end
where id = $1`, where id = $1`,
subID, subID,
clean(r.FormValue("title"), maxTitle), has("title"), clean(r.FormValue("title"), maxTitle),
clean(r.FormValue("artist"), maxArtist), has("artist"), clean(r.FormValue("artist"), maxArtist),
genre, has("genre"), genre,
clean(r.FormValue("description"), maxDescription)) has("description"), clean(r.FormValue("description"), maxDescription),
// Line breaks are the whole point of lyrics, so they survive rather than being cleaned away.
has("lyrics"), cleanLyrics(r.FormValue("lyrics")))
return err return err
} }
@@ -536,10 +567,11 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
var songID int64 var songID int64
err = tx.QueryRow(r.Context(), ` err = tx.QueryRow(r.Context(), `
insert into songs (title, artist, genre, description, audio_file, duration_seconds, insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds,
source_url, submitted_by) source_url, submitted_by)
values ($1, $2, $3, $4, '', $5, $6, $7) returning id`, values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`,
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)), title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
nilIfEmpty(cleanLyrics(s.Lyrics)),
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID) int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
if err != nil { if err != nil {
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID) slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
+48 -14
View File
@@ -1,49 +1,53 @@
{{define "content"}} {{define "content"}}
<h1>Ylläpito</h1> <h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section> <section class="adminsection">
<header>
<h2>Kutsut</h2> <h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form> <form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
</header>
<div class="body">
<table> <table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead> <thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody> <tbody>
{{range .Data.Invites}} {{range .Data.Invites}}
<tr> <tr>
<td> <td>
{{if .IsValid}} <a href="{{.Link}}">{{.Link}}</a>
<a href="{{.Link}}" class="invite">{{.Link}}</a>
{{else}}
<code class="muted">{{.Code}}</code>
{{end}}
</td> </td>
<td>{{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td> <td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td> <td>{{fidate .CreatedAt}}</td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="3" class="muted">Ei kutsuja.</td></tr> <tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
<p class="muted">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.</p> <p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi
täytettynä. Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p>
</div>
</section> </section>
<section> <section class="adminsection">
<h2>Jäsenet</h2> <header><h2>Jäsenet</h2></header>
<div class="body">
<table> <table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead> <thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<tbody> <tbody>
{{range .Data.Members}} {{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}> <tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="tag">estetty</span>{{end}}</td> <td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td> <td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td> <td>{{fidate .CreatedAt}}</td>
<td class="actions"> <td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban"> <form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button> <button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
</form> </form>
<form method="post" action="/admin/users/{{.ID}}/password"> <form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required> <input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit">Vaihda salasana</button> <button type="submit" class="ghost">Vaihda salasana</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -52,5 +56,35 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}} {{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<h1>Palautteet</h1>
<p><a href="/admin">← Ylläpito</a></p>
{{range .Data}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="who">{{.Reporter}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
<p class="meta break">{{.UserAgent}}</p>
{{if .Open}}
<form method="post" action="/admin/reports/{{.ID}}/resolve">
<button type="submit" class="ghost">Merkitse käsitellyksi</button>
</form>
{{end}}
</article>
{{else}}
<p class="empty">Ei palautteita.</p>
{{end}}
{{end}}
+61 -11
View File
@@ -4,29 +4,79 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Levyraati</title> <title>{{.Title}} — Levyraati</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/static/fonts/oswald.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/player.js" defer></script>
<script src="/static/lyrics.js" defer></script>
</head> </head>
<body> <body>
<header> <header class="topbar">
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a> <div class="topbar-inner">
<nav> <a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
{{if .Admin}} {{if .Admin}}
<a href="/admin">Ylläpito</a> <nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
<span></span>
{{else if .Member}} {{else if .Member}}
<a href="/">Jono</a> <nav class="navlinks">
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
</nav>
<div class="userblock">
<span class="lines">
<span class="name">{{.Member.Name}}</span>
<span class="email">{{.Member.Email}}</span>
</span>
<a href="/profile" title="{{.Member.Name}}">
{{if .Member.Avatar}}<img class="avatar" src="/avatars/{{.Member.ID}}" alt="Oma profiili">
{{else}}<span class="avatar">{{.Member.Initials}}</span>{{end}}
</a>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
<!-- <details> is the mobile panel: no JS, and Esc/click-away come free. -->
<details class="mobilenav">
<summary aria-label="Valikko"></summary>
<div class="panel">
<a href="/">Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
<a href="/songs">Kappaleet</a> <a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a> <a href="/submit">Lähetä</a>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span> <a href="/stats">Tilastot</a>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form> <a href="/profile">Oma profiili</a>
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
</div>
</details>
</div>
{{else}} {{else}}
<a href="/login">Kirjaudu</a> <span></span>
<nav class="navlinks"><a href="/login">Kirjaudu</a></nav>
{{end}} {{end}}
</nav> </div>
</header> </header>
{{with .Flash}}<p class="flash">{{.}}</p>{{end}} <main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
<main>{{template "content" .}}</main> <footer class="sitefooter">
{{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}}
<span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span>
<span class="version" title="Käytössä oleva versio">v{{.Version}}</span>
</footer>
{{with .Flash}}
<div class="toasts">
<div class="toast" role="status">
<p>{{.}}</p>
<button class="dismiss" aria-label="Sulje"
onclick="this.closest('.toast').remove()">&times;</button>
</div>
</div>
{{end}}
</body> </body>
</html> </html>
+2
View File
@@ -1,5 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>Kirjaudu</h1> <h1>Kirjaudu</h1>
<!-- The app's own slogan, three decades old. A mark, not interface copy, so it stays English. -->
<p class="slogan hero">We know good music, baby!</p>
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}} {{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
+63 -28
View File
@@ -1,30 +1,65 @@
{{define "player"}} {{/* Two shapes, because timed lyrics and guessed lyrics deserve different treatment.
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio> Synced: one element per line with its own timestamp, highlighted as it comes.
{{end}} Plain: one block that scrolls continuously, with a nudge knob, because a highlight on evenly
guessed timings turns guaranteed drift into what looks like a bug. */}}
{{define "songrow"}} {{define "lyricsview"}}
<tr> <span class="cap">Sanoitukset</span>
<td> {{$lines := .LyricLines}}
<a href="/songs/{{.ID}}">{{.Title}}</a> {{if $lines}}
<span class="muted"> — {{.Artist}}</span> <div class="lyricsbox synced" data-song="{{.ID}}">
{{if .Own}}<span class="tag">oma</span>{{else if .Reviewed}}<span class="tag done">arvosteltu</span>{{end}} {{range $lines}}<p class="lline" data-t="{{.At}}">{{if .Text}}{{.Text}}{{else}}&nbsp;{{end}}</p>{{end}}
</td> </div>
<td class="nowrap">{{.GenreLabel}}</td> <label class="follow">
<td class="nowrap">{{.Length}}</td> <input type="checkbox" checked> Seuraa kappaletta
<td class="nowrap"> <span class="muted small">— korostus jatkuu, sivu ei vieri</span>
{{if .Average}}<strong>{{score .Average}}</strong> <span class="muted">({{.ReviewCount}})</span> </label>
{{else if .ReviewCount}}<span class="muted">{{.ReviewCount}} arvostelua</span> {{else}}
{{else}}<span class="muted"></span>{{end}} <div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
</td> <div class="lscroll">{{lyricstext .Lyrics}}</div>
</tr> </div>
{{end}} <label class="nudge">
Ajoitus
{{define "scorefield"}} <input type="range" min="-10" max="10" step="0.5" value="0" aria-label="Ajoituksen siirto sekunteina">
<label>Pisteet <output>0 s</output>
<span class="scorerow">
<input type="range" name="score" min="1" max="100" value="{{.}}"
oninput="this.nextElementSibling.value = this.value">
<output>{{.}}</output>
</span>
</label> </label>
{{end}} {{end}}
{{end}}
{{define "player"}}
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
the browser's own player, which is plain but complete. -->
<div class="playerwrap" data-duration="{{.Duration}}">
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
</div>
{{end}}
{{define "songcard"}}
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
{{if .Average}}<span class="scorebadge">{{score .Average}}</span>
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Pisteet paljastuvat kun arvostelet"></span>{{end}}
<span class="title">{{.Title}}</span>
<span class="artist">{{.Artist}}</span>
<span class="meta">
<span class="badge genre">{{.GenreLabel}}</span>
<span>{{.Length}}</span>
<span>{{.Submitter}}</span>
{{if .Own}}<span class="badge admin">oma</span>
{{else if .Reviewed}}<span class="badge reviewed">arvosteltu</span>
{{else}}<span class="badge pending">arvostelematta</span>{{end}}
{{if .ReviewCount}}<span>{{.ReviewCount}} arvostelua</span>{{end}}
</span>
</a>
{{end}}
{{define "fader"}}
<!-- A native range input turned vertical: keyboard, form submission and the value all stay free. -->
<div class="fader">
<span class="ticks" aria-hidden="true">
<span>100</span><span>75</span><span>50</span><span>25</span><span>1</span>
</span>
<input type="range" name="score" min="1" max="100" value="{{.}}"
aria-label="Pisteet 1100"
oninput="this.closest('.fader').querySelector('output').value = this.value">
<output class="readout">{{.}}</output>
</div>
{{end}}
+67
View File
@@ -0,0 +1,67 @@
{{define "content"}}
{{$p := .Data}}
<div class="profilehead">
{{if $p.Avatar}}
<img class="avatar big" src="/avatars/{{$p.ID}}" alt="">
{{else}}
<span class="avatar big">{{$p.Initials}}</span>
{{end}}
<div>
<h1>{{$p.Name}}</h1>
<p class="muted">Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}</p>
</div>
</div>
<div class="statgrid">
<div class="statcard"><span class="statvalue">{{$p.Stats.SongsSubmitted}}</span><span class="muted small">kappaletta</span></div>
<div class="statcard"><span class="statvalue">{{$p.Stats.ReviewsWritten}}</span><span class="muted small">arvostelua</span></div>
<!-- The one comparison that says something about a person, in the same language as the review
form: what they give versus what they get. -->
<div class="statcard wide">
<div class="channels compare">
{{if $p.Stats.AverageGiven}}
<div class="chan" style="--v: {{score $p.Stats.AverageGiven}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $p.Stats.AverageGiven}}</span>
<span class="chan-who">antanut</span>
</div>
{{end}}
{{if $p.Stats.AverageReceived}}
<div class="chan own" style="--v: {{score $p.Stats.AverageReceived}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $p.Stats.AverageReceived}}</span>
<span class="chan-who">saanut</span>
</div>
{{end}}
{{if and (not $p.Stats.AverageGiven) (not $p.Stats.AverageReceived)}}
<p class="muted small">Ei vielä pisteitä kumpaankaan suuntaan.</p>
{{end}}
</div>
</div>
</div>
{{if $p.Own}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
<form method="post" action="/profile" enctype="multipart/form-data" class="stack">
<label>Nimi <input name="name" value="{{$p.Name}}" maxlength="50" required></label>
<label>Sähköposti <input type="email" name="email" value="{{$p.Email}}" required></label>
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
<button type="submit">Tallenna</button>
</form>
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
</details>
{{end}}
<section>
<h2>Kappaleet</h2>
{{if $p.Songs}}
<div class="songgrid">{{range $p.Songs}}{{template "songcard" .}}{{end}}</div>
{{else}}
<p class="muted">Ei vielä yhtään kappaletta.</p>
{{end}}
</section>
{{end}}
+10 -9
View File
@@ -4,15 +4,16 @@
{{if .Data.Items}} {{if .Data.Items}}
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun <p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
olet kirjoittanut oman arvostelusi.</p> olet kirjoittanut oman arvostelusi.</p>
<table> <div class="songgrid">
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Arvostelut</th></tr></thead> {{range .Data.Items}}{{template "songcard" .}}{{end}}
<tbody> </div>
{{range .Data.Items}}{{template "songrow" .}}{{end}} <p class="pager">
</tbody> {{if .Data.Cursor}}<a href="/">← Alkuun</a>{{end}}
</table> {{with .Data.NextCursor}}<a href="/?cursor={{.}}">Lisää →</a>{{end}}
{{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}} </p>
{{else}} {{else}}
<p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p> <p class="empty">Kaikki kuunneltu.</p>
<p><a href="/submit">Lähetä kappale</a> tai selaa <a href="/songs">kaikkia kappaleita</a>.</p> <p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
{{end}} {{end}}
{{end}} {{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "content"}}
<h1>Palaute</h1>
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
virke riittää.</p>
<form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}">
<label>Palaute
<textarea name="body" rows="6" maxlength="2000" required autofocus
placeholder="Esim. soitin ei toimi puhelimella."></textarea>
</label>
<button type="submit">Lähetä palaute</button>
</form>
<p class="muted small">Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.</p>
{{with .Data.Mine}}
<section>
<h2>Omat palautteet</h2>
{{range .}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
</article>
{{end}}
</section>
{{end}}
{{end}}
+108 -34
View File
@@ -1,16 +1,18 @@
{{define "content"}} {{define "content"}}
{{$s := .Data}} {{$s := .Data}}
<h1>{{$s.Title}}</h1> <h1>{{$s.Title}}</h1>
<p class="byline"> <p class="by">{{$s.Artist}}</p>
{{$s.Artist}} · {{$s.GenreLabel}} · {{$s.Length}} ·
lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}}
{{if $s.Own}}<span class="tag">oma kappale</span>{{end}}
</p>
{{template "player" $s}} <!-- Four different kinds of fact, so four labelled cells rather than one run of text. "Oma
kappale" belongs here as the submitter, not as a badge: it is a fact about you. -->
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}} <dl class="spec">
{{with $s.SourceURL}}<p class="muted"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}} <div><dt>Genre</dt><dd>{{$s.GenreLabel}}</dd></div>
<div><dt>Kesto</dt><dd>{{$s.Length}}</dd></div>
<div><dt>Lähetti</dt>
<dd>{{if $s.Own}}sinä{{else}}<a href="/profile/{{$s.SubmitterID}}">{{$s.Submitter}}</a>{{end}}</dd>
</div>
<div><dt>Julkaistu</dt><dd>{{fiday $s.CreatedAt}}</dd></div>
</dl>
{{if $s.CanEdit}} {{if $s.CanEdit}}
<details class="editbox"> <details class="editbox">
@@ -31,7 +33,7 @@
</form> </form>
<form method="post" action="/songs/{{$s.ID}}/delete" <form method="post" action="/songs/{{$s.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')"> onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
<button type="submit" class="danger">Poista kappale</button> <button type="submit" class="ghost danger">Poista kappale</button>
</form> </form>
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p> <p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
</details> </details>
@@ -39,67 +41,139 @@
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p> <p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
{{end}} {{end}}
<section>
{{if $s.CanReview}} {{if $s.CanReview}}
<h2>Arvostele</h2> <!-- The channel strip: the fader is the score, the panel beside it is everything else you do
<form method="post" action="/songs/{{$s.ID}}/review" class="stack"> while the track plays. -->
{{template "scorefield" 50}} <form method="post" action="/songs/{{$s.ID}}/review" class="strip">
<label>Arvostelu {{template "fader" 50}}
<textarea name="text" rows="6" maxlength="5000" required <div class="deck">
placeholder="Mitä kuulit?"></textarea> {{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
<!-- Two panes when the song has lyrics: read on the left, write on the right, so following
the words costs no scrolling. Without lyrics the pane is absent, not empty. -->
<div class="panes{{if not $s.Lyrics}} solo{{end}}">
{{if $s.Lyrics}}
<div class="lyricspane">{{template "lyricsview" $s}}</div>
{{end}}
<div class="writepane">
<label class="grow">Arvostelu
<textarea name="text" maxlength="5000" required placeholder="Mitä kuulit?"></textarea>
</label> </label>
<div class="deckfoot">
<button type="submit">Tallenna arvostelu</button> <button type="submit">Tallenna arvostelu</button>
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
tai poistaa arvostelusi 30 minuutin ajan.</span>
</div>
</div>
</div>
</div>
</form> </form>
<p class="muted small">Muiden arvostelut ja pisteet paljastuvat kun olet tallentanut omasi. {{else}}
Voit muokata tai poistaa arvostelusi 30 minuutin ajan.</p> {{template "player" $s}}
{{else if $s.ViewerReview}} {{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
{{end}}
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}}
<!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in
the left pane. This panel is for reading afterwards and for the submitter's edits. -->
<details class="lyrics">
<summary>Sanoitukset{{if not $s.Lyrics}} <span class="muted small">— ei vielä lisätty</span>{{end}}</summary>
{{if $s.Lyrics}}<pre class="lyricstext">{{lyricstext $s.Lyrics}}</pre>{{end}}
{{if $s.Own}}
<form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack">
<label>Muokkaa sanoituksia
<textarea name="lyrics" rows="10" maxlength="20000"
placeholder="Liitä sanoitukset tähän.">{{$s.Lyrics}}</textarea>
</label>
<button type="submit">Tallenna sanoitukset</button>
</form>
<p class="muted small">Sanoituksia voi muokata vielä arvostelujenkin jälkeen.</p>
{{end}}
</details>
{{end}}
{{if $s.ViewerReview}}
<section>
<h2>Oma arvostelusi</h2> <h2>Oma arvostelusi</h2>
{{with $s.ViewerReview}} {{with $s.ViewerReview}}
{{if .CanEdit}} {{if .CanEdit}}
<form method="post" action="/reviews/{{.ID}}" class="stack"> <form method="post" action="/reviews/{{.ID}}" class="strip">
<input type="hidden" name="from" value="/songs/{{$s.ID}}"> <input type="hidden" name="from" value="/songs/{{$s.ID}}">
{{template "scorefield" .Score}} {{template "fader" .Score}}
<label>Arvostelu <div class="deck">
<label class="grow">Arvostelu
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea> <textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
</label> </label>
<div class="deckfoot">
<button type="submit">Päivitä</button> <button type="submit">Päivitä</button>
<span class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</span>
</div>
</div>
</form> </form>
<form method="post" action="/reviews/{{.ID}}/delete" <form method="post" action="/reviews/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko arvostelu?')"> onsubmit="return confirm('Poistetaanko arvostelu?')">
<input type="hidden" name="from" value="/songs/{{$s.ID}}"> <input type="hidden" name="from" value="/songs/{{$s.ID}}">
<button type="submit" class="danger">Poista arvostelu</button> <button type="submit" class="ghost danger">Poista arvostelu</button>
</form> </form>
<p class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</p>
{{else}} {{else}}
<p class="score">{{.Score}}</p> <p class="score">{{.Score}}</p>
<p>{{.Text}}</p> <p class="reviewtext">{{.Text}}</p>
<p class="muted small">Muokkausaika on päättynyt.</p> <p class="muted small">Muokkausaika on päättynyt.</p>
{{end}} {{end}}
{{end}} {{end}}
{{end}}
</section> </section>
{{end}}
<section> <section>
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2> <h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
{{if $s.Revealed}} {{if $s.Revealed}}
{{if $s.Average}}<p class="average">Keskiarvo <strong>{{score $s.Average}}</strong></p>{{end}} {{if $s.Reviews}}
<!-- One channel per reviewer: the spread across the row is what "divisive" looks like. -->
<div class="channels">
{{range $i, $r := $s.Reviews}}
<div class="chan{{if $r.Own}} own{{end}}" style="--v: {{$r.Score}}; --i: {{$i}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{$r.Score}}</span>
<span class="chan-who" title="{{$r.Reviewer}}">{{$r.Initials}}</span>
</div>
{{end}}
{{if $s.Average}}
<div class="chan avg" style="--v: {{score $s.Average}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $s.Average}}</span>
<span class="chan-who">ka.</span>
</div>
{{end}}
</div>
{{range $s.Reviews}} {{range $s.Reviews}}
<article class="review"> <article class="review{{if .Own}} own{{end}}">
<header> <header>
<span class="avatar">{{.Initials}}</span> <span class="avatar small">{{.Initials}}</span>
<strong>{{.Reviewer}}</strong> <span class="who">{{.Reviewer}}</span>
<span class="score">{{.Score}}</span> <span class="score">{{.Score}}</span>
<span class="muted small">{{fidate .CreatedAt}}</span> <span class="meta">{{fidate .CreatedAt}}</span>
</header> </header>
<p>{{.Text}}</p> <p>{{.Text}}</p>
</article> </article>
{{end}}
{{else}} {{else}}
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p> <p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
{{end}} {{end}}
{{else}} {{else}}
<p class="muted">Muiden arvostelut ja keskiarvo näkyvät kun olet kirjoittanut omasi. <p class="sealed-note">
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}</p> <span class="sealed" aria-hidden="true"></span>
Muiden pisteet ja arvostelut paljastuvat kun kirjoitat omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}
</p>
{{end}} {{end}}
</section> </section>
{{if $s.NextInQueue}}
<p class="nextup"><a href="/songs/{{$s.NextInQueue}}">Seuraava jonossa →</a></p>
{{end}}
{{end}} {{end}}
+7 -7
View File
@@ -2,13 +2,13 @@
<h1>Kappaleet</h1> <h1>Kappaleet</h1>
{{if .Data.Items}} {{if .Data.Items}}
<table> <div class="songgrid">
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Pisteet</th></tr></thead> {{range .Data.Items}}{{template "songcard" .}}{{end}}
<tbody> </div>
{{range .Data.Items}}{{template "songrow" .}}{{end}} <p class="pager">
</tbody> {{if .Data.Cursor}}<a href="/songs">← Uusimmat</a>{{end}}
</table> {{with .Data.NextCursor}}<a href="/songs?cursor={{.}}">Vanhempia →</a>{{end}}
{{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}} </p>
{{else}} {{else}}
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p> <p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
<p><a href="/submit">Lähetä ensimmäinen</a>.</p> <p><a href="/submit">Lähetä ensimmäinen</a>.</p>
+65
View File
@@ -0,0 +1,65 @@
{{define "songboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/songs/{{.ID}}">{{.Title}}</a>
<span class="muted small">{{.Artist}}</span>
<span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.ReviewCount}} arv.</span>{{end}}
{{if $.Spread}}
<!-- Where the scores actually landed: a range says more than a deviation. -->
<span class="bar range" title="{{.Min}}{{.Max}}">
<span class="fill" style="left: {{.MinPct}}%; width: {{.SpanPct}}%"></span>
</span>
{{else if not $.Count}}
<span class="bar"><span class="fill" style="width: {{.Pct}}%"></span></span>
{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "userboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/profile/{{.ID}}">{{.Name}}</a>
<span class="value">{{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.Count}} kpl</span>{{end}}
{{if not $.Count}}<span class="bar"><span class="fill" style="width: {{value .Value}}%"></span></span>{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "content"}}
<h1>Tilastot</h1>
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p>
<div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
{{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}}
{{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive "Spread" true}}
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified "Spread" true}}
{{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}}
{{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}}
{{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}}
{{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}}
{{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}}
</div>
{{end}}
+30 -1
View File
@@ -1,5 +1,11 @@
{{define "submission-status"}} {{define "submission-status"}}
<div class="status" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}> <div class="status{{if .Failed}} failed{{end}}" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<ol class="segments" aria-label="Lähetyksen tila">
<li class="{{if .Done}}done{{else}}now{{end}}">Lähetetty</li>
<li class="{{if .Done}}done{{else if eq .Status "converting"}}now{{else if eq .Status "downloading"}}now{{end}}">
{{if eq .Status "downloading"}}Ladataan{{else}}Muunnetaan{{end}}</li>
<li class="{{if .Ready}}done{{else if .Failed}}failed{{end}}">{{if .Failed}}Epäonnistui{{else}}Valmis{{end}}</li>
</ol>
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p> <p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}} {{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}} {{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
@@ -25,6 +31,28 @@
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}} {{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
<!-- Included by the page and returned alone by the suggestion button, so the markup exists once.
hx-include sends the current title and artist, which is the whole point: the lookup uses what
the submitter just fixed, not what the tags claimed. -->
{{define "lyricsfield"}}
<div id="lyricsfield">
<label>Sanoitukset <span class="muted small">(vapaaehtoinen)</span>
<textarea name="lyrics" rows="8" maxlength="20000"
placeholder="Liitä sanoitukset tähän tai hae ne alta.">{{.Lyrics}}</textarea>
</label>
<div class="lyricsbar">
<button type="button" class="ghost"
hx-post="/submit/{{.ID}}/lyrics"
hx-include="[name='title'], [name='artist']"
hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button>
{{if .Found}}<span class="muted small">Löytyi — tarkista ja muokkaa tarvittaessa.</span>{{end}}
{{if .Searched}}{{if not .Found}}
<span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span>
{{end}}{{end}}
</div>
</div>
{{end}}
{{define "content"}} {{define "content"}}
<h1>Lähetys</h1> <h1>Lähetys</h1>
@@ -52,6 +80,7 @@
<label>Esittely <span class="muted small">(vapaaehtoinen)</span> <label>Esittely <span class="muted small">(vapaaehtoinen)</span>
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea> <textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
</label> </label>
{{template "lyricsfield" dict "ID" .Data.ID "Lyrics" .Data.Lyrics}}
{{template "saved" ""}} {{template "saved" ""}}
</form> </form>