Files
playlink/internal/art/prepared.go
T
cdnutterandClaude Opus 5.5 d8ac25a55d Add album art search, auto-match and a whole-album tag editor
Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers,
embed them and/or save cover.jpg, auto-match covers for albums, and edit an
album's shared tags and track list in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:30:19 -07:00

122 lines
3.0 KiB
Go

package art
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"image"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"time"
)
// Prepared images are downloaded or uploaded covers converted to a clean
// JPEG, waiting to be applied. They live in a cache folder under a random
// token and expire after a few hours.
type Prepared struct {
Token string `json:"token"`
Width int `json:"width"`
Height int `json:"height"`
Size int64 `json:"size"`
Square bool `json:"square"`
}
type Store struct {
Dir string
FFmpeg string
}
var tokenRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
var ErrBadToken = errors.New("unknown or expired image; choose it again")
const preparedTTL = 3 * time.Hour
// Prepare converts image bytes to a JPEG no larger than maxSize pixels on
// its longest side (0 keeps the original size).
func (s *Store) Prepare(ctx context.Context, data []byte, maxSize int) (Prepared, error) {
if s.FFmpeg == "" {
return Prepared{}, errors.New("preparing images needs ffmpeg")
}
if err := os.MkdirAll(s.Dir, 0o750); err != nil {
return Prepared{}, err
}
s.cleanup()
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return Prepared{}, err
}
token := hex.EncodeToString(b[:])
raw := filepath.Join(s.Dir, token+".src")
out := filepath.Join(s.Dir, token+".jpg")
defer os.Remove(raw)
if err := os.WriteFile(raw, data, 0o640); err != nil {
return Prepared{}, err
}
scale := "scale=iw:ih"
if maxSize > 0 {
m := strconv.Itoa(maxSize)
scale = "scale='if(gt(iw,ih),min(" + m + ",iw),-2)':'if(gt(iw,ih),-2,min(" + m + ",ih))'"
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.FFmpeg, "-v", "error", "-nostdin", "-y", "-i", raw, "-frames:v", "1",
"-vf", scale+",format=yuvj420p", "-q:v", "2", "-f", "image2", out)
if msg, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(out)
return Prepared{}, fmt.Errorf("that file is not a usable image: %s", firstLine(string(msg)))
}
f, err := os.Open(out)
if err != nil {
return Prepared{}, err
}
cfg, _, err := image.DecodeConfig(f)
f.Close()
if err != nil {
_ = os.Remove(out)
return Prepared{}, fmt.Errorf("converted image unreadable: %w", err)
}
fi, _ := os.Stat(out)
return Prepared{Token: token, Width: cfg.Width, Height: cfg.Height, Size: fi.Size(), Square: Square(cfg.Width, cfg.Height)}, nil
}
// Path returns the prepared JPEG for a token.
func (s *Store) Path(token string) (string, error) {
if !tokenRe.MatchString(token) {
return "", ErrBadToken
}
p := filepath.Join(s.Dir, token+".jpg")
fi, err := os.Stat(p)
if err != nil || time.Since(fi.ModTime()) > preparedTTL {
return "", ErrBadToken
}
return p, nil
}
func (s *Store) cleanup() {
ents, _ := os.ReadDir(s.Dir)
for _, en := range ents {
if fi, err := en.Info(); err == nil && time.Since(fi.ModTime()) > preparedTTL {
_ = os.Remove(filepath.Join(s.Dir, en.Name()))
}
}
}
func firstLine(s string) string {
for i, r := range s {
if r == '\n' {
return s[:i]
}
}
if len(s) > 200 {
return s[:200]
}
return s
}