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 "" 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.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 } // 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.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.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.pool.QueryRow(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.pool.Exec(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.pool.Exec(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.pool.Exec(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.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) // 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.pool.QueryRow(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.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, ''), 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, 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 { // 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.pool.Exec(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.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, 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.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() }