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