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:
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -95,6 +96,86 @@ func clean(s string, max int) string {
|
||||
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 1–3 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.
|
||||
// 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".
|
||||
|
||||
Reference in New Issue
Block a user