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
+25 -7
View File
@@ -96,10 +96,20 @@ zero reviews, it is editable again.
### 2.2 Genres
Fixed list, `text` column, validated app-side:
Fixed list, `text` column, validated app-side. 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:
Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack,
Experimental, Finnish, Just Plain Weird, Other
| Code | Label | | Code | Label |
|---|---|---|---|---|
| Rock | Rock | | Soundtrack | Elokuvamusiikki |
| Metal | Metal | | Experimental | Kokeellinen |
| Punk | Punk | | Classical | Klassinen |
| Blues | Blues | | Electronic | Elektroninen |
| Jazz | Jazz | | Hip Hop | Hip hop |
| Pop | Pop | | Finnish | Kotimainen |
| Folk / Country | Folk / Country | | Just Plain Weird | Ihan outoa |
| | | | Other | Muu |
---
@@ -201,9 +211,17 @@ States: `queued` → (`downloading`, URL only) → `converting` → `ready` | `f
Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the
introduction rather than watching a spinner.
The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes
**Julkaise**. Publishing is always an explicit click — firing it automatically would race the
submitter mid-sentence.
**One button, at the bottom of the form: Julkaise**, disabled until `status = 'ready'`. Publishing
is always an explicit click — firing it automatically would race the submitter mid-sentence.
There is no separate save button: two buttons made it unclear which one committed the text.
- The metadata form **autosaves**`hx-post` on `input changed delay:1.2s` and on `change`,
answering with a quiet "Tallennettu 21.37" line and nothing else.
- Julkaise lives outside the form and is bound to it with the HTML `form=` attribute, so pressing
it submits the metadata *and* publishes in one request. The last keystrokes therefore arrive with
the click even if the autosave never fired — which is also what makes the page work with no JS at
all.
The live part is HTMX polling a fragment:
@@ -211,7 +229,7 @@ The live part is HTMX polling a fragment:
<!-- {{define "submission-status"}} — included by the page, returned alone by the poll -->
<div hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML">
<p>{{.Label}}</p>
<button {{if not .Ready}}disabled{{end}}>Julkaise</button>
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
</div>
```
+18 -3
View File
@@ -135,9 +135,24 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /logout", a.logout)
mux.HandleFunc("GET /{$}", a.requireMember(func(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"})
}))
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
return mux
}
+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
}
+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()))
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const (
editWindow = 30 * time.Minute
maxReview = 5000
)
type review struct {
ID int64
SongID int64
ReviewerID int64
Reviewer string
Score int
Text string
CreatedAt time.Time
UpdatedAt time.Time
Own bool
}
// The window is measured from updated_at, so an edit extends it. It gates deletion as well as
// editing: for 30 minutes a review is yours to change or withdraw, after that it is on the record.
func (r *review) EditableUntil() time.Time { return r.UpdatedAt.Add(editWindow) }
func (r *review) CanEdit() bool { return r.Own && time.Now().Before(r.EditableUntil()) }
func (r *review) Initials() string {
m := member{Name: r.Reviewer}
return m.Initials()
}
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
rows, err := a.pool.Query(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
r.reviewer_id = $2
from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1
order by r.created_at`, songID, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*review
for rows.Next() {
var v review
if err := rows.Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own); err != nil {
return nil, err
}
out = append(out, &v)
}
return out, rows.Err()
}
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
var v review
err := a.pool.QueryRow(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return &v, err
}
func reviewInput(r *http.Request) (int, string, string) {
score, _ := strconv.Atoi(r.FormValue("score"))
text := clean(r.FormValue("text"), maxReview)
switch {
case score < 1 || score > 100:
return 0, "", "Pisteiden tulee olla 1100."
case text == "":
return 0, "", "Kirjoita muutama sana."
}
return score, text, ""
}
func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
songID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
me := memberFrom(r.Context())
score, text, problem := reviewInput(r)
if problem != "" {
a.flash(w, problem)
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
return
}
// You cannot review your own song, and the unique constraint is what stops a second review —
// no read-then-write race to lose.
var submitter int64
err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
slog.Error("load song", "ctx", "reviews", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if submitter == me.ID {
http.Error(w, "omaa kappaletta ei voi arvostella", http.StatusForbidden)
return
}
_, err = a.pool.Exec(r.Context(),
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
songID, me.ID, score, text)
if isUnique(err) {
a.flash(w, "Olet jo arvostellut tämän kappaleen.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("create review", "ctx", "reviews", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("review written", "ctx", "reviews", "song", songID, "user", me.ID)
a.flash(w, "Arvostelu tallennettu. Nyt näet muidenkin arvostelut.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
// Both edit and delete are gated by the same window, in the same WHERE clause — the database
// decides, so there is no clock-check in Go to get subtly wrong.
func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
score, text, problem := reviewInput(r)
if problem != "" {
a.flash(w, problem)
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
update reviews set score = $3, text = $4, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
returning song_id`,
id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("edit review", "ctx", "reviews", "error", err, "review", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.flash(w, "Arvostelu päivitetty.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
// Deleting the last review unlocks the song for its submitter again — locked is a live state, and
// the 30-minute window is what keeps that from being a rug-pull months later.
func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning song_id`,
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("delete review", "ctx", "reviews", "error", err, "review", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("review deleted", "ctx", "reviews", "review", id, "song", songID)
a.flash(w, "Arvostelu poistettu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
+314
View File
@@ -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)
}
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"context"
"testing"
)
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(), `
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
title, submitter).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(), `
insert into reviews (song_id, reviewer_id, score, text)
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
// A member who hasn't reviewed a song must not receive other reviews *in the result set*, and must
// not receive the average either — not merely fail to render them.
func TestRevealRuleWithholdsReviewsAndAverage(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
cecilia := a.seedMember(t, "[email protected]")
songID := a.seedSong(t, aino, "Testikappale")
a.seedReview(t, songID, bertta, 88)
// Cecilia has not reviewed it.
d, err := a.song(ctx, cecilia, songID)
if err != nil {
t.Fatal(err)
}
if d.Revealed() {
t.Fatal("song is revealed to a member who has not reviewed it")
}
if d.Reviews != nil {
t.Fatalf("withheld reviews were still fetched: %d of them", len(d.Reviews))
}
if d.Average != nil {
t.Fatalf("withheld average was still sent: %v", *d.Average)
}
if d.ReviewCount != 1 {
t.Fatalf("review count = %d, want 1 — the count is not secret", d.ReviewCount)
}
// Writing her own review unlocks both.
a.seedReview(t, songID, cecilia, 60)
d, err = a.song(ctx, cecilia, songID)
if err != nil {
t.Fatal(err)
}
if !d.Revealed() || len(d.Reviews) != 2 {
t.Fatalf("after reviewing: revealed = %v, reviews = %d, want true and 2",
d.Revealed(), len(d.Reviews))
}
if d.Average == nil || *d.Average != 74 {
t.Fatalf("average = %v, want 74", d.Average)
}
// The submitter sees everything without reviewing — they cannot review their own song.
d, err = a.song(ctx, aino, songID)
if err != nil {
t.Fatal(err)
}
if !d.Revealed() || len(d.Reviews) != 2 || d.Average == nil {
t.Fatal("the submitter cannot see the reviews of their own song")
}
if d.CanReview {
t.Fatal("the submitter is offered a review form for their own song")
}
// And the same rule holds in the list query, which is a different SQL path.
list, err := a.browse(ctx, cecilia, 0)
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || list.Items[0].Average == nil {
t.Fatal("browse withheld the average from someone who has reviewed the song")
}
list, err = a.browse(ctx, a.seedMember(t, "[email protected]"), 0)
if err != nil {
t.Fatal(err)
}
if list.Items[0].Average != nil {
t.Fatal("browse leaked the average to someone who has not reviewed the song")
}
}
// The queue excludes your own songs and anything you have already reviewed, oldest first.
func TestQueueContents(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
own := a.seedSong(t, aino, "Oma kappale")
reviewed := a.seedSong(t, bertta, "Jo arvosteltu")
fresh := a.seedSong(t, bertta, "Arvostelematon")
a.seedReview(t, reviewed, aino, 50)
list, err := a.queue(ctx, aino, 0)
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 {
var titles []string
for _, s := range list.Items {
titles = append(titles, s.Title)
}
t.Fatalf("queue = %v, want just the unreviewed song", titles)
}
if list.Items[0].ID != fresh {
t.Fatalf("queue holds song %d, want %d", list.Items[0].ID, fresh)
}
_ = own
// Oldest first: a second unreviewed song comes after the first.
older := a.seedSong(t, bertta, "Vanhempi")
if _, err := a.pool.Exec(ctx,
`update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
t.Fatal(err)
}
list, err = a.queue(ctx, aino, 0)
if err != nil {
t.Fatal(err)
}
if list.Items[0].ID != older {
t.Fatal("queue is not oldest first")
}
}
// Locked is a live state: deleting the only review makes the song editable again.
func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
songID := a.seedSong(t, aino, "Testikappale")
d, _ := a.song(ctx, aino, songID)
if !d.CanEdit {
t.Fatal("a song with no reviews is not editable by its submitter")
}
reviewID := a.seedReview(t, songID, bertta, 88)
d, _ = a.song(ctx, aino, songID)
if d.CanEdit {
t.Fatal("a reviewed song is still editable")
}
if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
t.Fatal(err)
}
d, _ = a.song(ctx, aino, songID)
if !d.CanEdit {
t.Fatal("song did not unlock after its only review was deleted")
}
}
// The window is measured from updated_at, so an edit extends it — and it gates delete too.
func TestEditWindow(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
songID := a.seedSong(t, aino, "Testikappale")
reviewID := a.seedReview(t, songID, bertta, 88)
v, err := a.viewerReview(ctx, songID, bertta)
if err != nil {
t.Fatal(err)
}
if !v.CanEdit() {
t.Fatal("a fresh review is not editable")
}
// Just inside the window.
if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
reviewID); err != nil {
t.Fatal(err)
}
v, _ = a.viewerReview(ctx, songID, bertta)
if !v.CanEdit() {
t.Fatal("a 29-minute-old review is not editable")
}
// Past it.
if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
reviewID); err != nil {
t.Fatal(err)
}
v, _ = a.viewerReview(ctx, songID, bertta)
if v.CanEdit() {
t.Fatal("a 31-minute-old review is still editable")
}
// The database is the authority, not the Go clock: the update and the delete both refuse.
var n int64
err = a.pool.QueryRow(ctx, `
update reviews set score = 1, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil {
t.Fatal("an expired review was edited")
}
err = a.pool.QueryRow(ctx, `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil {
t.Fatal("an expired review was deleted")
}
}
+1
View File
File diff suppressed because one or more lines are too long
+83
View File
@@ -145,3 +145,86 @@ tr.banned { opacity: 0.55; }
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); }
.invite { word-break: break-all; }
/* Drop target that is the file input the native control is hidden behind it, so keyboard
focus, validation and submission keep working. */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 2.2rem 1rem;
border: 2px dashed var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
.dropzone:hover { border-color: var(--accent); }
.dropzone.over { border-color: var(--accent); background: var(--surface-2); }
.dropzone.has-file { border-style: solid; border-color: var(--accent); }
/* Visually hidden, still focusable and still the thing that gets submitted. */
.dropzone input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.dropzone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.dz-title { font-family: var(--font-head); text-transform: uppercase; letter-spacing: 0.04em; }
.filename { color: var(--accent); word-break: break-all; }
.small { font-size: 0.85rem; }
select, textarea {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.6rem;
font: inherit;
}
textarea { resize: vertical; }
select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.player { width: 100%; margin: 0.6rem 0 1rem; }
.byline { color: var(--muted); margin-top: -0.3rem; }
.intro { white-space: pre-wrap; background: var(--surface); padding: 0.8rem 1rem;
border-left: 3px solid var(--border); border-radius: var(--radius); }
.empty { font-family: var(--font-head); text-transform: uppercase; color: var(--muted);
padding: 2rem 0; }
.nowrap { white-space: nowrap; }
.tag.done { background: var(--surface-2); color: var(--muted); }
.average { font-size: 1.1rem; }
.score { display: inline-block; font-family: var(--font-head); font-size: 1.1rem;
color: var(--accent); }
.review { background: var(--surface); border-radius: var(--radius); padding: 0.8rem 1rem;
margin-bottom: 0.8rem; }
.review header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.4rem; }
.review p { white-space: pre-wrap; margin: 0; }
.scorerow { display: flex; align-items: center; gap: 0.8rem; }
.scorerow input[type="range"] { flex: 1; accent-color: var(--accent); }
.scorerow output { font-family: var(--font-head); font-size: 1.3rem; color: var(--accent);
min-width: 2.5ch; text-align: right; }
.editbox { background: var(--surface); border-radius: var(--radius); padding: 0.6rem 1rem;
margin-bottom: 1.5rem; }
.editbox summary { cursor: pointer; font-family: var(--font-head); text-transform: uppercase; }
.editbox form { margin: 0.8rem 0; }
button.danger { background: var(--accent-2); color: var(--text); }
button.danger:hover { background: #e53935; }
.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.status { background: var(--surface); border-left: 3px solid var(--accent); border-radius: var(--radius);
padding: 0.8rem 1rem; margin-bottom: 1.2rem; }
.status p { margin: 0 0 0.5rem; }
button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
+519
View File
@@ -0,0 +1,519 @@
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()
}
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestClean(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"Testikappale", "Testikappale"},
{" padded ", "padded"},
{"line\nbreak", "line break"},
{"tab\tsep", "tab sep"},
{"bell\x07null\x00", "bellnull"},
{"a b c", "a b c"},
} {
if got := clean(tc.in, 100); got != tc.want {
t.Errorf("clean(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// Truncation counts runes, not bytes: a 100-ä title is 100 characters, not 50.
long := ""
for range 150 {
long += "ä"
}
if got := []rune(clean(long, 100)); len(got) != 100 {
t.Errorf("truncated to %d runes, want 100", len(got))
}
}
func makeAudio(t *testing.T, path string) {
t.Helper()
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not on PATH")
}
cmd := exec.Command("ffmpeg", "-nostdin", "-y", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-c:a", "libopus", path)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("ffmpeg: %v\n%s", err, out)
}
}
func (a *app) readySubmission(t *testing.T, userID int64) *submission {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(), `
insert into submissions (user_id, status, title, artist, genre)
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
userID).Scan(&id)
if err != nil {
t.Fatal(err)
}
path := a.tmpPath(id, ".ogg")
makeAudio(t, path)
if _, err := a.pool.Exec(context.Background(),
`update submissions set tmp_path = $2 where id = $1`, id, path); err != nil {
t.Fatal(err)
}
return &submission{ID: id, UserID: userID, Status: "ready", TmpPath: path}
}
// A song row and its .ogg appear together, or neither does.
func TestPublishIsAllOrNothing(t *testing.T) {
a := testApp(t)
ctx := context.Background()
a.cfg.storageDir = t.TempDir()
audioDir := filepath.Join(a.cfg.storageDir, "audio")
for _, d := range []string{"audio", "tmp"} {
if err := os.MkdirAll(filepath.Join(a.cfg.storageDir, d), 0o755); err != nil {
t.Fatal(err)
}
}
id := a.seedMember(t, "[email protected]")
sub := a.readySubmission(t, id)
// Make the move impossible, the same way a full or read-only disk would.
if err := os.Chmod(audioDir, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(audioDir, 0o755) })
r := httptest.NewRequest("POST", fmt.Sprintf("/submit/%d/publish", sub.ID), nil)
r.SetPathValue("id", fmt.Sprint(sub.ID))
r = r.WithContext(context.WithValue(ctx, memberKey, &member{ID: id}))
w := httptest.NewRecorder()
a.publish(w, r)
if w.Code != http.StatusInternalServerError {
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
}
var songs, submissions int
if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil {
t.Fatal(err)
}
if songs != 0 {
t.Fatalf("orphan song row: %d rows with no audio file", songs)
}
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
t.Fatal(err)
}
if submissions != 1 {
t.Fatalf("submission rows = %d, want 1 — a failed publish must leave it recoverable", submissions)
}
if _, err := os.Stat(sub.TmpPath); err != nil {
t.Fatalf("converted audio was lost: %v", err)
}
// With the directory writable again, the same submission publishes.
os.Chmod(audioDir, 0o755)
w = httptest.NewRecorder()
a.publish(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("publish: status = %d, want 303", w.Code)
}
var songID int64
if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(a.audioPath(songID)); err != nil {
t.Fatalf("published song has no audio file: %v", err)
}
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
t.Fatal(err)
}
if submissions != 0 {
t.Fatalf("submission survived publish: %d rows", submissions)
}
}
// Five per rolling 24 hours, counting published songs, never counting failures.
func TestSubmissionQuota(t *testing.T) {
a := testApp(t)
ctx := context.Background()
id := a.seedMember(t, "[email protected]")
check := func(want bool, why string) {
t.Helper()
over, err := a.overQuota(ctx, id)
if err != nil {
t.Fatal(err)
}
if over != want {
t.Fatalf("%s: overQuota = %v, want %v", why, over, want)
}
}
check(false, "no submissions")
for range 4 {
if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil {
t.Fatal(err)
}
}
check(false, "four in flight")
// Failures never count — yt-dlp rot and bad files are not the submitter's fault.
for range 10 {
if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil {
t.Fatal(err)
}
}
check(false, "failures do not count")
// A published song still occupies a slot, even though its submission row is gone.
if _, err := a.pool.Exec(ctx, `
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
t.Fatal(err)
}
check(true, "four in flight plus one published")
// Yesterday's submissions are outside the window.
if _, err := a.pool.Exec(ctx,
`update submissions set created_at = now() - interval '25 hours' where user_id = $1`,
id); err != nil {
t.Fatal(err)
}
check(false, "older than 24 hours")
}
// A submission left mid-conversion by a restart must not say "converting" forever.
func TestRestartRecovery(t *testing.T) {
a := testApp(t)
ctx := context.Background()
id := a.seedMember(t, "[email protected]")
for _, status := range []string{"queued", "downloading", "converting"} {
if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
t.Fatal(err)
}
}
if err := sweep(ctx, a.pool); err != nil {
t.Fatal(err)
}
var stuck int
if err := a.pool.QueryRow(ctx,
`select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil {
t.Fatal(err)
}
if stuck != 0 {
t.Fatalf("%d submissions survived the sweep still in flight", stuck)
}
var msg string
if err := a.pool.QueryRow(ctx,
`select status_msg from submissions limit 1`).Scan(&msg); err != nil {
t.Fatal(err)
}
if msg == "" {
t.Fatal("swept submission carries no explanation")
}
}
-5
View File
@@ -1,5 +0,0 @@
{{define "content"}}
<h1>Jono</h1>
<p class="muted">Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun
lähetysputki ja arvostelut ovat valmiit.</p>
{{end}}
+3
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Levyraati</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<header>
@@ -14,6 +15,8 @@
<a href="/admin">Ylläpito</a>
{{else if .Member}}
<a href="/">Jono</a>
<a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
{{else}}
+30
View File
@@ -0,0 +1,30 @@
{{define "player"}}
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
{{end}}
{{define "songrow"}}
<tr>
<td>
<a href="/songs/{{.ID}}">{{.Title}}</a>
<span class="muted"> — {{.Artist}}</span>
{{if .Own}}<span class="tag">oma</span>{{else if .Reviewed}}<span class="tag done">arvosteltu</span>{{end}}
</td>
<td class="nowrap">{{.GenreLabel}}</td>
<td class="nowrap">{{.Length}}</td>
<td class="nowrap">
{{if .Average}}<strong>{{score .Average}}</strong> <span class="muted">({{.ReviewCount}})</span>
{{else if .ReviewCount}}<span class="muted">{{.ReviewCount}} arvostelua</span>
{{else}}<span class="muted"></span>{{end}}
</td>
</tr>
{{end}}
{{define "scorefield"}}
<label>Pisteet
<span class="scorerow">
<input type="range" name="score" min="1" max="100" value="{{.}}"
oninput="this.nextElementSibling.value = this.value">
<output>{{.}}</output>
</span>
</label>
{{end}}
+18
View File
@@ -0,0 +1,18 @@
{{define "content"}}
<h1>Jono</h1>
{{if .Data.Items}}
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
olet kirjoittanut oman arvostelusi.</p>
<table>
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Arvostelut</th></tr></thead>
<tbody>
{{range .Data.Items}}{{template "songrow" .}}{{end}}
</tbody>
</table>
{{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}}
{{else}}
<p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p>
<p><a href="/submit">Lähetä kappale</a> tai selaa <a href="/songs">kaikkia kappaleita</a>.</p>
{{end}}
{{end}}
+105
View File
@@ -0,0 +1,105 @@
{{define "content"}}
{{$s := .Data}}
<h1>{{$s.Title}}</h1>
<p class="byline">
{{$s.Artist}} · {{$s.GenreLabel}} · {{$s.Length}} ·
lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}}
{{if $s.Own}}<span class="tag">oma kappale</span>{{end}}
</p>
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
{{with $s.SourceURL}}<p class="muted"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if $s.CanEdit}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
<form method="post" action="/songs/{{$s.ID}}" class="stack">
<label>Nimi <input name="title" value="{{$s.Title}}" maxlength="100" required></label>
<label>Esittäjä <input name="artist" value="{{$s.Artist}}" maxlength="100" required></label>
<label>Genre
<select name="genre" required>
{{$current := $s.Genre}}
{{range $s.Genres}}
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</label>
<label>Esittely <textarea name="description" rows="4" maxlength="2000">{{$s.Description}}</textarea></label>
<button type="submit">Tallenna</button>
</form>
<form method="post" action="/songs/{{$s.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
<button type="submit" class="danger">Poista kappale</button>
</form>
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
</details>
{{else if $s.Own}}
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
{{end}}
<section>
{{if $s.CanReview}}
<h2>Arvostele</h2>
<form method="post" action="/songs/{{$s.ID}}/review" class="stack">
{{template "scorefield" 50}}
<label>Arvostelu
<textarea name="text" rows="6" maxlength="5000" required
placeholder="Mitä kuulit?"></textarea>
</label>
<button type="submit">Tallenna arvostelu</button>
</form>
<p class="muted small">Muiden arvostelut ja pisteet paljastuvat kun olet tallentanut omasi.
Voit muokata tai poistaa arvostelusi 30 minuutin ajan.</p>
{{else if $s.ViewerReview}}
<h2>Oma arvostelusi</h2>
{{with $s.ViewerReview}}
{{if .CanEdit}}
<form method="post" action="/reviews/{{.ID}}" class="stack">
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
{{template "scorefield" .Score}}
<label>Arvostelu
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
</label>
<button type="submit">Päivitä</button>
</form>
<form method="post" action="/reviews/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko arvostelu?')">
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
<button type="submit" class="danger">Poista arvostelu</button>
</form>
<p class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</p>
{{else}}
<p class="score">{{.Score}}</p>
<p>{{.Text}}</p>
<p class="muted small">Muokkausaika on päättynyt.</p>
{{end}}
{{end}}
{{end}}
</section>
<section>
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
{{if $s.Revealed}}
{{if $s.Average}}<p class="average">Keskiarvo <strong>{{score $s.Average}}</strong></p>{{end}}
{{range $s.Reviews}}
<article class="review">
<header>
<span class="avatar">{{.Initials}}</span>
<strong>{{.Reviewer}}</strong>
<span class="score">{{.Score}}</span>
<span class="muted small">{{fidate .CreatedAt}}</span>
</header>
<p>{{.Text}}</p>
</article>
{{else}}
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
{{end}}
{{else}}
<p class="muted">Muiden arvostelut ja keskiarvo näkyvät kun olet kirjoittanut omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}</p>
{{end}}
</section>
{{end}}
+16
View File
@@ -0,0 +1,16 @@
{{define "content"}}
<h1>Kappaleet</h1>
{{if .Data.Items}}
<table>
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Pisteet</th></tr></thead>
<tbody>
{{range .Data.Items}}{{template "songrow" .}}{{end}}
</tbody>
</table>
{{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}}
{{else}}
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
<p><a href="/submit">Lähetä ensimmäinen</a>.</p>
{{end}}
{{end}}
+55
View File
@@ -0,0 +1,55 @@
{{define "submission-status"}}
<div class="status" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
<form method="post" action="/submit/{{.ID}}/discard">
<button type="submit">Poista lähetys</button>
</form>
{{else}}
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
and publishes. Disabled until the audio has finished converting. -->
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun muunnos on valmis.</p>{{end}}
{{end}}
</div>
{{end}}
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
{{define "content"}}
<h1>Lähetys</h1>
{{if .Data.Failed}}{{template "submission-status" .Data}}{{end}}
{{if not .Data.Failed}}
<form id="meta" method="post" action="/submit/{{.Data.ID}}/publish" class="stack"
hx-post="/submit/{{.Data.ID}}" hx-trigger="input changed delay:1.2s, change"
hx-target="#saved" hx-swap="outerHTML">
<label>Kappaleen nimi
<input name="title" value="{{.Data.Title}}" maxlength="100" required>
</label>
<label>Esittäjä
<input name="artist" value="{{.Data.Artist}}" maxlength="100" required>
</label>
<label>Genre
<select name="genre" required>
<option value="">— valitse —</option>
{{$current := .Data.Genre}}
{{range .Data.Genres}}
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</label>
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
</label>
{{template "saved" ""}}
</form>
<p class="muted">Tiedot tallentuvat itsestään kirjoittaessasi. Nimi, esittäjä ja genre tarvitaan
ennen julkaisua.</p>
{{template "submission-status" .Data}}
{{end}}
{{end}}
+67
View File
@@ -0,0 +1,67 @@
{{define "content"}}
<h1>Lähetä kappale</h1>
{{with .Data.Error}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/submit" enctype="multipart/form-data" class="stack">
<label class="dropzone" id="dropzone" for="audio">
<input type="file" id="audio" name="audio" accept="audio/*" required>
<span class="dz-title">Raahaa äänitiedosto tähän</span>
<span class="muted small">tai valitse napsauttamalla</span>
<span class="filename" id="filename"></span>
</label>
<button type="submit">Lähetä</button>
</form>
{{with .Data.Submissions}}
<section>
<h2>Omat lähetykset</h2>
<table>
<thead><tr><th>Kappale</th><th>Tila</th><th>Lähetetty</th></tr></thead>
<tbody>
{{range .}}
<tr>
<td><a href="/submit/{{.ID}}">{{if .Title}}{{.Title}}{{else}}(nimetön){{end}}</a></td>
<td>{{.Label}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted small">Valmis lähetys odottaa Julkaise-painallusta — vasta se tuo kappaleen
muiden nähtäville.</p>
</section>
{{end}}
<p class="muted">Enintään 50 MB ja 15 minuuttia. Tiedosto muunnetaan Opus-muotoon, ja pääset
kirjoittamaan esittelyn odotellessa. Kappale julkaistaan vasta kun painat Julkaise.</p>
<script>
// The native control can't be relabelled or styled, so it is hidden behind this one — which
// still is the input, so keyboard focus and form submission work untouched.
(function () {
const zone = document.getElementById('dropzone')
const input = document.getElementById('audio')
const name = document.getElementById('filename')
const show = () => {
name.textContent = input.files.length ? input.files[0].name : ''
zone.classList.toggle('has-file', input.files.length > 0)
}
input.addEventListener('change', show)
for (const type of ['dragenter', 'dragover']) {
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.add('over') })
}
for (const type of ['dragleave', 'dragend', 'drop']) {
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.remove('over') })
}
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files
show()
}
})
})()
</script>
{{end}}