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
+48 -16
View File
@@ -21,28 +21,60 @@ Two things to remember when it happens:
## 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
`syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by
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.
- **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
line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and
outros alone break a uniform mapping.
**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
LRCLIB would miss nearly all Finnish music. It does not:
| Search | Results | With `syncedLyrics` |
|---|---|---|
| Nightwish | 20 | 20 |
| Eppu Normaali | 20 | 13 |
| 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
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
no drift accumulation.
- **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.
- 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.
- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*,
*Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the
textarea is the entire feature.
- Copyright posture is the same as the YouTube note: private app, ten people, written down
deliberately.
**Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
and a scrolling lyrics panel competes with the review textarea for both space and attention — a
collapsed panel under the player is the starting guess, not a decision.
Copyright posture is the same as the YouTube note: private app, ten people, written down
deliberately.
---