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:
Esa Kataja
2026-07-31 21:42:49 +03:00
parent 41c8a2914f
commit 80d3e36679
19 changed files with 2040 additions and 16 deletions
+6 -1
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
)
@@ -15,6 +16,7 @@ var assetFS embed.FS
var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
}
// Each page is parsed with the layout into its own set, so two pages may both define "content".
@@ -29,8 +31,11 @@ func init() {
if e.Name() == "layout.html" {
continue
}
if e.IsDir() {
continue
}
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name()))
ParseFS(assetFS, "templates/layout.html", "templates/partials/*.html", "templates/"+e.Name()))
}
}