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.
This commit is contained in:
Esa Kataja
2026-08-01 00:45:55 +03:00
parent 1b3bbbbd7b
commit 400b5d3833
16 changed files with 869 additions and 41 deletions
+42 -10
View File
@@ -84,6 +84,7 @@ type submission struct {
Artist string
Genre string
Description string
Lyrics string
CreatedAt time.Time
}
@@ -376,6 +377,21 @@ func (a *app) process(subID int64, sourceURL, src string) {
return
}
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) {
@@ -399,10 +415,10 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission
err = a.pool.QueryRow(r.Context(), `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), created_at
coalesce(description, ''), coalesce(lyrics, ''), created_at
from submissions where id = $1`, id).
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) {
http.NotFound(w, r)
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
// fields to publish, so a browser without JS loses nothing.
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")
if genre != "" && !validGenre(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, `
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
genre = nullif($4, ''), description = nullif($5, '')
update submissions set
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`,
subID,
clean(r.FormValue("title"), maxTitle),
clean(r.FormValue("artist"), maxArtist),
genre,
clean(r.FormValue("description"), maxDescription))
has("title"), clean(r.FormValue("title"), maxTitle),
has("artist"), clean(r.FormValue("artist"), maxArtist),
has("genre"), genre,
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
}
@@ -536,10 +567,11 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
var songID int64
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)
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)),
nilIfEmpty(cleanLyrics(s.Lyrics)),
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
if err != nil {
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)