Lyrics, versioning, and the song page's fact line. - Lyrics are suggested at submission and never imposed: the worker makes one LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist are typed. Neither overwrites what the submitter wrote. They live on the submission, travel to the song at publish, and stay editable after the song locks — the lock freezes what a song claims to be, and nobody reviewed the lyrics - The review strip reads and writes side by side: lyrics left, review right. Synced LRC highlights the playing line and seeks on click; plain text scrolls continuously with a nudge knob. Following can be turned off - CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the footer, the startup log and /healthz - The song page's metadata became four labelled cells instead of one flat run of five different kinds of fact Fixes: publishing wiped lyrics the worker had just fetched (a request that omitted a field cleared it), lyric auto-scroll landed in the wrong place, and the fader shifted the deck sideways at score 100.
377 lines
12 KiB
Go
377 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const pageSize = 20
|
|
|
|
type songSummary struct {
|
|
ID int64
|
|
Title string
|
|
Artist string
|
|
Genre string
|
|
Duration int
|
|
CreatedAt time.Time
|
|
Submitter string
|
|
SubmitterID int64
|
|
ReviewCount int
|
|
// Nil unless the viewer has revealed the song. The reveal rule is applied in the query, not
|
|
// in the template — a hidden average is never sent.
|
|
Average *float64
|
|
Own bool
|
|
Reviewed bool
|
|
}
|
|
|
|
func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) }
|
|
func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed }
|
|
|
|
func (s *songSummary) Length() string {
|
|
return fmt.Sprintf("%d:%02d", s.Duration/60, s.Duration%60)
|
|
}
|
|
|
|
type songList struct {
|
|
Items []*songSummary
|
|
Cursor int64 // the cursor this page was fetched with; 0 means the first page
|
|
NextCursor int64 // 0 when there is no next page
|
|
Queue bool
|
|
}
|
|
|
|
// The select list is identical for both lists, so the reveal rule cannot drift between them.
|
|
const songColumns = `
|
|
s.id, s.title, s.artist, s.genre, s.duration_seconds, s.created_at, u.id, u.name,
|
|
(select count(*) from reviews r where r.song_id = s.id),
|
|
case when s.submitted_by = $1
|
|
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
|
then (select avg(r.score)::float from reviews r where r.song_id = s.id)
|
|
end,
|
|
s.submitted_by = $1,
|
|
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
|
|
|
|
func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
|
|
defer rows.Close()
|
|
var out []*songSummary
|
|
for rows.Next() {
|
|
var s songSummary
|
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Genre, &s.Duration, &s.CreatedAt,
|
|
&s.SubmitterID, &s.Submitter, &s.ReviewCount, &s.Average, &s.Own, &s.Reviewed); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can
|
|
// never act on those, so they would sit at the front forever.
|
|
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
|
from songs s join users u on u.id = s.submitted_by
|
|
where s.submitted_by <> $1
|
|
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
|
and ($2 = 0 or s.id > $2)
|
|
order by s.created_at, s.id
|
|
limit $3`, viewerID, cursor, pageSize+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := scanSongs(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return paginate(items, cursor, true), nil
|
|
}
|
|
|
|
// Everything, newest first. This is where a song lives once it has left the queue.
|
|
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
|
from songs s join users u on u.id = s.submitted_by
|
|
where ($2 = 0 or s.id < $2)
|
|
order by s.created_at desc, s.id desc
|
|
limit $3`, viewerID, cursor, pageSize+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := scanSongs(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return paginate(items, cursor, false), nil
|
|
}
|
|
|
|
// One row over the page size is fetched so "is there more" needs no second count query.
|
|
func paginate(items []*songSummary, cursor int64, isQueue bool) *songList {
|
|
l := &songList{Items: items, Cursor: cursor, Queue: isQueue}
|
|
if len(items) > pageSize {
|
|
l.Items = items[:pageSize]
|
|
l.NextCursor = l.Items[pageSize-1].ID
|
|
}
|
|
return l
|
|
}
|
|
|
|
func cursorOf(r *http.Request) int64 {
|
|
n, _ := strconv.ParseInt(r.URL.Query().Get("cursor"), 10, 64)
|
|
return n
|
|
}
|
|
|
|
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
|
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
|
if err != nil {
|
|
slog.Error("queue", "ctx", "songs", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list})
|
|
}
|
|
|
|
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
|
list, err := a.browse(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
|
if err != nil {
|
|
slog.Error("browse", "ctx", "songs", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "songs.html", page{Title: "Kappaleet", Data: list})
|
|
}
|
|
|
|
// --- detail ---
|
|
|
|
type songDetail struct {
|
|
songSummary
|
|
Description string
|
|
Lyrics string
|
|
SourceURL *string
|
|
Reviews []*review // nil when the reveal rule is withholding them
|
|
ViewerReview *review
|
|
CanReview bool
|
|
CanEdit bool // submitter, and the song is unlocked
|
|
NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review
|
|
Genres []genre
|
|
}
|
|
|
|
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, ''), 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.Lyrics, &d.SourceURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.Genres = genres
|
|
d.CanReview = !d.Own && !d.Reviewed
|
|
d.CanEdit = d.Own && d.ReviewCount == 0
|
|
|
|
if d.Reviewed {
|
|
d.ViewerReview, err = a.viewerReview(ctx, songID, viewerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// The query only runs when the song is revealed: hidden reviews are never fetched, let alone
|
|
// sent and hidden with CSS.
|
|
if d.Revealed() {
|
|
d.Reviews, err = a.reviewsFor(ctx, songID, viewerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if !d.CanReview {
|
|
d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// The oldest song the viewer still owes a review on. Offered right after they finish one, so
|
|
// draining the queue never means navigating back to it.
|
|
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
|
|
var id int64
|
|
err := a.pool.QueryRow(ctx, `
|
|
select s.id from songs s
|
|
where s.submitted_by <> $1 and s.id <> $2
|
|
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
|
order by s.created_at, s.id
|
|
limit 1`, viewerID, exceptID).Scan(&id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, nil
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
} else if err != nil {
|
|
slog.Error("song", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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
|
|
// reviewed it, the thing they reviewed stops changing under them.
|
|
func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
genre := r.FormValue("genre")
|
|
if !validGenre(genre) {
|
|
http.Error(w, "tuntematon genre", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
title, artist := clean(r.FormValue("title"), maxTitle), clean(r.FormValue("artist"), maxArtist)
|
|
if title == "" || artist == "" {
|
|
a.flash(w, "Nimi ja esittäjä ovat pakollisia.")
|
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
tag, err := a.pool.Exec(r.Context(), `
|
|
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
|
|
where id = $1 and submitted_by = $2
|
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
|
id, memberFrom(r.Context()).ID, title, artist, genre,
|
|
clean(r.FormValue("description"), maxDescription))
|
|
if err != nil {
|
|
slog.Error("edit song", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
|
|
} else {
|
|
a.flash(w, "Tiedot tallennettu.")
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
|
}
|
|
|
|
// The row and the file go together, always.
|
|
func (a *app) deleteSong(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(), `
|
|
delete from songs where id = $1 and submitted_by = $2
|
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
|
id, memberFrom(r.Context()).ID)
|
|
if err != nil {
|
|
slog.Error("delete song", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
|
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
|
return
|
|
}
|
|
removeFile(a.audioPath(id))
|
|
slog.Info("song deleted", "ctx", "songs", "song", id)
|
|
a.flash(w, "Kappale poistettu.")
|
|
http.Redirect(w, r, "/songs", http.StatusSeeOther)
|
|
}
|
|
|
|
// A missing file is fine — the row is gone either way — but anything else is worth knowing about.
|
|
func removeFile(path string) {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
slog.Error("remove file", "ctx", "songs", "error", err, "path", path)
|
|
}
|
|
}
|
|
|
|
// --- audio ---
|
|
|
|
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
|
|
// Parsing the id as an integer is the traversal check.
|
|
func (a *app) audio(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var name string
|
|
err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
} else if err != nil {
|
|
slog.Error("audio lookup", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(a.audioPath(id))
|
|
if err != nil {
|
|
slog.Error("audio open", "ctx", "songs", "error", err, "song", id)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "audio/ogg")
|
|
// ServeContent handles 206, 416 and If-Range correctly, which hand-rolled Range parsing does not.
|
|
http.ServeContent(w, r, name, info.ModTime(), f)
|
|
}
|