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
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"encoding/json"
"os/exec"
"strconv"
"strings"
"time"
)
// Everything here shells out with exec.CommandContext and an argument list — never a shell string.
type probeResult struct {
Title string
Artist string
Duration time.Duration
}
type ffprobeOutput struct {
Format struct {
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
// probe reads duration and whatever title/artist tags the container carries. Tag keys vary in case
// by container (title, TITLE, Title), so the map is lowercased before anything is read from it.
func probe(ctx context.Context, path string) (probeResult, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "ffprobe",
"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path).Output()
if err != nil {
return probeResult{}, err
}
var parsed ffprobeOutput
if err := json.Unmarshal(out, &parsed); err != nil {
return probeResult{}, err
}
tags := map[string]string{}
for _, stream := range parsed.Streams {
if stream.CodecType != "audio" {
continue
}
for k, v := range stream.Tags {
tags[strings.ToLower(k)] = v
}
}
// Container tags win over stream tags when both exist.
for k, v := range parsed.Format.Tags {
tags[strings.ToLower(k)] = v
}
var res probeResult
res.Title = clean(tags["title"], 100)
res.Artist = clean(firstOf(tags, "artist", "album_artist"), 100)
if secs, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil {
res.Duration = time.Duration(secs * float64(time.Second))
}
return res, nil
}
func firstOf(m map[string]string, keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(m[k]); v != "" {
return v
}
}
return ""
}
// Tag text is attacker-controlled and arrives inside an uploaded file. html/template escapes on
// render, but a title with an embedded newline wrecks every list layout it appears in.
func clean(s string, max int) string {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
s = strings.TrimSpace(strings.Join(strings.Fields(s), " "))
if r := []rune(s); len(r) > max {
s = strings.TrimSpace(string(r[:max]))
}
return s
}
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
// showing — "Invalid data found when processing input" beats "submission failed".
func convertToOpus(ctx context.Context, in, out string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y",
"-i", in, "-c:a", "libopus", "-b:a", "96k", "-ac", "2", "-vn", out)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return tail(stderr.String(), 400), err
}
return "", nil
}
func tail(s string, n int) string {
s = strings.TrimSpace(s)
lines := strings.Split(s, "\n")
if len(lines) > 3 {
lines = lines[len(lines)-3:]
}
s = strings.TrimSpace(strings.Join(lines, " "))
if r := []rune(s); len(r) > n {
s = string(r[len(r)-n:])
}
return s
}