Add the YouTube submission path

Step 5. A URL goes through the same pipeline as an upload — it just gains a
download step and a source_url.

- The host allowlist is checked on the parsed hostname before yt-dlp is
  invoked, so lookalikes and userinfo tricks are refused too
- yt-dlp -J reads metadata synchronously with a 15s timeout; a timeout leaves
  the fields blank rather than failing the submission
- Over-long tracks are refused from that metadata, before a byte is downloaded
- Failed URL submissions offer Yritä uudelleen with the typed text intact;
  uploads cannot retry, so they offer re-upload

Prefill takes track then title, and artist then creator then uploader, and
leaves a field blank rather than inventing one. testdata/ytdlp-noose.json is a
real dump of an ordinary upload, which has none of the music fields.

Also fixes a URL-only submit being blocked by the file input's required
attribute — HTML cannot express "one of these two", so the server says it.

The image now takes yt-dlp from Alpine 3.24 instead of pip, which drops
python3 and pip entirely; see decision 19.
This commit is contained in:
Esa Kataja
2026-07-31 21:54:15 +03:00
parent 80d3e36679
commit 91e136055c
12 changed files with 307 additions and 16 deletions
+108 -2
View File
@@ -110,6 +110,9 @@ func (s *submission) Label() string {
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) }
@@ -169,8 +172,21 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
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.")
@@ -226,10 +242,68 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
}
slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID)
go a.convert(subID, src)
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)
@@ -241,12 +315,44 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
// --- convert ---
func (a *app) convert(subID int64, src string) {
// 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")