Files
playlink/internal/engine/artwork.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

566 lines
16 KiB
Go
Raw 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 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
}