Add lyrics: paste, fetch, and read them while reviewing

Lyrics are suggested at submission and never imposed. The conversion worker
makes one LRCLIB lookup with whatever metadata exists, and the waiting page
has a Hae sanoitukset button that re-queries with whatever title and artist
are currently typed — which is the case that matters, since our metadata comes
from ID3 tags and YouTube uploaders. Neither path overwrites typed text.

- lyrics text on both submissions and songs, copied across at publish. Nothing
  has launched, so the column goes into 001_init.sql rather than a migration
- The lock does not cover lyrics: it freezes what the song claims to be, and
  nobody reviewed the lyrics. So the submitter can still fix them afterwards,
  or paste them for an old song a year later
- The review strip gained a second pane: lyrics on the left, review on the
  right, so following the words costs no scrolling. No lyrics means no pane,
  not an empty one. Below 1024px the panes stack
- LRC timestamps are stored but stripped for reading — they belong to the
  player, not the reader
- The lyrics box is capped and scrolls inside itself, so a long song cannot
  stretch the strip past the screen

Fixes a real bug found on the way: saveMetadata cleared any field the request
did not carry, so publishing wiped the lyrics the worker had just fetched.
Fields absent from a request now keep their stored value.

The client identifies itself to LRCLIB as "levyraati" and nothing more.

Tests cover cleanLyrics keeping line breaks, and fetchLyrics against a local
server: synced beats plain, instrumentals and wrong-length takes are skipped,
and a miss is empty with no error.
This commit is contained in:
Esa Kataja
2026-08-01 00:21:31 +03:00
parent ac2cfaebac
commit 4f337b6202
13 changed files with 593 additions and 38 deletions
+2
View File
@@ -146,6 +146,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
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 /avatars/{id}", a.avatar) // public: avatars are not secret
@@ -167,6 +168,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
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}/discard", a.requireMember(a.discard))
return mux