SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
657 lines
22 KiB
Go
657 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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
|
|
Lyrics 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 }
|
|
|
|
// Only URL submissions can retry: an upload's temp file is gone, so that case offers re-upload.
|
|
func (s *submission) CanRetry() bool { return s.Failed() && s.SourceURL != nil }
|
|
|
|
// 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.db.QueryRowContext(ctx, `
|
|
select (select count(*) from submissions
|
|
where user_id = $1 and status <> 'failed'
|
|
and created_at > datetime('now', '-24 hours'))
|
|
+ (select count(*) from songs
|
|
where submitted_by = $1 and created_at > datetime('now', '-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
|
|
}
|
|
|
|
// A YouTube song is not a different kind of song — it just has an extra download step and a
|
|
// source_url. Both paths converge on the same worker.
|
|
if raw := r.FormValue("url"); strings.TrimSpace(raw) != "" {
|
|
a.submitURL(w, r, m.ID, raw)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
|
file, header, err := r.FormFile("audio")
|
|
if errors.Is(err, http.ErrMissingFile) {
|
|
// Neither field filled. HTML cannot express "one of these two", so the server says it.
|
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
|
"Valitse äänitiedosto tai anna YouTube-linkki.")
|
|
return
|
|
}
|
|
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.db.QueryRowContext(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.db.ExecContext(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.process(subID, "", src)
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, raw string) {
|
|
// The allowlist is checked before yt-dlp is invoked at all.
|
|
link, ok := allowedYouTubeURL(raw)
|
|
if !ok {
|
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
|
"Vain YouTube-linkit kelpaavat (youtube.com, youtu.be, music.youtube.com).")
|
|
return
|
|
}
|
|
|
|
// Metadata first, so an over-long track is refused before a byte is downloaded. A timeout is
|
|
// not fatal: blank fields are a fine outcome, since the submitter fills them in anyway.
|
|
meta, err := youtubeMeta(r.Context(), link)
|
|
if err != nil {
|
|
slog.Warn("yt-dlp metadata", "ctx", "submissions", "error", err)
|
|
}
|
|
if meta.Duration > maxDuration {
|
|
a.submitError(w, r, http.StatusUnprocessableEntity, "Kappale on yli 15 minuuttia pitkä.")
|
|
return
|
|
}
|
|
|
|
var subID int64
|
|
err = a.db.QueryRowContext(r.Context(), `
|
|
insert into submissions (user_id, status, source_url, title, artist)
|
|
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
|
|
userID, link, meta.Title, meta.Artist).Scan(&subID)
|
|
if err != nil {
|
|
slog.Error("create submission", "ctx", "submissions", "error", err)
|
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
|
return
|
|
}
|
|
|
|
slog.Info("url submission received", "ctx", "submissions", "submission", subID, "user", userID)
|
|
go a.process(subID, link, "")
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
|
}
|
|
|
|
// Retry re-queues a failed URL submission with the typed title and introduction intact. An upload
|
|
// cannot retry — its temp file is gone — so that case offers re-upload instead.
|
|
func (a *app) retry(w http.ResponseWriter, r *http.Request) {
|
|
s := a.loadSubmission(w, r)
|
|
if s == nil {
|
|
return
|
|
}
|
|
if !s.CanRetry() {
|
|
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
|
|
return
|
|
}
|
|
if _, err := a.db.ExecContext(r.Context(),
|
|
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
|
|
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
slog.Info("submission retried", "ctx", "submissions", "submission", s.ID)
|
|
go a.process(s.ID, *s.SourceURL, "")
|
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
|
if path != "" {
|
|
os.Remove(path)
|
|
}
|
|
if _, err := a.db.ExecContext(ctx, `delete from submissions where id = $1`, subID); err != nil {
|
|
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
|
|
}
|
|
}
|
|
|
|
// --- convert ---
|
|
|
|
// process is the whole background half of the pipeline: download when the source is a URL, then
|
|
// convert. Everything past the two slots waits in `queued`.
|
|
func (a *app) process(subID int64, sourceURL, src string) {
|
|
slots <- struct{}{}
|
|
defer func() { <-slots }()
|
|
|
|
// Detached from the request: the submitter's browser is long gone by now.
|
|
ctx := context.Background()
|
|
|
|
if sourceURL != "" {
|
|
a.setStatus(ctx, subID, "downloading", "")
|
|
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
|
|
if err != nil {
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
a.setStatus(ctx, subID, "failed", msg)
|
|
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
|
|
return
|
|
}
|
|
// yt-dlp names the file after whatever container YouTube served.
|
|
matches, _ := filepath.Glob(a.tmpPath(subID, ".*"))
|
|
for _, m := range matches {
|
|
if filepath.Ext(m) != ".ogg" {
|
|
src = m
|
|
break
|
|
}
|
|
}
|
|
if src == "" {
|
|
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
|
|
return
|
|
}
|
|
if _, err := a.db.ExecContext(ctx,
|
|
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
|
|
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
|
|
}
|
|
}
|
|
|
|
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.db.ExecContext(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)
|
|
|
|
// One automatic lyrics attempt, after the audio is safe. It runs on whatever metadata exists,
|
|
// so it covers well-tagged music; the Hae sanoitukset button on the waiting page is what
|
|
// covers everything else, once the submitter has fixed the title and artist.
|
|
var title, artist string
|
|
if err := a.db.QueryRowContext(ctx,
|
|
`select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`,
|
|
subID).Scan(&title, &artist); err != nil {
|
|
return
|
|
}
|
|
seconds := 0
|
|
if meta, err := probe(ctx, out); err == nil {
|
|
seconds = int(meta.Duration.Seconds())
|
|
}
|
|
a.autoFetchLyrics(ctx, subID, title, artist, seconds)
|
|
}
|
|
|
|
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
|
|
if _, err := a.db.ExecContext(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.db.QueryRowContext(r.Context(), `
|
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
|
coalesce(description, ''), coalesce(lyrics, ''), 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.Lyrics, &s.CreatedAt)
|
|
if errors.Is(err, sql.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 {
|
|
// r.Form is only populated once the body has been parsed, and the check below reads it.
|
|
if err := r.ParseForm(); err != nil {
|
|
return err
|
|
}
|
|
genre := r.FormValue("genre")
|
|
if genre != "" && !validGenre(genre) {
|
|
return fmt.Errorf("unknown genre %q", genre)
|
|
}
|
|
// A field the request does not carry keeps its stored value. Without this, any post that omits
|
|
// a field silently clears it — which is exactly how a publish request wiped lyrics that the
|
|
// worker had just fetched.
|
|
has := func(field string) bool { _, ok := r.Form[field]; return ok }
|
|
|
|
_, err := a.db.ExecContext(ctx, `
|
|
update submissions set
|
|
title = case when $2 then nullif($3, '') else title end,
|
|
artist = case when $4 then nullif($5, '') else artist end,
|
|
genre = case when $6 then nullif($7, '') else genre end,
|
|
description = case when $8 then nullif($9, '') else description end,
|
|
lyrics = case when $10 then nullif($11, '') else lyrics end
|
|
where id = $1`,
|
|
subID,
|
|
has("title"), clean(r.FormValue("title"), maxTitle),
|
|
has("artist"), clean(r.FormValue("artist"), maxArtist),
|
|
has("genre"), genre,
|
|
has("description"), clean(r.FormValue("description"), maxDescription),
|
|
// Line breaks are the whole point of lyrics, so they survive rather than being cleaned away.
|
|
has("lyrics"), cleanLyrics(r.FormValue("lyrics")))
|
|
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.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
slog.Error("begin publish", "ctx", "submissions", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var songID int64
|
|
err = tx.QueryRowContext(r.Context(), `
|
|
insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds,
|
|
source_url, submitted_by)
|
|
values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`,
|
|
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
|
|
nilIfEmpty(cleanLyrics(s.Lyrics)),
|
|
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.ExecContext(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.ExecContext(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(); 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.db.QueryContext(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()
|
|
}
|