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.
520 lines
17 KiB
Go
520 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const (
|
|
maxUploadBytes = 50 << 20
|
|
maxDuration = 15 * time.Minute
|
|
maxPerDay = 5
|
|
maxTitle = 100
|
|
maxArtist = 100
|
|
maxDescription = 2000
|
|
quotaWindowText = "24 tunnin"
|
|
)
|
|
|
|
// Fixed list, validated app-side. Not a table: it never changes without a code change anyway.
|
|
//
|
|
// The stored value is the English code and the Finnish label is display only — the same split the
|
|
// statuses use, so rewording a genre never touches a song row.
|
|
type genre struct {
|
|
Code string
|
|
Label string
|
|
}
|
|
|
|
var genres = []genre{
|
|
{"Rock", "Rock"},
|
|
{"Metal", "Metal"},
|
|
{"Punk", "Punk"},
|
|
{"Blues", "Blues"},
|
|
{"Jazz", "Jazz"},
|
|
{"Electronic", "Elektroninen"},
|
|
{"Hip Hop", "Hip hop"},
|
|
{"Pop", "Pop"},
|
|
{"Folk / Country", "Folk / Country"},
|
|
{"Classical", "Klassinen"},
|
|
{"Soundtrack", "Elokuvamusiikki"},
|
|
{"Experimental", "Kokeellinen"},
|
|
{"Finnish", "Kotimainen"},
|
|
{"Just Plain Weird", "Ihan outoa"},
|
|
{"Other", "Muu"},
|
|
}
|
|
|
|
func validGenre(code string) bool {
|
|
return genreLabel(code) != ""
|
|
}
|
|
|
|
func genreLabel(code string) string {
|
|
for _, g := range genres {
|
|
if g.Code == code {
|
|
return g.Label
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ponytail: in-process goroutines, 2 at a time. A real queue is the upgrade if this ever needs to
|
|
// survive a restart mid-conversion or run on another box. Unbounded goroutines shelling out to
|
|
// ffmpeg is how one enthusiastic evening fork-bombs a small VPS.
|
|
var slots = make(chan struct{}, 2)
|
|
|
|
// The nullable metadata columns are read with coalesce and held as plain strings: templates
|
|
// indirect pointers when printing, so a nil *string would render as "<nil>" inside a form field.
|
|
type submission struct {
|
|
ID int64
|
|
UserID int64
|
|
Status string
|
|
StatusMsg *string
|
|
SourceURL *string
|
|
TmpPath string
|
|
Title string
|
|
Artist string
|
|
Genre string
|
|
Description string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func (s *submission) Ready() bool { return s.Status == "ready" }
|
|
func (s *submission) Failed() bool { return s.Status == "failed" }
|
|
func (s *submission) Done() bool { return s.Ready() || s.Failed() }
|
|
|
|
// Every status ships with its Finnish label, so the strings never leave Go.
|
|
func (s *submission) Label() string {
|
|
switch s.Status {
|
|
case "queued":
|
|
return "Jonossa…"
|
|
case "downloading":
|
|
return "Ladataan…"
|
|
case "converting":
|
|
return "Muunnetaan…"
|
|
case "ready":
|
|
return "Valmis julkaistavaksi"
|
|
case "failed":
|
|
return "Epäonnistui"
|
|
}
|
|
return s.Status
|
|
}
|
|
|
|
func (s *submission) Genres() []genre { return genres }
|
|
|
|
// The chosen genre's Finnish label, for pages that show it rather than offer it.
|
|
func (s *submission) GenreLabel() string { return genreLabel(s.Genre) }
|
|
|
|
func (a *app) tmpPath(id int64, ext string) string {
|
|
return filepath.Join(a.cfg.storageDir, "tmp", strconv.FormatInt(id, 10)+ext)
|
|
}
|
|
|
|
func (a *app) audioPath(songID int64) string {
|
|
return filepath.Join(a.cfg.storageDir, "audio", strconv.FormatInt(songID, 10)+".ogg")
|
|
}
|
|
|
|
// --- submit ---
|
|
|
|
// The submit page also lists your own submissions still in flight, so one is reachable by
|
|
// something other than its URL.
|
|
func (a *app) submitPage(w http.ResponseWriter, r *http.Request) {
|
|
a.submitError(w, r, http.StatusOK, "")
|
|
}
|
|
|
|
func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
|
subs, err := a.mySubmissions(r.Context(), memberFrom(r.Context()).ID)
|
|
if err != nil {
|
|
slog.Error("list submissions", "ctx", "submissions", "error", err)
|
|
}
|
|
a.render(w, r, status, "submit.html",
|
|
page{Title: "Lähetä kappale", Data: map[string]any{"Error": msg, "Submissions": subs}})
|
|
}
|
|
|
|
// Five submissions per rolling 24 hours. Failed ones never count: no audio, no submission, and
|
|
// yt-dlp breaking is not the submitter's fault. Published songs do count, so the row being gone
|
|
// from `submissions` is why this also looks at `songs`.
|
|
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
|
|
var n int
|
|
err := a.pool.QueryRow(ctx, `
|
|
select (select count(*) from submissions
|
|
where user_id = $1 and status <> 'failed'
|
|
and created_at > now() - interval '24 hours')
|
|
+ (select count(*) from songs
|
|
where submitted_by = $1 and created_at > now() - interval '24 hours')`,
|
|
userID).Scan(&n)
|
|
return n >= maxPerDay, err
|
|
}
|
|
|
|
func (a *app) submit(w http.ResponseWriter, r *http.Request) {
|
|
m := memberFrom(r.Context())
|
|
|
|
over, err := a.overQuota(r.Context(), m.ID)
|
|
if err != nil {
|
|
slog.Error("quota", "ctx", "submissions", "error", err)
|
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
|
return
|
|
}
|
|
if over {
|
|
a.submitError(w, r, http.StatusTooManyRequests,
|
|
fmt.Sprintf("Olet lähettänyt jo %d kappaletta viimeisen %s aikana. Yritä huomenna.",
|
|
maxPerDay, quotaWindowText))
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
|
file, header, err := r.FormFile("audio")
|
|
if err != nil {
|
|
a.submitError(w, r, http.StatusRequestEntityTooLarge,
|
|
"Tiedostoa ei voitu lukea. Enintään 50 MB.")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
var subID int64
|
|
err = a.pool.QueryRow(r.Context(),
|
|
`insert into submissions (user_id, status) values ($1, 'queued') returning id`,
|
|
m.ID).Scan(&subID)
|
|
if err != nil {
|
|
slog.Error("create submission", "ctx", "submissions", "error", err)
|
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
|
return
|
|
}
|
|
|
|
// The row exists before the file does, so nothing on disk is ever unaccounted for.
|
|
src := a.tmpPath(subID, filepath.Ext(header.Filename))
|
|
dst, err := os.Create(src)
|
|
if err == nil {
|
|
_, err = io.Copy(dst, file)
|
|
dst.Close()
|
|
}
|
|
if err != nil {
|
|
slog.Error("save upload", "ctx", "submissions", "error", err, "submission", subID)
|
|
a.discardSubmission(r.Context(), subID, src)
|
|
a.submitError(w, r, http.StatusRequestEntityTooLarge,
|
|
"Tiedostoa ei voitu tallentaa. Enintään 50 MB.")
|
|
return
|
|
}
|
|
|
|
// Metadata is read synchronously: arriving later, it would land in a form the submitter is
|
|
// already typing into and race their keystrokes.
|
|
meta, err := probe(r.Context(), src)
|
|
if err != nil {
|
|
a.discardSubmission(r.Context(), subID, src)
|
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
|
"Tiedostosta ei löytynyt ääntä. Onko se varmasti äänitiedosto?")
|
|
return
|
|
}
|
|
if meta.Duration > maxDuration {
|
|
a.discardSubmission(r.Context(), subID, src)
|
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
|
"Kappale on yli 15 minuuttia pitkä.")
|
|
return
|
|
}
|
|
|
|
if _, err := a.pool.Exec(r.Context(),
|
|
`update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '')
|
|
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
|
|
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
|
|
}
|
|
|
|
slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID)
|
|
go a.convert(subID, src)
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
|
if path != "" {
|
|
os.Remove(path)
|
|
}
|
|
if _, err := a.pool.Exec(ctx, `delete from submissions where id = $1`, subID); err != nil {
|
|
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
|
|
}
|
|
}
|
|
|
|
// --- convert ---
|
|
|
|
func (a *app) convert(subID int64, src string) {
|
|
slots <- struct{}{}
|
|
defer func() { <-slots }()
|
|
|
|
// Detached from the request: the submitter's browser is long gone by now.
|
|
ctx := context.Background()
|
|
a.setStatus(ctx, subID, "converting", "")
|
|
|
|
out := a.tmpPath(subID, ".ogg")
|
|
msg, err := convertToOpus(ctx, src, out)
|
|
if err != nil {
|
|
os.Remove(out)
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
a.setStatus(ctx, subID, "failed", msg)
|
|
slog.Warn("conversion failed", "ctx", "submissions", "submission", subID, "error", err)
|
|
return
|
|
}
|
|
// The original is discarded as soon as the Opus exists.
|
|
os.Remove(src)
|
|
|
|
if _, err := a.pool.Exec(ctx,
|
|
`update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`,
|
|
subID, out); err != nil {
|
|
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
|
|
return
|
|
}
|
|
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
|
|
}
|
|
|
|
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
|
|
if _, err := a.pool.Exec(ctx,
|
|
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
|
|
subID, status, msg); err != nil {
|
|
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
|
|
}
|
|
}
|
|
|
|
// --- the waiting page ---
|
|
|
|
// Submitter-only: a submission is invisible to everyone else, including a failed one.
|
|
func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return nil
|
|
}
|
|
var s 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
|
|
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)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return nil
|
|
} else if err != nil {
|
|
slog.Error("load submission", "ctx", "submissions", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return nil
|
|
}
|
|
if s.UserID != memberFrom(r.Context()).ID {
|
|
http.NotFound(w, r)
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func (a *app) submissionPage(w http.ResponseWriter, r *http.Request) {
|
|
s := a.loadSubmission(w, r)
|
|
if s == nil {
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "submission.html", page{Title: "Lähetys", Data: s})
|
|
}
|
|
|
|
// The same partial the page includes on first paint, returned alone for the HTMX poll — so the
|
|
// markup exists once and arrives already populated. HTMX stops polling when the fragment drops
|
|
// hx-trigger, which it does on a terminal status.
|
|
func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) {
|
|
s := a.loadSubmission(w, r)
|
|
if s == nil {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := pages["submission.html"].ExecuteTemplate(w, "submission-status", s); err != nil {
|
|
slog.Error("render status", "ctx", "submissions", "error", err)
|
|
}
|
|
}
|
|
|
|
// Metadata is editable while the conversion runs — that is the point of the waiting page. There is
|
|
// 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 {
|
|
genre := r.FormValue("genre")
|
|
if genre != "" && !validGenre(genre) {
|
|
return fmt.Errorf("unknown genre %q", genre)
|
|
}
|
|
_, err := a.pool.Exec(ctx, `
|
|
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
|
|
genre = nullif($4, ''), description = nullif($5, '')
|
|
where id = $1`,
|
|
subID,
|
|
clean(r.FormValue("title"), maxTitle),
|
|
clean(r.FormValue("artist"), maxArtist),
|
|
genre,
|
|
clean(r.FormValue("description"), maxDescription))
|
|
return err
|
|
}
|
|
|
|
func (a *app) saveSubmission(w http.ResponseWriter, r *http.Request) {
|
|
s := a.loadSubmission(w, r)
|
|
if s == nil {
|
|
return
|
|
}
|
|
if err := a.saveMetadata(r.Context(), s.ID, r); err != nil {
|
|
slog.Error("save submission", "ctx", "submissions", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
// The autosave answers with the "saved at" line and nothing else; a plain POST (no JS) goes
|
|
// back to the page.
|
|
if r.Header.Get("HX-Request") == "" {
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := pages["submission.html"].ExecuteTemplate(w, "saved",
|
|
time.Now().Local().Format("15.04")); err != nil {
|
|
slog.Error("render saved", "ctx", "submissions", "error", err)
|
|
}
|
|
}
|
|
|
|
// --- publish ---
|
|
|
|
func (a *app) publish(w http.ResponseWriter, r *http.Request) {
|
|
s := a.loadSubmission(w, r)
|
|
if s == nil {
|
|
return
|
|
}
|
|
if !s.Ready() {
|
|
http.Error(w, "ei vielä valmis", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Julkaise submits the metadata form, so the last keystrokes arrive with it — the autosave is
|
|
// a convenience, not the only path.
|
|
if r.FormValue("title") != "" || r.FormValue("artist") != "" || r.FormValue("genre") != "" {
|
|
if err := a.saveMetadata(r.Context(), s.ID, r); err != nil {
|
|
slog.Error("save before publish", "ctx", "submissions", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
if s = a.loadSubmission(w, r); s == nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Title, artist and genre are required here rather than at submit: the form is meant to be
|
|
// filled while the conversion runs, and prefill can legitimately produce nothing.
|
|
title, artist, genre := clean(s.Title, maxTitle), clean(s.Artist, maxArtist), s.Genre
|
|
if title == "" || artist == "" || !validGenre(genre) {
|
|
a.flash(w, "Täytä nimi, esittäjä ja genre ennen julkaisua.")
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
src := s.TmpPath
|
|
meta, err := probe(r.Context(), src)
|
|
if err != nil {
|
|
slog.Error("probe before publish", "ctx", "submissions", "error", err, "submission", s.ID)
|
|
a.flash(w, "Äänitiedostoa ei löytynyt. Lähetä kappale uudelleen.")
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
tx, err := a.pool.Begin(r.Context())
|
|
if err != nil {
|
|
slog.Error("begin publish", "ctx", "submissions", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
var songID int64
|
|
err = tx.QueryRow(r.Context(), `
|
|
insert into songs (title, artist, genre, description, audio_file, duration_seconds,
|
|
source_url, submitted_by)
|
|
values ($1, $2, $3, $4, '', $5, $6, $7) returning id`,
|
|
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
|
|
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
|
|
if err != nil {
|
|
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// The rename is inside the transaction: if the file move fails, the song row never existed.
|
|
// A crash between rename and commit leaves an orphan .ogg — the startup sweep gets it.
|
|
dst := a.audioPath(songID)
|
|
if err := os.Rename(src, dst); err != nil {
|
|
slog.Error("move audio", "ctx", "songs", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := tx.Exec(r.Context(),
|
|
`update songs set audio_file = $2 where id = $1`,
|
|
songID, filepath.Base(dst)); err != nil {
|
|
os.Rename(dst, src)
|
|
slog.Error("set audio file", "ctx", "songs", "error", err, "song", songID)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil {
|
|
os.Rename(dst, src)
|
|
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
os.Rename(dst, src)
|
|
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
slog.Info("song published", "ctx", "songs", "song", songID, "user", s.UserID)
|
|
a.flash(w, "Kappale julkaistu.")
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// A failed submission is denied in every sense that matters: invisible to everyone but its
|
|
// submitter and never in `songs`. Discard removes the row and its temp file.
|
|
func (a *app) discard(w http.ResponseWriter, r *http.Request) {
|
|
s := a.loadSubmission(w, r)
|
|
if s == nil {
|
|
return
|
|
}
|
|
a.discardSubmission(r.Context(), s.ID, s.TmpPath)
|
|
os.Remove(a.tmpPath(s.ID, ".ogg"))
|
|
a.flash(w, "Lähetys poistettu.")
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func nilIfEmpty(s string) *string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL.
|
|
func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) {
|
|
rows, err := a.pool.Query(ctx, `
|
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
|
coalesce(description, ''), created_at
|
|
from submissions where user_id = $1 order by created_at desc`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*submission
|
|
for rows.Next() {
|
|
var s submission
|
|
if err := rows.Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
|
|
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|