SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
255 lines
7.3 KiB
Go
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
|
|
}
|
|
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)
|
|
}
|
|
}
|