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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user