Files
Levyraati26_go/songs.go
T
Esa Kataja a9776c6dde Follow the song in the lyrics
Synced LRC highlights the playing line, keeps it centred and seeks on click.
Plain text scrolls continuously with a nudge knob instead — a highlight on
guessed timings turns guaranteed drift into what looks like a bug. Seuraa
kappaletta turns following off without losing the highlight, and scrolling by
hand turns it off too.

Fixes the scroll landing in the wrong place (offsetTop measured from a
different coordinate space than the box it was applied to) and the fader
shifting the deck sideways at score 100 (auto-sized grid columns plus a
readout spanning both).
2026-08-01 00:45:40 +03:00

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)
}