Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
400b5d3833 |
@@ -191,3 +191,21 @@ says so.
|
|||||||
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
|
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
|
||||||
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
|
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
|
||||||
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
|
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
|
||||||
|
45. **Lyrics are suggested at submission, and stay editable forever.** Four decisions in one, taken
|
||||||
|
2026-07-31 while scoping the feature in [later.md](./later.md):
|
||||||
|
- **Suggested, never imposed.** The worker attempts one LRCLIB lookup after conversion, and the
|
||||||
|
waiting page carries a *Hae sanoitukset* button that re-queries with whatever title and artist
|
||||||
|
are currently typed. The button exists because our metadata comes from ID3 tags and YouTube
|
||||||
|
uploaders, so the automatic attempt misses exactly the songs with messy names — and would look
|
||||||
|
broken rather than absent. Neither path overwrites text the submitter has typed.
|
||||||
|
- **Lyrics live on the submission, not just the song**, and are copied across at publish, because
|
||||||
|
they are part of preparing a song rather than something bolted on afterwards.
|
||||||
|
- **No migration 002.** Nothing has launched, so the column goes into `001_init.sql` and the
|
||||||
|
database is recreated. The schema has no legacy to respect until there is data worth keeping.
|
||||||
|
- **The lock does not cover lyrics.** It exists so the thing people reviewed stops changing under
|
||||||
|
them, and nobody reviewed the lyrics — the rule is now *the lock freezes what the song claims
|
||||||
|
to be; lyrics are an attachment to it.* This also allows pasting lyrics for an old song, which
|
||||||
|
is when the feature is worth most.
|
||||||
|
|
||||||
|
The coverage assumption that shaped the earlier sketch was wrong and is corrected in `later.md`:
|
||||||
|
LRCLIB has synced lyrics for a good share of Finnish rock, not almost none.
|
||||||
|
|||||||
+48
-16
@@ -21,28 +21,60 @@ Two things to remember when it happens:
|
|||||||
|
|
||||||
## Lyrics with scaled autoscroll
|
## Lyrics with scaled autoscroll
|
||||||
|
|
||||||
Fetch lyrics and scroll them in time with the audio.
|
Fetch lyrics and scroll them in time with the audio. Designed and decided (decisions 45), not built.
|
||||||
|
|
||||||
- **LRCLIB** (`lrclib.net`) is a community database with no API key, and its responses include
|
**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
|
||||||
`syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by
|
LRCLIB would miss nearly all Finnish music. It does not:
|
||||||
track, artist and duration, all of which are already on the song row. So a decent share of songs
|
|
||||||
need no faked timing at all.
|
| Search | Results | With `syncedLyrics` |
|
||||||
- **Synced hit** → highlight the current line properly. **Plain hit or manual paste** → distribute
|
|---|---|---|
|
||||||
lines evenly across `duration_seconds` and scroll the block *continuously without highlighting a
|
| Nightwish | 20 | 20 |
|
||||||
line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and
|
| Eppu Normaali | 20 | 13 |
|
||||||
outros alone break a uniform mapping.
|
| CMX | 15 | 12 |
|
||||||
|
| Popeda | 20 | 8 |
|
||||||
|
|
||||||
|
**LRCLIB** (`lrclib.net`) needs no API key. `/api/get` matches on artist, track and duration within
|
||||||
|
±2 s and returns `syncedLyrics` — real LRC with `[mm:ss.xx]` per line — alongside `plainLyrics`;
|
||||||
|
`/api/search?q=` is the looser fallback. Go's side is `net/http` and `encoding/json`, so the
|
||||||
|
dependency budget survives, and it is treated exactly like ffmpeg and yt-dlp: a timeout, allowed to
|
||||||
|
fail, never blocking anything.
|
||||||
|
|
||||||
|
**Where it happens: at submission, as a suggestion.**
|
||||||
|
|
||||||
|
- The worker attempts one automatic lookup after conversion, using whatever metadata exists.
|
||||||
|
- The waiting page has a **Hae sanoitukset** button that re-queries with whatever is currently typed
|
||||||
|
in the title and artist fields. That is the answer for messy tags — `Sentenced Noose` from a
|
||||||
|
YouTube upload will not match until the submitter fixes it, and the automatic attempt would
|
||||||
|
otherwise just look broken.
|
||||||
|
- Neither ever overwrites text the submitter has typed. They can accept the suggestion, edit it, or
|
||||||
|
leave the field empty.
|
||||||
|
|
||||||
|
**Storage:** one nullable `lyrics text` column on both `submissions` and `songs`, copied across at
|
||||||
|
publish. LRC or plain is told apart by whether the first line starts with `[`, so no second column
|
||||||
|
and no flag. **Nothing has launched, so this goes into `001_init.sql` rather than a migration 002.**
|
||||||
|
|
||||||
|
**Lyrics stay editable after the song locks** — the lock exists so the thing people reviewed stops
|
||||||
|
changing, and nobody reviewed the lyrics. It also means someone can paste them for an old song a
|
||||||
|
year later, which is when this feature is most useful.
|
||||||
|
|
||||||
|
**Playback:**
|
||||||
|
|
||||||
|
- **Synced hit** → highlight the current line properly, driven by the transport's `timeupdate`.
|
||||||
|
- **Plain hit or manual paste** → distribute lines evenly across `duration_seconds` and scroll the
|
||||||
|
block *continuously without highlighting a line*. Highlighting makes every second of drift read as
|
||||||
|
a bug, and drift is guaranteed — intros and outros alone break a uniform mapping.
|
||||||
- The Web Animations API does the whole thing including seeking: build the scroll animation with
|
- The Web Animations API does the whole thing including seeking: build the scroll animation with
|
||||||
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
|
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
|
||||||
no drift accumulation.
|
no drift accumulation.
|
||||||
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
|
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
|
||||||
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
|
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
|
||||||
- Storage: one nullable `lyrics text` column. LRC or plain — tell them apart by whether the first
|
|
||||||
line starts with `[`, so no second column and no flag. Fetched best-effort in the publish worker.
|
**Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
|
||||||
- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*,
|
and a scrolling lyrics panel competes with the review textarea for both space and attention — a
|
||||||
*Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the
|
collapsed panel under the player is the starting guess, not a decision.
|
||||||
textarea is the entire feature.
|
|
||||||
- Copyright posture is the same as the YouTube note: private app, ten people, written down
|
Copyright posture is the same as the YouTube note: private app, ten people, written down
|
||||||
deliberately.
|
deliberately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Line breaks are the content here — LRC timestamps are per line — so cleanLyrics must not do what
|
||||||
|
// clean() does to a title.
|
||||||
|
func TestCleanLyrics(t *testing.T) {
|
||||||
|
got := cleanLyrics(" [00:11.74] Rivi yksi\r\n[00:13.99] Rivi\x07 kaksi\r\n\n")
|
||||||
|
want := "[00:11.74] Rivi yksi\n[00:13.99] Rivi kaksi"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("cleanLyrics gave %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if n := len([]rune(cleanLyrics(strings.Repeat("a", maxLyrics+500)))); n != maxLyrics {
|
||||||
|
t.Fatalf("truncated to %d runes, want %d", n, maxLyrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text must parse to nil: that is the signal to scroll continuously rather than highlight
|
||||||
|
// lines on timings nobody measured.
|
||||||
|
func TestParseLRC(t *testing.T) {
|
||||||
|
if got := parseLRC("Ihan tavallista tekstiä\ntoinen rivi"); got != nil {
|
||||||
|
t.Fatalf("plain text parsed as synced: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseLRC("[00:11.74] Ensimmäinen\n[01:02] Toinen\n[00:05.5] Aikaisempi\nrivi ilman aikaa")
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("got %d lines, want 3 — untimed lines are dropped", len(lines))
|
||||||
|
}
|
||||||
|
// Sorted by time, whatever order the file had.
|
||||||
|
if lines[0].At != 5.5 || lines[0].Text != "Aikaisempi" {
|
||||||
|
t.Fatalf("first line is %+v, want 5.5s Aikaisempi", lines[0])
|
||||||
|
}
|
||||||
|
if lines[1].At != 11.74 || lines[2].At != 62 {
|
||||||
|
t.Fatalf("timestamps parsed as %v and %v, want 11.74 and 62", lines[1].At, lines[2].At)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refrain can carry several timestamps on one line, and each is its own occurrence.
|
||||||
|
rep := parseLRC("[00:10.00][01:10.00] Kertosäe")
|
||||||
|
if len(rep) != 2 || rep[0].At != 10 || rep[1].At != 70 {
|
||||||
|
t.Fatalf("repeated stamps gave %+v, want two occurrences", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a
|
||||||
|
// synced hit always beats a plain one.
|
||||||
|
func TestFetchLyrics(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/get" && r.URL.Query().Get("track_name") == "Paranoid":
|
||||||
|
w.Write([]byte(`{"trackName":"Paranoid","artistName":"Black Sabbath","duration":168,
|
||||||
|
"plainLyrics":"plain version","syncedLyrics":"[00:11.74] synced version"}`))
|
||||||
|
case r.URL.Path == "/get":
|
||||||
|
http.Error(w, `{"code":404}`, http.StatusNotFound)
|
||||||
|
case r.URL.Path == "/search" && strings.Contains(r.URL.Query().Get("q"), "Soittorasia"):
|
||||||
|
// An instrumental and a wrong-length take come first: both must be skipped.
|
||||||
|
w.Write([]byte(`[{"trackName":"Soittorasia","duration":200,"instrumental":true,
|
||||||
|
"plainLyrics":"","syncedLyrics":"[00:01.00] should be skipped"},
|
||||||
|
{"trackName":"Soittorasia","duration":600,
|
||||||
|
"plainLyrics":"wrong length take"},
|
||||||
|
{"trackName":"Soittorasia","duration":201,
|
||||||
|
"plainLyrics":"right one"}]`))
|
||||||
|
default:
|
||||||
|
w.Write([]byte(`[]`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
old := lrclibBase
|
||||||
|
lrclibBase = srv.URL
|
||||||
|
defer func() { lrclibBase = old }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := fetchLyrics(ctx, "Paranoid", "Black Sabbath", 168)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "[00:11.74] synced version" {
|
||||||
|
t.Fatalf("exact match returned %q, want the synced version", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = fetchLyrics(ctx, "Soittorasia", "Joku", 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "right one" {
|
||||||
|
t.Fatalf("search fallback returned %q — instrumental and wrong-length takes must be skipped", got)
|
||||||
|
}
|
||||||
|
if lastPath != "/search" {
|
||||||
|
t.Fatalf("last request was %s, want the search fallback", lastPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing found is not an error: the submitter simply types their own.
|
||||||
|
got, err = fetchLyrics(ctx, "Ei olemassa", "Kukaan", 100)
|
||||||
|
if err != nil || got != "" {
|
||||||
|
t.Fatalf("miss returned %q, %v — want empty and no error", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No title means nothing to match on, and no request at all.
|
||||||
|
if got, err := fetchLyrics(ctx, "", "Artisti", 100); err != nil || got != "" {
|
||||||
|
t.Fatalf("empty title returned %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ func (a *app) memberMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
|
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
|
||||||
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
||||||
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
||||||
|
mux.HandleFunc("POST /songs/{id}/lyrics", a.requireMember(a.editLyrics))
|
||||||
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
||||||
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@ func (a *app) memberMux() *http.ServeMux {
|
|||||||
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
||||||
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
||||||
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/lyrics", a.requireMember(a.suggestLyrics))
|
||||||
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
||||||
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
||||||
return mux
|
return mux
|
||||||
|
|||||||
@@ -96,6 +96,29 @@ func clean(s string, max int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxLyrics = 20000
|
||||||
|
|
||||||
|
// Lyrics are the one field where line breaks carry meaning — LRC timestamps are per line — so they
|
||||||
|
// survive, and only the other control characters go.
|
||||||
|
func cleanLyrics(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\n")
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if r == '\n' || r == '\t' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if r := []rune(s); len(r) > maxLyrics {
|
||||||
|
s = strings.TrimSpace(string(r[:maxLyrics]))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
|
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
|
||||||
// argument list — and it never goes through a shell.
|
// argument list — and it never goes through a shell.
|
||||||
var allowedHosts = map[string]bool{
|
var allowedHosts = map[string]bool{
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ create table songs (
|
|||||||
artist text not null,
|
artist text not null,
|
||||||
genre text not null,
|
genre text not null,
|
||||||
description text,
|
description text,
|
||||||
|
-- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the
|
||||||
|
-- lock: nobody reviewed the lyrics.
|
||||||
|
lyrics text,
|
||||||
audio_file text not null,
|
audio_file text not null,
|
||||||
duration_seconds integer not null,
|
duration_seconds integer not null,
|
||||||
source_url text,
|
source_url text,
|
||||||
@@ -51,6 +54,7 @@ create table submissions (
|
|||||||
artist text,
|
artist text,
|
||||||
genre text,
|
genre text,
|
||||||
description text,
|
description text,
|
||||||
|
lyrics text,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
constraint submissions_status check (
|
constraint submissions_status check (
|
||||||
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
|
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ var funcs = template.FuncMap{
|
|||||||
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
||||||
// Date without the clock: the minute a song was published is noise.
|
// Date without the clock: the minute a song was published is noise.
|
||||||
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
|
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
|
||||||
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
// Lyrics as they are meant to be read: LRC timestamps belong to the player, not the reader.
|
||||||
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
"lyricstext": stripLRC,
|
||||||
|
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
||||||
|
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
||||||
// Lets one board partial be called with a title and a list, instead of two near-identical
|
// Lets one board partial be called with a title and a list, instead of two near-identical
|
||||||
// partials per leaderboard.
|
// partials per leaderboard.
|
||||||
"dict": func(pairs ...any) map[string]any {
|
"dict": func(pairs ...any) map[string]any {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
type songDetail struct {
|
type songDetail struct {
|
||||||
songSummary
|
songSummary
|
||||||
Description string
|
Description string
|
||||||
|
Lyrics string
|
||||||
SourceURL *string
|
SourceURL *string
|
||||||
Reviews []*review // nil when the reveal rule is withholding them
|
Reviews []*review // nil when the reveal rule is withholding them
|
||||||
ViewerReview *review
|
ViewerReview *review
|
||||||
@@ -159,14 +160,19 @@ type songDetail struct {
|
|||||||
|
|
||||||
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
|
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
|
||||||
|
|
||||||
|
// Synced lyrics get a line-by-line highlight; plain text scrolls continuously instead, because a
|
||||||
|
// highlight on guessed timings makes every second of drift look like a bug.
|
||||||
|
func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
|
||||||
|
|
||||||
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
|
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
|
||||||
var d songDetail
|
var d songDetail
|
||||||
err := a.pool.QueryRow(ctx, `select`+songColumns+`, coalesce(s.description, ''), s.source_url
|
err := a.pool.QueryRow(ctx, `select`+songColumns+`,
|
||||||
|
coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url
|
||||||
from songs s join users u on u.id = s.submitted_by
|
from songs s join users u on u.id = s.submitted_by
|
||||||
where s.id = $2`, viewerID, songID).
|
where s.id = $2`, viewerID, songID).
|
||||||
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
|
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
|
||||||
&d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
|
&d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
|
||||||
&d.Description, &d.SourceURL)
|
&d.Description, &d.Lyrics, &d.SourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -231,6 +237,31 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
|
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lyrics are not covered by the lock: it freezes what the song claims to be, and nobody reviewed
|
||||||
|
// the lyrics. So this checks the submitter and nothing else, which also lets someone paste them for
|
||||||
|
// an old song a year later.
|
||||||
|
func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, err := a.pool.Exec(r.Context(),
|
||||||
|
`update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`,
|
||||||
|
id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics")))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("edit lyrics", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.flash(w, "Sanoitukset tallennettu.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// --- edit and delete ---
|
// --- edit and delete ---
|
||||||
|
|
||||||
// The submitter may change the four text fields while the song is unlocked. Once people have
|
// The submitter may change the four text fields while the song is unlocked. Once people have
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Lyrics that follow the audio. Two behaviours, because the two kinds of lyrics deserve different
|
||||||
|
// treatment: real LRC timestamps get a line highlight, guessed timings get a continuous scroll and
|
||||||
|
// a nudge knob. Progressive enhancement — without this file the lyrics are still readable text.
|
||||||
|
(function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
// The audio element belonging to the same strip, falling back to the only one on the page.
|
||||||
|
const audioFor = (box) => {
|
||||||
|
const scope = box.closest('.strip') || box.closest('section') || document
|
||||||
|
return scope.querySelector('audio') || document.querySelector('audio')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- synced: highlight the line that is playing ---
|
||||||
|
|
||||||
|
function enhanceSynced(box) {
|
||||||
|
const audio = audioFor(box)
|
||||||
|
if (!audio) return
|
||||||
|
const lines = [...box.querySelectorAll('.lline')]
|
||||||
|
if (!lines.length) return
|
||||||
|
const times = lines.map((l) => Number(l.dataset.t))
|
||||||
|
let current = -1
|
||||||
|
|
||||||
|
// Following is what moves the box. Timestamps are somebody else's guess at where a line
|
||||||
|
// starts, so when they are off, the scrolling is the part that fights you — the highlight can
|
||||||
|
// stay. Remembered per song.
|
||||||
|
const follow = box.parentElement.querySelector('.follow input')
|
||||||
|
const key = 'lyricsfollow:' + box.dataset.song
|
||||||
|
if (follow && localStorage.getItem(key) === 'off') follow.checked = false
|
||||||
|
if (follow) {
|
||||||
|
follow.addEventListener('change', () => {
|
||||||
|
localStorage.setItem(key, follow.checked ? 'on' : 'off')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrolling the box by hand turns following off: reading somewhere else is a clear statement
|
||||||
|
// that you do not want to be dragged back.
|
||||||
|
let selfScroll = false
|
||||||
|
box.addEventListener('scroll', () => {
|
||||||
|
if (selfScroll || !follow || !follow.checked) return
|
||||||
|
follow.checked = false
|
||||||
|
localStorage.setItem(key, 'off')
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = (i) => {
|
||||||
|
if (i === current) return
|
||||||
|
if (lines[current]) lines[current].classList.remove('on')
|
||||||
|
current = i
|
||||||
|
const line = lines[i]
|
||||||
|
if (!line) return
|
||||||
|
line.classList.add('on')
|
||||||
|
if (follow && !follow.checked) return
|
||||||
|
// Measured against the box itself. offsetTop is relative to the nearest positioned ancestor,
|
||||||
|
// which is not this box, so using it scrolls to a position from a different coordinate space.
|
||||||
|
const boxRect = box.getBoundingClientRect()
|
||||||
|
const lineRect = line.getBoundingClientRect()
|
||||||
|
const target = box.scrollTop + (lineRect.top - boxRect.top)
|
||||||
|
- box.clientHeight / 2 + lineRect.height / 2
|
||||||
|
selfScroll = true
|
||||||
|
box.scrollTo({ top: target, behavior: quiet ? 'auto' : 'smooth' })
|
||||||
|
// Long enough for the smooth scroll to finish, so our own movement is not mistaken for the
|
||||||
|
// reader's.
|
||||||
|
setTimeout(() => { selfScroll = false }, 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
const t = audio.currentTime
|
||||||
|
let i = current
|
||||||
|
// Usually one step forward; a seek walks from wherever it lands.
|
||||||
|
if (i < 0 || times[i] > t) i = 0
|
||||||
|
while (i + 1 < times.length && times[i + 1] <= t) i++
|
||||||
|
if (times[i] <= t) show(i)
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.addEventListener('seeked', () => {
|
||||||
|
current = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clicking a line seeks to it: the lyrics become a way to navigate the song.
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
line.addEventListener('click', () => {
|
||||||
|
audio.currentTime = times[i]
|
||||||
|
if (audio.paused) audio.play()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plain: scroll the block in step with the audio ---
|
||||||
|
|
||||||
|
function enhancePlain(box) {
|
||||||
|
const audio = audioFor(box)
|
||||||
|
const inner = box.querySelector('.lscroll')
|
||||||
|
if (!audio || !inner) return
|
||||||
|
|
||||||
|
const nudge = box.parentElement.querySelector('.nudge input')
|
||||||
|
const readout = box.parentElement.querySelector('.nudge output')
|
||||||
|
const key = 'lyricsoffset:' + box.dataset.song
|
||||||
|
let offset = Number(localStorage.getItem(key) || 0)
|
||||||
|
if (nudge) {
|
||||||
|
nudge.value = offset
|
||||||
|
readout.value = offset + ' s'
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = () => Number(audio.duration) || Number(box.dataset.duration) || 0
|
||||||
|
|
||||||
|
// Position is a pure function of time, so a seek needs no bookkeeping and drift cannot
|
||||||
|
// accumulate the way it would with a timer.
|
||||||
|
const place = () => {
|
||||||
|
const total = duration()
|
||||||
|
const travel = inner.scrollHeight - box.clientHeight
|
||||||
|
if (total <= 0 || travel <= 0) return
|
||||||
|
const at = (audio.currentTime + offset) / total
|
||||||
|
box.scrollTop = Math.max(0, Math.min(travel, at * travel))
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', place)
|
||||||
|
audio.addEventListener('seeked', place)
|
||||||
|
audio.addEventListener('loadedmetadata', place)
|
||||||
|
|
||||||
|
if (nudge) {
|
||||||
|
nudge.addEventListener('input', () => {
|
||||||
|
offset = Number(nudge.value)
|
||||||
|
readout.value = (offset > 0 ? '+' : '') + offset + ' s'
|
||||||
|
localStorage.setItem(key, offset)
|
||||||
|
place()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.lyricsbox.synced').forEach(enhanceSynced)
|
||||||
|
document.querySelectorAll('.lyricsbox.plain').forEach(enhancePlain)
|
||||||
|
})
|
||||||
|
})()
|
||||||
+107
-3
@@ -330,6 +330,35 @@ footer.sitefooter {
|
|||||||
|
|
||||||
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
|
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
|
||||||
|
|
||||||
|
.lyrics {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lyrics are typed with intent: line breaks and indentation are the content. */
|
||||||
|
.lyricstext {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
max-height: 26rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics form { margin-top: var(--space-4); }
|
||||||
|
|
||||||
.review {
|
.review {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-left: 3px solid var(--input-border);
|
border-left: 3px solid var(--input-border);
|
||||||
@@ -424,10 +453,77 @@ button.link:hover { background: none; color: var(--primary-hover); }
|
|||||||
|
|
||||||
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||||
.deck .grow { flex: 1; }
|
.deck .grow { flex: 1; }
|
||||||
|
|
||||||
|
/* Read on the left, write on the right. One column when the song has no lyrics — the pane is
|
||||||
|
absent rather than empty. */
|
||||||
|
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); flex: 1; min-height: 0; }
|
||||||
|
.panes.solo { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.lyricspane, .writepane { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||||
|
.writepane .grow { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||||
|
.writepane textarea { flex: 1; min-height: 12rem; }
|
||||||
|
|
||||||
|
.cap {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyricsbox {
|
||||||
|
/* Grows with the pane but stops before it can push the page: long lyrics scroll inside the box
|
||||||
|
rather than stretching the strip past the screen. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 10rem;
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--bar);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
/* Scrolling is smoothed in JS, which also knows when to skip it. Doing it here as well makes two
|
||||||
|
mechanisms fight over the same element. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Synced lyrics: the line that is playing is the only bright one, and clicking any line seeks. */
|
||||||
|
.lyricsbox.synced { white-space: normal; }
|
||||||
|
|
||||||
|
.lline {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lline:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.lline.on {
|
||||||
|
color: var(--gold-1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uniform distribution models a song no real song obeys, so the reader gets a knob. */
|
||||||
|
.follow { display: flex; align-items: center; gap: var(--space-2); font-size: 0.8rem;
|
||||||
|
color: var(--muted); cursor: pointer; }
|
||||||
|
.follow input { width: auto; accent-color: var(--primary); }
|
||||||
|
|
||||||
|
.nudge { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem;
|
||||||
|
color: var(--muted); font-family: var(--font-display); text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em; }
|
||||||
|
.nudge input { flex: 1; accent-color: var(--primary); }
|
||||||
|
.nudge output { min-width: 4ch; text-align: right; color: var(--text); }
|
||||||
|
|
||||||
.deck .player { margin: 0; }
|
.deck .player { margin: 0; }
|
||||||
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||||
|
|
||||||
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
|
/* Fixed columns, not auto: the readout spans both, so an auto track would widen the whole fader
|
||||||
|
when the score reaches three digits and shove the deck sideways mid-drag. */
|
||||||
|
.fader { display: grid; grid-template-columns: 2rem 2rem; grid-template-rows: 1fr auto;
|
||||||
gap: var(--space-2); align-items: stretch; }
|
gap: var(--space-2); align-items: stretch; }
|
||||||
|
|
||||||
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
|
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
|
||||||
@@ -486,14 +582,16 @@ button.link:hover { background: none; color: var(--primary-hover); }
|
|||||||
.readout {
|
.readout {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 2rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
/* Digits of equal width, so 99 → 100 does not shift anything inside the box either. */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--gold-1);
|
color: var(--gold-1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--bar);
|
background: var(--bar);
|
||||||
border: 1px solid var(--input-border);
|
border: 1px solid var(--input-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0 var(--space-2);
|
padding: 0 var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- the reveal: one channel per reviewer --- */
|
/* --- the reveal: one channel per reviewer --- */
|
||||||
@@ -740,6 +838,9 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
|
|||||||
|
|
||||||
/* A 200px fader on a phone is worse than a horizontal one. */
|
/* A 200px fader on a phone is worse than a horizontal one. */
|
||||||
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
|
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
|
||||||
|
/* Side by side needs width it does not have here, so reading stacks above writing. */
|
||||||
|
.panes { grid-template-columns: 1fr; }
|
||||||
|
.lyricsbox { max-height: 14rem; }
|
||||||
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
|
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
|
||||||
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
|
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
|
||||||
width: 100%; height: auto; min-height: 0; }
|
width: 100%; height: auto; min-height: 0; }
|
||||||
@@ -980,3 +1081,6 @@ img.avatar { object-fit: cover; }
|
|||||||
|
|
||||||
.version { color: var(--muted); font-family: var(--font-display); }
|
.version { color: var(--muted); font-family: var(--font-display); }
|
||||||
.version::before { content: "·"; margin: 0 var(--space-2); }
|
.version::before { content: "·"; margin: 0 var(--space-2); }
|
||||||
|
|
||||||
|
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
||||||
|
margin-top: var(--space-2); }
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type submission struct {
|
|||||||
Artist string
|
Artist string
|
||||||
Genre string
|
Genre string
|
||||||
Description string
|
Description string
|
||||||
|
Lyrics string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +377,21 @@ func (a *app) process(subID int64, sourceURL, src string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
|
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) {
|
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(), `
|
err = a.pool.QueryRow(r.Context(), `
|
||||||
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
||||||
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
||||||
coalesce(description, ''), created_at
|
coalesce(description, ''), coalesce(lyrics, ''), created_at
|
||||||
from submissions where id = $1`, id).
|
from submissions where id = $1`, id).
|
||||||
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return nil
|
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
|
// 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.
|
// fields to publish, so a browser without JS loses nothing.
|
||||||
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
|
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")
|
genre := r.FormValue("genre")
|
||||||
if genre != "" && !validGenre(genre) {
|
if genre != "" && !validGenre(genre) {
|
||||||
return fmt.Errorf("unknown genre %q", 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, `
|
_, err := a.pool.Exec(ctx, `
|
||||||
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
|
update submissions set
|
||||||
genre = nullif($4, ''), description = nullif($5, '')
|
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`,
|
where id = $1`,
|
||||||
subID,
|
subID,
|
||||||
clean(r.FormValue("title"), maxTitle),
|
has("title"), clean(r.FormValue("title"), maxTitle),
|
||||||
clean(r.FormValue("artist"), maxArtist),
|
has("artist"), clean(r.FormValue("artist"), maxArtist),
|
||||||
genre,
|
has("genre"), genre,
|
||||||
clean(r.FormValue("description"), maxDescription))
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,10 +567,11 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var songID int64
|
var songID int64
|
||||||
err = tx.QueryRow(r.Context(), `
|
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)
|
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)),
|
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
|
||||||
|
nilIfEmpty(cleanLyrics(s.Lyrics)),
|
||||||
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
|
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
|
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
<script src="/static/player.js" defer></script>
|
<script src="/static/player.js" defer></script>
|
||||||
|
<script src="/static/lyrics.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
{{/* Two shapes, because timed lyrics and guessed lyrics deserve different treatment.
|
||||||
|
Synced: one element per line with its own timestamp, highlighted as it comes.
|
||||||
|
Plain: one block that scrolls continuously, with a nudge knob, because a highlight on evenly
|
||||||
|
guessed timings turns guaranteed drift into what looks like a bug. */}}
|
||||||
|
{{define "lyricsview"}}
|
||||||
|
<span class="cap">Sanoitukset</span>
|
||||||
|
{{$lines := .LyricLines}}
|
||||||
|
{{if $lines}}
|
||||||
|
<div class="lyricsbox synced" data-song="{{.ID}}">
|
||||||
|
{{range $lines}}<p class="lline" data-t="{{.At}}">{{if .Text}}{{.Text}}{{else}} {{end}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<label class="follow">
|
||||||
|
<input type="checkbox" checked> Seuraa kappaletta
|
||||||
|
<span class="muted small">— korostus jatkuu, sivu ei vieri</span>
|
||||||
|
</label>
|
||||||
|
{{else}}
|
||||||
|
<div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
|
||||||
|
<div class="lscroll">{{lyricstext .Lyrics}}</div>
|
||||||
|
</div>
|
||||||
|
<label class="nudge">
|
||||||
|
Ajoitus
|
||||||
|
<input type="range" min="-10" max="10" step="0.5" value="0" aria-label="Ajoituksen siirto sekunteina">
|
||||||
|
<output>0 s</output>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "player"}}
|
{{define "player"}}
|
||||||
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
|
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
|
||||||
the browser's own player, which is plain but complete. -->
|
the browser's own player, which is plain but complete. -->
|
||||||
|
|||||||
+36
-8
@@ -49,14 +49,23 @@
|
|||||||
<div class="deck">
|
<div class="deck">
|
||||||
{{template "player" $s}}
|
{{template "player" $s}}
|
||||||
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
||||||
<label class="grow">Arvostelu
|
|
||||||
<textarea name="text" rows="8" maxlength="5000" required
|
<!-- Two panes when the song has lyrics: read on the left, write on the right, so following
|
||||||
placeholder="Mitä kuulit?"></textarea>
|
the words costs no scrolling. Without lyrics the pane is absent, not empty. -->
|
||||||
</label>
|
<div class="panes{{if not $s.Lyrics}} solo{{end}}">
|
||||||
<div class="deckfoot">
|
{{if $s.Lyrics}}
|
||||||
<button type="submit">Tallenna arvostelu</button>
|
<div class="lyricspane">{{template "lyricsview" $s}}</div>
|
||||||
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
|
{{end}}
|
||||||
tai poistaa arvostelusi 30 minuutin ajan.</span>
|
<div class="writepane">
|
||||||
|
<label class="grow">Arvostelu
|
||||||
|
<textarea name="text" maxlength="5000" required placeholder="Mitä kuulit?"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="deckfoot">
|
||||||
|
<button type="submit">Tallenna arvostelu</button>
|
||||||
|
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
|
||||||
|
tai poistaa arvostelusi 30 minuutin ajan.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -67,6 +76,25 @@
|
|||||||
|
|
||||||
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
|
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
|
||||||
|
|
||||||
|
{{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}}
|
||||||
|
<!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in
|
||||||
|
the left pane. This panel is for reading afterwards and for the submitter's edits. -->
|
||||||
|
<details class="lyrics">
|
||||||
|
<summary>Sanoitukset{{if not $s.Lyrics}} <span class="muted small">— ei vielä lisätty</span>{{end}}</summary>
|
||||||
|
{{if $s.Lyrics}}<pre class="lyricstext">{{lyricstext $s.Lyrics}}</pre>{{end}}
|
||||||
|
{{if $s.Own}}
|
||||||
|
<form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack">
|
||||||
|
<label>Muokkaa sanoituksia
|
||||||
|
<textarea name="lyrics" rows="10" maxlength="20000"
|
||||||
|
placeholder="Liitä sanoitukset tähän.">{{$s.Lyrics}}</textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Tallenna sanoitukset</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small">Sanoituksia voi muokata vielä arvostelujenkin jälkeen.</p>
|
||||||
|
{{end}}
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if $s.ViewerReview}}
|
{{if $s.ViewerReview}}
|
||||||
<section>
|
<section>
|
||||||
<h2>Oma arvostelusi</h2>
|
<h2>Oma arvostelusi</h2>
|
||||||
|
|||||||
@@ -31,6 +31,28 @@
|
|||||||
|
|
||||||
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
|
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
|
||||||
|
|
||||||
|
<!-- Included by the page and returned alone by the suggestion button, so the markup exists once.
|
||||||
|
hx-include sends the current title and artist, which is the whole point: the lookup uses what
|
||||||
|
the submitter just fixed, not what the tags claimed. -->
|
||||||
|
{{define "lyricsfield"}}
|
||||||
|
<div id="lyricsfield">
|
||||||
|
<label>Sanoitukset <span class="muted small">(vapaaehtoinen)</span>
|
||||||
|
<textarea name="lyrics" rows="8" maxlength="20000"
|
||||||
|
placeholder="Liitä sanoitukset tähän tai hae ne alta.">{{.Lyrics}}</textarea>
|
||||||
|
</label>
|
||||||
|
<div class="lyricsbar">
|
||||||
|
<button type="button" class="ghost"
|
||||||
|
hx-post="/submit/{{.ID}}/lyrics"
|
||||||
|
hx-include="[name='title'], [name='artist']"
|
||||||
|
hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button>
|
||||||
|
{{if .Found}}<span class="muted small">Löytyi — tarkista ja muokkaa tarvittaessa.</span>{{end}}
|
||||||
|
{{if .Searched}}{{if not .Found}}
|
||||||
|
<span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span>
|
||||||
|
{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Lähetys</h1>
|
<h1>Lähetys</h1>
|
||||||
|
|
||||||
@@ -58,6 +80,7 @@
|
|||||||
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
|
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
|
||||||
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
|
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
|
||||||
</label>
|
</label>
|
||||||
|
{{template "lyricsfield" dict "ID" .Data.ID "Lyrics" .Data.Lyrics}}
|
||||||
{{template "saved" ""}}
|
{{template "saved" ""}}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user