Files
Esa Kataja 400b5d3833 Release 2026.08.01-1
Lyrics, versioning, and the song page's fact line.

- Lyrics are suggested at submission and never imposed: the worker makes one
  LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist
  are typed. Neither overwrites what the submitter wrote. They live on the
  submission, travel to the song at publish, and stay editable after the song
  locks — the lock freezes what a song claims to be, and nobody reviewed the
  lyrics
- The review strip reads and writes side by side: lyrics left, review right.
  Synced LRC highlights the playing line and seeks on click; plain text scrolls
  continuously with a nudge knob. Following can be turned off
- CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the
  footer, the startup log and /healthz
- The song page's metadata became four labelled cells instead of one flat run
  of five different kinds of fact

Fixes: publishing wiped lyrics the worker had just fetched (a request that
omitted a field cleared it), lyric auto-scroll landed in the wrong place, and
the fader shifted the deck sideways at score 100.
2026-08-01 00:45:55 +03:00

242 lines
7.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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
}
// 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
}