diff --git a/README.md b/README.md
index cb0c682..394f8ac 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,25 @@ tags** buttons. Edits are safe with hardlinks:
- Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on
startup. Dry run applies.
+**Album editor.** Every folder in *Tags* has an **Edit album** button (like AudioShell's folder view). It shows the album fields
+once (album, album artist, year, genre, discs, composer, comment); a change there updates every song in the album, including songs in
+`Disc 01`/`Disc 02` subfolders. Below is a track grid to edit each song's disc, track number, title and artist, with **Number tracks
+in order** (fills track and disc totals too) and **Artist = album artist**. All songs are saved as one job with the same safety
+checks as above.
+
+**Album art.** The song and album editors can swap the cover. Search iTunes, Deezer, MusicBrainz (Cover Art Archive), DuckDuckGo
+or Google by artist and album or by free text; only square images are shown, and results under a minimum size are hidden. You can
+also paste an image address or upload a file. The chosen image is converted to JPEG at the saved size (1000 × 1000 by default) and
+can be embedded in the songs, saved as `cover.jpg` in the album folder, or both. OGG and Opus can't hold embedded art here, so they
+use the folder image. A replaced `cover.jpg` goes to quarantine, and every change can be undone from History.
+
+- **Auto-match art…** walks the current folder, looks up each album on iTunes, Deezer and MusicBrainz, and applies a cover only when
+ the artist and album clearly match (release suffixes like "Deluxe Edition" are ignored). Albums that already have art can be
+ skipped; albums without a confident match are listed and left untouched.
+- Sources are chosen in *Settings → Album art*. Google needs an API key with the Custom Search API and a Programmable Search Engine
+ id (cx). DuckDuckGo has no official API and may stop working. Images are downloaded by playlink itself and never from addresses on
+ your local network.
+
**Organize (rename files from tags).** The *Organize* tab sets how files are named, in the style of Lidarr: an **artist folder**, an
**album folder**, a **track format** and a separate **multi-disc track format**, with presets. The default is
`{Artist Name}/{Album Title}/{track:00} {Track Title}`, and multi-disc albums get `{Medium Format} {medium:00}/…` → `Disc 01`, `Disc 02`.
diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go
index ff12b19..d4e649e 100644
--- a/cmd/playlink/main.go
+++ b/cmd/playlink/main.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/cdnutter/playlink/internal/api"
+ "github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/probe"
@@ -99,6 +100,7 @@ func main() {
// The tag editor writes with ffmpeg (verified with ffprobe).
if ff, err := exec.LookPath(env("PLAYLINK_FFMPEG", "ffmpeg")); err == nil && ffprobe != "" {
eng.SetTagTools(tags.Tools{FFmpeg: ff, FFprobe: ffprobe})
+ eng.SetArt(art.New(version), &art.Store{Dir: filepath.Join(dataDir, "art-cache"), FFmpeg: ff})
} else {
log.Warn("ffmpeg not found; tag editing disabled")
}
diff --git a/internal/api/art.go b/internal/api/art.go
new file mode 100644
index 0000000..aa69284
--- /dev/null
+++ b/internal/api/art.go
@@ -0,0 +1,154 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/cdnutter/playlink/internal/art"
+ "github.com/cdnutter/playlink/internal/engine"
+)
+
+func (s *Server) handleArtSettings(w http.ResponseWriter, r *http.Request) {
+ cfg := s.eng.ArtSettings(r.Context())
+ writeJSON(w, 200, map[string]any{"settings": cfg, "all_sources": art.AllSources})
+}
+
+func (s *Server) handlePutArtSettings(w http.ResponseWriter, r *http.Request) {
+ var cfg engine.ArtSettings
+ if !readJSON(w, r, &cfg) {
+ return
+ }
+ if err := s.eng.SetArtSettings(r.Context(), cfg); err != nil {
+ writeErr(w, 400, err)
+ return
+ }
+ s.handleArtSettings(w, r)
+}
+
+func (s *Server) handleArtSearch(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ query := art.Query{Artist: q.Get("artist"), Album: q.Get("album"), Text: q.Get("q")}
+ if strings.TrimSpace(query.Artist+query.Album+query.Text) == "" {
+ writeErr(w, 400, errors.New("enter an artist, album or search text"))
+ return
+ }
+ var sources []string
+ if src := q.Get("sources"); src != "" {
+ sources = strings.Split(src, ",")
+ }
+ res, errs, err := s.eng.SearchArt(r.Context(), query, sources)
+ if err != nil {
+ writeErr(w, 500, err)
+ return
+ }
+ writeJSON(w, 200, map[string]any{"results": res, "errors": errs})
+}
+
+func (s *Server) handleArtPrepare(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ URL string `json:"url"`
+ }
+ if !readJSON(w, r, &body) {
+ return
+ }
+ p, err := s.eng.PrepareArtURL(r.Context(), body.URL)
+ if err != nil {
+ writeErr(w, 400, err)
+ return
+ }
+ writeJSON(w, 200, p)
+}
+
+func (s *Server) handleArtUpload(w http.ResponseWriter, r *http.Request) {
+ f, _, err := r.FormFile("image")
+ if err != nil {
+ writeErr(w, 400, errors.New("choose an image file"))
+ return
+ }
+ defer f.Close()
+ p, err := s.eng.PrepareArtUpload(r.Context(), f)
+ if err != nil {
+ writeErr(w, 400, err)
+ return
+ }
+ writeJSON(w, 200, p)
+}
+
+func (s *Server) handleArtPrepared(w http.ResponseWriter, r *http.Request) {
+ p, err := s.eng.PreparedArtPath(chi.URLParam(r, "token"))
+ if err != nil {
+ writeErr(w, 404, err)
+ return
+ }
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Header().Set("Cache-Control", "private, max-age=3600")
+ http.ServeFile(w, r, p)
+}
+
+func (s *Server) handleCover(w http.ResponseWriter, r *http.Request) {
+ id, _ := idParam(r)
+ q := r.URL.Query()
+ b, err := s.eng.Cover(r.Context(), id, q.Get("side"), q.Get("path"))
+ if err != nil {
+ opErr(w, err)
+ return
+ }
+ if len(b) == 0 {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ w.Header().Set("Content-Type", http.DetectContentType(b))
+ w.Header().Set("Cache-Control", "no-cache")
+ http.ServeContent(w, r, "cover", time.Time{}, strings.NewReader(string(b)))
+}
+
+func (s *Server) handleAlbum(w http.ResponseWriter, r *http.Request) {
+ id, _ := idParam(r)
+ q := r.URL.Query()
+ a, err := s.eng.AlbumSongs(r.Context(), id, q.Get("side"), q.Get("path"))
+ if err != nil {
+ opErr(w, err)
+ return
+ }
+ writeJSON(w, 200, a)
+}
+
+func (s *Server) handleArtAuto(w http.ResponseWriter, r *http.Request) {
+ id, _ := idParam(r)
+ var m engine.ArtMatch
+ if !readJSON(w, r, &m) {
+ return
+ }
+ if !s.preAction(w, r, id, "auto-match album art") {
+ return
+ }
+ j, err := s.eng.StartArtAutoMatch(id, m)
+ if err != nil {
+ opErr(w, err)
+ return
+ }
+ writeJSON(w, 202, j)
+}
+
+// handleArtThumb shows a search result's thumbnail through the server, so
+// the page keeps its strict content policy and the browser never contacts
+// the image hosts directly. Fetching uses the local-network-safe client.
+func (s *Server) handleArtThumb(w http.ResponseWriter, r *http.Request) {
+ b, err := s.eng.FetchArtThumb(r.Context(), r.URL.Query().Get("u"))
+ if err != nil {
+ writeErr(w, 502, err)
+ return
+ }
+ ct := http.DetectContentType(b)
+ if !strings.HasPrefix(ct, "image/") {
+ writeErr(w, 502, errors.New("not an image"))
+ return
+ }
+ w.Header().Set("Content-Type", ct)
+ w.Header().Set("Cache-Control", "private, max-age=86400")
+ w.Write(b)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 25d02d7..62dcc36 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -58,6 +58,9 @@ func (s *Server) Handler() http.Handler {
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := int64(1 << 20)
+ if strings.HasSuffix(r.URL.Path, "/art/upload") {
+ limit = 21 << 20
+ }
if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") {
limit = 20 << 30 // backup zips can be large
}
@@ -80,6 +83,13 @@ func (s *Server) Handler() http.Handler {
r.Put("/settings", s.handlePutSettings)
r.Get("/browse", s.handleBrowse)
r.Get("/naming", s.handleNamingInfo)
+ r.Get("/art/settings", s.handleArtSettings)
+ r.Put("/art/settings", s.handlePutArtSettings)
+ r.Get("/art/search", s.handleArtSearch)
+ r.Post("/art/prepare", s.handleArtPrepare)
+ r.Post("/art/upload", s.handleArtUpload)
+ r.Get("/art/prepared/{token}", s.handleArtPrepared)
+ r.Get("/art/thumb", s.handleArtThumb)
r.Post("/naming/examples", s.handleNamingExamples)
if s.cfg.Backups != nil {
s.backupRoutes(r)
@@ -101,6 +111,9 @@ func (s *Server) Handler() http.Handler {
r.Post("/bulk/preview", s.handleBulkPreview)
r.Post("/bulk", s.handleBulk)
r.Get("/files", s.handleFiles)
+ r.Get("/cover", s.handleCover)
+ r.Get("/album", s.handleAlbum)
+ r.Post("/art/auto", s.handleArtAuto)
r.Post("/tags/read", s.handleReadTags)
r.Post("/tags", s.handleEditTags)
r.Post("/organize/preview", s.handleOrganizePreview)
diff --git a/internal/art/art.go b/internal/art/art.go
new file mode 100644
index 0000000..9b3f33d
--- /dev/null
+++ b/internal/art/art.go
@@ -0,0 +1,462 @@
+// Package art finds and prepares album art.
+//
+// Sources: iTunes, Deezer and MusicBrainz/Cover Art Archive (no key, made
+// for album covers), DuckDuckGo images (unofficial endpoint, best effort) and
+// Google Custom Search (needs an API key and search engine id). Only square
+// images are returned.
+//
+// Images are fetched by the server, so all fetching goes through a client
+// that refuses private, loopback and link-local addresses: a crafted result
+// or pasted URL cannot reach the local network.
+package art
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+// Sources.
+const (
+ ITunes = "itunes"
+ Deezer = "deezer"
+ MusicBrainz = "musicbrainz"
+ DuckDuckGo = "duckduckgo"
+ Google = "google"
+)
+
+var AllSources = []string{ITunes, Deezer, MusicBrainz, DuckDuckGo, Google}
+
+// MatchSources are reliable enough for automatic matching.
+var MatchSources = []string{ITunes, Deezer, MusicBrainz}
+
+type Result struct {
+ Source string `json:"source"`
+ Title string `json:"title"`
+ Artist string `json:"artist,omitempty"`
+ URL string `json:"url"`
+ Thumb string `json:"thumb"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+type Query struct {
+ Artist string `json:"artist"`
+ Album string `json:"album"`
+ Text string `json:"text"` // free-form search for the web engines
+}
+
+func (q Query) text() string {
+ if strings.TrimSpace(q.Text) != "" {
+ return strings.TrimSpace(q.Text)
+ }
+ return strings.TrimSpace(q.Artist + " " + q.Album)
+}
+
+type Config struct {
+ Sources []string
+ GoogleKey string
+ GoogleCX string
+ MinSize int
+}
+
+// Client talks to the art sources. The base URLs can be pointed at test
+// servers.
+type Client struct {
+ HTTP *http.Client
+ UserAgent string
+ ITunesURL, DeezerURL, MusicBrainzURL, CAAURL, DDGURL, GoogleURL string
+}
+
+func New(version string) *Client {
+ return &Client{
+ HTTP: SafeHTTPClient(20 * time.Second),
+ UserAgent: "playlink/" + version + " (self-hosted music library tool)",
+ ITunesURL: "https://itunes.apple.com",
+ DeezerURL: "https://api.deezer.com",
+ MusicBrainzURL: "https://musicbrainz.org",
+ CAAURL: "https://coverartarchive.org",
+ DDGURL: "https://duckduckgo.com",
+ GoogleURL: "https://www.googleapis.com",
+ }
+}
+
+// ---- safe fetching ----
+
+var ErrBlockedAddress = errors.New("that address is on a private or local network and is not fetched")
+
+func blockedIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
+ ip.IsMulticast() || ip.IsUnspecified() || ip.IsInterfaceLocalMulticast() {
+ return true
+ }
+ if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64 { // 100.64.0.0/10 (CGNAT)
+ return true
+ }
+ return false
+}
+
+// SafeHTTPClient refuses to connect to private/local addresses, including
+// after redirects and DNS tricks (the check runs on the resolved address).
+func SafeHTTPClient(timeout time.Duration) *http.Client {
+ dialer := &net.Dialer{Timeout: 10 * time.Second, Control: func(network, address string, _ syscall.RawConn) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return err
+ }
+ if ip := net.ParseIP(host); ip == nil || blockedIP(ip) {
+ return ErrBlockedAddress
+ }
+ return nil
+ }}
+ tr := &http.Transport{Proxy: nil, DialContext: dialer.DialContext, TLSHandshakeTimeout: 10 * time.Second,
+ ResponseHeaderTimeout: 15 * time.Second, MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}
+ return &http.Client{Transport: tr, Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return errors.New("too many redirects")
+ }
+ if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
+ return errors.New("only http and https are fetched")
+ }
+ return nil
+ }}
+}
+
+// MaxImageBytes caps a downloaded image.
+const MaxImageBytes = 20 << 20
+
+// FetchImage downloads an image URL (http/https only, size capped).
+func (c *Client) FetchImage(ctx context.Context, raw string) ([]byte, error) {
+ u, err := url.Parse(raw)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return nil, errors.New("enter an http or https image address")
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", c.UserAgent)
+ req.Header.Set("Accept", "image/*")
+ res, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("the image server answered %s", res.Status)
+ }
+ if ct := res.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "application/octet-stream") {
+ return nil, fmt.Errorf("that address is not an image (%s)", ct)
+ }
+ b, err := io.ReadAll(io.LimitReader(res.Body, MaxImageBytes+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(b) > MaxImageBytes {
+ return nil, errors.New("the image is larger than 20 MB")
+ }
+ return b, nil
+}
+
+func (c *Client) getJSON(ctx context.Context, u string, header map[string]string, v any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", c.UserAgent)
+ req.Header.Set("Accept", "application/json")
+ for k, val := range header {
+ req.Header.Set(k, val)
+ }
+ res, err := c.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s", res.Status)
+ }
+ return json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(v)
+}
+
+// ---- search ----
+
+// Square reports whether an image is square: within 0.5% or 2 pixels.
+func Square(w, h int) bool {
+ if w <= 0 || h <= 0 {
+ return false
+ }
+ d := w - h
+ if d < 0 {
+ d = -d
+ }
+ return d <= max(2, max(w, h)/200)
+}
+
+// Search queries the enabled sources concurrently and returns square images
+// of at least the minimum size, best first. Per-source errors are returned
+// separately so one failing source does not hide the others.
+func (c *Client) Search(ctx context.Context, q Query, cfg Config) ([]Result, map[string]string) {
+ if cfg.MinSize <= 0 {
+ cfg.MinSize = 300
+ }
+ type out struct {
+ source string
+ res []Result
+ err error
+ }
+ ch := make(chan out, len(cfg.Sources))
+ var wg sync.WaitGroup
+ for _, s := range cfg.Sources {
+ var fn func(context.Context, Query, Config) ([]Result, error)
+ switch s {
+ case ITunes:
+ fn = c.itunes
+ case Deezer:
+ fn = c.deezer
+ case MusicBrainz:
+ fn = c.musicbrainz
+ case DuckDuckGo:
+ fn = c.duckduckgo
+ case Google:
+ if cfg.GoogleKey == "" || cfg.GoogleCX == "" {
+ continue
+ }
+ fn = c.google
+ default:
+ continue
+ }
+ wg.Add(1)
+ go func(s string, fn func(context.Context, Query, Config) ([]Result, error)) {
+ defer wg.Done()
+ ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
+ defer cancel()
+ r, err := fn(ctx, q, cfg)
+ ch <- out{s, r, err}
+ }(s, fn)
+ }
+ wg.Wait()
+ close(ch)
+ errs := map[string]string{}
+ seen := map[string]bool{}
+ var all []Result
+ for o := range ch {
+ if o.err != nil {
+ errs[o.source] = o.err.Error()
+ }
+ for _, r := range o.res {
+ if seen[r.URL] || !Square(r.Width, r.Height) || min(r.Width, r.Height) < cfg.MinSize {
+ continue
+ }
+ seen[r.URL] = true
+ all = append(all, r)
+ }
+ }
+ rank := map[string]int{ITunes: 0, Deezer: 1, MusicBrainz: 2, Google: 3, DuckDuckGo: 4}
+ sort.SliceStable(all, func(i, j int) bool {
+ if rank[all[i].Source] != rank[all[j].Source] {
+ return rank[all[i].Source] < rank[all[j].Source]
+ }
+ return all[i].Width > all[j].Width
+ })
+ return all, errs
+}
+
+func (c *Client) itunes(ctx context.Context, q Query, _ Config) ([]Result, error) {
+ var d struct {
+ Results []struct {
+ ArtistName string `json:"artistName"`
+ CollectionName string `json:"collectionName"`
+ ArtworkURL100 string `json:"artworkUrl100"`
+ } `json:"results"`
+ }
+ u := c.ITunesURL + "/search?" + url.Values{"term": {q.text()}, "entity": {"album"}, "limit": {"25"}}.Encode()
+ if err := c.getJSON(ctx, u, nil, &d); err != nil {
+ return nil, err
+ }
+ var out []Result
+ for _, r := range d.Results {
+ if !strings.Contains(r.ArtworkURL100, "100x100") {
+ continue
+ }
+ // iTunes serves the same artwork at any requested size.
+ out = append(out, Result{Source: ITunes, Title: r.CollectionName, Artist: r.ArtistName, Width: 1000, Height: 1000,
+ URL: strings.Replace(r.ArtworkURL100, "100x100", "1000x1000", 1), Thumb: strings.Replace(r.ArtworkURL100, "100x100", "300x300", 1)})
+ }
+ return out, nil
+}
+
+func (c *Client) deezer(ctx context.Context, q Query, _ Config) ([]Result, error) {
+ query := q.text()
+ if q.Artist != "" && q.Album != "" && q.Text == "" {
+ query = fmt.Sprintf(`artist:"%s" album:"%s"`, q.Artist, q.Album)
+ }
+ var d struct {
+ Data []struct {
+ Title string `json:"title"`
+ CoverXL string `json:"cover_xl"`
+ CoverMedium string `json:"cover_medium"`
+ Artist struct {
+ Name string `json:"name"`
+ } `json:"artist"`
+ } `json:"data"`
+ }
+ if err := c.getJSON(ctx, c.DeezerURL+"/search/album?"+url.Values{"q": {query}, "limit": {"25"}}.Encode(), nil, &d); err != nil {
+ return nil, err
+ }
+ var out []Result
+ for _, r := range d.Data {
+ if r.CoverXL == "" {
+ continue
+ }
+ out = append(out, Result{Source: Deezer, Title: r.Title, Artist: r.Artist.Name, URL: r.CoverXL, Thumb: r.CoverMedium, Width: 1000, Height: 1000})
+ }
+ return out, nil
+}
+
+func (c *Client) musicbrainz(ctx context.Context, q Query, cfg Config) ([]Result, error) {
+ query := q.text()
+ if q.Album != "" && q.Text == "" {
+ query = fmt.Sprintf(`releasegroup:"%s"`, strings.ReplaceAll(q.Album, `"`, ""))
+ if q.Artist != "" {
+ query += fmt.Sprintf(` AND artist:"%s"`, strings.ReplaceAll(q.Artist, `"`, ""))
+ }
+ }
+ var d struct {
+ ReleaseGroups []struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ ArtistCredit []struct {
+ Name string `json:"name"`
+ } `json:"artist-credit"`
+ } `json:"release-groups"`
+ }
+ u := c.MusicBrainzURL + "/ws/2/release-group/?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"8"}}.Encode()
+ if err := c.getJSON(ctx, u, nil, &d); err != nil {
+ return nil, err
+ }
+ // The Cover Art Archive does not report sizes: check each front cover's
+ // thumbnail to keep only square ones (and skip groups without art).
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ var out []Result
+ for _, rg := range d.ReleaseGroups {
+ artist := ""
+ if len(rg.ArtistCredit) > 0 {
+ artist = rg.ArtistCredit[0].Name
+ }
+ wg.Add(1)
+ go func(id, title, artist string) {
+ defer wg.Done()
+ thumb := c.CAAURL + "/release-group/" + id + "/front-250"
+ w, h, err := c.imageSize(ctx, thumb)
+ if err != nil || !Square(w, h) {
+ return
+ }
+ mu.Lock()
+ out = append(out, Result{Source: MusicBrainz, Title: title, Artist: artist, URL: c.CAAURL + "/release-group/" + id + "/front-1200",
+ Thumb: thumb, Width: 1200, Height: 1200})
+ mu.Unlock()
+ }(rg.ID, rg.Title, artist)
+ }
+ wg.Wait()
+ return out, nil
+}
+
+// imageSize reads just enough of an image to learn its dimensions.
+func (c *Client) imageSize(ctx context.Context, u string) (int, int, error) {
+ b, err := c.FetchImage(ctx, u)
+ if err != nil {
+ return 0, 0, err
+ }
+ cfg, _, err := image.DecodeConfig(bytes.NewReader(b))
+ return cfg.Width, cfg.Height, err
+}
+
+var vqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
+
+func (c *Client) duckduckgo(ctx context.Context, q Query, _ Config) ([]Result, error) {
+ text := q.Text
+ if strings.TrimSpace(text) == "" {
+ text = q.text() + " album cover"
+ }
+ // DuckDuckGo has no official image API: a page request yields the token
+ // its own image search uses. This can break if they change their site.
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.DDGURL+"/?"+url.Values{"q": {text}, "iax": {"images"}, "ia": {"images"}}.Encode(), nil)
+ req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) playlink")
+ res, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ page, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20))
+ res.Body.Close()
+ m := vqdRe.FindSubmatch(page)
+ if m == nil {
+ return nil, errors.New("DuckDuckGo did not return a search token (their site may have changed)")
+ }
+ var d struct {
+ Results []struct {
+ Title string `json:"title"`
+ Image string `json:"image"`
+ Thumbnail string `json:"thumbnail"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ } `json:"results"`
+ }
+ u := c.DDGURL + "/i.js?" + url.Values{"q": {text}, "vqd": {string(m[1])}, "o": {"json"}, "l": {"us-en"}, "f": {",,,,,layout:Square"}, "p": {"1"}}.Encode()
+ if err := c.getJSON(ctx, u, map[string]string{"Referer": c.DDGURL + "/", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) playlink"}, &d); err != nil {
+ return nil, err
+ }
+ var out []Result
+ for _, r := range d.Results {
+ out = append(out, Result{Source: DuckDuckGo, Title: r.Title, URL: r.Image, Thumb: r.Thumbnail, Width: r.Width, Height: r.Height})
+ }
+ return out, nil
+}
+
+func (c *Client) google(ctx context.Context, q Query, cfg Config) ([]Result, error) {
+ text := q.Text
+ if strings.TrimSpace(text) == "" {
+ text = q.text() + " album cover"
+ }
+ var d struct {
+ Items []struct {
+ Title string `json:"title"`
+ Link string `json:"link"`
+ Image struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+ ThumbnailLink string `json:"thumbnailLink"`
+ } `json:"image"`
+ } `json:"items"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ u := c.GoogleURL + "/customsearch/v1?" + url.Values{"key": {cfg.GoogleKey}, "cx": {cfg.GoogleCX}, "q": {text},
+ "searchType": {"image"}, "num": {"10"}, "imgSize": {"large"}}.Encode()
+ if err := c.getJSON(ctx, u, nil, &d); err != nil {
+ return nil, fmt.Errorf("Google: %v (check the API key and search engine id)", err)
+ }
+ var out []Result
+ for _, it := range d.Items {
+ out = append(out, Result{Source: Google, Title: it.Title, URL: it.Link, Thumb: it.Image.ThumbnailLink, Width: it.Image.Width, Height: it.Image.Height})
+ }
+ return out, nil
+}
diff --git a/internal/art/art_test.go b/internal/art/art_test.go
new file mode 100644
index 0000000..a360f5f
--- /dev/null
+++ b/internal/art/art_test.go
@@ -0,0 +1,204 @@
+package art
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+var ctx = context.Background()
+
+func pngBytes(w, h int) []byte {
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for x := 0; x < w; x++ {
+ img.Set(x, 0, color.RGBA{200, 0, 0, 255})
+ }
+ var b bytes.Buffer
+ png.Encode(&b, img)
+ return b.Bytes()
+}
+
+// fakeSources serves canned answers for every source.
+func fakeSources(t *testing.T, failDeezer bool) *Client {
+ t.Helper()
+ mux := http.NewServeMux()
+ var base string
+ mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { // iTunes
+ fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base)
+ })
+ mux.HandleFunc("/search/album", func(w http.ResponseWriter, r *http.Request) { // Deezer
+ if failDeezer {
+ http.Error(w, "down", 500)
+ return
+ }
+ fmt.Fprintf(w, `{"data":[{"title":"Nightmare","cover_xl":"%s/dz/xl.jpg","cover_medium":"%s/dz/m.jpg","artist":{"name":"Avenged Sevenfold"}}]}`, base, base)
+ })
+ mux.HandleFunc("/ws/2/release-group/", func(w http.ResponseWriter, r *http.Request) { // MusicBrainz
+ fmt.Fprint(w, `{"release-groups":[{"id":"sq","title":"Nightmare","artist-credit":[{"name":"Avenged Sevenfold"}]},
+ {"id":"wide","title":"Nightmare (Live)","artist-credit":[{"name":"Avenged Sevenfold"}]},{"id":"none","title":"X"}]}`)
+ })
+ mux.HandleFunc("/release-group/sq/front-250", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(pngBytes(250, 250))
+ })
+ mux.HandleFunc("/release-group/wide/front-250", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(pngBytes(250, 180))
+ })
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // DDG page with token
+ if r.URL.Path == "/" {
+ fmt.Fprint(w, ``)
+ return
+ }
+ http.NotFound(w, r)
+ })
+ mux.HandleFunc("/i.js", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("vqd") != "4-12345" || !strings.Contains(r.URL.Query().Get("f"), "layout:Square") {
+ http.Error(w, "bad", 400)
+ return
+ }
+ fmt.Fprint(w, `{"results":[{"title":"near square","image":"https://x/a.jpg","thumbnail":"t","width":600,"height":594},
+ {"title":"square","image":"https://x/b.jpg","thumbnail":"t","width":800,"height":800},
+ {"title":"tiny","image":"https://x/c.jpg","thumbnail":"t","width":200,"height":200}]}`)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ base = srv.URL
+ c := New("test")
+ c.HTTP = srv.Client() // the safe client refuses loopback, which the fakes use
+ c.ITunesURL, c.DeezerURL, c.MusicBrainzURL, c.CAAURL, c.DDGURL, c.GoogleURL = base, base, base, base, base, base
+ return c
+}
+
+func TestSearchSquareOnly(t *testing.T) {
+ c := fakeSources(t, false)
+ res, errs := c.Search(ctx, Query{Artist: "Avenged Sevenfold", Album: "Nightmare"}, Config{Sources: AllSources})
+ if len(errs) != 0 {
+ t.Fatalf("errors: %v", errs)
+ }
+ got := map[string]Result{}
+ for _, r := range res {
+ got[r.Source+":"+r.Title] = r
+ }
+ for _, want := range []string{"itunes:Nightmare", "deezer:Nightmare", "musicbrainz:Nightmare", "duckduckgo:square"} {
+ if _, ok := got[want]; !ok {
+ t.Errorf("missing %s in %+v", want, res)
+ }
+ }
+ for _, not := range []string{"duckduckgo:near square", "duckduckgo:tiny", "musicbrainz:Nightmare (Live)"} {
+ if _, ok := got[not]; ok {
+ t.Errorf("%s must be filtered out (not square or too small)", not)
+ }
+ }
+ if r := got["itunes:Nightmare"]; !strings.HasSuffix(r.URL, "/img/1000x1000bb.jpg") || res[0].Source != ITunes {
+ t.Errorf("iTunes should come first at 1000px: %+v", r)
+ }
+ for _, r := range res {
+ if r.Source == Google {
+ t.Error("Google must not be queried without an API key")
+ }
+ }
+}
+
+func TestOneFailingSourceDoesNotHideOthers(t *testing.T) {
+ c := fakeSources(t, true)
+ res, errs := c.Search(ctx, Query{Artist: "A", Album: "B"}, Config{Sources: []string{ITunes, Deezer}})
+ if errs[Deezer] == "" || len(res) != 1 || res[0].Source != ITunes {
+ t.Fatalf("want iTunes result plus a Deezer error, got %+v %v", res, errs)
+ }
+}
+
+func TestSquare(t *testing.T) {
+ for _, c := range []struct {
+ w, h int
+ want bool
+ }{{1000, 1000, true}, {1000, 996, true}, {600, 594, false}, {500, 498, true}, {0, 0, false}, {1200, 1100, false}} {
+ if Square(c.w, c.h) != c.want {
+ t.Errorf("Square(%d,%d) != %v", c.w, c.h, c.want)
+ }
+ }
+}
+
+func TestSafeFetch(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/page" {
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, "")
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(pngBytes(10, 10))
+ }))
+ defer srv.Close()
+ c := New("test") // the real, safe client
+ if _, err := c.FetchImage(ctx, srv.URL+"/img.png"); !errors.Is(err, ErrBlockedAddress) {
+ t.Fatalf("loopback must be refused, got %v", err)
+ }
+ for _, u := range []string{"file:///etc/passwd", "ftp://x/y.png", "gopher://x", "not a url"} {
+ if _, err := c.FetchImage(ctx, u); err == nil {
+ t.Errorf("%q must be refused", u)
+ }
+ }
+ c.HTTP = srv.Client()
+ if _, err := c.FetchImage(ctx, srv.URL+"/page"); err == nil || !strings.Contains(err.Error(), "not an image") {
+ t.Fatalf("non-images must be refused, got %v", err)
+ }
+ if b, err := c.FetchImage(ctx, srv.URL+"/img.png"); err != nil || len(b) == 0 {
+ t.Fatalf("image fetch: %v", err)
+ }
+ for _, ip := range []string{"10.0.0.1", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "::1", "fc00::1", "0.0.0.0"} {
+ if !blockedIP(parseIP(ip)) {
+ t.Errorf("%s should be blocked", ip)
+ }
+ }
+ if blockedIP(parseIP("1.1.1.1")) {
+ t.Error("public addresses are allowed")
+ }
+}
+
+func TestPrepare(t *testing.T) {
+ ff, err := exec.LookPath("ffmpeg")
+ if err != nil {
+ t.Skip("ffmpeg not installed")
+ }
+ s := &Store{Dir: t.TempDir(), FFmpeg: ff}
+ p, err := s.Prepare(ctx, pngBytes(1600, 1600), 1000)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Width != 1000 || p.Height != 1000 || !p.Square {
+ t.Fatalf("want 1000x1000 square, got %+v", p)
+ }
+ path, err := s.Path(p.Token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := os.ReadFile(path)
+ if len(b) < 3 || b[0] != 0xFF || b[1] != 0xD8 {
+ t.Fatal("prepared image should be a JPEG")
+ }
+ if small, _ := s.Prepare(ctx, pngBytes(300, 300), 1000); small.Width != 300 {
+ t.Fatal("images are never enlarged")
+ }
+ if _, err := s.Prepare(ctx, []byte("not an image"), 1000); err == nil {
+ t.Fatal("garbage must be refused")
+ }
+ for _, bad := range []string{"../etc", "abc", strings.Repeat("g", 32)} {
+ if _, err := s.Path(bad); !errors.Is(err, ErrBadToken) {
+ t.Errorf("token %q must be refused", bad)
+ }
+ }
+}
+
+func parseIP(s string) net.IP { return net.ParseIP(s) }
diff --git a/internal/art/prepared.go b/internal/art/prepared.go
new file mode 100644
index 0000000..9d5098e
--- /dev/null
+++ b/internal/art/prepared.go
@@ -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
+}
diff --git a/internal/engine/artwork.go b/internal/engine/artwork.go
new file mode 100644
index 0000000..6a739c0
--- /dev/null
+++ b/internal/engine/artwork.go
@@ -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
+}
diff --git a/internal/engine/artwork_test.go b/internal/engine/artwork_test.go
new file mode 100644
index 0000000..0cdcf36
--- /dev/null
+++ b/internal/engine/artwork_test.go
@@ -0,0 +1,181 @@
+package engine
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/cdnutter/playlink/internal/art"
+ "github.com/cdnutter/playlink/internal/store"
+ "github.com/cdnutter/playlink/internal/tags"
+)
+
+func squarePNG(n int) []byte {
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ for i := 0; i < n; i++ {
+ img.Set(i, i, color.RGBA{0, 0, 200, 255})
+ }
+ var b bytes.Buffer
+ png.Encode(&b, img)
+ return b.Bytes()
+}
+
+// artEnv is a tag-editing env with an art store and a fake iTunes that knows
+// one album: Avenged Sevenfold - Nightmare.
+func artEnv(t *testing.T) *env {
+ t.Helper()
+ ev := tagEnv(t, false)
+ var base string
+ mux := http.NewServeMux()
+ mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare (Deluxe Edition)","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base)
+ })
+ mux.HandleFunc("/img/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(squarePNG(1200))
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ base = srv.URL
+ c := art.New("test")
+ c.HTTP = srv.Client()
+ c.ITunesURL = base
+ ff, _ := exec.LookPath("ffmpeg")
+ ev.e.SetArt(c, &art.Store{Dir: filepath.Join(ev.root, "art-cache"), FFmpeg: ff})
+ ev.st.SetSetting(ctx, "art_sources", "itunes")
+ return ev
+}
+
+func prepared(t *testing.T, ev *env) string {
+ t.Helper()
+ p, err := ev.e.artStore.Prepare(ctx, squarePNG(800), 1000)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p.Token
+}
+
+func TestAlbumEditWithArt(t *testing.T) {
+ ev := artEnv(t)
+ dir := "Avenged Sevenfold/Nightmare"
+ mkTagged(t, filepath.Join(ev.slave, dir, "a.mp3"), "title", "x", "artist", "Avenged Sevenfold", "album", "Nightmar")
+ mkTagged(t, filepath.Join(ev.slave, dir, "Disc 2/b.flac"), "title", "y", "artist", "Avenged Sevenfold", "album", "Nightmar")
+ mkTagged(t, filepath.Join(ev.slave, dir, "c.ogg"), "title", "z", "artist", "Avenged Sevenfold", "album", "Nightmar")
+
+ a, err := ev.e.AlbumSongs(ctx, ev.pair.ID, SideSlave, dir)
+ if err != nil || len(a.Songs) != 3 {
+ t.Fatalf("album should include the disc folder: %+v %v", a, err)
+ }
+ req := TagEdit{Side: SideSlave, Set: map[string]string{tags.Album: "Nightmare", tags.Genre: "Metal", tags.TrackTotal: "3"},
+ Files: []FileEdit{
+ {Path: dir + "/a.mp3", Set: map[string]string{tags.Title: "Nightmare", tags.Track: "1"}},
+ {Path: dir + "/Disc 2/b.flac", Set: map[string]string{tags.Title: "Welcome to the Family", tags.Track: "2"}},
+ {Path: dir + "/c.ogg", Set: map[string]string{tags.Title: "Danger Line", tags.Track: "3"}},
+ },
+ Art: &ArtApply{Token: prepared(t, ev), Embed: true, FolderFile: true}}
+ if _, err := ev.e.StartTagEdit(ev.pair.ID, req); err != nil {
+ t.Fatal(err)
+ }
+ j := ev.wait()
+ if j.Results["edited"] != 3 || j.Results["folder art"] != 1 {
+ t.Fatalf("results %+v %v", j.Results, j.Skipped)
+ }
+ for rel, title := range map[string]string{"a.mp3": "Nightmare", "Disc 2/b.flac": "Welcome to the Family", "c.ogg": "Danger Line"} {
+ p := filepath.Join(ev.slave, dir, rel)
+ f, _ := ev.e.tagTools.Read(ctx, p)
+ if f.Fields[tags.Title] != title || f.Fields[tags.Album] != "Nightmare" || f.Fields[tags.Genre] != "Metal" {
+ t.Errorf("%s: fields %+v", rel, f.Fields)
+ }
+ wantCover := !strings.HasSuffix(rel, ".ogg")
+ if ev.e.tagTools.HasCover(ctx, p) != wantCover {
+ t.Errorf("%s: embedded cover should be %v", rel, wantCover)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(ev.slave, dir, "cover.jpg")); err != nil {
+ t.Fatal("the album folder should get cover.jpg")
+ }
+ // Undo the folder image: it is removed again.
+ ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
+ for _, o := range ops {
+ if o.Action == store.ActCover {
+ if err := ev.e.Undo(ctx, o.ID); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if _, err := os.Stat(filepath.Join(ev.slave, dir, "cover.jpg")); !os.IsNotExist(err) {
+ t.Fatal("undo should remove the new cover.jpg")
+ }
+}
+
+func TestFolderCoverReplaceKeepsOld(t *testing.T) {
+ ev := artEnv(t)
+ dir := "A/B"
+ mkTagged(t, filepath.Join(ev.slave, dir, "s.flac"), "title", "S", "artist", "A", "album", "B")
+ old := filepath.Join(ev.slave, dir, "cover.jpg")
+ os.WriteFile(old, []byte("old cover"), 0o644)
+ ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{dir + "/s.flac"}, Art: &ArtApply{Token: prepared(t, ev), FolderFile: true}})
+ ev.wait()
+ if b, _ := os.ReadFile(old); string(b) == "old cover" {
+ t.Fatal("cover.jpg should be replaced")
+ }
+ ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 1})
+ if ops[0].Action != store.ActCover || ops[0].QuarantinePath == "" {
+ t.Fatalf("old cover should be quarantined: %+v", ops[0])
+ }
+ if err := ev.e.Undo(ctx, ops[0].ID); err != nil {
+ t.Fatal(err)
+ }
+ if b, _ := os.ReadFile(old); string(b) != "old cover" {
+ t.Fatal("undo should restore the old cover.jpg")
+ }
+}
+
+func TestAutoMatchArt(t *testing.T) {
+ ev := artEnv(t)
+ mkTagged(t, filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
+ mkTagged(t, filepath.Join(ev.slave, "Other/Record/01.mp3"), "title", "Song", "artist", "Other Band", "album", "Record")
+ m := ArtMatch{Side: SideSlave, OnlyMissing: true, Embed: true, FolderFile: true}
+ if _, err := ev.e.StartArtAutoMatch(ev.pair.ID, m); err != nil {
+ t.Fatal(err)
+ }
+ j := ev.wait()
+ if j.Results["albums matched"] != 1 || len(j.Notes) != 1 || !strings.Contains(strings.Join(j.Skipped, "\n"), "Other Band – Record: no confident match") {
+ t.Fatalf("results %+v notes %v skipped %v", j.Results, j.Notes, j.Skipped)
+ }
+ if !ev.e.tagTools.HasCover(ctx, filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01.mp3")) {
+ t.Fatal("the matched album should get embedded art")
+ }
+ if ev.e.tagTools.HasCover(ctx, filepath.Join(ev.slave, "Other/Record/01.mp3")) {
+ t.Fatal("an unmatched album must not get art")
+ }
+ // Running again: the matched album already has art.
+ ev.e.StartArtAutoMatch(ev.pair.ID, m)
+ if j := ev.wait(); j.Results["already have art"] != 1 {
+ t.Fatalf("second run: %+v", j.Results)
+ }
+}
+
+func TestBestArt(t *testing.T) {
+ res := []art.Result{
+ {Source: "itunes", Artist: "Avenged Sevenfold", Title: "Nightmare (Deluxe Edition)", Width: 1000},
+ {Source: "deezer", Artist: "Avenged Sevenfold", Title: "Hail to the King", Width: 1000},
+ {Source: "deezer", Artist: "Someone Else", Title: "Nightmare", Width: 1000},
+ }
+ best, _, ok := bestArt(res, "Avenged Sevenfold", "Nightmare")
+ if !ok || best.Title != "Nightmare (Deluxe Edition)" {
+ t.Fatalf("got %+v", best)
+ }
+ if _, _, ok := bestArt(res, "The Beatles", "Abbey Road"); ok {
+ t.Fatal("no confident match expected")
+ }
+}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index 703fd90..0646715 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -13,6 +13,7 @@ import (
"sync"
"time"
+ "github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
@@ -45,6 +46,8 @@ type Engine struct {
tagTools tags.Tools
beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
+ artClient *art.Client
+ artStore *art.Store
plansMu sync.Mutex
plans map[int64]*organizePlan // latest "rename now" preview per pair
@@ -74,6 +77,7 @@ type Job struct {
Error string `json:"error"`
Results map[string]int `json:"results"`
Skipped []string `json:"skipped"`
+ Notes []string `json:"notes"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at,omitempty"`
@@ -95,6 +99,15 @@ func (j *Job) tick() { j.update(func(j *Job) { j.Done++ }) }
func (j *Job) count(key string) { j.update(func(j *Job) { j.Results[key]++ }) }
+// note records an informational line (e.g. which cover was chosen).
+func (j *Job) note(msg string) {
+ j.update(func(j *Job) {
+ if len(j.Notes) < 300 {
+ j.Notes = append(j.Notes, msg)
+ }
+ })
+}
+
func (j *Job) skip(msg string) {
j.update(func(j *Job) {
j.Results["skipped"]++
@@ -118,6 +131,7 @@ func (e *Engine) Job(pairID int64) *Job {
cp.Results[k] = v
}
cp.Skipped = append([]string{}, j.Skipped...) // never nil: the UI expects an array
+ cp.Notes = append([]string{}, j.Notes...)
return &cp
}
diff --git a/internal/engine/ops.go b/internal/engine/ops.go
index 5b76711..fbb7f3a 100644
--- a/internal/engine/ops.go
+++ b/internal/engine/ops.go
@@ -625,6 +625,16 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
if err != nil {
return err
}
+ if o.Action == store.ActCover {
+ if err := e.undoCover(l, o); err != nil {
+ return err
+ }
+ if err := e.st.MarkUndone(ctx, o.ID); err != nil {
+ return err
+ }
+ e.log.Info("folder cover undone", "op", o.ID)
+ return nil
+ }
if o.Action == store.ActMove {
if err := undoMove(l, o); err != nil {
return err
diff --git a/internal/engine/tagedit.go b/internal/engine/tagedit.go
index 1b5da66..d989026 100644
--- a/internal/engine/tagedit.go
+++ b/internal/engine/tagedit.go
@@ -194,16 +194,29 @@ func (e *Engine) ReadTags(ctx context.Context, pairID int64, side string, rels [
type TagEdit struct {
Side string `json:"side"`
Paths []string `json:"paths"`
- Set map[string]string `json:"set"` // field -> new value ("" removes the tag)
+ Set map[string]string `json:"set"` // field -> new value ("" removes the tag), for every file
+ // Files adds per-song changes (an album's track titles and numbers).
+ Files []FileEdit `json:"files,omitempty"`
+ // Art applies album art to the edited files and/or their album folder.
+ Art *ArtApply `json:"art,omitempty"`
// Separate gives a hardlinked file its own edited copy instead of
// updating it in place for every linked path.
Separate bool `json:"separate"`
}
+type FileEdit struct {
+ Path string `json:"path"`
+ Set map[string]string `json:"set"`
+}
+
+// ArtApply is a prepared cover (see art.Store) and where it goes.
+type ArtApply struct {
+ Token string `json:"token"`
+ Embed bool `json:"embed"` // into each song (MP3, FLAC, M4A)
+ FolderFile bool `json:"folder_file"` // as cover.jpg in the album folder
+}
+
func validTagChanges(set map[string]string) error {
- if len(set) == 0 {
- return fmt.Errorf("%w: no tags changed", ErrInvalidBulk)
- }
known := map[string]bool{}
for _, id := range tags.FieldIDs {
known[id] = true
@@ -227,39 +240,118 @@ func (e *Engine) StartTagEdit(pairID int64, req TagEdit) (*Job, error) {
if req.Side != SideMaster && req.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
- rels, err := cleanRels(req.Paths)
- if err != nil {
- return nil, err
+ // Every file gets the shared changes plus its own.
+ perFile := map[string]map[string]string{}
+ var order []string
+ add := func(p string, set map[string]string) error {
+ c, err := fsops.CleanRel(p)
+ if err != nil {
+ return err
+ }
+ if _, ok := perFile[c]; !ok {
+ perFile[c] = map[string]string{}
+ order = append(order, c)
+ }
+ for k, v := range set {
+ perFile[c][k] = v
+ }
+ return nil
}
- if err := validTagChanges(req.Set); err != nil {
- return nil, err
+ for _, p := range req.Paths {
+ if err := add(p, req.Set); err != nil {
+ return nil, err
+ }
+ }
+ for _, f := range req.Files {
+ if err := add(f.Path, req.Set); err != nil {
+ return nil, err
+ }
+ if err := add(f.Path, f.Set); err != nil {
+ return nil, err
+ }
+ }
+ if len(order) == 0 {
+ return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
+ }
+ if len(order) > maxTagFiles {
+ return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles)
+ }
+ changed := false
+ for _, set := range perFile {
+ if len(set) > 0 {
+ if err := validTagChanges(set); err != nil {
+ return nil, err
+ }
+ changed = true
+ }
+ }
+ var cover string
+ if req.Art != nil && (req.Art.Embed || req.Art.FolderFile) {
+ p, err := e.preparedArt(req.Art.Token)
+ if err != nil {
+ return nil, err
+ }
+ cover = p
+ }
+ if !changed && cover == "" {
+ return nil, fmt.Errorf("%w: nothing changed", ErrInvalidBulk)
}
return e.startJob(pairID, "edit-tags", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
- j.phase("Editing tags", int64(len(rels)))
- for _, rel := range rels {
+ embed := ""
+ if cover != "" && req.Art.Embed {
+ embed = cover
+ }
+ j.phase("Editing tags", int64(len(order)))
+ for _, rel := range order {
if ctx.Err() != nil {
return ctx.Err()
}
- if err := e.editTagsOne(ctx, j, l, req.Side, rel, req.Set, req.Separate); err != nil {
+ if err := e.editTagsOne(ctx, j, l, req.Side, rel, perFile[rel], embed, req.Separate); err != nil {
j.skip(rel + ": " + err.Error())
}
j.tick()
}
+ if cover != "" && req.Art.FolderFile {
+ for _, folder := range albumFolders(order) {
+ if err := e.writeFolderCover(ctx, l, req.Side, folder, cover); err != nil {
+ j.skip(folder + " cover.jpg: " + err.Error())
+ } else {
+ j.count("folder art")
+ }
+ }
+ }
return nil
})
}
+// albumFolders lists the distinct album folders of some songs (disc folders
+// folded into their album).
+func albumFolders(rels []string) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, r := range rels {
+ f := AlbumFolder(r)
+ if f != "" && !seen[f] {
+ seen[f] = true
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
type tagDetail struct {
Side string `json:"side"`
Mode string `json:"mode"`
- Changes map[string][2]string `json:"changes"` // field -> [old, new]
+ Changes map[string][2]string `json:"changes"` // field -> [old, new]
+ Cover bool `json:"cover,omitempty"` // new art embedded
}
-func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]string, separate bool) error {
+// editTagsOne applies tag changes and/or a new embedded cover to one file.
+func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]string, cover string, separate bool) error {
e.mu.Lock()
defer e.mu.Unlock()
root, _ := sideRoot(l, side)
@@ -283,7 +375,15 @@ func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel str
d.Changes[id] = [2]string{f.Fields[id], v}
}
}
- if len(changes) == 0 {
+ if cover != "" && !f.Format.EmbedsCover() {
+ cover = "" // Ogg-based files: the folder image carries their art
+ if len(changes) == 0 {
+ j.count("art in folder only")
+ return nil
+ }
+ }
+ d.Cover = cover != ""
+ if len(changes) == 0 && cover == "" {
j.count("unchanged")
return nil
}
@@ -314,10 +414,10 @@ func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel str
tmp := filepath.Join(filepath.Dir(abs), fsops.TmpPrefix+hex.EncodeToString(b[:])+filepath.Ext(abs))
defer os.Remove(tmp)
err = func() error {
- if err := e.tagTools.Write(ctx, abs, tmp, f, plan); err != nil {
+ if err := e.tagTools.Write(ctx, abs, tmp, f, plan, cover); err != nil {
return err
}
- if err := e.tagTools.Verify(ctx, abs, tmp, changes); err != nil {
+ if err := e.tagTools.Verify(ctx, abs, tmp, changes, cover != ""); err != nil {
return err
}
// Nothing may have touched the original while the new file was made.
diff --git a/internal/store/models.go b/internal/store/models.go
index 589aceb..5a4d677 100644
--- a/internal/store/models.go
+++ b/internal/store/models.go
@@ -671,6 +671,7 @@ const (
ActRename = "rename" // slave folder renamed to the master's spelling
ActTags = "tags" // tags of a file edited
ActMove = "move" // file renamed/moved by the organizer
+ ActCover = "cover" // album folder image written
)
// Operation statuses.
diff --git a/internal/tags/tags.go b/internal/tags/tags.go
index 7edee2d..67b5d9b 100644
--- a/internal/tags/tags.go
+++ b/internal/tags/tags.go
@@ -281,12 +281,28 @@ func (f File) Plan(changes map[string]string) (map[string]string, error) {
return set, nil
}
+// EmbedsCover reports whether ffmpeg can embed cover art in this format
+// (Ogg-based formats cannot; their art goes into a folder image instead).
+func (f Format) EmbedsCover() bool { return !f.StreamTags }
+
// Write copies src to dst (which must not exist and must keep src's
// extension) with the planned tags applied. Audio and other streams, such
-// as cover art, are copied untouched.
-func (t Tools) Write(ctx context.Context, src, dst string, f File, set map[string]string) error {
- args := []string{"-v", "error", "-nostdin", "-n", "-i", src, "-map", "0", "-c", "copy",
- "-map_metadata", "0", "-fflags", "+bitexact"}
+// as cover art, are copied untouched. A non-empty cover replaces any
+// embedded art with that JPEG.
+func (t Tools) Write(ctx context.Context, src, dst string, f File, set map[string]string, cover string) error {
+ args := []string{"-v", "error", "-nostdin", "-n", "-i", src}
+ if cover != "" {
+ if !f.Format.EmbedsCover() {
+ return fmt.Errorf("%s files cannot hold embedded cover art", f.Format.Name)
+ }
+ args = append(args, "-i", cover, "-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic")
+ if f.Format.Name == "mp3" {
+ args = append(args, "-metadata:s:v:0", "title=Album cover", "-metadata:s:v:0", "comment=Cover (front)")
+ }
+ } else {
+ args = append(args, "-map", "0")
+ }
+ args = append(args, "-c", "copy", "-map_metadata", "0", "-fflags", "+bitexact")
target := "-metadata"
if f.Format.StreamTags {
args = append(args, "-map_metadata:s:a", "0:s:a")
@@ -309,12 +325,15 @@ func (t Tools) Write(ctx context.Context, src, dst string, f File, set map[strin
var ErrVerify = errors.New("tag write verification failed")
// Verify checks that dst carries the requested field values and exactly the
-// same audio as src.
-func (t Tools) Verify(ctx context.Context, src, dst string, changes map[string]string) error {
+// same audio as src, and embedded art when wantCover is set.
+func (t Tools) Verify(ctx context.Context, src, dst string, changes map[string]string, wantCover bool) error {
got, err := t.Read(ctx, dst)
if err != nil {
return fmt.Errorf("%w: %v", ErrVerify, err)
}
+ if wantCover && !t.HasCover(ctx, dst) {
+ return fmt.Errorf("%w: the cover art was not embedded", ErrVerify)
+ }
for id, want := range changes {
if got.Fields[id] != want {
return fmt.Errorf("%w: %s reads back as %q, not %q", ErrVerify, id, got.Fields[id], want)
@@ -342,3 +361,18 @@ func (t Tools) audioHash(ctx context.Context, path string) (string, error) {
}
return strings.TrimSpace(string(out)), nil
}
+
+// HasCover reports whether a file has embedded cover art.
+func (t Tools) HasCover(ctx context.Context, path string) bool {
+ out, err := t.run(ctx, t.FFprobe, "-v", "error", "-select_streams", "v", "-show_entries", "stream=index", "-of", "csv=p=0", "--", path)
+ return err == nil && strings.TrimSpace(string(out)) != ""
+}
+
+// Cover returns a file's embedded cover art as a JPEG, or nil when it has none.
+func (t Tools) Cover(ctx context.Context, path string) ([]byte, error) {
+ if !t.HasCover(ctx, path) {
+ return nil, nil
+ }
+ return t.run(ctx, t.FFmpeg, "-v", "error", "-nostdin", "-i", path, "-map", "0:v:0", "-frames:v", "1",
+ "-vf", "scale='min(600,iw)':-2", "-c:v", "mjpeg", "-q:v", "3", "-f", "image2pipe", "-")
+}
diff --git a/internal/tags/tags_test.go b/internal/tags/tags_test.go
index 55fc07c..9d2cfa5 100644
--- a/internal/tags/tags_test.go
+++ b/internal/tags/tags_test.go
@@ -75,10 +75,10 @@ func TestEditEveryFormat(t *testing.T) {
t.Fatal(err)
}
dst := filepath.Join(dir, ".tmp-out."+ext)
- if err := tl.Write(ctx, src, dst, f, set); err != nil {
+ if err := tl.Write(ctx, src, dst, f, set, ""); err != nil {
t.Fatal(err)
}
- if err := tl.Verify(ctx, src, dst, changes); err != nil {
+ if err := tl.Verify(ctx, src, dst, changes, false); err != nil {
t.Fatal(err)
}
got, _ := tl.Read(ctx, dst)
@@ -119,10 +119,10 @@ func TestEditRawVorbisFLAC(t *testing.T) {
changes := map[string]string{Title: "Nightmare", AlbumArtist: "Avenged Sevenfold", Track: "4", Date: "2011"}
set, _ := f.Plan(changes)
dst := filepath.Join(dir, ".tmp-out.flac")
- if err := tl.Write(ctx, src, dst, f, set); err != nil {
+ if err := tl.Write(ctx, src, dst, f, set, ""); err != nil {
t.Fatal(err)
}
- if err := tl.Verify(ctx, src, dst, changes); err != nil {
+ if err := tl.Verify(ctx, src, dst, changes, false); err != nil {
t.Fatal(err)
}
out, _ := exec.Command(metaflac, "--export-tags-to=-", dst).Output()
@@ -148,7 +148,7 @@ func TestVerifyRejectsChangedAudio(t *testing.T) {
if out, err := exec.Command(tl.FFmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "sine=f=900:d=1", "-metadata", "title=Old Title", b).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
- if err := tl.Verify(ctx, a, b, map[string]string{Title: "Old Title"}); !errors.Is(err, ErrVerify) {
+ if err := tl.Verify(ctx, a, b, map[string]string{Title: "Old Title"}, false); !errors.Is(err, ErrVerify) {
t.Fatalf("different audio must fail verification, got %v", err)
}
}
diff --git a/web/src/api.ts b/web/src/api.ts
index fe4fa1d..e2b3264 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -50,6 +50,7 @@ export type Job = {
error: string;
results: Record OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.
+ Only tags and art change; the audio is verified to be identical. Previous versions go to quarantine, and every change can be undone
+ from History.
+ Where the tag editor searches for covers. Only square images are shown.
+ Searching sends the artist and album (or your search text) to the selected services. Images are downloaded by playlink itself, never from
+ addresses on your local network.
+
+
+
+
+
+
+
+ {songs.map((s) => (
+ Disc
+ #
+ Title
+ Artist
+ File
+
+ {ROW_FIELDS.map((id) => {
+ const v = rowValue(s, id);
+ const changed = v !== (s.fields[id] ?? "");
+ return (
+
+ ))}
+
+
+ setRow(s.rel, id, e.target.value)}
+ />
+
+ );
+ })}
+
+ {s.links > 1 &&
+ setFailed(true)} />;
+}
+
+/**
+ * Finds a new cover: search the art sources (square images only), paste an
+ * image address, or upload a file. The chosen image is prepared on the server
+ * (converted to JPEG, resized) and returned as a token to apply later.
+ */
+export function ArtPicker({
+ artist,
+ album,
+ onChoose,
+}: {
+ artist: string;
+ album: string;
+ onChoose: (p: PreparedArt) => void;
+}) {
+ const settings = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
+ const [q, setQ] = useState({ artist, album, text: "" });
+ const [free, setFree] = useState(false);
+ const [sources, setSources] = useState
+
+
- Click a song to edit its tags, or tick several (for example a whole album) to edit the tags they share. shared marks files that are - hardlinked to the other library. + Click a song to edit its tags, tick several to edit the tags they share, or use Edit album to edit a whole album (album fields, + every track's number and title, and the cover). shared marks files that are hardlinked to the other library.
{editing && (OGG/Opus files can't hold embedded art; use Edit album to save a folder cover.jpg for them.
+ )} +
+ Finds covers for every album in {folder || `the ${side} library`} from iTunes, Deezer and MusicBrainz,
+ using each album's artist and album tags. A cover is only applied when both clearly match, and only square covers are used.
+
+ The album names are sent to those services to search. Every change goes through History and can be undone. +
+ {dry &&Use Edit album → Change art… to search those by hand.
+