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>
This commit is contained in:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2719
-38
No files matched your search
@@ -0,0 +1,121 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user