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 }