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..6a517b9 --- /dev/null +++ b/lyrics.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "regexp" + "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*)+`) + +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..14b0b89 --- /dev/null +++ b/lyrics_test.go @@ -0,0 +1,86 @@ +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) + } +} + +// 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..a2fd035 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 @@ -161,12 +162,13 @@ func (s *songDetail) Locked() bool { return s.ReviewCount > 0 } 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 +233,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/style.css b/static/style.css index 821bdcd..795b23b 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,6 +453,39 @@ 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; +} .deck .player { margin: 0; } .deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; } @@ -740,6 +802,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 +1045,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/song.html b/templates/song.html index 26b8477..42f581a 100644 --- a/templates/song.html +++ b/templates/song.html @@ -49,14 +49,26 @@
{{.}}
{{end}} - -{{lyricstext $s.Lyrics}}{{end}}
+ {{if $s.Own}}
+
+ Sanoituksia voi muokata vielä arvostelujenkin jälkeen.
+ {{end}} +