package main import ( "context" "encoding/json" "net/url" "os/exec" "strconv" "strings" "time" ) // Everything here shells out with exec.CommandContext and an argument list — never a shell string. type probeResult struct { Title string Artist string Duration time.Duration } type ffprobeOutput struct { Format struct { Duration string `json:"duration"` Tags map[string]string `json:"tags"` } `json:"format"` Streams []struct { CodecType string `json:"codec_type"` Tags map[string]string `json:"tags"` } `json:"streams"` } // probe reads duration and whatever title/artist tags the container carries. Tag keys vary in case // by container (title, TITLE, Title), so the map is lowercased before anything is read from it. func probe(ctx context.Context, path string) (probeResult, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path).Output() if err != nil { return probeResult{}, err } var parsed ffprobeOutput if err := json.Unmarshal(out, &parsed); err != nil { return probeResult{}, err } tags := map[string]string{} for _, stream := range parsed.Streams { if stream.CodecType != "audio" { continue } for k, v := range stream.Tags { tags[strings.ToLower(k)] = v } } // Container tags win over stream tags when both exist. for k, v := range parsed.Format.Tags { tags[strings.ToLower(k)] = v } var res probeResult res.Title = clean(tags["title"], 100) res.Artist = clean(firstOf(tags, "artist", "album_artist"), 100) if secs, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil { res.Duration = time.Duration(secs * float64(time.Second)) } return res, nil } func firstOf(m map[string]string, keys ...string) string { for _, k := range keys { if v := strings.TrimSpace(m[k]); v != "" { return v } } return "" } // Tag text is attacker-controlled and arrives inside an uploaded file. html/template escapes on // render, but a title with an embedded newline wrecks every list layout it appears in. func clean(s string, max int) string { s = strings.Map(func(r rune) rune { if r == '\n' || r == '\r' || r == '\t' { return ' ' } if r < 0x20 || r == 0x7f { return -1 } return r }, s) s = strings.TrimSpace(strings.Join(strings.Fields(s), " ")) if r := []rune(s); len(r) > max { s = strings.TrimSpace(string(r[:max])) } return s } const maxLyrics = 20000 // Lyrics are the one field where line breaks carry meaning — LRC timestamps are per line — so they // survive, and only the other control characters go. func cleanLyrics(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") s = strings.Map(func(r rune) rune { if r == '\n' || r == '\t' { return r } if r < 0x20 || r == 0x7f { return -1 } return r }, s) s = strings.TrimSpace(s) if r := []rune(s); len(r) > maxLyrics { s = strings.TrimSpace(string(r[:maxLyrics])) } 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 } // toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is // the validation and the size cap in one — webp and avif included, which stdlib image cannot read. func toAvatarJPEG(ctx context.Context, in, out string) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in, "-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256", "-frames:v", "1", "-q:v", "3", out).Run() } // 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". func convertToOpus(ctx context.Context, in, out string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in, "-c:a", "libopus", "-b:a", "96k", "-ac", "2", "-vn", out) var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return tail(stderr.String(), 400), err } return "", nil } func tail(s string, n int) string { s = strings.TrimSpace(s) lines := strings.Split(s, "\n") if len(lines) > 3 { lines = lines[len(lines)-3:] } s = strings.TrimSpace(strings.Join(lines, " ")) if r := []rune(s); len(r) > n { s = string(r[len(r)-n:]) } return s }