Lyrics are suggested at submission and never imposed. The conversion worker makes one LRCLIB lookup with whatever metadata exists, and the waiting page has a Hae sanoitukset button that re-queries with whatever title and artist are currently typed — which is the case that matters, since our metadata comes from ID3 tags and YouTube uploaders. Neither path overwrites typed text. - lyrics text on both submissions and songs, copied across at publish. Nothing has launched, so the column goes into 001_init.sql rather than a migration - The lock does not cover lyrics: it freezes what the song claims to be, and nobody reviewed the lyrics. So the submitter can still fix them afterwards, or paste them for an old song a year later - The review strip gained a second pane: lyrics on the left, review on the right, so following the words costs no scrolling. No lyrics means no pane, not an empty one. Below 1024px the panes stack - LRC timestamps are stored but stripped for reading — they belong to the player, not the reader - The lyrics box is capped and scrolls inside itself, so a long song cannot stretch the strip past the screen Fixes a real bug found on the way: saveMetadata cleared any field the request did not carry, so publishing wiped the lyrics the worker had just fetched. Fields absent from a request now keep their stored value. The client identifies itself to LRCLIB as "levyraati" and nothing more. Tests cover cleanLyrics keeping line breaks, and fetchLyrics against a local server: synced beats plain, instrumentals and wrong-length takes are skipped, and a miss is empty with no error.
242 lines
7.3 KiB
Go
242 lines
7.3 KiB
Go
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
|
||
}
|