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
+5 -5
View File
@@ -1,14 +1,14 @@
FROM golang:1.24-alpine AS build FROM golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -o /levyraati . RUN CGO_ENABLED=0 go build -o /levyraati .
FROM alpine:3.21 FROM alpine:3.24
# yt-dlp rots against YouTube, so it is installed unpinned at build time and updated by rebuilding. # yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
RUN apk add --no-cache ffmpeg python3 py3-pip ca-certificates \ # release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
&& pip install --break-system-packages --no-cache-dir -U yt-dlp RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
COPY --from=build /levyraati /usr/local/bin/levyraati COPY --from=build /levyraati /usr/local/bin/levyraati
ENV STORAGE_DIR=/storage ENV STORAGE_DIR=/storage
EXPOSE 8080 EXPOSE 8080
+2 -2
View File
@@ -100,8 +100,8 @@ endpoint and no recovery key — the credentials are the environment.
### yt-dlp goes stale ### yt-dlp goes stale
yt-dlp needs regular updates to keep working against YouTube. It is installed with yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community
`pip install -U yt-dlp` at image build time, so rebuilding is how you update it: repository, whose active branch tracks upstream closely, so rebuilding is how you update it:
```sh ```sh
docker compose build --no-cache app && docker compose up -d app docker compose build --no-cache app && docker compose up -d app
+6 -2
View File
@@ -66,8 +66,12 @@ and `storage` was test data, so **the schema has no legacy to respect.**
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed 18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
tracking. tracking.
19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the 19. **yt-dlp is updated by rebuilding the image, monthly.** Pinning a version only schedules the
breakage for a moment you did not choose. breakage for a moment you did not choose. Originally `pip install -U yt-dlp`; changed on
2026-07-31 to `apk add yt-dlp` once Alpine 3.24 turned out to carry the current release
(2026.07.04, four weeks old) — which drops python3 and pip from the image entirely. The apk
route inherits Alpine's packaging lag, so pip is the fallback if it ever goes stale at a bad
moment. Note that this only holds on the *active* branch: 3.21 was 16 months behind.
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the 20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
unbuilt-stats list ships in v1. unbuilt-stats list ships in v1.
+3 -1
View File
@@ -288,7 +288,9 @@ but its submitter, so a broken pipeline has no other way of announcing itself.
### 4.7 Operational notes ### 4.7 Operational notes
- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly. - **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks
upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment,
`pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image.
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the - Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
decision is deliberate rather than accidental. decision is deliberate rather than accidental.
+1
View File
@@ -152,6 +152,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus)) mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission)) mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish)) mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard)) mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
return mux return mux
} }
+81
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/url"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -95,6 +96,86 @@ func clean(s string, max int) string {
return s return s
} }
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
// argument list — and it never goes through a shell.
var allowedHosts = map[string]bool{
"youtube.com": true, "www.youtube.com": true, "m.youtube.com": true,
"youtu.be": true, "www.youtu.be": true, "music.youtube.com": true,
}
func allowedYouTubeURL(raw string) (string, bool) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "", false
}
if !allowedHosts[strings.ToLower(u.Hostname())] {
return "", false
}
return u.String(), true
}
type ytOutput struct {
Title string `json:"title"`
Track string `json:"track"`
Artist string `json:"artist"`
Creator string `json:"creator"`
Uploader string `json:"uploader"`
Duration float64 `json:"duration"`
}
// youtubeMeta asks yt-dlp for metadata only — no download. A 13 s network call, so the handler
// gives it 15 s and renders blank fields on timeout rather than failing the submission.
func youtubeMeta(ctx context.Context, url string) (probeResult, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "yt-dlp", "-J", "--no-playlist", "--no-warnings", url).Output()
if err != nil {
return probeResult{}, err
}
return parseYouTubeMeta(out)
}
// track/artist exist only for Topic channels, YouTube Music entries and videos with a "Music in
// this video" panel. An ordinary upload gives a title and an uploader and nothing else — and if a
// field resolves to empty it stays empty, because a blank field prompts the submitter while a
// plausible "Unknown" does not.
func parseYouTubeMeta(jsonBytes []byte) (probeResult, error) {
var y ytOutput
if err := json.Unmarshal(jsonBytes, &y); err != nil {
return probeResult{}, err
}
title := y.Track
if title == "" {
title = y.Title
}
artist := firstOf(map[string]string{
"artist": y.Artist, "creator": y.Creator, "uploader": y.Uploader,
}, "artist", "creator", "uploader")
return probeResult{
Title: clean(title, 100),
Artist: clean(artist, 100),
Duration: time.Duration(y.Duration * float64(time.Second)),
}, nil
}
// download fetches the best audio-only stream. The extension is whatever YouTube served, so the
// caller globs for it — ffmpeg does not care which container it gets.
func downloadYouTube(ctx context.Context, url, outTemplate string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "yt-dlp",
"-f", "bestaudio", "--no-playlist", "--max-filesize", "100M",
"--no-warnings", "-o", outTemplate, url)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return tail(stderr.String(), 400), err
}
return "", nil
}
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio. // convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth // No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
// showing — "Invalid data found when processing input" beats "submission failed". // showing — "Invalid data found when processing input" beats "submission failed".
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"os"
"testing"
"time"
)
func TestAllowedYouTubeURL(t *testing.T) {
for _, ok := range []string{
"https://www.youtube.com/watch?v=XnfMBo4IQ-g",
"https://youtu.be/XnfMBo4IQ-g",
"https://music.youtube.com/watch?v=XnfMBo4IQ-g",
"http://m.youtube.com/watch?v=XnfMBo4IQ-g",
} {
if _, allowed := allowedYouTubeURL(ok); !allowed {
t.Errorf("%q was rejected", ok)
}
}
for _, bad := range []string{
"",
"not a url",
"file:///etc/passwd",
"https://evil.example.com/watch?v=x",
// The allowlist is on the host, so a lookalike path or userinfo must not pass.
"https://evil.example.com/www.youtube.com/watch?v=x",
"https://youtube.com.evil.example.com/watch?v=x",
"https://[email protected]/",
"-oExecuteMe",
} {
if _, allowed := allowedYouTubeURL(bad); allowed {
t.Errorf("%q was allowed", bad)
}
}
}
// A real dump of an ordinary upload: no track, no artist, no creator, no album — just a title with
// double spaces and an uploader. This is why prefill must never invent an "Unknown".
func TestYouTubeMetaFromOrdinaryUpload(t *testing.T) {
raw, err := os.ReadFile("testdata/ytdlp-noose.json")
if err != nil {
t.Skipf("fixture missing: %v", err)
}
meta, err := parseYouTubeMeta(raw)
if err != nil {
t.Fatal(err)
}
if meta.Title != "Sentenced Noose" {
t.Errorf("title = %q, want the cleaned video title", meta.Title)
}
if meta.Artist != "Heikki Rokkonen" {
t.Errorf("artist = %q, want the uploader as the last fallback", meta.Artist)
}
if meta.Duration != 245*time.Second {
t.Errorf("duration = %v, want 4m5s", meta.Duration)
}
}
func TestYouTubeMetaPrefersMusicFields(t *testing.T) {
meta, err := parseYouTubeMeta([]byte(`{
"title": "Sentenced - Noose (Official Video)",
"track": "Noose", "artist": "Sentenced", "creator": "ignored",
"uploader": "SentencedVEVO", "duration": 245.0}`))
if err != nil {
t.Fatal(err)
}
if meta.Title != "Noose" || meta.Artist != "Sentenced" {
t.Errorf("got %q by %q, want the track/artist fields to win", meta.Title, meta.Artist)
}
}
// Empty stays empty: a blank field prompts the submitter, a plausible "Unknown" does not.
func TestYouTubeMetaLeavesBlanksBlank(t *testing.T) {
meta, err := parseYouTubeMeta([]byte(`{"duration": 10.0}`))
if err != nil {
t.Fatal(err)
}
if meta.Title != "" || meta.Artist != "" {
t.Errorf("got %q by %q, want both empty", meta.Title, meta.Artist)
}
}
+3
View File
@@ -228,3 +228,6 @@ button.danger:hover { background: #e53935; }
padding: 0.8rem 1rem; margin-bottom: 1.2rem; } padding: 0.8rem 1rem; margin-bottom: 1.2rem; }
.status p { margin: 0 0 0.5rem; } .status p { margin: 0 0 0.5rem; }
button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; } button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
.or { text-align: center; color: var(--muted); text-transform: uppercase;
font-family: var(--font-head); margin: 0; }
+108 -2
View File
@@ -110,6 +110,9 @@ func (s *submission) Label() string {
func (s *submission) Genres() []genre { return genres } 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. // 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 (s *submission) GenreLabel() string { return genreLabel(s.Genre) }
@@ -169,8 +172,21 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
return 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) r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
file, header, err := r.FormFile("audio") 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 { if err != nil {
a.submitError(w, r, http.StatusRequestEntityTooLarge, a.submitError(w, r, http.StatusRequestEntityTooLarge,
"Tiedostoa ei voitu lukea. Enintään 50 MB.") "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) 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) 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) { func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
if path != "" { if path != "" {
os.Remove(path) os.Remove(path)
@@ -241,12 +315,44 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
// --- convert --- // --- 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{}{} slots <- struct{}{}
defer func() { <-slots }() defer func() { <-slots }()
// Detached from the request: the submitter's browser is long gone by now. // Detached from the request: the submitter's browser is long gone by now.
ctx := context.Background() 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", "") a.setStatus(ctx, subID, "converting", "")
out := a.tmpPath(subID, ".ogg") out := a.tmpPath(subID, ".ogg")
+10 -2
View File
@@ -3,9 +3,17 @@
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p> <p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}} {{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}} {{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
<form method="post" action="/submit/{{.ID}}/discard"> <div class="actions">
<button type="submit">Poista lähetys</button> {{if .CanRetry}}
<form method="post" action="/submit/{{.ID}}/retry">
<button type="submit">Yritä uudelleen</button>
</form> </form>
{{end}}
<form method="post" action="/submit/{{.ID}}/discard">
<button type="submit" class="danger">Poista lähetys</button>
</form>
</div>
{{if not .CanRetry}}<p class="muted small">Lataa tiedosto uudelleen, jos haluat yrittää toisen kerran.</p>{{end}}
{{else}} {{else}}
<!-- Outside the form and attached to it with form=, so one button both submits the metadata <!-- Outside the form and attached to it with form=, so one button both submits the metadata
and publishes. Disabled until the audio has finished converting. --> and publishes. Disabled until the audio has finished converting. -->
+5 -1
View File
@@ -5,11 +5,15 @@
<form method="post" action="/submit" enctype="multipart/form-data" class="stack"> <form method="post" action="/submit" enctype="multipart/form-data" class="stack">
<label class="dropzone" id="dropzone" for="audio"> <label class="dropzone" id="dropzone" for="audio">
<input type="file" id="audio" name="audio" accept="audio/*" required> <input type="file" id="audio" name="audio" accept="audio/*">
<span class="dz-title">Raahaa äänitiedosto tähän</span> <span class="dz-title">Raahaa äänitiedosto tähän</span>
<span class="muted small">tai valitse napsauttamalla</span> <span class="muted small">tai valitse napsauttamalla</span>
<span class="filename" id="filename"></span> <span class="filename" id="filename"></span>
</label> </label>
<p class="or">tai</p>
<label>YouTube-linkki
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=…">
</label>
<button type="submit">Lähetä</button> <button type="submit">Lähetä</button>
</form> </form>
+1
View File
File diff suppressed because one or more lines are too long