Add album art search, auto-match and a whole-album tag editor
Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers, embed them and/or save cover.jpg, auto-match covers for albums, and edit an album's shared tags and track list in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2712
-31
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `<html><script>vqd="4-12345";</script></html>`)
|
||||
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, "<html>")
|
||||
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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+113
-13
@@ -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)
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := validTagChanges(req.Set); 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]
|
||||
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 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+40
-6
@@ -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", "-")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export type Job = {
|
||||
error: string;
|
||||
results: Record<string, number>;
|
||||
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<string, number> };
|
||||
|
||||
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}` : ""}`;
|
||||
@@ -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<Album>(`/pairs/${pairId}/album?side=${side}&path=${encodeURIComponent(folder)}`),
|
||||
gcTime: 0,
|
||||
});
|
||||
const [albumEdits, setAlbumEdits] = useState<Record<string, string>>({});
|
||||
const [rowEdits, setRowEdits] = useState<Record<string, Record<string, string>>>({});
|
||||
const [art, setArt] = useState<PreparedArt | null>(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<string, { value: string; mixed: boolean }> = {};
|
||||
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<number, AlbumSong[]>();
|
||||
for (const s of songs) {
|
||||
const d = num(rowValue(s, "disc")) || 1;
|
||||
byDisc.set(d, [...(byDisc.get(d) ?? []), s]);
|
||||
}
|
||||
const next: Record<string, Record<string, string>> = { ...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 (
|
||||
<Modal open onClose={onClose} title={`Edit album: ${folder.split("/").pop() || folder}`} wide>
|
||||
{data.isLoading && <Spinner />}
|
||||
{data.error && <Alert tone="red">{data.error.message}</Alert>}
|
||||
{data.data && songs.length === 0 && <Alert tone="amber">No editable songs in this folder.</Alert>}
|
||||
{data.data && songs.length > 0 && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="text-xs text-muted">
|
||||
<span className="mono">{folder}</span> · {songs.length} songs{blocked.length > 0 && `, ${blocked.length} not editable`}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[11rem_1fr]">
|
||||
<div className="space-y-2">
|
||||
{art ? <ChosenArt art={art} onClear={() => setArt(null)} /> : <CoverImage src={currentCover} className="aspect-square w-full" />}
|
||||
{!art && (
|
||||
<Button size="sm" className="w-full" onClick={() => setPicking(!picking)}>
|
||||
<ImageIcon className="size-3.5" /> {picking ? "Close search" : "Change art…"}
|
||||
</Button>
|
||||
)}
|
||||
{art && (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<Toggle checked={embed} onChange={setEmbed} label={`Embed in songs (${embeddable})`} />
|
||||
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in folder" />
|
||||
{embeddable < songs.length && (
|
||||
<p className="text-[11px] text-muted">OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 md:grid-cols-4">
|
||||
{ALBUM_FIELDS.map((f) => {
|
||||
const changed = albumChanged.includes(f.id);
|
||||
return (
|
||||
<label key={f.id} className={cx("block space-y-1", f.wide ? "col-span-2" : "col-span-1")}>
|
||||
<span className="flex justify-between text-xs font-medium text-gray-300">
|
||||
{f.label}
|
||||
{changed && (
|
||||
<button type="button" className="text-muted hover:text-white cursor-pointer" onClick={() => setAlbumEdits(({ [f.id]: _, ...r }) => r)}>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
className={cx(inputCls, "h-8 text-xs", changed && "border-accent ring-1 ring-accent/40")}
|
||||
value={albumValue(f.id)}
|
||||
placeholder={common[f.id]?.mixed ? "(differs per song: keep)" : ""}
|
||||
onChange={(e) => setAlbumEdits((a) => ({ ...a, [f.id]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{picking && !art && (
|
||||
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
|
||||
<ArtPicker artist={albumValue("album_artist") || songs[0]?.fields.artist || ""} album={albumValue("album")} onChoose={setArt} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-gray-300">Tracks</span>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" variant="ghost" onClick={numberTracks} title="Number the tracks 1, 2, 3… in the order shown, per disc">
|
||||
<ListOrdered className="size-3.5" /> Number tracks in order
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={artistFromAlbumArtist} disabled={!albumValue("album_artist")}>
|
||||
<UserRound className="size-3.5" /> Artist = album artist
|
||||
</Button>
|
||||
{rowChanges.length > 0 && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setRowEdits({})}>
|
||||
<RotateCcw className="size-3.5" /> Revert tracks
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[40vh] overflow-auto rounded-lg border border-line">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-panel-2 text-left text-muted">
|
||||
<tr>
|
||||
<th className="w-14 px-2 py-1.5 font-medium">Disc</th>
|
||||
<th className="w-14 px-2 py-1.5 font-medium">#</th>
|
||||
<th className="px-2 py-1.5 font-medium">Title</th>
|
||||
<th className="w-44 px-2 py-1.5 font-medium">Artist</th>
|
||||
<th className="w-40 px-2 py-1.5 font-medium">File</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{songs.map((s) => (
|
||||
<tr key={s.rel} className="border-t border-line/60">
|
||||
{ROW_FIELDS.map((id) => {
|
||||
const v = rowValue(s, id);
|
||||
const changed = v !== (s.fields[id] ?? "");
|
||||
return (
|
||||
<td key={id} className="px-1 py-0.5">
|
||||
<input
|
||||
className={cx(
|
||||
"w-full rounded border border-transparent bg-transparent px-1.5 py-1 outline-none hover:border-line focus:border-accent",
|
||||
changed && "border-accent/60 bg-accent/10",
|
||||
(id === "disc" || id === "track") && "text-right tabular-nums",
|
||||
)}
|
||||
value={v}
|
||||
onChange={(e) => setRow(s.rel, id, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="max-w-40 truncate px-2 py-1 mono text-[11px] text-muted" title={s.rel}>
|
||||
{s.links > 1 && <Link2 className="mr-1 inline size-3 text-emerald-400" />}
|
||||
{s.rel.split("/").pop()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shared > 0 && (
|
||||
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
|
||||
<div>
|
||||
{shared} of these songs are <b>hardlinked</b> to the {side === "slave" ? "master" : "slave"} library, so edits show up in both
|
||||
libraries and the files stay linked.
|
||||
</div>
|
||||
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
|
||||
<span>Change only the {side} library's copies instead (breaks the links and uses extra disk space).</span>
|
||||
</label>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
{dry && <Alert tone="blue">Dry run is on, so the changes are recorded as planned in Activity. No files change.</Alert>}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<span className="mr-auto text-xs text-muted">
|
||||
{[
|
||||
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(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!hasChanges || (!!art && !embed && !folderFile && albumChanged.length + rowChanges.length === 0)} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save album
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className={cx("grid place-items-center rounded-lg border border-line bg-panel-2 text-muted", className)}>
|
||||
<ImageOff className="size-6" />
|
||||
</div>
|
||||
);
|
||||
return <img src={src} alt="" className={cx("rounded-lg border border-line object-cover", className)} onError={() => 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<string[] | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [broken, setBroken] = useState<Set<string>>(new Set());
|
||||
const fileRef = useRef<HTMLInputElement>(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<string, string> }>(`/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<PreparedArt>("/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 (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
search.mutate();
|
||||
}}
|
||||
>
|
||||
{free ? (
|
||||
<input className={inputCls + " min-w-0 flex-1"} placeholder="Search text" value={q.text} onChange={(e) => setQ({ ...q, text: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<input className={inputCls + " min-w-0 flex-1"} placeholder="Artist" value={q.artist} onChange={(e) => setQ({ ...q, artist: e.target.value })} />
|
||||
<input className={inputCls + " min-w-0 flex-1"} placeholder="Album" value={q.album} onChange={(e) => setQ({ ...q, album: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
<Button type="submit" variant="primary" loading={search.isPending}>
|
||||
<Search className="size-4" /> Search
|
||||
</Button>
|
||||
</form>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<button type="button" className="mr-2 text-accent hover:underline cursor-pointer" onClick={() => setFree(!free)}>
|
||||
{free ? "Search by artist and album" : "Free-text search"}
|
||||
</button>
|
||||
{enabled.map((s) => {
|
||||
const disabled = s === "google" && !googleReady;
|
||||
const on = active.includes(s) && !disabled;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabled ? "Add a Google API key and search engine id in Settings → Album art" : undefined}
|
||||
onClick={() => setSources(on ? active.filter((x) => x !== s) : [...active, s])}
|
||||
className={cx(
|
||||
"rounded-full border px-2 py-0.5",
|
||||
on ? "border-accent bg-accent/15 text-white" : "border-line text-muted",
|
||||
disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{SOURCE_LABEL[s] ?? s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="text-muted">· square images only</span>
|
||||
</div>
|
||||
|
||||
{search.data && Object.keys(search.data.errors).length > 0 && (
|
||||
<Alert tone="amber">
|
||||
{Object.entries(search.data.errors).map(([s, e]) => (
|
||||
<div key={s} className="text-xs">
|
||||
{SOURCE_LABEL[s] ?? s}: {e}
|
||||
</div>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
{search.isPending && <Spinner />}
|
||||
{search.data && search.data.results.length === 0 && <div className="py-4 text-center text-sm text-muted">No square covers found. Try other words.</div>}
|
||||
{search.data && search.data.results.length > 0 && (
|
||||
<div className="grid max-h-[45vh] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4 md:grid-cols-5">
|
||||
{search.data.results.filter((r) => !broken.has(r.url)).map((r) => (
|
||||
<button
|
||||
key={r.url}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => prepare.mutate(r.url)}
|
||||
title={[r.artist, r.title].filter(Boolean).join(" – ")}
|
||||
className="group relative overflow-hidden rounded-lg border border-line text-left hover:border-accent cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<img
|
||||
src={artThumb(r.thumb || r.url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="aspect-square w-full object-cover"
|
||||
// Some web images vanish or refuse hotlinking; drop them from the grid.
|
||||
onError={() => setBroken((b) => new Set(b).add(r.url))}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-1.5 py-1 text-[10px] leading-tight">
|
||||
<div className="truncate text-gray-100">{r.title || "untitled"}</div>
|
||||
<div className="flex justify-between text-gray-400">
|
||||
<span>{SOURCE_LABEL[r.source] ?? r.source}</span>
|
||||
<span>
|
||||
{r.width}×{r.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-3">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Link2 className="absolute left-2.5 top-2.5 size-4 text-muted" />
|
||||
<input className={inputCls + " pl-8 text-xs"} placeholder="Or paste an image address (https://…)" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
</div>
|
||||
<Button disabled={!url.trim() || busy} onClick={() => prepare.mutate(url.trim())}>
|
||||
Use address
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) upload.mutate(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button disabled={busy} onClick={() => fileRef.current?.click()}>
|
||||
<Upload className="size-4" /> Upload…
|
||||
</Button>
|
||||
</div>
|
||||
{busy && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Spinner className="size-3.5" /> Downloading and preparing the image…
|
||||
</div>
|
||||
)}
|
||||
{(prepare.error || upload.error) && <Alert tone="red">{(prepare.error || upload.error)!.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A chosen cover waiting to be saved. */
|
||||
export function ChosenArt({ art, onClear }: { art: PreparedArt; onClear: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<CoverImage src={`/api/art/prepared/${art.token}`} className="size-20" />
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-gray-100">New cover</div>
|
||||
<div className="text-muted">
|
||||
{art.width}×{art.height} · {formatBytes(art.size)} JPEG
|
||||
</div>
|
||||
{!art.square && <Badge tone="amber">not square</Badge>}
|
||||
<button className="mt-1 block text-accent hover:underline cursor-pointer" onClick={onClear}>
|
||||
Keep the current cover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ArtSettings | null>(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 (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<ImageIcon className="size-4 text-accent" /> Album art
|
||||
</h2>
|
||||
<p className="text-sm text-muted">Where the tag editor searches for covers. Only square images are shown.</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<Button size="sm" variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{SOURCES.map((src) => (
|
||||
<label key={src.id} className={cx("flex items-start gap-2 rounded-lg border border-line p-2.5 text-sm cursor-pointer", s.sources.includes(src.id) && "border-accent/50 bg-accent/5")}>
|
||||
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={s.sources.includes(src.id)} onChange={() => toggle(src.id)} />
|
||||
<span>
|
||||
<span className="font-medium">{src.label}</span>
|
||||
<span className="block text-xs text-muted">{src.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{s.sources.includes("google") && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Google API key" hint="From Google Cloud, with the Custom Search API enabled.">
|
||||
<input className={inputCls + " mono text-xs"} type="password" autoComplete="off" value={s.google_key} onChange={(e) => setS({ ...s, google_key: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Search engine id (cx)" hint="A Programmable Search Engine with image search on.">
|
||||
<input className={inputCls + " mono text-xs"} value={s.google_cx} onChange={(e) => setS({ ...s, google_cx: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Saved cover size" hint="Covers are converted to JPEG at this size.">
|
||||
<select className={inputCls} value={s.max_size} onChange={(e) => setS({ ...s, max_size: Number(e.target.value) })}>
|
||||
<option value={600}>600 × 600</option>
|
||||
<option value={1000}>1000 × 1000 (recommended)</option>
|
||||
<option value={1400}>1400 × 1400</option>
|
||||
<option value={3000}>3000 × 3000</option>
|
||||
<option value={0}>Original size</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Smallest search result" hint="Hide covers smaller than this.">
|
||||
<select className={inputCls} value={s.min_size} onChange={(e) => setS({ ...s, min_size: Number(e.target.value) })}>
|
||||
<option value={300}>300 px</option>
|
||||
<option value={500}>500 px</option>
|
||||
<option value={800}>800 px</option>
|
||||
<option value={1000}>1000 px</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
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.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<ArtSettingsCard />
|
||||
|
||||
<BackupsCard />
|
||||
|
||||
<Card className="space-y-2 p-5">
|
||||
|
||||
+180
-7
@@ -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<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<string[] | null>(null);
|
||||
const [albumFolder, setAlbumFolder] = useState<string | null>(null);
|
||||
const [autoMatch, setAutoMatch] = useState(false);
|
||||
useEffect(() => setSel(new Set()), [side, path]);
|
||||
|
||||
const files = useQuery({
|
||||
@@ -82,10 +86,20 @@ export function TagsTab({ pair }: { pair: Pair }) {
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setAutoMatch(true)} title="Find covers for the albums in this folder">
|
||||
<Wand2 className="size-3.5" /> Auto-match art…
|
||||
</Button>
|
||||
{songs.length > 0 && (
|
||||
<Button size="sm" onClick={() => setAlbumFolder(path)}>
|
||||
<Disc3 className="size-3.5" /> Edit album
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" size="sm" disabled={sel.size === 0} onClick={() => setEditing([...sel])}>
|
||||
<TagsIcon className="size-3.5" /> Edit tags{sel.size > 0 && ` (${sel.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{files.isLoading && (
|
||||
@@ -119,6 +133,9 @@ export function TagsTab({ pair }: { pair: Pair }) {
|
||||
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => setPath(e.rel)}>
|
||||
{e.name}
|
||||
</button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAlbumFolder(e.rel)} title="Edit this folder as an album">
|
||||
<Disc3 className="size-3.5" /> Edit album
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -157,8 +174,8 @@ export function TagsTab({ pair }: { pair: Pair }) {
|
||||
))}
|
||||
</Card>
|
||||
<p className="text-xs text-muted">
|
||||
Click a song to edit its tags, or tick several (for example a whole album) to edit the tags they share. <b>shared</b> 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 <b>Edit album</b> to edit a whole album (album fields,
|
||||
every track's number and title, and the cover). <b>shared</b> marks files that are hardlinked to the other library.
|
||||
</p>
|
||||
{editing && (
|
||||
<TagEditor
|
||||
@@ -169,6 +186,8 @@ export function TagsTab({ pair }: { pair: Pair }) {
|
||||
onSaved={() => setSel(new Set())}
|
||||
/>
|
||||
)}
|
||||
{albumFolder !== null && <AlbumEditor pairId={pair.id} side={side} folder={albumFolder} onClose={() => setAlbumFolder(null)} />}
|
||||
{autoMatch && <AutoMatchDialog pairId={pair.id} side={side} folder={path} onClose={() => setAutoMatch(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,6 +220,8 @@ export function TagEditor({
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [separate, setSeparate] = useState(false);
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const [art, setArt] = useState<PreparedArt | null>(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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{art ? (
|
||||
<ChosenArt art={art} onClear={() => setArt(null)} />
|
||||
) : (
|
||||
<>
|
||||
<CoverImage src={coverURL(pairId, side, editable[0].rel)} className="size-16" />
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-gray-300">Album art{multi && " (first song)"}</div>
|
||||
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPicking(!picking)}>
|
||||
{picking ? "Close search" : "Change art…"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{picking && !art && (
|
||||
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
|
||||
<ArtPicker
|
||||
artist={common.album_artist?.value || common.artist?.value || ""}
|
||||
album={common.album?.mixed ? "" : common.album?.value ?? ""}
|
||||
onChoose={(p) => {
|
||||
setArt(p);
|
||||
setPicking(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{art && editable.some((f) => /\.(ogg|oga|opus)$/i.test(f.rel)) && (
|
||||
<p className="text-[11px] text-muted">OGG/Opus files can't hold embedded art; use Edit album to save a folder cover.jpg for them.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shared > 0 && (
|
||||
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
|
||||
<div>
|
||||
@@ -355,7 +418,7 @@ export function TagEditor({
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" disabled={changed.length === 0 || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
<Button variant="primary" disabled={(changed.length === 0 && !art) || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
|
||||
Save tags
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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<Job | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const running = job?.state === "running";
|
||||
|
||||
const start = async () => {
|
||||
setError("");
|
||||
try {
|
||||
const j = await api.post<Job>(`/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<Job | null>(`/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 (
|
||||
<Modal open onClose={onClose} title="Auto-match album art" wide>
|
||||
{!job && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-gray-300">
|
||||
Finds covers for every album in <code className="mono">{folder || `the ${side} library`}</code> 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.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Toggle checked={onlyMissing} onChange={setOnlyMissing} label="Only albums that have no art yet" />
|
||||
<Toggle checked={embed} onChange={setEmbed} label="Embed in songs (MP3, FLAC, M4A)" />
|
||||
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in each album folder" />
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
The album names are sent to those services to search. Every change goes through History and can be undone.
|
||||
</p>
|
||||
{dry && <Alert tone="blue">Dry run is on, so matches are recorded as planned in Activity. No files change.</Alert>}
|
||||
{error && <Alert tone="red">{error}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
{job && (
|
||||
<div className="space-y-3 text-sm">
|
||||
{running ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<Spinner className="size-4" /> {job.phase || "Working"}… {job.total > 0 && `${job.done} / ${job.total}`}
|
||||
</div>
|
||||
{job.total > 0 && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-line">
|
||||
<div className="h-full bg-accent transition-all" style={{ width: `${Math.round((job.done / job.total) * 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert tone={job.state === "done" ? "green" : "red"}>
|
||||
{job.state === "done"
|
||||
? Object.entries(job.results)
|
||||
.map(([k, v]) => `${v} ${k}`)
|
||||
.join(" · ") || "Nothing to do"
|
||||
: job.error}
|
||||
</Alert>
|
||||
)}
|
||||
{(job.notes?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-gray-300">Matched</div>
|
||||
<ul className="max-h-40 space-y-0.5 overflow-y-auto text-xs text-emerald-200/90">
|
||||
{job.notes!.map((n, i) => (
|
||||
<li key={i}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{job.skipped.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-gray-300">Not matched</div>
|
||||
<ul className="max-h-40 space-y-0.5 overflow-y-auto text-xs text-amber-200/90">
|
||||
{job.skipped.map((n, i) => (
|
||||
<li key={i}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1 text-[11px] text-muted">Use Edit album → Change art… to search those by hand.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{job && !running ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{!job && (
|
||||
<Button variant="primary" disabled={!embed && !folderFile} onClick={start}>
|
||||
<Wand2 className="size-4" /> Find covers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user