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
+18
View File
@@ -191,3 +191,21 @@ says so.
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches 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 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`. 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.
+48 -16
View File
@@ -21,28 +21,60 @@ 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.
--- ---
+207
View File
@@ -0,0 +1,207 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"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*)+`)
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)
}
}
+86
View File
@@ -0,0 +1,86 @@
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)
}
}
// 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)
}
}
+2
View File
@@ -146,6 +146,7 @@ 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 /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("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
+23
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{
+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')
+2
View File
@@ -18,6 +18,8 @@ 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. // 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") }, "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) }, "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 // Lets one board partial be called with a title and a list, instead of two near-identical
+29 -2
View File
@@ -148,6 +148,7 @@ 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
@@ -161,12 +162,13 @@ func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
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
} }
@@ -231,6 +233,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
+68
View File
@@ -330,6 +330,35 @@ footer.sitefooter {
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); } .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 { .review {
padding: var(--space-4); padding: var(--space-4);
border-left: 3px solid var(--input-border); 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 { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.deck .grow { flex: 1; } .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; } .deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; } .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. */ /* 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); } .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 { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr; .fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
width: 100%; height: auto; min-height: 0; } 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 { color: var(--muted); font-family: var(--font-display); }
.version::before { content: "·"; margin: 0 var(--space-2); } .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); }
+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)
+33 -2
View File
@@ -49,9 +49,19 @@
<div class="deck"> <div class="deck">
{{template "player" $s}} {{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}} {{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}}">
{{with $s.Lyrics}}
<div class="lyricspane">
<span class="cap">Sanoitukset</span>
<div class="lyricsbox">{{lyricstext .}}</div>
</div>
{{end}}
<div class="writepane">
<label class="grow">Arvostelu <label class="grow">Arvostelu
<textarea name="text" rows="8" maxlength="5000" required <textarea name="text" maxlength="5000" required placeholder="Mitä kuulit?"></textarea>
placeholder="Mitä kuulit?"></textarea>
</label> </label>
<div class="deckfoot"> <div class="deckfoot">
<button type="submit">Tallenna arvostelu</button> <button type="submit">Tallenna arvostelu</button>
@@ -59,6 +69,8 @@
tai poistaa arvostelusi 30 minuutin ajan.</span> tai poistaa arvostelusi 30 minuutin ajan.</span>
</div> </div>
</div> </div>
</div>
</div>
</form> </form>
{{else}} {{else}}
{{template "player" $s}} {{template "player" $s}}
@@ -67,6 +79,25 @@
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{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}} {{if $s.ViewerReview}}
<section> <section>
<h2>Oma arvostelusi</h2> <h2>Oma arvostelusi</h2>
+23
View File
@@ -31,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>
@@ -58,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>