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