Files
Levyraati26_go/submit.go
T
Esa Kataja 0f15ae0bfc Release 2026.08.02-1
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
2026-08-02 20:58:52 +03:00

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