Files
Levyraati26_go/media.go
T
Esa Kataja 4f337b6202 Add lyrics: paste, fetch, and read them while reviewing
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.
2026-08-01 00:21:31 +03:00

242 lines
7.3 KiB
Go
Raw 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
}