diff --git a/docs/decisions.md b/docs/decisions.md index bc2b3b3..70000b6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 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`. +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. diff --git a/docs/later.md b/docs/later.md index d0e255f..b40f5ca 100644 --- a/docs/later.md +++ b/docs/later.md @@ -21,28 +21,60 @@ Two things to remember when it happens: ## 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 - `syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by - 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. -- **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 - line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and - outros alone break a uniform mapping. +**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed +LRCLIB would miss nearly all Finnish music. It does not: + +| Search | Results | With `syncedLyrics` | +|---|---|---| +| Nightwish | 20 | 20 | +| Eppu Normaali | 20 | 13 | +| 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 `duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers, no drift accumulation. - **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. -- 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. -- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*, - *Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the - textarea is the entire feature. -- Copyright posture is the same as the YouTube note: private app, ten people, written down - deliberately. + +**Still open:** where the panel lives on the song page. That page's job is now *listen and write*, +and a scrolling lyrics panel competes with the review textarea for both space and attention — a +collapsed panel under the player is the starting guess, not a decision. + +Copyright posture is the same as the YouTube note: private app, ten people, written down +deliberately. --- diff --git a/lyrics.go b/lyrics.go new file mode 100644 index 0000000..d5ad22c --- /dev/null +++ b/lyrics.go @@ -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) + } +} diff --git a/lyrics_test.go b/lyrics_test.go new file mode 100644 index 0000000..fa9cb9d --- /dev/null +++ b/lyrics_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 2ae4d56..4b123c5 100644 --- a/main.go +++ b/main.go @@ -146,6 +146,7 @@ func (a *app) memberMux() *http.ServeMux { mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage)) mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong)) 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 /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("POST /submit/{id}", a.requireMember(a.saveSubmission)) 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}/discard", a.requireMember(a.discard)) return mux diff --git a/media.go b/media.go index e3e9abc..eee3623 100644 --- a/media.go +++ b/media.go @@ -96,6 +96,29 @@ func clean(s string, max int) string { 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 // argument list — and it never goes through a shell. var allowedHosts = map[string]bool{ diff --git a/migrations/001_init.sql b/migrations/001_init.sql index efce1e3..68cf52a 100644 --- a/migrations/001_init.sql +++ b/migrations/001_init.sql @@ -31,6 +31,9 @@ create table songs ( artist text not null, genre text not null, 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, duration_seconds integer not null, source_url text, @@ -51,6 +54,7 @@ create table submissions ( artist text, genre text, description text, + lyrics text, created_at timestamptz not null default now(), constraint submissions_status check ( status in ('queued', 'downloading', 'converting', 'ready', 'failed') diff --git a/render.go b/render.go index 5047d13..84c6f43 100644 --- a/render.go +++ b/render.go @@ -18,8 +18,10 @@ var funcs = template.FuncMap{ "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. "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) }, - "value": 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. + "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 // partials per leaderboard. "dict": func(pairs ...any) map[string]any { diff --git a/songs.go b/songs.go index 3925c8e..c7bd8eb 100644 --- a/songs.go +++ b/songs.go @@ -148,6 +148,7 @@ func (a *app) browsePage(w http.ResponseWriter, r *http.Request) { type songDetail struct { songSummary Description string + Lyrics string SourceURL *string Reviews []*review // nil when the reveal rule is withholding them ViewerReview *review @@ -159,14 +160,19 @@ type songDetail struct { 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) { 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 where s.id = $2`, viewerID, songID). 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.Description, &d.SourceURL) + &d.Description, &d.Lyrics, &d.SourceURL) if err != nil { 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}) } +// 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 --- // The submitter may change the four text fields while the song is unlocked. Once people have diff --git a/static/lyrics.js b/static/lyrics.js new file mode 100644 index 0000000..15ae7da --- /dev/null +++ b/static/lyrics.js @@ -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) + }) +})() diff --git a/static/style.css b/static/style.css index 821bdcd..108e4fc 100644 --- a/static/style.css +++ b/static/style.css @@ -330,6 +330,35 @@ footer.sitefooter { .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 { padding: var(--space-4); 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 .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; } .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; } .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 { grid-column: 1 / -1; font-family: var(--font-display); - font-size: 2rem; + font-size: 1.8rem; 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); text-align: center; background: var(--bar); border: 1px solid var(--input-border); border-radius: var(--radius); - padding: 0 var(--space-2); + padding: 0 var(--space-1); } /* --- 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. */ .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 input[type="range"] { writing-mode: horizontal-tb; direction: ltr; 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::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); } diff --git a/submit.go b/submit.go index fc06b30..9d60605 100644 --- a/submit.go +++ b/submit.go @@ -84,6 +84,7 @@ type submission struct { Artist string Genre string Description string + Lyrics string CreatedAt time.Time } @@ -376,6 +377,21 @@ func (a *app) process(subID int64, sourceURL, src string) { return } 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) { @@ -399,10 +415,10 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission err = a.pool.QueryRow(r.Context(), ` select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''), coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''), - coalesce(description, ''), created_at + coalesce(description, ''), coalesce(lyrics, ''), created_at from submissions where id = $1`, id). 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) { http.NotFound(w, r) 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 // fields to publish, so a browser without JS loses nothing. 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") if genre != "" && !validGenre(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, ` - update submissions set title = nullif($2, ''), artist = nullif($3, ''), - genre = nullif($4, ''), description = nullif($5, '') + update submissions set + 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`, subID, - clean(r.FormValue("title"), maxTitle), - clean(r.FormValue("artist"), maxArtist), - genre, - clean(r.FormValue("description"), maxDescription)) + has("title"), clean(r.FormValue("title"), maxTitle), + has("artist"), clean(r.FormValue("artist"), maxArtist), + has("genre"), genre, + 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 } @@ -536,10 +567,11 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) { var songID int64 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) - 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)), + nilIfEmpty(cleanLyrics(s.Lyrics)), int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID) if err != nil { slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID) diff --git a/templates/layout.html b/templates/layout.html index 5231d49..4d3220f 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -9,6 +9,7 @@ +
diff --git a/templates/partials/player.html b/templates/partials/player.html index 1c83017..f95b6e9 100644 --- a/templates/partials/player.html +++ b/templates/partials/player.html @@ -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"}} +Sanoitukset +{{$lines := .LyricLines}} +{{if $lines}} +
+ {{range $lines}}

{{if .Text}}{{.Text}}{{else}} {{end}}

{{end}} +
+ +{{else}} +
+
{{lyricstext .Lyrics}}
+
+ +{{end}} +{{end}} + {{define "player"}} diff --git a/templates/song.html b/templates/song.html index 26b8477..f89176c 100644 --- a/templates/song.html +++ b/templates/song.html @@ -49,14 +49,23 @@
{{template "player" $s}} {{with $s.Description}}

{{.}}

{{end}} - -
- - Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata - tai poistaa arvostelusi 30 minuutin ajan. + + +
+ {{if $s.Lyrics}} +
{{template "lyricsview" $s}}
+ {{end}} +
+ +
+ + Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata + tai poistaa arvostelusi 30 minuutin ajan. +
+
@@ -67,6 +76,25 @@ {{with $s.SourceURL}}

Kuuntele YouTubessa

{{end}} +{{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}} + +
+ Sanoitukset{{if not $s.Lyrics}} — ei vielä lisätty{{end}} + {{if $s.Lyrics}}
{{lyricstext $s.Lyrics}}
{{end}} + {{if $s.Own}} +
+ + +
+

Sanoituksia voi muokata vielä arvostelujenkin jälkeen.

+ {{end}} +
+{{end}} + {{if $s.ViewerReview}}

Oma arvostelusi

diff --git a/templates/submission.html b/templates/submission.html index 45c7be8..465c7c0 100644 --- a/templates/submission.html +++ b/templates/submission.html @@ -31,6 +31,28 @@ {{define "saved"}}{{if .}}Tallennettu {{.}}{{end}}{{end}} + +{{define "lyricsfield"}} +
+ +
+ + {{if .Found}}Löytyi — tarkista ja muokkaa tarvittaessa.{{end}} + {{if .Searched}}{{if not .Found}} + Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse. + {{end}}{{end}} +
+
+{{end}} + {{define "content"}}

Lähetys

@@ -58,6 +80,7 @@ + {{template "lyricsfield" dict "ID" .Data.ID "Lyrics" .Data.Lyrics}} {{template "saved" ""}}