Files
Levyraati26_go/songs.go
T
Esa Kataja f51dcd743e Rebuild the review page as a channel strip
The app is about operating something — playing a track and setting a level on
it — but every screen looked like a form. One metaphor now does three jobs.

- Reviewing: a vertical fader beside the text, so the two things you do at
  once stop being a screen apart. Native range input, so keyboard, focus and
  form submission are unchanged; on mobile it lies down and the ticks reverse
- The reveal: everyone's scores as a row of channels. The silhouette of that
  row is the spread, which the stats page can only tell you as a number
- Profiles: given versus received as two faders, the one comparison that says
  something about a person

The player is now a transport: play/pause, a range input for seeking so arrow
keys come free, and a stereo level meter driven by a real AnalyserNode. It is
progressive enhancement — the page ships native audio controls and the script
takes over, so no JS means the browser's own player. The meter is dark until
audio actually plays and stops when it does; reduced motion skips it entirely.

Also: hidden scores are hatched rather than blank, the nav carries the queue
count, "Seuraava jonossa" keeps the loop going after a review, leaderboards
gained level bars and a range bar where divisive is the point, durations read
3:54, both lists can get back to the start, and the admin invite table lists
unused codes instead of silently truncating at 50.

Slogan restored from the original app, three decades on.
2026-07-31 23:17:40 +03:00

346 lines
11 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
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 }
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
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)
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})
}
// --- 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)
}