Files
Levyraati26_go/submit.go
T
Esa Kataja 400b5d3833 Release 2026.08.01-1
Lyrics, versioning, and the song page's fact line.

- Lyrics are suggested at submission and never imposed: the worker makes one
  LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist
  are typed. Neither overwrites what the submitter wrote. They live on the
  submission, travel to the song at publish, and stay editable after the song
  locks — the lock freezes what a song claims to be, and nobody reviewed the
  lyrics
- The review strip reads and writes side by side: lyrics left, review right.
  Synced LRC highlights the playing line and seeks on click; plain text scrolls
  continuously with a nudge knob. Following can be turned off
- CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the
  footer, the startup log and /healthz
- The song page's metadata became four labelled cells instead of one flat run
  of five different kinds of fact

Fixes: publishing wiped lyrics the worker had just fetched (a request that
omitted a field cleared it), lyric auto-scroll landed in the wrong place, and
the fader shifted the deck sideways at score 100.
2026-08-01 00:45:55 +03:00

658 lines
22 KiB
Go

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
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()
}