Add the submission pipeline and the review loop
Steps 3 and 4 of the build order. A member can now upload a song, watch it convert, publish it, and review what everyone else has published. Pipeline: - ffprobe reads tags synchronously at submit so prefill never races typing; ffmpeg converts to Opus in the background, two at a time - ffmpeg succeeding is the validation — no container sniffing - publish moves the file inside the transaction, so a song row and its .ogg appear together or neither does - five submissions per rolling 24h, failures excluded Reviews and the reveal rule: - the queue is unreviewed songs only, oldest first, never your own - other people's reviews and the average are withheld in the query, not the template — a hidden average is never sent - 30 minutes to edit or delete your own review, enforced in the WHERE clause - deleting the last review unlocks the song for its submitter again The waiting page has one button: the metadata form autosaves after a pause in typing, and Julkaise submits it and publishes in the same request, so nothing is lost without JS. Genres store an English code and render a Finnish label.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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
|
||||
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, 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, false), nil
|
||||
}
|
||||
|
||||
// One row over the page size is fetched so "is there more" needs no second count query.
|
||||
func paginate(items []*songSummary, isQueue bool) *songList {
|
||||
l := &songList{Items: items, 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
|
||||
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
|
||||
}
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
os.Remove(a.audioPath(id))
|
||||
slog.Info("song deleted", "ctx", "songs", "song", id)
|
||||
a.flash(w, "Kappale poistettu.")
|
||||
http.Redirect(w, r, "/songs", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
Reference in New Issue
Block a user