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>
122 lines
3.0 KiB
Go
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
|
|
}
|