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:
cdnutterandClaude Opus 5.5 committed 2026-09-29 15:30:19 -07:00
1 parent 5fb195f0d6
commit d8ac25a55d
21 files changed
+2719 -38

No files matched your search

+565
View File
@@ -0,0 +1,565 @@
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// FolderCoverName is the album-folder image playlink writes (read by Plex,
// Jellyfin, Navidrome and most players).
const FolderCoverName = "cover.jpg"
var folderCoverNames = []string{"cover.jpg", "folder.jpg", "cover.png", "folder.png", "front.jpg"}
// SetArt enables album art search and preparation.
func (e *Engine) SetArt(c *art.Client, s *art.Store) { e.artClient, e.artStore = c, s }
func (e *Engine) preparedArt(token string) (string, error) {
if e.artStore == nil {
return "", errors.New("album art is not available")
}
return e.artStore.Path(token)
}
// ---- settings ----
// ArtSettings are the album art options (Settings -> Album art).
type ArtSettings struct {
Sources []string `json:"sources"`
GoogleKey string `json:"google_key"`
GoogleCX string `json:"google_cx"`
MaxSize int `json:"max_size"` // longest side of prepared covers; 0 keeps the original
MinSize int `json:"min_size"` // smallest search result shown
}
func (e *Engine) ArtSettings(ctx context.Context) ArtSettings {
s := ArtSettings{
GoogleKey: e.st.Setting(ctx, "art_google_key", ""),
GoogleCX: e.st.Setting(ctx, "art_google_cx", ""),
}
src := e.st.Setting(ctx, "art_sources", strings.Join([]string{art.ITunes, art.Deezer, art.MusicBrainz, art.DuckDuckGo, art.Google}, ","))
for _, x := range strings.Split(src, ",") {
if x != "" {
s.Sources = append(s.Sources, x)
}
}
s.MaxSize, _ = strconv.Atoi(e.st.Setting(ctx, "art_max_size", "1000"))
s.MinSize, _ = strconv.Atoi(e.st.Setting(ctx, "art_min_size", "500"))
return s
}
func (e *Engine) SetArtSettings(ctx context.Context, s ArtSettings) error {
known := map[string]bool{}
for _, x := range art.AllSources {
known[x] = true
}
var src []string
for _, x := range s.Sources {
if !known[x] {
return fmt.Errorf("unknown art source %q", x)
}
src = append(src, x)
}
if s.MaxSize != 0 && (s.MaxSize < 300 || s.MaxSize > 4000) {
return errors.New("cover size must be 300-4000 pixels, or 0 to keep the original")
}
if s.MinSize < 100 || s.MinSize > 3000 {
return errors.New("minimum search result size must be 100-3000 pixels")
}
for k, v := range map[string]string{"art_sources": strings.Join(src, ","), "art_google_key": strings.TrimSpace(s.GoogleKey),
"art_google_cx": strings.TrimSpace(s.GoogleCX), "art_max_size": strconv.Itoa(s.MaxSize), "art_min_size": strconv.Itoa(s.MinSize)} {
if err := e.st.SetSetting(ctx, k, v); err != nil {
return err
}
}
return nil
}
func (e *Engine) artConfig(ctx context.Context, sources []string) art.Config {
s := e.ArtSettings(ctx)
enabled := map[string]bool{}
for _, x := range s.Sources {
enabled[x] = true
}
var use []string
for _, x := range sources {
if enabled[x] {
use = append(use, x)
}
}
if len(sources) == 0 {
use = s.Sources
}
return art.Config{Sources: use, GoogleKey: s.GoogleKey, GoogleCX: s.GoogleCX, MinSize: s.MinSize}
}
// SearchArt searches album art (square images only).
func (e *Engine) SearchArt(ctx context.Context, q art.Query, sources []string) ([]art.Result, map[string]string, error) {
if e.artClient == nil {
return nil, nil, errors.New("album art is not available")
}
res, errs := e.artClient.Search(ctx, q, e.artConfig(ctx, sources))
if res == nil {
res = []art.Result{}
}
return res, errs, nil
}
// PrepareArtURL downloads and prepares an image for applying.
func (e *Engine) PrepareArtURL(ctx context.Context, u string) (art.Prepared, error) {
if e.artClient == nil || e.artStore == nil {
return art.Prepared{}, errors.New("album art is not available")
}
b, err := e.artClient.FetchImage(ctx, u)
if err != nil {
return art.Prepared{}, err
}
return e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
}
// PrepareArtUpload prepares an uploaded image.
func (e *Engine) PrepareArtUpload(ctx context.Context, r io.Reader) (art.Prepared, error) {
if e.artStore == nil {
return art.Prepared{}, errors.New("album art is not available")
}
b, err := io.ReadAll(io.LimitReader(r, art.MaxImageBytes+1))
if err != nil {
return art.Prepared{}, err
}
if len(b) > art.MaxImageBytes {
return art.Prepared{}, errors.New("the image is larger than 20 MB")
}
return e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
}
func (e *Engine) PreparedArtPath(token string) (string, error) { return e.preparedArt(token) }
// FetchArtThumb downloads a search result thumbnail for display.
func (e *Engine) FetchArtThumb(ctx context.Context, u string) ([]byte, error) {
if e.artClient == nil {
return nil, errors.New("album art is not available")
}
return e.artClient.FetchImage(ctx, u)
}
// ---- reading covers ----
// Cover returns the art for a song (embedded) or an album folder (its image
// file), as image bytes.
func (e *Engine) Cover(ctx context.Context, pairID int64, side, rel string) ([]byte, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
abs := root.Path
if rel != "" {
if abs, err = root.Resolve(rel); err != nil {
return nil, err
}
}
fi, err := os.Lstat(abs)
if err != nil {
return nil, err
}
if fi.IsDir() {
for _, n := range folderCoverNames {
p := filepath.Join(abs, n)
if in, err := fsops.RegularFile(p); err == nil && in.Size < art.MaxImageBytes {
return os.ReadFile(p)
}
}
return nil, nil
}
if !fi.Mode().IsRegular() {
return nil, fsops.ErrNotRegular
}
return e.tagTools.Cover(ctx, abs)
}
// AlbumSong is one song in the album editor.
type AlbumSong struct {
TagFile
HasCover bool `json:"has_cover"`
EmbedCover bool `json:"embed_cover"` // the format can hold embedded art
}
// Album is an album folder with its songs (disc folders included).
type Album struct {
Folder string `json:"folder"`
Songs []AlbumSong `json:"songs"`
FolderCover string `json:"folder_cover,omitempty"` // name of the folder image, if any
}
func (e *Engine) AlbumSongs(ctx context.Context, pairID int64, side, folder string) (Album, error) {
a := Album{Folder: folder, Songs: []AlbumSong{}}
if !e.TagEditing() {
return a, ErrNoTagTools
}
l, err := e.loadPair(ctx, pairID)
if err != nil {
return a, err
}
root, err := sideRoot(l, side)
if err != nil {
return a, err
}
dir := root.Path
if folder != "" {
if dir, err = root.Resolve(folder); err != nil {
return a, err
}
}
for _, n := range folderCoverNames {
if _, err := fsops.RegularFile(filepath.Join(dir, n)); err == nil {
a.FolderCover = n
break
}
}
var rels []string
collect := func(d, prefix string) {
ents, _ := os.ReadDir(d)
for _, en := range ents {
if en.Type().IsRegular() && l.hasExt(en.Name()) && !strings.HasPrefix(en.Name(), ".") {
rels = append(rels, filepath.Join(prefix, en.Name()))
}
}
}
collect(dir, folder)
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.IsDir() && discFolder.MatchString(en.Name()) {
collect(filepath.Join(dir, en.Name()), filepath.Join(folder, en.Name()))
}
}
if len(rels) == 0 {
return a, nil
}
if len(rels) > maxTagFiles {
return a, fmt.Errorf("%w: the folder has more than %d songs", ErrInvalidBulk, maxTagFiles)
}
files, err := e.ReadTags(ctx, pairID, side, rels)
if err != nil {
return a, err
}
for _, f := range files {
s := AlbumSong{TagFile: f}
if fm, ok := tags.FormatOf(f.Rel); ok {
s.EmbedCover = fm.EmbedsCover()
}
if f.Editable {
if abs, err := root.Resolve(f.Rel); err == nil {
s.HasCover = e.tagTools.HasCover(ctx, abs)
}
}
a.Songs = append(a.Songs, s)
}
return a, nil
}
// ---- folder image ----
type coverDetail struct {
Side string `json:"side"`
Existed bool `json:"existed"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
}
// writeFolderCover writes cover.jpg into an album folder. An existing one is
// kept in quarantine first; the operation can be undone.
func (e *Engine) writeFolderCover(ctx context.Context, l *libs, side, folder, src string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, _ := sideRoot(l, side)
rel := filepath.Join(folder, FolderCoverName)
abs, err := root.Resolve(rel)
if err != nil {
return err
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActCover, QuarantineSide: side, Trigger: "art"}
setSidePath(&o, side, rel)
old, oldErr := fsops.RegularFile(abs)
d := coverDetail{Side: side, Existed: oldErr == nil}
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
tmp := filepath.Join(filepath.Dir(abs), fsops.TmpPrefix+"cover.jpg")
_ = os.Remove(tmp)
err = func() error {
if d.Existed {
q := quarantinePath(l, o.ID, side, rel)
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
if err := fsops.LinkNew(abs, old, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
}
if err := fsops.CopyNew(src, tmp, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, abs); err != nil {
_ = os.Remove(tmp)
return err
}
in, _ := fsops.Lstat(abs)
d.Size, d.Mtime = in.Size, in.MtimeNs
det, _ := json.Marshal(d)
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET detail = ? WHERE id = ?`, string(det), o.ID)
return fsops.SyncDir(filepath.Dir(abs))
}()
return e.finish(ctx, &o, err)
}
// undoCover restores the previous folder image, or removes the new one.
func (e *Engine) undoCover(l *libs, o store.Operation) error {
var d coverDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
abs, err := root.Resolve(rel)
if err != nil {
return err
}
cur, err := fsops.RegularFile(abs)
if err != nil {
return err
}
if !cur.Unchanged(d.Size, d.Mtime) {
return fmt.Errorf("%s changed after it was written; refusing to undo", rel)
}
if d.Existed {
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if err := fsops.ReplaceWithLink(o.QuarantinePath, qi, abs); err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
return nil
}
if err := os.Remove(abs); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(abs))
}
// ---- automatic matching ----
// ArtMatch are the options of an automatic match.
type ArtMatch struct {
Side string `json:"side"`
Folder string `json:"folder"`
OnlyMissing bool `json:"only_missing"`
Embed bool `json:"embed"`
FolderFile bool `json:"folder_file"`
}
// matchKey normalizes an artist or album name for comparison: case,
// punctuation, a leading "The" and release labels such as "(Deluxe)" are
// ignored.
func matchKey(s string) string {
s = stripRelease(s)
s = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s)), "the ")
var b strings.Builder
for _, r := range accentFold.Replace(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// bestArt picks a confident match: artist and album must both closely match.
func bestArt(results []art.Result, artist, album string) (art.Result, float64, bool) {
ak, bk := matchKey(artist), matchKey(album)
var best art.Result
bestScore := 0.0
for _, r := range results {
if r.Artist == "" || r.Title == "" {
continue
}
sa := levenshteinRatio(matchKey(r.Artist), ak)
sb := levenshteinRatio(matchKey(r.Title), bk)
if sa < 0.85 || sb < 0.85 {
continue
}
score := sa*0.4 + sb*0.6 + float64(min(r.Width, 3000))/100000
if score > bestScore {
best, bestScore = r, score
}
}
return best, bestScore, bestScore > 0
}
// StartArtAutoMatch finds and applies covers for the albums in a folder.
func (e *Engine) StartArtAutoMatch(pairID int64, m ArtMatch) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if e.artClient == nil || e.artStore == nil {
return nil, errors.New("album art is not available")
}
if m.Side != SideMaster && m.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
if !m.Embed && !m.FolderFile {
return nil, fmt.Errorf("%w: choose where the art goes", ErrInvalidBulk)
}
return e.startJob(pairID, "art-auto-match", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
root, _ := sideRoot(l, m.Side)
start := root
if m.Folder != "" {
abs, err := root.Resolve(m.Folder)
if err != nil {
return err
}
if start, err = fsops.NewRoot(abs); err != nil {
return err
}
}
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, start, files); err != nil {
return err
}
type album struct {
artist, title string
rels []string
}
albums := map[string]*album{}
var order []string
j.phase("Reading tags", int64(len(files)))
for _, rel := range sortedKeys(files) {
j.tick()
full := filepath.Join(m.Folder, rel)
fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(start.Path, rel), files[rel])
if err != nil {
continue
}
v := valuesOf(fields)
if v.artist == "" || v.album == "" {
j.skip(full + ": missing artist or album tag")
continue
}
key := matchKey(v.artist) + "|" + matchKey(v.album)
if albums[key] == nil {
albums[key] = &album{artist: v.artist, title: v.album}
order = append(order, key)
}
albums[key].rels = append(albums[key].rels, full)
}
j.phase("Finding covers", int64(len(order)))
for _, key := range order {
if ctx.Err() != nil {
return ctx.Err()
}
a := albums[key]
label := a.artist + " – " + a.title
j.tick()
need := a.rels
if m.OnlyMissing {
need = nil
for _, rel := range a.rels {
abs, _ := root.Resolve(rel)
if fm, ok := tags.FormatOf(rel); ok && fm.EmbedsCover() && m.Embed && !e.tagTools.HasCover(ctx, abs) {
need = append(need, rel)
}
}
folderMissing := false
if m.FolderFile {
for _, f := range albumFolders(a.rels) {
if abs, err := root.Resolve(f); err == nil && !hasFolderCover(abs) {
folderMissing = true
}
}
}
if len(need) == 0 && !folderMissing {
j.count("already have art")
continue
}
}
res, _ := e.artClient.Search(ctx, art.Query{Artist: a.artist, Album: a.title}, e.artConfig(ctx, art.MatchSources))
best, _, ok := bestArt(res, a.artist, a.title)
if !ok {
j.skip(label + ": no confident match")
continue
}
b, err := e.artClient.FetchImage(ctx, best.URL)
if err != nil {
j.skip(label + ": download failed: " + err.Error())
continue
}
p, err := e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
if err != nil {
j.skip(label + ": " + err.Error())
continue
}
cover, _ := e.artStore.Path(p.Token)
if m.Embed {
for _, rel := range need {
if err := e.editTagsOne(ctx, j, l, m.Side, rel, nil, cover, false); err != nil {
j.skip(rel + ": " + err.Error())
}
}
}
if m.FolderFile {
for _, f := range albumFolders(a.rels) {
abs, _ := root.Resolve(f)
if m.OnlyMissing && hasFolderCover(abs) {
continue
}
if err := e.writeFolderCover(ctx, l, m.Side, f, cover); err != nil {
j.skip(f + " cover.jpg: " + err.Error())
}
}
}
j.count("albums matched")
j.note(fmt.Sprintf("%s: %s (%dx%d)", label, best.Source, p.Width, p.Height))
}
return nil
})
}
func hasFolderCover(dir string) bool {
for _, n := range folderCoverNames {
if _, err := fsops.RegularFile(filepath.Join(dir, n)); err == nil {
return true
}
}
return false
}