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,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
|
||||
}
|
||||
Reference in New Issue
Block a user