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; skipped: string[]; + notes?: string[]; started_at: number; finished_at?: number; }; @@ -253,3 +254,14 @@ export type Naming = { export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] }; export type OrganizePlan = { sides: Side[]; created_at: number; scanned: number; conforming: number; moves: number; skips: Record }; + +export type ArtResult = { source: string; title: string; artist?: string; url: string; thumb: string; width: number; height: number }; +export type PreparedArt = { token: string; width: number; height: number; size: number; square: boolean }; +export type ArtSettings = { sources: string[]; google_key: string; google_cx: string; max_size: number; min_size: number }; +export type AlbumSong = TagFile & { has_cover: boolean; embed_cover: boolean }; +export type Album = { folder: string; songs: AlbumSong[]; folder_cover?: string }; +export type ArtApply = { token: string; embed: boolean; folder_file: boolean }; + +export const artThumb = (u: string) => `/api/art/thumb?u=${encodeURIComponent(u)}`; +export const coverURL = (pairId: number, side: Side, path: string, bust = "") => + `/api/pairs/${pairId}/cover?side=${side}&path=${encodeURIComponent(path)}${bust ? `&v=${bust}` : ""}`; diff --git a/web/src/pages/Album.tsx b/web/src/pages/Album.tsx new file mode 100644 index 0000000..8962671 --- /dev/null +++ b/web/src/pages/Album.tsx @@ -0,0 +1,287 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Image as ImageIcon, Link2, ListOrdered, RotateCcw, UserRound } from "lucide-react"; +import { api, coverURL, type Album, type AlbumSong, type PreparedArt, type Side } from "../api"; +import { useInfo } from "../App"; +import { Alert, Button, Modal, Spinner, Toggle, cx, inputCls } from "../ui"; +import { useToast } from "../toast"; +import { ArtPicker, ChosenArt, CoverImage } from "./Art"; + +// Album-level fields apply to every song; row fields are per song. +const ALBUM_FIELDS: { id: string; label: string; wide?: boolean }[] = [ + { id: "album", label: "Album", wide: true }, + { id: "album_artist", label: "Album artist", wide: true }, + { id: "date", label: "Year / date" }, + { id: "genre", label: "Genre" }, + { id: "disc_total", label: "Discs" }, + { id: "composer", label: "Composer", wide: true }, + { id: "comment", label: "Comment", wide: true }, +]; +const ROW_FIELDS = ["disc", "track", "title", "artist"] as const; + +const num = (s?: string) => parseInt(s ?? "", 10) || 0; + +/** + * Edits a whole album at once, AudioShell-style: album fields for every song, + * a grid for each song's number, title and artist, and the album art. + */ +export function AlbumEditor({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) { + const qc = useQueryClient(); + const toast = useToast(); + const dry = useInfo().data?.dry_run ?? true; + const data = useQuery({ + queryKey: ["album", pairId, side, folder], + queryFn: () => api.get(`/pairs/${pairId}/album?side=${side}&path=${encodeURIComponent(folder)}`), + gcTime: 0, + }); + const [albumEdits, setAlbumEdits] = useState>({}); + const [rowEdits, setRowEdits] = useState>>({}); + const [art, setArt] = useState(null); + const [picking, setPicking] = useState(false); + const [embed, setEmbed] = useState(true); + const [folderFile, setFolderFile] = useState(true); + const [separate, setSeparate] = useState(false); + + const songs = useMemo(() => { + const s = (data.data?.songs ?? []).filter((x) => x.editable); + return [...s].sort( + (a, b) => + num(a.fields.disc) - num(b.fields.disc) || num(a.fields.track) - num(b.fields.track) || a.rel.localeCompare(b.rel), + ); + }, [data.data]); + const blocked = (data.data?.songs ?? []).filter((x) => !x.editable); + + const common = useMemo(() => { + const c: Record = {}; + for (const f of ALBUM_FIELDS) { + const vals = songs.map((s) => s.fields[f.id] ?? ""); + c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]) }; + } + return c; + }, [songs]); + + const albumValue = (id: string) => albumEdits[id] ?? (common[id]?.mixed ? "" : common[id]?.value ?? ""); + const rowValue = (s: AlbumSong, id: string) => rowEdits[s.rel]?.[id] ?? s.fields[id] ?? ""; + const setRow = (rel: string, id: string, v: string) => setRowEdits((r) => ({ ...r, [rel]: { ...r[rel], [id]: v } })); + + const albumChanged = Object.keys(albumEdits).filter((id) => common[id]?.mixed || albumEdits[id] !== common[id]?.value); + const rowChanges = songs + .map((s) => ({ + path: s.rel, + set: Object.fromEntries(Object.entries(rowEdits[s.rel] ?? {}).filter(([id, v]) => v !== (s.fields[id] ?? ""))), + })) + .filter((r) => Object.keys(r.set).length > 0); + const hasChanges = albumChanged.length > 0 || rowChanges.length > 0 || !!art; + + // "Number tracks in order": 1..n within each disc, with the disc's total. + const numberTracks = () => { + const byDisc = new Map(); + for (const s of songs) { + const d = num(rowValue(s, "disc")) || 1; + byDisc.set(d, [...(byDisc.get(d) ?? []), s]); + } + const next: Record> = { ...rowEdits }; + for (const list of byDisc.values()) { + list.forEach((s, i) => { + next[s.rel] = { ...next[s.rel], track: String(i + 1), track_total: String(list.length) }; + }); + } + setRowEdits(next); + if (byDisc.size > 1) setAlbumEdits((a) => ({ ...a, disc_total: String(byDisc.size) })); + }; + const artistFromAlbumArtist = () => { + const aa = albumValue("album_artist"); + if (!aa) return; + setRowEdits((r) => Object.fromEntries(songs.map((s) => [s.rel, { ...r[s.rel], artist: aa }]))); + }; + + const save = useMutation({ + mutationFn: () => + api.post(`/pairs/${pairId}/tags`, { + side, + paths: songs.map((s) => s.rel), + set: Object.fromEntries(albumChanged.map((id) => [id, albumEdits[id].trim()])), + files: rowChanges.map((r) => ({ path: r.path, set: Object.fromEntries(Object.entries(r.set).map(([k, v]) => [k, v.trim()])) })), + art: art ? { token: art.token, embed, folder_file: folderFile } : undefined, + separate, + }), + onSuccess: () => { + toast("ok", dry ? "Started (dry run): recorded as planned only" : `Saving the album (${songs.length} songs)…`); + qc.invalidateQueries({ queryKey: ["files"] }); + qc.invalidateQueries({ queryKey: ["pair", pairId] }); + onClose(); + }, + onError: (e) => toast("err", e.message), + }); + + const shared = songs.filter((s) => s.links > 1).length; + const embeddable = songs.filter((s) => s.embed_cover).length; + const currentCover = data.data + ? data.data.folder_cover + ? coverURL(pairId, side, folder) + : songs.find((s) => s.has_cover) + ? coverURL(pairId, side, songs.find((s) => s.has_cover)!.rel) + : null + : null; + useEffect(() => setPicking(false), [art]); + + return ( + + {data.isLoading && } + {data.error && {data.error.message}} + {data.data && songs.length === 0 && No editable songs in this folder.} + {data.data && songs.length > 0 && ( +
+
+ {folder} · {songs.length} songs{blocked.length > 0 && `, ${blocked.length} not editable`} +
+ +
+
+ {art ? setArt(null)} /> : } + {!art && ( + + )} + {art && ( +
+ + + {embeddable < songs.length && ( +

OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.

+ )} +
+ )} +
+
+ {ALBUM_FIELDS.map((f) => { + const changed = albumChanged.includes(f.id); + return ( + + ); + })} +
+
+ + {picking && !art && ( +
+ +
+ )} + +
+
+ Tracks +
+ + + {rowChanges.length > 0 && ( + + )} +
+
+
+ + + + + + + + + + + + {songs.map((s) => ( + + {ROW_FIELDS.map((id) => { + const v = rowValue(s, id); + const changed = v !== (s.fields[id] ?? ""); + return ( + + ); + })} + + + ))} + +
Disc#TitleArtistFile
+ setRow(s.rel, id, e.target.value)} + /> + + {s.links > 1 && } + {s.rel.split("/").pop()} +
+
+
+ + {shared > 0 && ( + }> +
+ {shared} of these songs are hardlinked to the {side === "slave" ? "master" : "slave"} library, so edits show up in both + libraries and the files stay linked. +
+ +
+ )} +

+ 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. +

+ {dry && Dry run is on, so the changes are recorded as planned in Activity. No files change.} +
+ )} +
+ {hasChanges && ( + + {[ + albumChanged.length && `${albumChanged.length} album field${albumChanged.length > 1 ? "s" : ""}`, + rowChanges.length && `${rowChanges.length} song${rowChanges.length > 1 ? "s" : ""} changed`, + art && "new cover", + ] + .filter(Boolean) + .join(" · ")} + + )} + + +
+
+ ); +} diff --git a/web/src/pages/Art.tsx b/web/src/pages/Art.tsx new file mode 100644 index 0000000..b6d3c98 --- /dev/null +++ b/web/src/pages/Art.tsx @@ -0,0 +1,229 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ImageOff, Link2, Search, Upload } from "lucide-react"; +import { api, artThumb, type ArtResult, type ArtSettings, type PreparedArt } from "../api"; +import { Alert, Badge, Button, Spinner, cx, formatBytes, inputCls } from "../ui"; + +const SOURCE_LABEL: Record = { + itunes: "iTunes", + deezer: "Deezer", + musicbrainz: "MusicBrainz", + duckduckgo: "DuckDuckGo", + google: "Google", +}; + +/** Shows a cover image, or a placeholder when there is none. */ +export function CoverImage({ src, className }: { src?: string | null; className?: string }) { + const [failed, setFailed] = useState(false); + useEffect(() => setFailed(false), [src]); + if (!src || failed) + return ( +
+ +
+ ); + return 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(null); + const [url, setUrl] = useState(""); + const [broken, setBroken] = useState>(new Set()); + const fileRef = useRef(null); + const enabled = settings.data?.settings.sources ?? []; + const googleReady = !!settings.data?.settings.google_key && !!settings.data?.settings.google_cx; + const usable = enabled.filter((s) => s !== "google" || googleReady); + const active = sources ?? usable; + + const search = useMutation({ + mutationFn: () => { + const p = new URLSearchParams({ sources: active.join(",") }); + if (free) p.set("q", q.text); + else { + p.set("artist", q.artist); + p.set("album", q.album); + } + return api.get<{ results: ArtResult[]; errors: Record }>(`/art/search?${p}`); + }, + }); + // Search right away with the album's own tags. + useEffect(() => { + if (settings.data && (artist || album)) search.mutate(); + }, [settings.data]); // eslint-disable-line react-hooks/exhaustive-deps + + const prepare = useMutation({ + mutationFn: (u: string) => api.post("/art/prepare", { url: u }), + onSuccess: onChoose, + }); + const upload = useMutation({ + mutationFn: async (f: File) => { + const form = new FormData(); + form.append("image", f); + const res = await fetch("/api/art/upload", { method: "POST", credentials: "same-origin", headers: { "X-Playlink-Request": "1" }, body: form }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error ?? res.statusText); + return data as PreparedArt; + }, + onSuccess: onChoose, + }); + const busy = prepare.isPending || upload.isPending; + + return ( +
+
{ + e.preventDefault(); + search.mutate(); + }} + > + {free ? ( + setQ({ ...q, text: e.target.value })} /> + ) : ( + <> + setQ({ ...q, artist: e.target.value })} /> + setQ({ ...q, album: e.target.value })} /> + + )} + +
+
+ + {enabled.map((s) => { + const disabled = s === "google" && !googleReady; + const on = active.includes(s) && !disabled; + return ( + + ); + })} + · square images only +
+ + {search.data && Object.keys(search.data.errors).length > 0 && ( + + {Object.entries(search.data.errors).map(([s, e]) => ( +
+ {SOURCE_LABEL[s] ?? s}: {e} +
+ ))} +
+ )} + {search.isPending && } + {search.data && search.data.results.length === 0 &&
No square covers found. Try other words.
} + {search.data && search.data.results.length > 0 && ( +
+ {search.data.results.filter((r) => !broken.has(r.url)).map((r) => ( + + ))} +
+ )} + +
+
+ + setUrl(e.target.value)} /> +
+ + { + const f = e.target.files?.[0]; + if (f) upload.mutate(f); + e.target.value = ""; + }} + /> + +
+ {busy && ( +
+ Downloading and preparing the image… +
+ )} + {(prepare.error || upload.error) && {(prepare.error || upload.error)!.message}} +
+ ); +} + +/** A chosen cover waiting to be saved. */ +export function ChosenArt({ art, onClear }: { art: PreparedArt; onClear: () => void }) { + return ( +
+ +
+
New cover
+
+ {art.width}×{art.height} · {formatBytes(art.size)} JPEG +
+ {!art.square && not square} + +
+
+ ); +} diff --git a/web/src/pages/ArtSettings.tsx b/web/src/pages/ArtSettings.tsx new file mode 100644 index 0000000..f539eee --- /dev/null +++ b/web/src/pages/ArtSettings.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Image as ImageIcon } from "lucide-react"; +import { api, type ArtSettings } from "../api"; +import { Button, Card, Field, cx, inputCls } from "../ui"; +import { useToast } from "../toast"; + +const SOURCES: { id: string; label: string; hint: string }[] = [ + { id: "itunes", label: "iTunes", hint: "Album covers, 1000 px, no key needed" }, + { id: "deezer", label: "Deezer", hint: "Album covers, 1000 px, no key needed" }, + { id: "musicbrainz", label: "MusicBrainz", hint: "Cover Art Archive, no key needed" }, + { id: "duckduckgo", label: "DuckDuckGo", hint: "Web images; unofficial, may stop working" }, + { id: "google", label: "Google", hint: "Web images; needs an API key and search engine id" }, +]; + +export function ArtSettingsCard() { + const qc = useQueryClient(); + const toast = useToast(); + const data = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") }); + const [s, setS] = useState(null); + useEffect(() => { + if (data.data) setS(data.data.settings); + }, [data.data]); + const dirty = s && data.data && JSON.stringify(s) !== JSON.stringify(data.data.settings); + const save = useMutation({ + mutationFn: () => api.put("/art/settings", s), + onSuccess: () => { + toast("ok", "Album art settings saved"); + qc.invalidateQueries({ queryKey: ["art-settings"] }); + }, + onError: (e) => toast("err", e.message), + }); + if (!s) return null; + const toggle = (id: string) => setS({ ...s, sources: s.sources.includes(id) ? s.sources.filter((x) => x !== id) : [...s.sources, id] }); + + return ( + +
+
+

+ Album art +

+

Where the tag editor searches for covers. Only square images are shown.

+
+ {dirty && ( + + )} +
+
+ {SOURCES.map((src) => ( + + ))} +
+ {s.sources.includes("google") && ( +
+ + setS({ ...s, google_key: e.target.value })} /> + + + setS({ ...s, google_cx: e.target.value })} /> + +
+ )} +
+ + + + + + +
+

+ 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. +

+
+ ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 5be1732..b60406d 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { useInfo } from "../App"; import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui"; import { useToast } from "../toast"; import { BackupsCard } from "./Backups"; +import { ArtSettingsCard } from "./ArtSettings"; export function SettingsPage() { const info = useInfo(); @@ -79,6 +80,8 @@ export function SettingsPage() {

+ + diff --git a/web/src/pages/Tags.tsx b/web/src/pages/Tags.tsx index 60d79e4..49a015b 100644 --- a/web/src/pages/Tags.tsx +++ b/web/src/pages/Tags.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, ChevronRight, FileAudio, Folder, Link2, RotateCcw, Tags as TagsIcon, X } from "lucide-react"; -import { api, type FileEntry, type Pair, type Side, type TagFile } from "../api"; +import { ChevronDown, ChevronRight, Disc3, FileAudio, Folder, Link2, RotateCcw, Tags as TagsIcon, Wand2, X } from "lucide-react"; +import { api, coverURL, type FileEntry, type Job, type Pair, type PreparedArt, type Side, type TagFile } from "../api"; +import { AlbumEditor } from "./Album"; +import { ArtPicker, ChosenArt, CoverImage } from "./Art"; import { useInfo } from "../App"; -import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, inputCls } from "../ui"; +import { Alert, Badge, Button, Card, Empty, Modal, Spinner, Toggle, cx, formatBytes, inputCls } from "../ui"; import { useToast } from "../toast"; // ---- field layout ---- @@ -33,6 +35,8 @@ export function TagsTab({ pair }: { pair: Pair }) { const [path, setPath] = useState(""); const [sel, setSel] = useState>(new Set()); const [editing, setEditing] = useState(null); + const [albumFolder, setAlbumFolder] = useState(null); + const [autoMatch, setAutoMatch] = useState(false); useEffect(() => setSel(new Set()), [side, path]); const files = useQuery({ @@ -82,9 +86,19 @@ export function TagsTab({ pair }: { pair: Pair }) { ))} - +
+ + {songs.length > 0 && ( + + )} + +
@@ -119,6 +133,9 @@ export function TagsTab({ pair }: { pair: Pair }) { + ) : ( <> @@ -157,8 +174,8 @@ export function TagsTab({ pair }: { pair: Pair }) { ))}

- 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 && ( setSel(new Set())} /> )} + {albumFolder !== null && setAlbumFolder(null)} />} + {autoMatch && setAutoMatch(false)} />} ); } @@ -201,6 +220,8 @@ export function TagEditor({ const [edits, setEdits] = useState>({}); const [separate, setSeparate] = useState(false); const [showOther, setShowOther] = useState(false); + const [art, setArt] = useState(null); + const [picking, setPicking] = useState(false); const editable = useMemo(() => (read.data?.files ?? []).filter((f) => f.editable), [read.data]); const blocked = (read.data?.files ?? []).filter((f) => !f.editable); @@ -220,7 +241,14 @@ export function TagEditor({ const set = Object.fromEntries(changed.map((id) => [id, edits[id].trim()])); const save = useMutation({ - mutationFn: () => api.post(`/pairs/${pairId}/tags`, { side, paths: editable.map((f) => f.rel), set, separate }), + mutationFn: () => + api.post(`/pairs/${pairId}/tags`, { + side, + paths: editable.map((f) => f.rel), + set, + separate, + art: art ? { token: art.token, embed: true, folder_file: false } : undefined, + }), onSuccess: () => { toast("ok", dry ? "Started (dry run): the edit is recorded as planned only" : `Saving tags for ${editable.length} song${editable.length > 1 ? "s" : ""}…`); qc.invalidateQueries({ queryKey: ["pair", pairId] }); @@ -323,6 +351,41 @@ export function TagEditor({ )} + {editable.length > 0 && ( +
+
+ {art ? ( + setArt(null)} /> + ) : ( + <> + +
+
Album art{multi && " (first song)"}
+ +
+ + )} +
+ {picking && !art && ( +
+ { + setArt(p); + setPicking(false); + }} + /> +
+ )} + {art && editable.some((f) => /\.(ogg|oga|opus)$/i.test(f.rel)) && ( +

OGG/Opus files can't hold embedded art; use Edit album to save a folder cover.jpg for them.

+ )} +
+ )} + {shared > 0 && ( }>
@@ -355,7 +418,7 @@ export function TagEditor({ -
@@ -374,3 +437,113 @@ function commonFolder(rels: string[]) { } return first.slice(0, n).join("/") || "the library root"; } + +// ---- automatic art matching ---- + +function AutoMatchDialog({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) { + const qc = useQueryClient(); + const dry = useInfo().data?.dry_run ?? true; + const [onlyMissing, setOnlyMissing] = useState(true); + const [embed, setEmbed] = useState(true); + const [folderFile, setFolderFile] = useState(true); + const [job, setJob] = useState(null); + const [error, setError] = useState(""); + const running = job?.state === "running"; + + const start = async () => { + setError(""); + try { + const j = await api.post(`/pairs/${pairId}/art/auto`, { side, folder, only_missing: onlyMissing, embed, folder_file: folderFile }); + setJob(j); + for (;;) { + await new Promise((r) => setTimeout(r, 800)); + const cur = await api.get(`/pairs/${pairId}/job`); + if (!cur || cur.id !== j.id) break; + setJob(cur); + if (cur.state !== "running") break; + } + qc.invalidateQueries({ queryKey: ["files"] }); + qc.invalidateQueries({ queryKey: ["pair", pairId] }); + } catch (e) { + setError((e as Error).message); + } + }; + + return ( + + {!job && ( +
+

+ 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 && Dry run is on, so matches are recorded as planned in Activity. No files change.} + {error && {error}} +
+ )} + {job && ( +
+ {running ? ( +
+
+ {job.phase || "Working"}… {job.total > 0 && `${job.done} / ${job.total}`} +
+ {job.total > 0 && ( +
+
+
+ )} +
+ ) : ( + + {job.state === "done" + ? Object.entries(job.results) + .map(([k, v]) => `${v} ${k}`) + .join(" · ") || "Nothing to do" + : job.error} + + )} + {(job.notes?.length ?? 0) > 0 && ( +
+
Matched
+
    + {job.notes!.map((n, i) => ( +
  • {n}
  • + ))} +
+
+ )} + {job.skipped.length > 0 && ( +
+
Not matched
+
    + {job.skipped.map((n, i) => ( +
  • {n}
  • + ))} +
+

Use Edit album → Change art… to search those by hand.

+
+ )} +
+ )} +
+ + {!job && ( + + )} +
+ + ); +}