Files
Levyraati26_go/lyrics.go
T
Esa Kataja a9776c6dde Follow the song in the lyrics
Synced LRC highlights the playing line, keeps it centred and seeks on click.
Plain text scrolls continuously with a nudge knob instead — a highlight on
guessed timings turns guaranteed drift into what looks like a bug. Seuraa
kappaletta turns following off without losing the highlight, and scrolling by
hand turns it off too.

Fixes the scroll landing in the wrong place (offsetTop measured from a
different coordinate space than the box it was applied to) and the fader
shifting the deck sideways at score 100 (auto-sized grid columns plus a
readout spanning both).
2026-08-01 00:45:40 +03:00

255 lines
7.3 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"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*)+`)
// A line of synced lyrics: the seconds it starts at, and the words.
type lyricLine struct {
At float64
Text string
}
var lrcOne = regexp.MustCompile(`\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]`)
// parseLRC returns nil for plain text, which is the signal to scroll continuously instead of
// highlighting: a line-by-line highlight on guessed timings makes every second of drift read as a
// bug.
func parseLRC(s string) []lyricLine {
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return nil
}
var out []lyricLine
for _, raw := range strings.Split(s, "\n") {
stamps := lrcOne.FindAllStringSubmatch(raw, -1)
if len(stamps) == 0 {
continue
}
text := strings.TrimSpace(lrcStamp.ReplaceAllString(raw, ""))
// One line can carry several timestamps when a refrain repeats.
for _, m := range stamps {
min, _ := strconv.Atoi(m[1])
sec, _ := strconv.Atoi(m[2])
at := float64(min*60 + sec)
if m[3] != "" {
frac, _ := strconv.Atoi(m[3])
switch len(m[3]) {
case 1:
at += float64(frac) / 10
case 2:
at += float64(frac) / 100
default:
at += float64(frac) / 1000
}
}
out = append(out, lyricLine{At: at, Text: text})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
return out
}
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)
}
}