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 } res, err := a.db.ExecContext(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 affected(res) > 0 { slog.Info("lyrics found", "ctx", "submissions", "submission", subID) } }