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
+68
View File
@@ -330,6 +330,35 @@ footer.sitefooter {
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
.lyrics {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-5);
}
.lyrics > summary {
cursor: pointer;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--primary);
}
/* Lyrics are typed with intent: line breaks and indentation are the content. */
.lyricstext {
font-family: inherit;
font-size: 0.95rem;
line-height: 1.7;
white-space: pre-wrap;
margin: var(--space-4) 0 0;
max-height: 26rem;
overflow-y: auto;
}
.lyrics form { margin-top: var(--space-4); }
.review {
padding: var(--space-4);
border-left: 3px solid var(--input-border);
@@ -424,6 +453,39 @@ button.link:hover { background: none; color: var(--primary-hover); }
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.deck .grow { flex: 1; }
/* Read on the left, write on the right. One column when the song has no lyrics — the pane is
absent rather than empty. */
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); flex: 1; min-height: 0; }
.panes.solo { grid-template-columns: 1fr; }
.lyricspane, .writepane { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.writepane .grow { display: flex; flex-direction: column; gap: var(--space-1); }
.writepane textarea { flex: 1; min-height: 12rem; }
.cap {
font-family: var(--font-display);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.lyricsbox {
/* Grows with the pane but stops before it can push the page: long lyrics scroll inside the box
rather than stretching the strip past the screen. */
flex: 1 1 auto;
min-height: 10rem;
max-height: 24rem;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
background: var(--bar);
border: 1px solid var(--hairline);
border-radius: var(--radius);
white-space: pre-wrap;
line-height: 1.7;
font-size: 0.95rem;
}
.deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
@@ -740,6 +802,9 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
/* A 200px fader on a phone is worse than a horizontal one. */
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
/* Side by side needs width it does not have here, so reading stacks above writing. */
.panes { grid-template-columns: 1fr; }
.lyricsbox { max-height: 14rem; }
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
width: 100%; height: auto; min-height: 0; }
@@ -980,3 +1045,6 @@ img.avatar { object-fit: cover; }
.version { color: var(--muted); font-family: var(--font-display); }
.version::before { content: "·"; margin: 0 var(--space-2); }
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
margin-top: var(--space-2); }