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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user