Files
Levyraati26_go/media.go
T
Esa Kataja f1e907bac3 Add stats, profiles, avatars and palaute
Step 6. The surfaces around the review loop.

- Nine leaderboards, ordered and limited in SQL, each with a deterministic
  tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews
  to qualify, for reviewer boards too
- Profiles show counts and history-wide averages and the member's songs,
  never a list of their reviews — per-song opinion stays gated
- Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation.
  No upload still means initials, and avatars are public
- Changing your own password requires the current one and drops your other
  sessions
- Palaute: free text plus the page you were on, carried in a footer link, and
  the user agent from the header. Reporters see their own; the admin resolves
  them with a timestamp rather than a status enum
- Admin gained the song list with delete, the reports page, and an open-report
  count on the dashboard

Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter
because display:grid suppresses list markers, and count-based boards were
printing 3.0 where they mean 3.
2026-07-31 22:34:04 +03:00

219 lines
6.7 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
}
// 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
}