Go back to the hardlink deduplicator (1da0132), keeping backups

Restores the code of 1da0132 ("Add a setting to hide the quarantine from
Plex") and removes everything added after it: the tag editor, album art,
organizing/naming, the step-by-step tune-up and library types.

Kept:
- Database backups (Settings -> Backups): scheduled, manual, download,
  restore, and the optional backup before every action.
- The .env-driven docker-compose.yml for Arcane.

Databases that already ran the later migrations keep working: migrations
6-10 stay in the list (they only add columns) so numbering never goes
backwards, and Undo refuses History entries of removed features instead
of treating them as links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 10:43:29 -07:00
1 parent 7b180e26db
commit e0617047f7
44 files changed
+103 -10456

No files matched your search

-154
View File
@@ -1,154 +0,0 @@
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)
}
-1
View File
@@ -23,7 +23,6 @@ var kindLabels = map[string]string{
store.KindFuzzy: "similar title",
store.KindElsewhere: "same top folder, other subfolder",
store.KindApprox: "approximate match",
store.KindInode: "same file, other path",
store.KindNone: "no match",
}
-94
View File
@@ -1,94 +0,0 @@
package api
import (
"net/http"
"github.com/cdnutter/playlink/internal/engine"
)
// ---- tune-up (step-by-step library fixing) ----
func (s *Server) handleFixer(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
p, err := s.st.Pair(r.Context(), id)
if err != nil {
opErr(w, err)
return
}
out := map[string]any{"state": engine.FixerStateOf(p), "steps": engine.FixerSteps, "plan": nil,
"items": []engine.FixItem{}, "albums": []*engine.AlbumFix{}}
if plan, items, albums := s.eng.FixerPlan(id); plan != nil {
out["plan"], out["items"], out["albums"] = plan, items, albums
}
writeJSON(w, 200, out)
}
func (s *Server) handleFixCheck(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.FixCheck
if !readJSON(w, r, &body) {
return
}
j, err := s.eng.StartFixCheck(id, body)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleFixApply(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.FixApply
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "tune-up") {
return
}
j, err := s.eng.StartFixApply(id, body)
if err != nil {
opErr(w, err)
return
}
s.log.Info("tune-up apply started", "pair", id, "selected", len(body.IDs))
writeJSON(w, 202, j)
}
func (s *Server) handleFixStep(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"` // master, slave, or "" for the link step
Step string `json:"step"`
State string `json:"state"` // done, skipped or "" (to do)
}
if !readJSON(w, r, &body) {
return
}
if err := s.eng.SetStep(r.Context(), id, body.Side, body.Step, body.State, ""); err != nil {
opErr(w, err)
return
}
s.handleFixer(w, r)
}
func (s *Server) handleFixRelease(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Album int `json:"album"`
Release string `json:"release"` // a MusicBrainz release id, or "search"
}
if !readJSON(w, r, &body) {
return
}
if j := s.eng.Job(id); j != nil && j.State == "running" {
writeErr(w, 409, engine.ErrBusy)
return
}
a, err := s.eng.ChooseRelease(r.Context(), id, body.Album, body.Release)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, a)
}
+4 -163
View File
@@ -23,7 +23,6 @@ import (
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
type Config struct {
@@ -58,9 +57,6 @@ 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
}
@@ -82,15 +78,6 @@ func (s *Server) Handler() http.Handler {
r.Get("/settings", s.handleGetSettings)
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)
}
@@ -110,23 +97,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/groups", s.handleGroups)
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)
r.Get("/organize/plan", s.handleOrganizePlan)
r.Post("/organize/apply", s.handleOrganizeApply)
r.Get("/renames", s.handleRenamePreview)
r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical)
r.Get("/fixer", s.handleFixer)
r.Post("/fixer/check", s.handleFixCheck)
r.Post("/fixer/apply", s.handleFixApply)
r.Put("/fixer/step", s.handleFixStep)
r.Post("/fixer/release", s.handleFixRelease)
r.Get("/tree", s.handleTree)
r.Post("/build", s.handleBuild)
r.Post("/remove", s.handleRemove)
@@ -215,7 +188,7 @@ func opErr(w http.ResponseWriter, err error) {
errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists):
writeErr(w, 409, err)
case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice),
errors.Is(err, engine.ErrInvalidBulk), errors.Is(err, engine.ErrNoTagTools):
errors.Is(err, engine.ErrInvalidBulk):
writeErr(w, 400, err)
default:
writeErr(w, 500, err)
@@ -231,8 +204,6 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"default_extensions": engine.DefaultExtensions,
"dry_run": s.st.DryRun(r.Context()),
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1",
"tag_editing": s.eng.TagEditing(),
"tag_fields": tags.FieldIDs,
})
}
@@ -326,15 +297,13 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
type pairView struct {
store.Pair
Naming engine.NamingConfig `json:"naming"`
Fixer engine.FixerState `json:"fixer"`
Summary store.Summary `json:"summary"`
Job *engine.Job `json:"job"`
Summary store.Summary `json:"summary"`
Job *engine.Job `json:"job"`
}
func (s *Server) view(r *http.Request, p store.Pair) pairView {
sum, _ := s.st.Summary(r.Context(), p.ID)
return pairView{Pair: p, Naming: engine.NamingOf(p), Fixer: engine.FixerStateOf(p), Summary: sum, Job: s.eng.Job(p.ID)}
return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)}
}
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
@@ -372,10 +341,6 @@ type pairInput struct {
SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"`
AutoOrganize string `json:"auto_organize"`
LibraryType string `json:"library_type"`
// Naming is optional: when absent, the pair keeps its current naming.
Naming *engine.NamingConfig `json:"naming,omitempty"`
}
func (in pairInput) apply(p *store.Pair) {
@@ -383,13 +348,6 @@ func (in pairInput) apply(p *store.Pair) {
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
p.AutoOrganize = in.AutoOrganize
if in.LibraryType != "" {
p.LibraryType = in.LibraryType
}
if in.Naming != nil {
p.OrganizeConfig, _ = json.Marshal(in.Naming)
}
}
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
@@ -430,8 +388,6 @@ func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) {
}
}
in.apply(&p)
engine.ChangeLibraryType(old, &p)
engine.ChangeLibraryType(old, &p)
warns, err := s.eng.NormalizePair(&p)
if err != nil {
writeErr(w, 400, err)
@@ -767,54 +723,6 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 202, j)
}
// ---- tag editor ----
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
ents, err := s.eng.ListFiles(r.Context(), id, q.Get("side"), q.Get("path"))
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, ents)
}
func (s *Server) handleReadTags(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"`
Paths []string `json:"paths"`
}
if !readJSON(w, r, &body) {
return
}
files, err := s.eng.ReadTags(r.Context(), id, body.Side, body.Paths)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, map[string]any{"files": files})
}
func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.TagEdit
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "edit tags") {
return
}
j, err := s.eng.StartTagEdit(id, body)
if err != nil {
opErr(w, err)
return
}
s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate)
writeJSON(w, 202, j)
}
// preAction runs the optional database backup before an action that changes
// files (live mode only). It refuses up front when the pair is busy, so no
// backup is made for an action that could not start.
@@ -832,70 +740,3 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64,
}
return true
}
// ---- organizer ----
func (s *Server) handleNamingInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"defaults": engine.DefaultNaming(), "presets": engine.NamingPresets, "tokens": engine.TokenHelp,
"folder_default": engine.SoundtrackFolder})
}
// handleNamingExamples renders sample songs with unsaved settings, using the
// same code as real renames.
func (s *Server) handleNamingExamples(w http.ResponseWriter, r *http.Request) {
var c engine.NamingConfig
if !readJSON(w, r, &c) {
return
}
if err := engine.ValidateNaming(c); err != nil {
writeJSON(w, 200, map[string]any{"error": err.Error(), "examples": []engine.NamingExample{}})
return
}
writeJSON(w, 200, map[string]any{"examples": engine.NamingExamples(c)})
}
func (s *Server) handleOrganizePreview(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"` // slave, master or both
}
if !readJSON(w, r, &body) {
return
}
j, err := s.eng.StartOrganizePreview(id, body.Side)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleOrganizePlan(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
plan, items := s.eng.OrganizePreview(id)
if plan == nil {
writeJSON(w, 200, map[string]any{"plan": nil, "items": []any{}})
return
}
writeJSON(w, 200, map[string]any{"plan": plan, "items": items})
}
func (s *Server) handleOrganizeApply(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
IDs []int `json:"ids"` // empty: every rename in the preview
}
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "rename files") {
return
}
j, err := s.eng.StartOrganizeApply(id, body.IDs)
if err != nil {
opErr(w, err)
return
}
s.log.Info("rename now started", "pair", id, "selected", len(body.IDs))
writeJSON(w, 202, j)
}
-462
View File
@@ -1,462 +0,0 @@
// 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
}
-204
View File
@@ -1,204 +0,0 @@
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) }
-121
View File
@@ -1,121 +0,0 @@
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
}
-565
View File
@@ -1,565 +0,0 @@
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
}
-181
View File
@@ -1,181 +0,0 @@
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")
}
}
-37
View File
@@ -13,12 +13,9 @@ import (
"sync"
"time"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// DefaultExtensions are the audio file types considered by default.
@@ -44,20 +41,7 @@ type Engine struct {
// the same files.
mu sync.Mutex
tagTools tags.Tools
beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
artClient *art.Client
artStore *art.Store
meta *meta.Client
fixMu sync.Mutex
fixPlans map[int64]*FixPlan // latest fixer check per pair
fixSeq int64
plansMu sync.Mutex
plans map[int64]*organizePlan // latest "rename now" preview per pair
jobsMu sync.Mutex
jobs map[int64]*Job
@@ -84,7 +68,6 @@ 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"`
@@ -106,15 +89,6 @@ 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"]++
@@ -138,7 +112,6 @@ 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
}
@@ -316,16 +289,6 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]store.Warning, error) {
if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 {
return nil, errors.New("intervals must not be negative")
}
switch p.LibraryType {
case "":
p.LibraryType = LibraryMusic
case LibraryMusic, LibrarySoundtracks:
default:
return nil, errors.New("library type must be music or soundtracks")
}
if err := normalizeOrganize(p); err != nil {
return nil, err
}
if p.ApproxThreshold == 0 {
p.ApproxThreshold = 85
}
+16
View File
@@ -565,3 +565,19 @@ func TestBeforeActionOncePerScan(t *testing.T) {
t.Fatal("a failed backup must stop the action")
}
}
// History can hold operations of removed features; they must never be
// undone as if they were links.
func TestUndoRefusesUnknownActions(t *testing.T) {
ev := setup(t, false)
o := store.Operation{PairID: ev.pair.ID, Action: "tags", RelSlave: "A/B/x.flac"}
if err := ev.st.CreateOp(ctx, &o); err != nil {
t.Fatal(err)
}
if err := ev.st.FinishOp(ctx, o.ID, store.OpDone, ""); err != nil {
t.Fatal(err)
}
if err := ev.e.Undo(ctx, o.ID); err == nil || !strings.Contains(err.Error(), "can no longer be undone") {
t.Fatalf("got %v", err)
}
}
-599
View File
@@ -1,599 +0,0 @@
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
)
// The tune-up takes each library through the same steps, the master first and
// then the slave:
//
// 1. structure: every album in its own folder at the right depth (Artist/
// Album for music, Album for soundtracks), disc folders tagged, and missing
// tags filled from folder and file names;
// 2. album tags (optional): look albums up online and review the changes;
// 3. folder names: album and artist folders renamed from the tags;
// 4. file names: songs renamed from the tags.
//
// Once both libraries are fixed they are linked. Every step is a check that
// produces a plan; nothing changes until the user applies the fixes they
// selected, and every change is an operation that can be undone.
const (
StepStructure = "structure"
StepTags = "tags"
StepFolders = "folders"
StepFiles = "files"
StepLink = "link"
)
// FixerSteps are the steps each library goes through, in order.
var FixerSteps = []string{StepStructure, StepTags, StepFolders, StepFiles}
// Kinds of fix.
const (
FixMoveFolder = "move-folder" // move or rename a folder with everything in it
FixMoveFile = "move-file" // move or rename one song
FixTags = "tags" // change one song's tags
FixAlbumTags = "album-tags" // change the tags of an album's songs (online lookup)
FixProblem = "problem" // needs a person: shown, never applied
)
type StepStatus struct {
State string `json:"state"` // "" (to do), done or skipped
At int64 `json:"at,omitempty"`
Note string `json:"note,omitempty"`
}
type FixerState struct {
Master map[string]StepStatus `json:"master"`
Slave map[string]StepStatus `json:"slave"`
Link StepStatus `json:"link"`
}
// FixerStateOf reads a pair's tune-up progress.
func FixerStateOf(p store.Pair) FixerState {
var s FixerState
if len(p.FixerState) > 0 {
_ = json.Unmarshal(p.FixerState, &s)
}
if s.Master == nil {
s.Master = map[string]StepStatus{}
}
if s.Slave == nil {
s.Slave = map[string]StepStatus{}
}
return s
}
func validStep(side, step string) error {
if step == StepLink && side == "" {
return nil
}
if side != SideMaster && side != SideSlave {
return fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
for _, s := range FixerSteps {
if s == step {
return nil
}
}
return fmt.Errorf("%w: unknown step %q", ErrInvalidBulk, step)
}
// SetStep records a step as done, skipped or to do ("").
func (e *Engine) SetStep(ctx context.Context, pairID int64, side, step, state, note string) error {
if err := validStep(side, step); err != nil {
return err
}
if state != "" && state != "done" && state != "skipped" {
return fmt.Errorf("%w: state must be done, skipped or empty", ErrInvalidBulk)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
p, err := e.st.Pair(ctx, pairID)
if err != nil {
return err
}
s := FixerStateOf(p)
st := StepStatus{State: state, Note: note}
if state != "" {
st.At = time.Now().Unix()
}
switch side {
case SideMaster:
s.Master[step] = st
case SideSlave:
s.Slave[step] = st
default:
s.Link = st
}
raw, _ := json.Marshal(s)
return e.st.SetFixerState(ctx, pairID, raw)
}
// FixItem is one proposed fix (or a problem that needs a person).
type FixItem struct {
ID int `json:"id"`
Kind string `json:"kind"`
Group string `json:"group"` // album folder, for grouping
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Why string `json:"why"`
Blocked string `json:"blocked,omitempty"` // why it can't be fixed automatically
Changes map[string][2]string `json:"changes,omitempty"` // tags: field -> [old, new]
// Unsure fixes are listed but not selected by default.
Unsure bool `json:"unsure,omitempty"`
org *organizeItem // moves: the file as it was when checked
}
// FixPlan is the result of checking one step of one library.
type FixPlan struct {
ID int64 `json:"id"` // unique per check, so selections never carry over
Side string `json:"side"`
Step string `json:"step"`
CreatedAt int64 `json:"created_at"`
Songs int `json:"songs"`
Albums int `json:"albums"`
Fixable int `json:"fixable"`
Blocked int `json:"blocked"`
Notices []string `json:"notices"`
Items []FixItem `json:"-"`
AlbumFixes []*AlbumFix `json:"-"`
Applied bool `json:"applied"`
}
func (p *FixPlan) add(it FixItem) {
it.ID = len(p.Items) + 1
if it.Kind == FixProblem || it.Blocked != "" {
p.Blocked++
} else {
p.Fixable++
}
p.Items = append(p.Items, it)
}
// FixerPlan returns the latest plan of a pair and its items.
func (e *Engine) FixerPlan(pairID int64) (*FixPlan, []FixItem, []*AlbumFix) {
e.fixMu.Lock()
defer e.fixMu.Unlock()
p := e.fixPlans[pairID]
if p == nil {
return nil, nil, nil
}
return p, append([]FixItem{}, p.Items...), append([]*AlbumFix{}, p.AlbumFixes...)
}
// FixCheck asks for one step of one library to be checked.
type FixCheck struct {
Side string `json:"side"`
Step string `json:"step"`
Folder string `json:"folder"` // optional: only this folder (album tags)
// OnlyIncomplete skips albums whose tags already look complete (album tags).
OnlyIncomplete bool `json:"only_incomplete"`
}
// StartFixCheck checks a step in a background job; the plan is kept for review.
func (e *Engine) StartFixCheck(pairID int64, c FixCheck) (*Job, error) {
if err := validStep(c.Side, c.Step); err != nil {
return nil, err
}
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if c.Folder != "" {
f, err := fsops.CleanRel(c.Folder)
if err != nil {
return nil, err
}
c.Folder = f
}
if c.Step == StepTags && e.meta == nil {
return nil, errors.New("online lookup is not available")
}
return e.startJob(pairID, "tune-up "+c.Step, func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
plan := &FixPlan{Side: c.Side, Step: c.Step, CreatedAt: time.Now().Unix(), Notices: []string{}}
switch c.Step {
case StepStructure:
err = e.checkStructure(ctx, j, l, plan)
case StepTags:
err = e.checkAlbumTags(ctx, j, l, plan, c)
case StepFolders:
err = e.checkFolders(ctx, j, l, plan)
case StepFiles:
err = e.checkFiles(ctx, j, l, plan)
}
if err != nil {
return err
}
e.fixMu.Lock()
if e.fixPlans == nil {
e.fixPlans = map[int64]*FixPlan{}
}
e.fixSeq++
plan.ID = e.fixSeq
e.fixPlans[pairID] = plan
e.fixMu.Unlock()
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d to fix, %d need attention", plan.Fixable, plan.Blocked)
})
return nil
})
}
// FixApply selects fixes of the latest plan.
type FixApply struct {
Plan int64 `json:"plan"` // the plan the ids belong to
IDs []int `json:"ids"`
}
// StartFixApply applies the selected fixes of the latest plan. Tags are
// changed first (at the paths that were checked), then folders move, then
// files. When the step has nothing left to fix it is marked done.
func (e *Engine) StartFixApply(pairID int64, req FixApply) (*Job, error) {
e.fixMu.Lock()
plan := e.fixPlans[pairID]
e.fixMu.Unlock()
if plan == nil {
return nil, fmt.Errorf("%w: check the step first", ErrInvalidBulk)
}
if req.Plan != plan.ID {
return nil, fmt.Errorf("%w: the check was replaced by a newer one; review it and apply again", ErrInvalidBulk)
}
if plan.Applied {
return nil, fmt.Errorf("%w: the check is out of date; check again", ErrInvalidBulk)
}
want := map[int]bool{}
for _, id := range req.IDs {
want[id] = true
}
applied := 0
var tagItems, folderItems []FixItem
var moves []organizeItem
var albums []*AlbumFix
for _, it := range plan.Items {
if !want[it.ID] || it.Kind == FixProblem || it.Blocked != "" {
continue
}
applied++
switch it.Kind {
case FixTags:
tagItems = append(tagItems, it)
case FixMoveFolder:
folderItems = append(folderItems, it)
case FixMoveFile:
moves = append(moves, *it.org)
case FixAlbumTags:
for _, a := range plan.AlbumFixes {
if a.ID == it.ID {
albums = append(albums, a)
}
}
}
}
if len(tagItems)+len(folderItems)+len(moves)+len(albums) == 0 {
return nil, fmt.Errorf("%w: nothing selected", ErrInvalidBulk)
}
side, step := plan.Side, plan.Step
return e.startJob(pairID, "tune-up "+step+" (apply)", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
e.fixMu.Lock()
plan.Applied = true // paths and tags change: the plan is stale
e.fixMu.Unlock()
trigger := "tune-up"
edited := map[string]bool{}
if n := len(tagItems) + songsIn(albums); n > 0 {
j.phase("Updating tags", int64(n))
for _, it := range tagItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.editTagsOne(ctx, j, l, side, it.From, newValues(it.Changes), "", false); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
edited[it.From] = true
}
j.tick()
}
for _, a := range albums {
for _, f := range a.Files {
if ctx.Err() != nil {
return ctx.Err()
}
if len(f.Changes) > 0 {
if err := e.editTagsOne(ctx, j, l, side, f.Path, newValues(f.Changes), "", false); err != nil {
j.skip(f.Path + ": " + err.Error())
}
}
j.tick()
}
j.count("albums updated")
}
}
if len(folderItems) > 0 {
j.phase("Moving folders", int64(len(folderItems)))
for _, it := range folderItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.moveDirOp(ctx, l, side, it.From, it.To, trigger); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
j.count("folders moved")
}
j.tick()
}
}
if len(moves) > 0 {
// A song whose tags were just rewritten is a new file: take it as it is now.
root, _ := sideRoot(l, side)
for i, m := range moves {
if edited[m.From] {
if in, err := fsops.RegularFile(filepath.Join(root.Path, m.From)); err == nil {
moves[i].ino, moves[i].size, moves[i].mtime = in.Ino, in.Size, in.MtimeNs
}
}
}
if err := e.applyOrganize(ctx, j, l, moves, trigger); err != nil {
return err
}
}
dry := e.st.DryRun(ctx)
skipped := 0
j.update(func(j *Job) {
skipped = j.Results["skipped"]
if dry {
j.Message = "Dry run: changes recorded as planned in Activity"
}
})
// Done when every fix was applied and nothing needs a person.
if !dry && skipped == 0 && applied >= plan.Fixable && plan.Blocked == 0 {
if err := e.SetStep(ctx, pairID, side, step, "done", fmt.Sprintf("%d fixed", applied)); err != nil {
e.log.Warn("tune-up step not recorded", "err", err)
}
}
return nil
})
}
func songsIn(albums []*AlbumFix) int {
n := 0
for _, a := range albums {
n += len(a.Files)
}
return n
}
func newValues(changes map[string][2]string) map[string]string {
set := map[string]string{}
for k, v := range changes {
set[k] = v[1]
}
return set
}
// ---- moving folders ----
type dirMoveDetail struct {
Side string `json:"side"`
}
// moveDirOp moves a folder (with everything in it) within one library.
func (e *Engine) moveDirOp(ctx context.Context, l *libs, side, from, to, trigger string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, err := sideRoot(l, side)
if err != nil {
return err
}
if why := checkDirMove(l, root, from, to); why != "" {
return errors.New(why)
}
src, _ := root.Resolve(from)
dst, _ := root.Resolve(to)
o := store.Operation{PairID: l.pair.ID, Action: store.ActMoveDir, RelNew: to, Trigger: trigger}
setSidePath(&o, side, from)
o.Detail, _ = json.Marshal(dirMoveDetail{Side: side})
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
err = func() error {
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := renameDir(src, dst); err != nil {
pruneEmpty(filepath.Dir(dst), root.Path)
return err
}
_ = fsops.SyncDir(filepath.Dir(src))
pruneEmpty(filepath.Dir(src), root.Path)
return nil
}()
return e.finish(ctx, &o, err)
}
// checkDirMove returns why a folder can't be moved, or "".
func checkDirMove(l *libs, root fsops.Root, from, to string) string {
src, err := root.Resolve(from)
if err != nil {
return err.Error()
}
dst, err := root.Resolve(to)
if err != nil {
return err.Error()
}
if src == root.Path || dst == root.Path {
return "the library folder itself can't be moved"
}
fi, err := os.Lstat(src)
switch {
case err != nil:
return "the folder no longer exists (check again)"
case fi.Mode()&os.ModeSymlink != 0:
return "is a symlink"
case !fi.IsDir():
return "is not a folder"
case src == l.quarantine || fsops.Within(src, l.quarantine):
return "contains the quarantine folder"
case fsops.Within(src, dst):
return "can't move a folder into itself"
}
if di, err := os.Lstat(dst); err == nil && !(strings.EqualFold(from, to) && os.SameFile(fi, di)) {
return fmt.Sprintf("%q already exists; merge the two by hand", to)
}
return ""
}
// undoMoveDir moves a folder back.
func undoMoveDir(l *libs, o store.Operation) error {
var d dirMoveDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
cur, err := root.Resolve(o.RelNew)
if err != nil {
return err
}
old, err := root.Resolve(from)
if err != nil {
return err
}
fi, err := os.Lstat(cur)
if err != nil || !fi.IsDir() {
return fmt.Errorf("the folder %q no longer exists", o.RelNew)
}
if di, err := os.Lstat(old); err == nil && !(strings.EqualFold(o.RelNew, from) && os.SameFile(fi, di)) {
return fmt.Errorf("%q exists again: %w", from, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
return err
}
if err := renameDir(cur, old); err != nil {
return err
}
pruneEmpty(filepath.Dir(cur), root.Path)
return nil
}
// recoverMoveDir finishes a case-only folder move cut short by a crash.
func recoverMoveDir(l *libs, o store.Operation, log *slog.Logger) {
var d dirMoveDetail
if json.Unmarshal(o.Detail, &d) != nil {
return
}
root, err := sideRoot(l, d.Side)
if err != nil {
return
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
src, dst := filepath.Join(root.Path, from), filepath.Join(root.Path, o.RelNew)
if _, err := os.Lstat(src); err == nil {
return // never moved
}
if _, err := os.Lstat(dst); err == nil {
return // completed
}
dir := filepath.Dir(src)
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.IsDir() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"dir-") {
if err := os.Rename(filepath.Join(dir, en.Name()), dst); err == nil {
log.Warn("finished interrupted folder move", "op", o.ID, "folder", o.RelNew)
}
return
}
}
}
// ---- library layout ----
// albumInfo is one album folder of a library as found on disk.
type albumInfo struct {
Dir string // the album folder (disc folders folded in)
Songs []string // every song, including those in disc folders
Discs map[string]int // disc folder -> disc number
Loose bool // songs directly in the album folder
Nested bool // other album folders live inside this one
}
// albumsOf groups a library's songs into album folders.
func albumsOf(files map[string]fsops.Info) []*albumInfo {
byDir := map[string]*albumInfo{}
for _, rel := range sortedKeys(files) {
dir := filepath.Dir(rel)
album, disc := dir, 0
if dir != "." {
if n := discNumber(filepath.Base(dir)); n > 0 && filepath.Dir(dir) != "." {
album, disc = filepath.Dir(dir), n
}
}
a := byDir[album]
if a == nil {
a = &albumInfo{Dir: album, Discs: map[string]int{}}
byDir[album] = a
}
a.Songs = append(a.Songs, rel)
if disc > 0 {
a.Discs[dir] = disc
} else {
a.Loose = true
}
}
out := make([]*albumInfo, 0, len(byDir))
for _, a := range byDir {
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return out[i].Dir < out[j].Dir })
for _, a := range out {
for _, b := range out {
if a != b && (a.Dir == "." || strings.HasPrefix(b.Dir, a.Dir+"/")) {
a.Nested = true
break
}
}
}
return out
}
// discNumber reads "Disc 2", "CD02", "disk_3" or "Disc 1 - Title" as a disc number.
func discNumber(name string) int {
m := discNum.FindStringSubmatch(name)
if m == nil {
return 0
}
return leadingNumber(m[1])
}
-500
View File
@@ -1,500 +0,0 @@
package engine
import (
"context"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/tags"
)
// discNum recognizes disc folders. "Vol. 2" is left out on purpose: volumes
// are usually albums of their own.
var discNum = regexp.MustCompile(`(?i)^(?:disc|disk|cd|dvd|part)[\s._-]*(\d{1,2})(?:\b|$)`)
// junkFolder matches extra folders inside an album that only describe the
// files ("FLAC", "MP3 320", "24bit"), whose songs belong in the album folder.
var junkFolder = regexp.MustCompile(`(?i)^[\[(]?\s*(?:flac|mp3|aac|m4a|alac|ogg|opus|wav|lossless|lossy|hi-?res|web|` +
`(?:mp3\s*)?(?:320|256|192|v0|v2)(?:\s*kbps)?|\d{2}\s*-?\s*bit.*|audio|tracks?|files?|songs?)\s*[\])]?$`)
// folderNoise is release information in folder names: "Nightmare (2010)",
// "Nightmare [FLAC]", "2010 - Nightmare".
var (
folderNoise = regexp.MustCompile(`(?i)\s*[\[({]\s*(?:\d{4}(?:[-.]\d{2}){0,2}|flac|mp3|aac|alac|ogg|opus|web|cd|vinyl|lossless|` +
`deluxe|remaster(?:ed)?|\d{2}\s*-?\s*bit[^\])}]*|\d+(?:\.\d+)?\s*k(?:hz|bps)[^\])}]*|v0|v2|320|\d{2}-\d{2,3})\s*[\])}]\s*$`)
yearPrefix = regexp.MustCompile(`^\s*[\[(]?\d{4}[\])]?\s*[-–.]\s+`)
)
// cleanFolderName turns a folder name into an album or artist name.
func cleanFolderName(s string) string {
for {
t := folderNoise.ReplaceAllString(s, "")
t = yearPrefix.ReplaceAllString(t, "")
t = strings.TrimSpace(t)
if t == s || t == "" {
return s
}
s = t
}
}
// layoutFor returns the formats that apply at rel and the folder they are
// relative to ("" for the library, or a folder with its own naming).
func (c NamingConfig) layoutFor(rel string) (string, formats) {
if rule, ok := c.folderOf(rel); ok {
return rel[:len(rule.Folder)], rule.formats()
}
return "", c.formats()
}
// albumDepth is how many folders deep albums sit: Artist/Album or Album.
func (f formats) albumDepth() int {
if f.artist == "" {
return 1
}
return 2
}
// renderComponent renders a one-folder format from a song's tags.
func renderComponent(c NamingConfig, format string, f map[string]string) string {
v := valuesOf(f)
return finishComponent(tokenPat.ReplaceAllStringFunc(format, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }))
}
// renderAlbumDir is the album folder a song at rel belongs in, from its tags.
func renderAlbumDir(c NamingConfig, rel string, f map[string]string) (string, string) {
flat := c
flat.TrackFormat, flat.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders = make([]FolderNaming, len(c.Folders))
for i, r := range c.Folders {
r.TrackFormat, r.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders[i] = r
}
g := map[string]string{}
for k, v := range f {
g[k] = v
}
if strings.TrimSpace(g[tags.Title]) == "" {
g[tags.Title] = "x" // the title doesn't name folders
}
p, why := renderPath(flat, rel, g)
if why != "" {
return "", why
}
return filepath.Dir(p), ""
}
func depthOf(rel string) int {
if rel == "" || rel == "." {
return 0
}
return strings.Count(rel, "/") + 1
}
// majority is the most common non-empty value of a field.
func majority(fields map[string]map[string]string, songs []string, keys ...string) string {
for _, key := range keys {
count := map[string]int{}
best, bestN := "", 0
for _, s := range songs {
v := strings.TrimSpace(fields[s][key])
if v == "" {
continue
}
count[v]++
if count[v] > bestN || (count[v] == bestN && v < best) {
best, bestN = v, count[v]
}
}
if best != "" {
return best
}
}
return ""
}
func similar(a, b string) bool {
ka, kb := matchKey(a), matchKey(b)
return ka != "" && kb != "" && (ka == kb || levenshteinRatio(ka, kb) >= 0.8)
}
// readLibrary lists a library's songs and reads their tags.
func (e *Engine) readLibrary(ctx context.Context, j *Job, l *libs, side string) (fsops.Root, map[string]fsops.Info, map[string]map[string]string, error) {
root, err := sideRoot(l, side)
if err != nil {
return root, nil, nil, err
}
j.phase("Listing "+side+" files", 0)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, root, files); err != nil {
return root, nil, nil, err
}
j.phase("Reading "+side+" tags", int64(len(files)))
fields := make(map[string]map[string]string, len(files))
for _, rel := range sortedKeys(files) {
if ctx.Err() != nil {
return root, nil, nil, ctx.Err()
}
f := map[string]string{}
if _, ok := tags.FormatOf(rel); ok {
if got, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), files[rel]); err == nil && got != nil {
f = got
}
}
fields[rel] = f
j.tick()
}
return root, files, fields, nil
}
func (e *Engine) moveItem(root fsops.Root, side, from, to string, in fsops.Info) *organizeItem {
return &organizeItem{Side: side, From: from, To: to, Move: true, ino: in.Ino, size: in.Size, mtime: in.MtimeNs,
Sidecars: sidecarsOf(root, from)}
}
// ---- step 1: structure ----
func (e *Engine) checkStructure(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{} // lower(new path) -> old path
claim := func(from, to string) string {
k := strings.ToLower(to)
if c := claimed[k]; c != "" && c != from {
return "another item gets the same name (" + c + ")"
}
claimed[k] = from
return ""
}
// Songs that change tags here, so a move of the same song can re-read it.
type deep struct {
item int
extra string
}
var deeps []deep
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
prefix, fm := naming.layoutFor(filepath.Join(a.Dir, "x"))
rest := strings.TrimPrefix(strings.TrimPrefix(a.Dir, prefix), "/")
if rest == "." {
rest = ""
}
want, depth := fm.albumDepth(), depthOf(rest)
target := a.Dir // where the album will be, for filling tags from names
// looseInto moves songs into album folders named from their tags,
// under base (or where the naming puts them when base is "").
looseInto := func(songs []string, base, why string) {
for _, s := range songs {
var dir, blocked string
if base == "" {
dir, blocked = renderAlbumDir(naming, s, fields[s])
} else if strings.TrimSpace(fields[s][tags.Album]) == "" {
blocked = "missing album tag"
} else {
dir = filepath.Join(base, renderComponent(naming, fm.album, fields[s]))
}
it := FixItem{Kind: FixMoveFile, From: s, Why: why, Group: dir}
if blocked != "" {
it.Blocked = blocked + ": set it in the Tags tab, then check again"
it.Group = filepath.Dir(s)
} else {
it.To = filepath.Join(dir, filepath.Base(s))
if targetTaken(root, s, it.To) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, it.To); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, it.To, files[s])
}
}
plan.add(it)
}
}
switch {
case depth == 0:
where := "the top of the library"
if prefix != "" {
where = "the top of " + prefix
}
looseInto(a.Songs, "", "Not in an album folder (at "+where+")")
continue
case depth < want:
// Music with one folder: an album without an artist folder, or an
// artist folder with songs directly inside it.
album := majority(fields, a.Songs, tags.Album)
artist := majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
name := filepath.Base(a.Dir)
isArtist := a.Nested || (artist != "" && similar(artist, cleanFolderName(name)) && !similar(album, cleanFolderName(name)))
if isArtist {
looseInto(a.Songs, a.Dir, "Songs directly in the artist folder, not in an album folder")
continue
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, Why: "Album folder is not inside an artist folder"}
if artist == "" {
it.Blocked = "its songs have no artist tag: set the album artist (Edit album in the Tags tab), then check again"
it.Group = a.Dir
} else {
f := map[string]string{tags.AlbumArtist: artist}
it.To = filepath.Join(prefix, renderComponent(naming, fm.artist, f), name)
it.Group = it.To
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
target = it.To
}
plan.add(it)
case depth > want:
comps := strings.Split(rest, "/")
last := comps[len(comps)-1]
if junkFolder.MatchString(last) && !a.Nested {
parent := filepath.Dir(a.Dir)
why := fmt.Sprintf("Extra folder %q inside the album", last)
for _, s := range a.Songs {
to := filepath.Join(parent, strings.TrimPrefix(s, a.Dir+"/"))
it := FixItem{Kind: FixMoveFile, From: s, To: to, Why: why, Group: parent}
if targetTaken(root, s, to) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, to); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, to, files[s])
}
plan.add(it)
}
target = parent
break
}
extra := comps[:len(comps)-want]
to := filepath.Join(prefix, filepath.Join(comps[len(comps)-want:]...))
kind := "Album"
if want == 1 {
kind = "Soundtrack"
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to,
Why: fmt.Sprintf("%s is inside extra folders (%s)", kind, strings.Join(extra, "/"))}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
}
plan.add(it)
deeps = append(deeps, deep{item: len(plan.Items) - 1, extra: filepath.Join(prefix, extra[0])})
target = to
}
// Disc folders and tags filled from folder and file names.
if len(a.Discs) > 0 && a.Loose {
plan.add(FixItem{Kind: FixProblem, Group: a.Dir, From: a.Dir, Why: "Songs are both in the album folder and in disc folders",
Blocked: "move them into the right disc folder by hand"})
}
discTotal := 0
for _, n := range a.Discs {
discTotal = max(discTotal, n)
}
albumName := cleanFolderName(filepath.Base(target))
artistName := ""
if want == 2 && depthOf(strings.TrimPrefix(strings.TrimPrefix(target, prefix), "/")) == 2 {
artistName = cleanFolderName(filepath.Base(filepath.Dir(target)))
}
for _, s := range a.Songs {
f := fields[s]
if _, ok := tags.FormatOf(s); !ok {
continue
}
ch := map[string][2]string{}
var why []string
set := func(k, v string) {
if v != "" && strings.TrimSpace(f[k]) != v {
ch[k] = [2]string{f[k], v}
}
}
if n := a.Discs[filepath.Dir(s)]; n > 0 {
if leadingNumber(f[tags.Disc]) != n {
set(tags.Disc, strconv.Itoa(n))
}
if leadingNumber(f[tags.DiscTotal]) != discTotal {
set(tags.DiscTotal, strconv.Itoa(discTotal))
}
if len(ch) > 0 {
why = append(why, "disc number from its disc folder")
}
}
filled := len(ch)
if strings.TrimSpace(f[tags.Album]) == "" {
set(tags.Album, albumName)
}
if artistName != "" && strings.TrimSpace(f[tags.Artist]) == "" && strings.TrimSpace(f[tags.AlbumArtist]) == "" {
set(tags.Artist, artistName)
}
stem := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))
if strings.TrimSpace(f[tags.Title]) == "" {
if t := strings.TrimSpace(trackPrefix.ReplaceAllString(stem, "")); t != "" {
set(tags.Title, t)
} else {
set(tags.Title, stem)
}
}
if strings.TrimSpace(f[tags.Track]) == "" {
if m := trackNum.FindStringSubmatch(stem); m != nil {
set(tags.Track, strconv.Itoa(leadingNumber(m[1])))
}
}
if len(ch) > filled {
why = append(why, "missing tags filled from the folder and file names")
}
if len(ch) > 0 {
w := strings.Join(why, "; ")
plan.add(FixItem{Kind: FixTags, From: s, Group: target, Changes: ch, Why: strings.ToUpper(w[:1]) + w[1:]})
}
}
}
// Several albums inside the same extra folder look like a category
// ("Video Games/…", "Rock/…") rather than a mistake: don't select those.
byExtra := map[string]int{}
for _, d := range deeps {
byExtra[d.extra]++
}
var cats []string
for _, d := range deeps {
if byExtra[d.extra] >= 3 {
plan.Items[d.item].Unsure = true
}
}
for x, n := range byExtra {
if n >= 3 {
cats = append(cats, fmt.Sprintf("%s (%d albums)", x, n))
}
}
if len(cats) > 0 {
sort.Strings(cats)
plan.Notices = append(plan.Notices, "Some folders hold several albums one level deeper than expected: "+strings.Join(cats, ", ")+
". If they are categories you want to keep (like Video Games or Movies), add them under Organize → Folders with their own naming "+
"instead of moving the albums out. Those moves are not selected.")
}
return nil
}
// ---- step 3: folder names ----
func (e *Engine) checkFolders(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{}
for _, a := range albums {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // loose songs: the file step puts them in album folders
}
targets := map[string]string{}
var missing string
untagged := 0
for _, s := range a.Songs {
t, why := renderAlbumDir(naming, s, fields[s])
if why != "" {
missing = why
untagged++
continue
}
targets[strings.ToLower(t)] = t
}
switch {
case len(targets) == 0:
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir, Why: "Can't name this album folder",
Blocked: missing + " (fix it with Edit album in the Tags tab, then check again)"})
continue
case len(targets) > 1:
var names []string
for _, t := range targets {
names = append(names, t)
}
sort.Strings(names)
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir,
Why: fmt.Sprintf("Songs belong to %d different album folders (%s)", len(names), strings.Join(names, ", ")),
Blocked: "the file step sorts them into their own folders; or make the album tags agree with Edit album"})
continue
}
var to string
for _, t := range targets {
to = t
}
if to == a.Dir {
continue
}
why := "Renamed from its tags"
if filepath.Dir(to) != filepath.Dir(a.Dir) {
why = "Moved to the folder its tags name"
}
if untagged > 0 {
why += fmt.Sprintf(" (%d songs without tags move along)", untagged)
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to, Why: why}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if b := checkDirMove(l, root, a.Dir, to); b != "" {
it.Blocked = b
if strings.Contains(b, "already exists") {
it.Blocked = "that folder already exists; its songs are moved into it in the file step"
}
} else if c := claimed[strings.ToLower(to)]; c != "" {
it.Blocked = "another folder gets the same name (" + c + ")"
} else {
claimed[strings.ToLower(to)] = a.Dir
}
}
plan.add(it)
}
return nil
}
// ---- step 4: file names ----
func (e *Engine) checkFiles(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
op, err := e.planOrganize(ctx, j, l, []string{plan.Side})
if err != nil {
return err
}
plan.Songs = op.Scanned
for _, it := range op.Items {
fi := FixItem{Kind: FixMoveFile, From: it.From, To: it.To, Why: "Renamed from its tags"}
if it.Move {
org := it
fi.org = &org
fi.Group = filepath.Dir(it.To)
if filepath.Dir(it.To) != filepath.Dir(it.From) {
fi.Why = "Moved and renamed from its tags"
}
} else {
fi.Kind, fi.Blocked, fi.Why, fi.Group = FixProblem, it.Reason, "Can't be renamed", filepath.Dir(it.From)
}
plan.add(fi)
}
return nil
}
-552
View File
@@ -1,552 +0,0 @@
package engine
import (
"context"
"errors"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/tags"
)
// The album tags step looks each album up and proposes corrected tags for
// review. The master is looked up on MusicBrainz; the slave uses the fixed
// master album when there is one (so both libraries agree), and MusicBrainz
// otherwise.
// SetMeta enables online album lookups.
func (e *Engine) SetMeta(c *meta.Client) { e.meta = c }
// Sources of album tags.
const (
SourceMusicBrainz = "musicbrainz"
SourceMaster = "master"
)
// AlbumFix is the lookup result for one album folder.
type AlbumFix struct {
ID int `json:"id"` // the plan item's id
Folder string `json:"folder"`
Artist string `json:"artist"` // what was searched for
Album string `json:"album"`
Source string `json:"source"`
Release *meta.Release `json:"release,omitempty"`
Candidates []meta.Release `json:"candidates"`
Confidence float64 `json:"confidence"` // 0..1
Matched int `json:"matched"` // songs matched to a track
Files []FileFix `json:"files"`
Shared map[string][2]string `json:"album_changes"` // changes to album-wide fields
Error string `json:"error,omitempty"`
songs []string
fields map[string]map[string]string
discs map[string]int
}
// FileFix is the proposed change of one song.
type FileFix struct {
Path string `json:"path"`
Track string `json:"track,omitempty"` // the matched track, "1-03 Title"
Changes map[string][2]string `json:"changes"`
}
// albumComplete reports whether an album's tags already look right: every
// song has a title, track number, album and artist, and they agree.
func albumComplete(songs []string, fields map[string]map[string]string) bool {
albums, artists, tracks := map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, s := range songs {
f := fields[s]
artist := strings.TrimSpace(f[tags.AlbumArtist])
if artist == "" {
artist = strings.TrimSpace(f[tags.Artist])
}
if strings.TrimSpace(f[tags.Title]) == "" || leadingNumber(f[tags.Track]) == 0 || strings.TrimSpace(f[tags.Album]) == "" || artist == "" {
return false
}
key := strconv.Itoa(leadingNumber(f[tags.Disc])) + "-" + strconv.Itoa(leadingNumber(f[tags.Track]))
if tracks[key] {
return false // two songs with the same number
}
tracks[key] = true
albums[f[tags.Album]] = true
artists[artist] = true
}
return len(albums) == 1 && len(artists) == 1
}
func (e *Engine) checkAlbumTags(ctx context.Context, j *Job, l *libs, plan *FixPlan, c FixCheck) error {
_, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
var albums []*albumInfo
complete := 0
for _, a := range albumsOf(files) {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // not in an album folder: the structure step handles those
}
if c.Folder != "" && a.Dir != c.Folder && !strings.HasPrefix(a.Dir, c.Folder+"/") {
continue
}
if c.OnlyIncomplete && albumComplete(a.Songs, fields) {
complete++
continue
}
albums = append(albums, a)
}
plan.Songs = len(files)
plan.Albums = len(albums)
if complete > 0 {
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already have complete tags and were not looked up.", complete))
}
var master map[string]*masterAlbum
if plan.Side == SideSlave {
master, err = e.masterAlbums(ctx, j, l)
if err != nil {
return err
}
}
j.phase("Looking up albums", int64(len(albums)))
correct := 0
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
af := &AlbumFix{Folder: a.Dir, songs: a.Songs, fields: fields, discs: map[string]int{}}
for _, s := range a.Songs {
af.discs[s] = a.Discs[filepath.Dir(s)]
}
af.Album = majority(fields, a.Songs, tags.Album)
if af.Album == "" {
af.Album = cleanFolderName(filepath.Base(a.Dir))
}
af.Artist = majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
if m := master[fixAlbumKey(af.Artist, af.Album)]; m != nil {
af.useMaster(m)
} else {
e.lookupAlbum(ctx, af, "")
}
if ctx.Err() != nil {
return ctx.Err()
}
j.tick()
if af.Error == "" && !af.hasChanges() {
correct++
continue
}
it := FixItem{Kind: FixAlbumTags, From: a.Dir, Group: a.Dir, Why: af.summary(), Blocked: af.Error, Unsure: af.Confidence < 0.75}
plan.add(it)
af.ID = plan.Items[len(plan.Items)-1].ID
plan.AlbumFixes = append(plan.AlbumFixes, af)
}
if correct > 0 {
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already match their lookup.", correct))
}
return nil
}
func (a *AlbumFix) hasChanges() bool {
for _, f := range a.Files {
if len(f.Changes) > 0 {
return true
}
}
return false
}
func (a *AlbumFix) summary() string {
if a.Error != "" {
return "No confident match"
}
n := 0
for _, f := range a.Files {
if len(f.Changes) > 0 {
n++
}
}
src := "MusicBrainz"
if a.Source == SourceMaster {
src = "the master library"
}
return fmt.Sprintf("%d of %d songs change (from %s)", n, len(a.Files), src)
}
func fixAlbumKey(artist, album string) string { return matchKey(artist) + "|" + matchKey(album) }
// ---- the master as reference (slave only) ----
type masterAlbum struct {
folder string
songs []string
fields map[string]map[string]string
}
// masterAlbums indexes the master's albums by artist and album tags.
func (e *Engine) masterAlbums(ctx context.Context, j *Job, l *libs) (map[string]*masterAlbum, error) {
_, files, fields, err := e.readLibrary(ctx, j, l, SideMaster)
if err != nil {
return nil, err
}
out := map[string]*masterAlbum{}
for _, a := range albumsOf(files) {
if a.Dir == "." || !albumComplete(a.Songs, fields) {
continue
}
k := fixAlbumKey(majority(fields, a.Songs, tags.AlbumArtist, tags.Artist), majority(fields, a.Songs, tags.Album))
if out[k] == nil {
out[k] = &masterAlbum{folder: a.Dir, songs: a.Songs, fields: fields}
}
}
return out, nil
}
// useMaster takes the tags of the same album in the master.
func (a *AlbumFix) useMaster(m *masterAlbum) {
a.Source = SourceMaster
d := &meta.ReleaseDetail{Release: meta.Release{ID: "master:" + m.folder, Title: majority(m.fields, m.songs, tags.Album),
Artist: majority(m.fields, m.songs, tags.AlbumArtist, tags.Artist), Date: majority(m.fields, m.songs, tags.Date)}}
discs := map[int]bool{}
for _, s := range m.songs {
f := m.fields[s]
disc := max(leadingNumber(f[tags.Disc]), 1)
discs[disc] = true
d.Tracks = append(d.Tracks, meta.Track{Disc: disc, Position: leadingNumber(f[tags.Track]), DiscTracks: leadingNumber(f[tags.TrackTotal]),
Title: f[tags.Title], Artist: f[tags.Artist]})
}
d.Discs = len(discs)
d.TrackCount = len(d.Tracks)
d.Formats = "master: " + m.folder
a.Candidates = []meta.Release{d.Release}
a.apply(d, 1)
}
// ---- MusicBrainz ----
// lookupAlbum searches MusicBrainz and applies the best release, or the one
// given by id.
func (e *Engine) lookupAlbum(ctx context.Context, a *AlbumFix, id string) {
a.Source, a.Error, a.Release, a.Files, a.Matched, a.Confidence = SourceMusicBrainz, "", nil, nil, 0, 0
if id == "" {
res, err := e.meta.SearchReleases(ctx, a.Artist, a.Album)
if err == nil && len(res) == 0 && a.Artist != "" {
res, err = e.meta.SearchReleases(ctx, "", a.Album) // soundtracks: the artist is often missing or a composer
}
if err != nil {
a.Error = "lookup failed: " + err.Error()
return
}
scored := make([]struct {
r meta.Release
s float64
}, len(res))
for i, r := range res {
scored[i].r, scored[i].s = r, a.releaseScore(r)
}
sort.SliceStable(scored, func(i, j int) bool { return scored[i].s > scored[j].s })
a.Candidates = a.Candidates[:0]
for i, s := range scored {
if i == 8 {
break
}
a.Candidates = append(a.Candidates, s.r)
}
// The search can't tell a promo single from the album it is named
// after: compare the track lists of the best few with the songs.
var best *meta.ReleaseDetail
bestScore := 0.0
for i, c := range scored {
if i == 3 || c.s < 0.45 {
break
}
d, err := e.meta.Release(ctx, c.r.ID)
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
fit := a.trackFit(d)
if score := 0.5*c.s + 0.5*fit; score > bestScore {
best, bestScore = d, score
}
if fit == 1 && c.s >= 0.75 {
break // every song is on this release
}
}
if best == nil || bestScore < 0.6 {
a.Error = "no confident match on MusicBrainz; pick a release or edit the album by hand"
return
}
a.apply(best, bestScore)
return
}
d, err := e.meta.Release(ctx, id)
if err != nil {
a.Error = "lookup failed: " + err.Error()
return
}
a.apply(d, 0.5*a.releaseScore(d.Release)+0.5*a.trackFit(d))
}
// releaseScore rates a search result before its track list is known (0..1).
func (a *AlbumFix) releaseScore(r meta.Release) float64 {
title := levenshteinRatio(matchKey(r.Title), matchKey(a.Album))
artist := 0.6
if a.Artist != "" {
artist = levenshteinRatio(matchKey(r.Artist), matchKey(a.Artist))
}
count := 0.0
switch n := len(a.songs); {
case r.TrackCount == n:
count = 1
case r.TrackCount > n:
count = 0.7 // a partial copy of an album is common
}
s := 0.45*title + 0.2*artist + 0.15*count + 0.2*typeFit(r.Type)
if r.Status != "" && r.Status != "Official" {
s -= 0.05
}
return s
}
// typeFit prefers albums and soundtracks over singles and EPs of the same name.
func typeFit(t string) float64 {
t = strings.ToLower(t)
switch {
case strings.Contains(t, "album"), strings.Contains(t, "soundtrack"):
return 1
case strings.Contains(t, "compilation"):
return 0.8
case strings.Contains(t, "ep"), t == "":
return 0.6
case strings.Contains(t, "single"):
return 0.3
}
return 0.5
}
// trackFit is the share of the album's titled songs found on a release.
func (a *AlbumFix) trackFit(d *meta.ReleaseDetail) float64 {
n, hit := 0, 0
for _, s := range a.songs {
t := matchKey(a.fields[s][tags.Title])
if t == "" {
continue
}
n++
for _, tr := range d.Tracks {
if k := matchKey(tr.Title); k == t || levenshteinRatio(k, t) >= 0.8 {
hit++
break
}
}
}
if n == 0 {
if len(d.Tracks) == len(a.songs) {
return 0.8
}
return 0.4
}
return float64(hit) / float64(n)
}
// ChooseRelease switches an album of the latest plan to another release (a
// MusicBrainz id), or to a new MusicBrainz search ("search").
func (e *Engine) ChooseRelease(ctx context.Context, pairID int64, albumID int, id string) (*AlbumFix, error) {
if e.meta == nil {
return nil, errors.New("online lookup is not available")
}
if id != "search" && !meta.ValidID(id) {
return nil, fmt.Errorf("%w: not a MusicBrainz release id", ErrInvalidBulk)
}
e.fixMu.Lock()
plan := e.fixPlans[pairID]
var af *AlbumFix
if plan != nil && plan.Step == StepTags && !plan.Applied {
for _, a := range plan.AlbumFixes {
if a.ID == albumID {
af = a
}
}
}
e.fixMu.Unlock()
if af == nil {
return nil, fmt.Errorf("%w: that album is not in the latest check", ErrInvalidBulk)
}
// Work on a copy so a failed or slow lookup never leaves a half-updated album.
cp := *af
if id == "search" {
id = ""
}
e.lookupAlbum(ctx, &cp, id)
if cp.Error != "" && cp.Release == nil && id != "" {
return nil, errors.New(cp.Error)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
*af = cp
for i := range plan.Items {
if plan.Items[i].ID == albumID {
wasBlocked := plan.Items[i].Blocked != ""
plan.Items[i].Blocked, plan.Items[i].Why, plan.Items[i].Unsure = af.Error, af.summary(), af.Confidence < 0.75
if wasBlocked && af.Error == "" {
plan.Blocked--
plan.Fixable++
} else if !wasBlocked && af.Error != "" {
plan.Blocked++
plan.Fixable--
}
}
}
return af, nil
}
// apply matches the album's songs to a release's tracks and works out the
// tag changes.
func (a *AlbumFix) apply(d *meta.ReleaseDetail, confidence float64) {
rel := d.Release
a.Release, a.Confidence, a.Error = &rel, confidence, ""
match := matchTracks(a.songs, a.fields, a.discs, d.Tracks)
a.Files, a.Matched = nil, 0
shared := map[string][2]string{}
for i, s := range a.songs {
f := a.fields[s]
want := map[string]string{tags.Album: d.Title, tags.AlbumArtist: d.Artist}
if d.Date != "" && !(len(f[tags.Date]) >= 4 && len(d.Date) >= 4 && f[tags.Date][:4] == d.Date[:4]) {
want[tags.Date] = d.Date
}
ff := FileFix{Path: s}
if t := match[i]; t >= 0 {
tr := d.Tracks[t]
a.Matched++
want[tags.Title], want[tags.Artist] = tr.Title, tr.Artist
if tr.Position > 0 {
want[tags.Track] = strconv.Itoa(tr.Position)
}
if tr.DiscTracks > 0 {
want[tags.TrackTotal] = strconv.Itoa(tr.DiscTracks)
}
if d.Discs > 1 || leadingNumber(f[tags.Disc]) > 1 {
want[tags.Disc] = strconv.Itoa(tr.Disc)
want[tags.DiscTotal] = strconv.Itoa(d.Discs)
}
ff.Track = fmt.Sprintf("%d-%02d %s", tr.Disc, tr.Position, tr.Title)
}
ff.Changes = map[string][2]string{}
for k, v := range want {
if strings.TrimSpace(v) == "" || sameTag(k, f[k], v) {
continue
}
ff.Changes[k] = [2]string{f[k], v}
if k == tags.Album || k == tags.AlbumArtist || k == tags.Date {
shared[k] = [2]string{f[k], v}
}
}
a.Files = append(a.Files, ff)
}
a.Shared = shared
if len(a.songs) > 0 && a.Matched*2 < len(a.songs) {
a.Confidence = min(a.Confidence, 0.5)
}
}
func sameTag(k, old, new string) bool {
switch k {
case tags.Track, tags.TrackTotal, tags.Disc, tags.DiscTotal:
return leadingNumber(old) == leadingNumber(new)
}
return strings.TrimSpace(old) == strings.TrimSpace(new)
}
// matchTracks pairs each song with a track of the release (-1: none). Track
// and disc numbers are trusted when the titles mostly agree with them;
// otherwise songs are matched by title, and failing that by file order when
// the counts are equal.
func matchTracks(songs []string, fields map[string]map[string]string, discs map[string]int, tracks []meta.Track) []int {
out := make([]int, len(songs))
for i := range out {
out[i] = -1
}
titleOf := func(i int) string {
if t := strings.TrimSpace(fields[songs[i]][tags.Title]); t != "" {
return matchKey(t)
}
return normTitle(filepath.Base(songs[i]))
}
sim := func(i, t int) float64 { return levenshteinRatio(titleOf(i), matchKey(tracks[t].Title)) }
// 1. by disc and track number
byNum := map[[2]int]int{}
multi := false
for t, tr := range tracks {
byNum[[2]int{tr.Disc, tr.Position}] = t
multi = multi || tr.Disc > 1
}
used := map[int]bool{}
agree, tried := 0, 0
for i, s := range songs {
n := leadingNumber(fields[s][tags.Track])
disc := leadingNumber(fields[s][tags.Disc])
if disc == 0 {
disc = discs[s]
}
if disc == 0 && !multi {
disc = 1
}
if t, ok := byNum[[2]int{disc, n}]; ok && n > 0 && !used[t] {
out[i], used[t] = t, true
tried++
if sim(i, t) >= 0.6 {
agree++
}
}
}
if tried > 0 && agree*10 < tried*7 {
for i := range out { // numbers don't fit these titles
out[i] = -1
}
used = map[int]bool{}
}
// 2. by title, best pairs first
type pair struct {
i, t int
s float64
}
var pairs []pair
for i := range songs {
if out[i] >= 0 {
continue
}
for t := range tracks {
if !used[t] {
if s := sim(i, t); s >= 0.6 {
pairs = append(pairs, pair{i, t, s})
}
}
}
}
sort.Slice(pairs, func(x, y int) bool { return pairs[x].s > pairs[y].s })
for _, p := range pairs {
if out[p.i] < 0 && !used[p.t] {
out[p.i], used[p.t] = p.t, true
}
}
// 3. by file order when nothing matched and the counts agree
if len(used) == 0 && len(songs) == len(tracks) {
order := make([]int, len(songs))
for i := range order {
order[i] = i
}
sort.Slice(order, func(x, y int) bool { return songs[order[x]] < songs[order[y]] })
for t, i := range order {
out[i] = t
}
}
return out
}
-384
View File
@@ -1,384 +0,0 @@
package engine
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// fixCheck runs a check and returns its plan and items.
func (ev *env) fixCheck(t *testing.T, c FixCheck) (*FixPlan, []FixItem, []*AlbumFix) {
t.Helper()
if _, err := ev.e.StartFixCheck(ev.pair.ID, c); err != nil {
t.Fatal(err)
}
ev.wait()
p, items, albums := ev.e.FixerPlan(ev.pair.ID)
if p == nil {
t.Fatal("no plan")
}
return p, items, albums
}
// fixAll applies every fix that isn't blocked (unsure ones included).
func (ev *env) fixAll(t *testing.T, items []FixItem) *Job {
t.Helper()
var ids []int
for _, it := range items {
if it.Blocked == "" && it.Kind != FixProblem {
ids = append(ids, it.ID)
}
}
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: ids}); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func byFrom(items []FixItem) map[string]FixItem {
m := map[string]FixItem{}
for _, it := range items {
if _, ok := m[it.From]; !ok || it.Kind != FixTags {
m[it.From] = it
}
}
return m
}
func (ev *env) fields(t *testing.T, p string) map[string]string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields
}
func TestFixStructureMusic(t *testing.T) {
ev := tagEnv(t, false)
s := ev.slave
mkTagged(t, filepath.Join(s, "Loose.mp3"), "title", "Loose", "artist", "A", "album", "B")
mkTagged(t, filepath.Join(s, "Nightmare/01 Nightmare.flac"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
os.WriteFile(filepath.Join(s, "Nightmare/cover.jpg"), []byte("art"), 0o644)
mkTagged(t, filepath.Join(s, "Band/song.mp3"), "title", "Song", "artist", "Band", "album", "Record")
mkTagged(t, filepath.Join(s, "X/Y/FLAC/01 a.flac"), "title", "a", "artist", "X", "album", "Y")
mkTagged(t, filepath.Join(s, "Metal/Artist2/Album2/01 s.mp3"), "title", "s", "artist", "Artist2", "album", "Album2")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD1/01 t.mp3"), "title", "t", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3"), "title", "u", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"))
mkTagged(t, filepath.Join(s, "NoTags.mp3"))
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
want := map[string]string{
"Loose.mp3": "A/B/Loose.mp3",
"Nightmare": "Avenged Sevenfold/Nightmare",
"Band/song.mp3": "Band/Record/song.mp3",
"X/Y/FLAC/01 a.flac": "X/Y/01 a.flac",
"Metal/Artist2/Album2": "Artist2/Album2",
"Artist3/Album3/CD2/01 u.mp3": "",
}
for from, to := range want {
it, ok := m[from]
if !ok || it.To != to || it.Blocked != "" {
t.Errorf("%s: got %+v, want to %q", from, it, to)
}
}
if it := m["Artist3/Album3/CD2/01 u.mp3"]; it.Changes[tags.Disc][1] != "2" || it.Changes[tags.DiscTotal][1] != "2" {
t.Errorf("disc tags from the disc folder: %+v", it.Changes)
}
fill := m["Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"].Changes
if fill[tags.Album][1] != "Album4" || fill[tags.Artist][1] != "Artist4" || fill[tags.Title][1] != "Song Name" || fill[tags.Track][1] != "5" {
t.Errorf("tags from names: %+v", fill)
}
if it := m["NoTags.mp3"]; it.Blocked == "" {
t.Errorf("a loose song without tags can't be placed: %+v", it)
}
if plan.Blocked != 1 {
t.Errorf("blocked %d", plan.Blocked)
}
j := ev.fixAll(t, items)
if j.Results["skipped"] != 0 {
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
}
for _, p := range []string{"A/B/Loose.mp3", "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold/Nightmare/cover.jpg",
"Band/Record/song.mp3", "X/Y/01 a.flac", "Artist2/Album2/01 s.mp3", "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"} {
if !exists(filepath.Join(s, p)) {
t.Errorf("missing %s", p)
}
}
for _, p := range []string{"Nightmare", "X/Y/FLAC", "Metal"} {
if exists(filepath.Join(s, p)) {
t.Errorf("%s should be gone", p)
}
}
if f := ev.fields(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3")); f[tags.Disc] != "2" || f[tags.DiscTotal] != "2" {
t.Errorf("disc tags not written: %+v", f)
}
// A folder move can be undone.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActMoveDir && o.RelNew == "Avenged Sevenfold/Nightmare" {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
}
}
waitIdle(ev)
if !exists(filepath.Join(s, "Nightmare/cover.jpg")) || exists(filepath.Join(s, "Avenged Sevenfold")) {
t.Fatal("undo should move the folder back and remove the empty artist folder")
}
// Not everything was fixable, so the step isn't marked done.
p, _ := ev.st.Pair(ctx, ev.pair.ID)
if FixerStateOf(p).Slave[StepStructure].State != "" {
t.Fatal("step marked done although a song still needs attention")
}
}
func waitIdle(ev *env) {
for i := 0; i < 500; i++ {
if j := ev.e.Job(ev.pair.ID); j == nil || j.State != "running" {
return
}
time.Sleep(10 * time.Millisecond)
}
}
func TestFixStructureSoundtracks(t *testing.T) {
ev := tagEnv(t, false)
ev.pair.LibraryType = LibrarySoundtracks
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
s := ev.slave
mkTagged(t, filepath.Join(s, "Nobuo Uematsu/Final Fantasy VII/01 Prelude.flac"), "title", "Prelude", "album", "Final Fantasy VII")
for _, g := range []string{"Halo 3", "Super Mario Galaxy", "Portal 2"} {
mkTagged(t, filepath.Join(s, "Video Games", g, "01 x.flac"), "title", "x", "album", g)
}
mkTagged(t, filepath.Join(s, "Journey/01 Nascence.flac"), "title", "Nascence", "album", "Journey")
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
if it := m["Nobuo Uematsu/Final Fantasy VII"]; it.To != "Final Fantasy VII" || it.Unsure || !strings.Contains(it.Why, "Soundtrack") {
t.Errorf("composer folder: %+v", it)
}
if it := m["Video Games/Halo 3"]; it.To != "Halo 3" || !it.Unsure {
t.Errorf("a category folder's albums should be listed but not selected: %+v", it)
}
if len(plan.Notices) != 1 || !strings.Contains(plan.Notices[0], "Video Games (3 albums)") {
t.Errorf("notices %v", plan.Notices)
}
if _, ok := m["Journey"]; ok {
t.Error("a soundtrack at the top of the library is fine")
}
}
func TestFixFoldersAndFiles(t *testing.T) {
ev := tagEnv(t, false)
m := ev.master
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/a.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/b.mp3"), "title", "Welcome to the Family", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "2")
mkTagged(t, filepath.Join(m, "Mix/1.mp3"), "title", "One", "artist", "A", "album", "First")
mkTagged(t, filepath.Join(m, "Mix/2.mp3"), "title", "Two", "artist", "B", "album", "Second")
_, items, _ := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFolders})
byF := byFrom(items)
if it := byF["avenged sevenfold/nightmare (2010)"]; it.Kind != FixMoveFolder || it.To != "Avenged Sevenfold/Nightmare" {
t.Fatalf("folder rename: %+v", it)
}
if it := byF["Mix"]; it.Kind != FixProblem || !strings.Contains(it.Why, "2 different album folders") {
t.Fatalf("mixed folder: %+v", it)
}
ev.fixAll(t, items)
if !exists(filepath.Join(m, "Avenged Sevenfold/Nightmare/a.mp3")) {
t.Fatal("folder not renamed")
}
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFolders].State != "" {
t.Fatal("a folder that needs attention keeps the step open")
}
_, items, _ = ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFiles})
ev.fixAll(t, items)
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFiles].State != "done" {
t.Fatal("applying every fix marks the step done")
}
for _, p := range []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3", "Avenged Sevenfold/Nightmare/02 Welcome to the Family.mp3", "A/First/One.mp3", "B/Second/Two.mp3"} {
if !exists(filepath.Join(m, p)) {
t.Errorf("missing %s", p)
}
}
// Applying the same plan twice is refused: paths changed.
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID - 1, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
}
// fakeMB serves one release: Avenged Sevenfold - Nightmare, 3 tracks.
func fakeMB(t *testing.T) *meta.Client {
const id = "85cb4213-ffc2-38b1-a256-bd6e5ce2e0af"
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, `{"id":"`+id+`","title":"Nightmare","date":"2010-07-26","status":"Official","artist-credit":[{"name":"Avenged Sevenfold"}],
"release-group":{"primary-type":"Album"},"media":[{"position":1,"format":"CD","track-count":3,"tracks":[
{"position":1,"title":"Nightmare","length":374000},{"position":2,"title":"Welcome to the Family","length":245000},
{"position":3,"title":"Danger Line","length":329000}]}]}`)
return
}
if !strings.Contains(r.URL.Query().Get("query"), "Nightmare") {
fmt.Fprint(w, `{"releases":[]}`)
return
}
fmt.Fprint(w, `{"releases":[{"id":"`+id+`","score":100,"title":"Nightmare","status":"Official","date":"2010-07-26",
"track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":3}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
return c
}
func TestFixAlbumTags(t *testing.T) {
ev := tagEnv(t, false)
ev.e.SetMeta(fakeMB(t))
m, s := ev.master, ev.slave
dir := "Avenged Sevenfold/Nightmare"
// Titles are slightly off and numbers are missing; one song has no title.
mkTagged(t, filepath.Join(m, dir, "a.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "b.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "03 Danger Line.mp3"), "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, "Nobody/Unknown/x.mp3"), "title", "x", "artist", "Nobody", "album", "Unknown")
plan, items, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 2 || plan.Fixable != 1 || plan.Blocked != 1 {
t.Fatalf("plan %+v albums %d", plan, len(albums))
}
var nm *AlbumFix
for _, a := range albums {
if a.Folder == dir {
nm = a
}
}
if nm == nil || nm.Matched != 3 || nm.Release.Title != "Nightmare" || nm.Confidence < 0.9 {
t.Fatalf("album fix %+v", nm)
}
ev.fixAll(t, items)
f := ev.fields(t, filepath.Join(m, dir, "b.mp3"))
if f[tags.Title] != "Welcome to the Family" || f[tags.Track] != "2" || f[tags.TrackTotal] != "3" || f[tags.AlbumArtist] != "Avenged Sevenfold" || f[tags.Date] != "2010-07-26" {
t.Fatalf("tags not updated: %+v", f)
}
if f := ev.fields(t, filepath.Join(m, dir, "03 Danger Line.mp3")); f[tags.Title] != "Danger Line" || f[tags.Track] != "3" {
t.Fatalf("untitled song matched by position: %+v", f)
}
// The slave copy takes its tags from the fixed master, not MusicBrainz.
mkTagged(t, filepath.Join(s, "A7X/Nightmare/x.mp3"), "title", "Welcome to the family", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums = ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepTags})
if len(albums) != 1 || albums[0].Source != SourceMaster || albums[0].Files[0].Changes[tags.Track][1] != "2" {
t.Fatalf("slave from master: %+v", albums)
}
// Choosing MusicBrainz instead.
a, err := ev.e.ChooseRelease(ctx, ev.pair.ID, albums[0].ID, "search")
if err != nil || a.Source != SourceMusicBrainz || a.Matched != 1 {
t.Fatalf("switch to MusicBrainz: %+v %v", a, err)
}
}
func TestFixerStepState(t *testing.T) {
ev := setup(t, false)
if err := ev.e.SetStep(ctx, ev.pair.ID, SideMaster, StepTags, "skipped", ""); err != nil {
t.Fatal(err)
}
if err := ev.e.SetStep(ctx, ev.pair.ID, "", StepLink, "done", ""); err != nil {
t.Fatal(err)
}
p, _ := ev.st.Pair(ctx, ev.pair.ID)
s := FixerStateOf(p)
if s.Master[StepTags].State != "skipped" || s.Link.State != "done" || s.Slave[StepTags].State != "" {
t.Fatalf("%+v", s)
}
for _, bad := range [][3]string{{"left", StepTags, "done"}, {SideMaster, "bogus", "done"}, {SideMaster, StepTags, "maybe"}} {
if ev.e.SetStep(ctx, ev.pair.ID, bad[0], bad[1], bad[2], "") == nil {
t.Errorf("%v should be refused", bad)
}
}
}
func TestCleanFolderName(t *testing.T) {
for in, want := range map[string]string{
"Nightmare (2010)": "Nightmare",
"Nightmare [FLAC]": "Nightmare",
"2010 - Nightmare": "Nightmare",
"Nightmare (2010) [24-96]": "Nightmare",
"Hail to the King (Deluxe)": "Hail to the King",
"(What's the Story) Morning Glory?": "(What's the Story) Morning Glory?",
"1989": "1989",
} {
if got := cleanFolderName(in); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
for in, want := range map[string]int{"CD2": 2, "Disc 01": 1, "disk_3": 3, "Disc 1 - The Beginning": 1, "Vol. 2": 0, "CDs": 0} {
if got := discNumber(in); got != want {
t.Errorf("disc %q: got %d, want %d", in, got, want)
}
}
}
// A promo single named after the album, with exactly as many tracks as the
// local (partial) album, must not beat the album itself.
func TestFixAlbumTagsPrefersTheAlbum(t *testing.T) {
ev := tagEnv(t, false)
const promo, album = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
tracks := func(titles ...string) string {
var b []string
for i, t := range titles {
b = append(b, fmt.Sprintf(`{"position":%d,"title":%q}`, i+1, t))
}
return strings.Join(b, ",")
}
release := map[string]string{
promo: `{"id":"` + promo + `","title":"Nightmare","status":"Promotion","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare (album version) (explicit)", "Nightmare (radio edit)", "Nightmare (instrumental)") + `]}]}`,
album: `{"id":"` + album + `","title":"Nightmare","status":"Official","date":"2010-07-26","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare", "Welcome to the Family", "Danger Line", "Buried Alive", "Natural Born Killer") + `]}]}`,
}
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
for id, body := range release {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, body)
return
}
}
fmt.Fprint(w, `{"releases":[
{"id":"`+promo+`","score":100,"title":"Nightmare","status":"Promotion","track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},"media":[{"format":"CD-R","track-count":3}]},
{"id":"`+album+`","score":95,"title":"Nightmare","status":"Official","track-count":5,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":5}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
ev.e.SetMeta(c)
dir := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare")
mkTagged(t, filepath.Join(dir, "01.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "02.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "03.mp3"), "title", "danger line", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 1 || albums[0].Release == nil || albums[0].Release.ID != album || albums[0].Matched != 3 || albums[0].Confidence < 0.75 {
t.Fatalf("want the album: %+v", albums)
}
}
-643
View File
@@ -1,643 +0,0 @@
package engine
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"reflect"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// Naming follows Lidarr's model: an artist folder, an album folder and a
// track format (with a separate format for multi-disc albums), built from
// tokens such as {Artist Name}, {Album Title} and {track:00}.
// An empty ArtistFolder means no artist folder: Album/01 Title.
type NamingConfig struct {
ArtistFolder string `json:"artist_folder"`
AlbumFolder string `json:"album_folder"`
TrackFormat string `json:"track_format"`
MultiDiscFormat string `json:"multi_disc_format"`
Colon string `json:"colon"` // smart, delete, dash, space_dash, space_dash_space
Illegal string `json:"illegal"` // replace or remove
// Folders give part of a library its own formats, such as soundtracks in
// "Video Games" named Album/01 Title. Files inside such a folder stay
// inside it, and other files are never moved into it.
Folders []FolderNaming `json:"folders"`
}
// FolderNaming is the naming for one folder of a library (relative to its root).
type FolderNaming struct {
Folder string `json:"folder"`
ArtistFolder string `json:"artist_folder"`
AlbumFolder string `json:"album_folder"`
TrackFormat string `json:"track_format"`
MultiDiscFormat string `json:"multi_disc_format"`
}
// formats are the four path formats in effect for one file.
type formats struct{ artist, album, track, multiDisc string }
func (c NamingConfig) formats() formats {
return formats{c.ArtistFolder, c.AlbumFolder, c.TrackFormat, c.MultiDiscFormat}
}
func (f FolderNaming) formats() formats {
return formats{f.ArtistFolder, f.AlbumFolder, f.TrackFormat, f.MultiDiscFormat}
}
// folderOf returns the folder rule a library path falls under, if any.
func (c NamingConfig) folderOf(rel string) (FolderNaming, bool) {
for _, f := range c.Folders {
if inFolder(rel, f.Folder) {
return f, true
}
}
return FolderNaming{}, false
}
// inFolder reports whether rel is inside folder (compared case-insensitively).
func inFolder(rel, folder string) bool {
return strings.HasPrefix(strings.ToLower(rel)+"/", strings.ToLower(folder)+"/") && len(rel) > len(folder)
}
func DefaultNaming() NamingConfig {
return NamingConfig{
ArtistFolder: "{Artist Name}",
AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}",
MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}",
Colon: "smart",
Illegal: "replace",
Folders: []FolderNaming{},
}
}
// Library types. Music is organized Artist/Album/01 Title; soundtracks (games,
// films) Album/01 Title, since their "artist" is often a composer or empty.
const (
LibraryMusic = "music"
LibrarySoundtracks = "soundtracks"
)
// DefaultNamingFor returns the recommended naming for a library type.
func DefaultNamingFor(libraryType string) NamingConfig {
c := DefaultNaming()
if libraryType == LibrarySoundtracks {
c.ArtistFolder = ""
}
return c
}
// SoundtrackFolder is the suggested folder rule for game and film soundtracks.
var SoundtrackFolder = FolderNaming{Folder: "Video Games", AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}"}
// NamingPresets are offered in the UI.
var NamingPresets = []struct {
Name string `json:"name"`
Config NamingConfig `json:"config"`
}{
{"Artist/Album/01 Title", DefaultNaming()},
{"Artist/Album (Year)/01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title} ({Release Year})",
TrackFormat: "{track:00} - {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}},
{"Artist/Album/Artist - Album - 01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}",
TrackFormat: "{Artist Name} - {Album Title} - {track:00} - {Track Title}",
MultiDiscFormat: "{Artist Name} - {Album Title} - {medium:00}-{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}},
{"Artist/Album/1-01 Title (disc in name)", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{medium:0}-{track:00} {Track Title}", Colon: "smart", Illegal: "replace"}},
{"Album/01 Title (no artist folder)", NamingConfig{AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}", Colon: "smart", Illegal: "replace"}},
}
// NamingOf returns a pair's naming, converting the settings of earlier
// versions (one path pattern plus a disc style) when none is stored yet.
func NamingOf(p store.Pair) NamingConfig {
c := DefaultNamingFor(p.LibraryType)
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" {
if stored, err := parseNaming(p.OrganizeConfig); err == nil {
return stored
}
}
old := map[string]string{"{artist}": "{Artist Name}", "{album}": "{Album Title}", "{track}": "{track:00}", "{title}": "{Track Title}",
"{year}": "{Release Year}", "{track_artist}": "{Track ArtistName}", "{genre}": "{Genre}"}
medium := "{medium:00}"
if p.OrganizeDiscDigits == 1 {
medium = "{medium:0}"
}
old["{disc}"] = medium
if parts := strings.Split(p.OrganizeTemplate, "/"); len(parts) == 3 {
conv := func(s string) string {
for k, v := range old {
s = strings.ReplaceAll(s, k, v)
}
return s
}
c.ArtistFolder, c.AlbumFolder, c.TrackFormat = conv(parts[0]), conv(parts[1]), conv(parts[2])
}
switch p.OrganizeMultiDisc {
case "prefix":
c.MultiDiscFormat = strings.Replace(c.TrackFormat, "{track:00}", medium+"-{track:00}", 1)
case "none":
c.MultiDiscFormat = c.TrackFormat
default:
c.MultiDiscFormat = "{Medium Format} " + medium + "/" + c.TrackFormat
}
return c
}
// parseNaming reads stored settings. Fields missing from the JSON take the
// defaults; an artist folder that is present but empty stays empty.
func parseNaming(raw []byte) (NamingConfig, error) {
c := DefaultNaming() // missing keys: only very old or hand-made settings
if err := json.Unmarshal(raw, &c); err != nil {
return c, err
}
fill(&c, DefaultNaming())
return c, nil
}
// ChangeLibraryType switches a pair's naming to the new type's recommended
// naming when it still uses the old type's, so custom naming is kept.
func ChangeLibraryType(old store.Pair, p *store.Pair) {
if old.ID == 0 || old.LibraryType == p.LibraryType {
return
}
if reflect.DeepEqual(NamingOf(*p), DefaultNamingFor(old.LibraryType)) {
p.OrganizeConfig = nil
}
}
func fill(c *NamingConfig, d NamingConfig) {
c.ArtistFolder = strings.TrimSpace(c.ArtistFolder)
if c.Folders == nil {
c.Folders = []FolderNaming{}
}
for i := range c.Folders {
f := &c.Folders[i]
f.Folder = strings.Trim(strings.TrimSpace(f.Folder), "/")
f.ArtistFolder = strings.TrimSpace(f.ArtistFolder)
if strings.TrimSpace(f.MultiDiscFormat) == "" {
f.MultiDiscFormat = "{Medium Format} {medium:00}/" + f.TrackFormat
}
}
if strings.TrimSpace(c.AlbumFolder) == "" {
c.AlbumFolder = d.AlbumFolder
}
if strings.TrimSpace(c.TrackFormat) == "" {
c.TrackFormat = d.TrackFormat
}
if strings.TrimSpace(c.MultiDiscFormat) == "" {
c.MultiDiscFormat = d.MultiDiscFormat
}
if c.Colon == "" {
c.Colon = d.Colon
}
if c.Illegal == "" {
c.Illegal = d.Illegal
}
}
// ---- tokens ----
var (
tokenPat = regexp.MustCompile(`\{([^{}]+)\}`)
numberTok = regexp.MustCompile(`(?i)^(track|medium):(0+)$`)
)
// TokenHelp documents the tokens (with example values) for the UI.
var TokenHelp = [][2]string{
{"{Artist Name}", "Avenged Sevenfold"}, {"{Artist NameThe}", "Beatles, The"}, {"{Artist CleanName}", "ACDC"},
{"{Artist NameFirstCharacter}", "A"}, {"{Album Title}", "Nightmare"}, {"{Album TitleThe}", "White Album, The"},
{"{Album CleanTitle}", "Nightmare"}, {"{Release Year}", "2010"}, {"{track:00}", "01"}, {"{track:0}", "1"},
{"{medium:00}", "01"}, {"{medium:0}", "1"}, {"{Medium Format}", "Disc"}, {"{Track Title}", "Nightmare"},
{"{Track CleanTitle}", "Nightmare"}, {"{Track ArtistName}", "Avenged Sevenfold"}, {"{Genre}", "Metal"},
}
var knownTokens = map[string]bool{
"artistname": true, "artistnamethe": true, "artistcleanname": true, "artistnamefirstcharacter": true,
"albumtitle": true, "albumtitlethe": true, "albumcleantitle": true, "releaseyear": true,
"tracktitle": true, "trackcleantitle": true, "trackartistname": true, "mediumformat": true, "genre": true,
}
// tokenKey normalizes "Artist Name", "Artist.Name" and "ARTIST_NAME".
func tokenKey(t string) string {
var b strings.Builder
for _, r := range strings.ToLower(t) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// styleOf returns the word separator and case a token is written in.
func styleOf(t string) (sep string, cased string) {
sep = " "
switch {
case strings.Contains(t, "."):
sep = "."
case strings.Contains(t, "_"):
sep = "_"
}
letters := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) {
return r
}
return -1
}, t)
switch {
case letters != "" && letters == strings.ToUpper(letters):
cased = "upper"
case letters != "" && letters == strings.ToLower(letters):
cased = "lower"
}
return
}
// ValidateNaming checks the formats.
func ValidateNaming(c NamingConfig) error {
if err := checkFormats("", c.formats()); err != nil {
return err
}
seen := map[string]bool{}
for _, f := range c.Folders {
label := fmt.Sprintf("Folder %q", f.Folder)
switch {
case f.Folder == "":
return errors.New("a folder rule has no folder")
case strings.Contains(f.Folder, "\\") || strings.HasPrefix(f.Folder, "/") || filepath.Clean(f.Folder) != f.Folder ||
f.Folder == "." || strings.HasPrefix(f.Folder, ".."):
return fmt.Errorf("%s must be a folder inside the library, like Video Games", label)
case strings.HasPrefix(filepath.Base(f.Folder), "."):
return fmt.Errorf("%s: hidden folders can't have naming rules", label)
case seen[strings.ToLower(f.Folder)]:
return fmt.Errorf("%s is listed twice", label)
}
seen[strings.ToLower(f.Folder)] = true
for _, o := range c.Folders {
if inFolder(f.Folder, o.Folder) {
return fmt.Errorf("%s is inside %q; folder rules can't be nested", label, o.Folder)
}
}
if err := checkFormats(label+": ", f.formats()); err != nil {
return err
}
}
switch c.Colon {
case "smart", "delete", "dash", "space_dash", "space_dash_space":
default:
return errors.New("unknown colon replacement")
}
if c.Illegal != "replace" && c.Illegal != "remove" {
return errors.New("illegal characters must be replace or remove")
}
return nil
}
// checkFormats validates one set of formats. The artist folder may be empty.
func checkFormats(prefix string, f formats) error {
if f.artist != "" {
if err := checkFormat(prefix+"Artist folder format", f.artist, true); err != nil {
return err
}
}
if err := checkFormat(prefix+"Album folder format", f.album, true); err != nil {
return err
}
for _, t := range []struct{ label, v string }{{"Track format", f.track}, {"Multi-disc track format", f.multiDisc}} {
if err := checkFormat(prefix+t.label, t.v, false); err != nil {
return err
}
if !strings.Contains(tokenKeys(t.v), "|tracktitle|") && !strings.Contains(tokenKeys(t.v), "|trackcleantitle|") {
return fmt.Errorf("%s%s must contain {Track Title}", prefix, t.label)
}
}
return nil
}
func checkFormat(label, f string, folder bool) error {
if strings.TrimSpace(f) == "" {
return fmt.Errorf("%s is empty", label)
}
if strings.Contains(f, "..") || strings.Contains(f, "\\") || strings.HasPrefix(f, "/") || strings.HasSuffix(f, "/") {
return fmt.Errorf("%s must be a relative name without .. or backslashes", label)
}
if folder && strings.Contains(f, "/") {
return fmt.Errorf("%s must be a single folder name (no /)", label)
}
for _, m := range tokenPat.FindAllStringSubmatch(f, -1) {
if numberTok.MatchString(m[1]) {
continue
}
if !knownTokens[tokenKey(m[1])] {
return fmt.Errorf("%s: unknown token {%s}", label, m[1])
}
}
if strings.Count(f, "{") != strings.Count(f, "}") {
return fmt.Errorf("%s has an unclosed {", label)
}
return nil
}
func tokenKeys(f string) string {
var b strings.Builder
b.WriteString("|")
for _, m := range tokenPat.FindAllStringSubmatch(f, -1) {
b.WriteString(tokenKey(m[1]) + "|")
}
return b.String()
}
// ---- value transforms ----
var articles = []string{"The ", "A ", "An "}
// nameThe moves a leading article to the end: "The Beatles" -> "Beatles, The".
func nameThe(s string) string {
for _, a := range articles {
if len(s) > len(a) && strings.EqualFold(s[:len(a)], a) {
return strings.TrimSpace(s[len(a):]) + ", " + strings.TrimSpace(s[:len(a)])
}
}
return s
}
// cleanName keeps letters, digits and spaces: "AC/DC" -> "ACDC".
func cleanName(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r), r == ' ':
return r
case r == '&':
return r
}
return -1
}, s)
return strings.TrimSpace(spaces.ReplaceAllString(s, " "))
}
func firstChar(s string) string {
for _, r := range nameThe(s) {
if unicode.IsLetter(r) {
return string(unicode.ToUpper(r))
}
if unicode.IsDigit(r) {
return "#"
}
}
return ""
}
func colonReplace(s, mode string) string {
switch mode {
case "delete":
return strings.ReplaceAll(s, ":", "")
case "dash":
return strings.ReplaceAll(s, ":", "-")
case "space_dash":
return strings.ReplaceAll(s, ":", " -")
case "space_dash_space":
return strings.ReplaceAll(strings.ReplaceAll(s, " :", ":"), ":", " - ")
}
// smart: "Title: Sub" -> "Title - Sub", "10:30" -> "10-30"
s = strings.ReplaceAll(s, ": ", " - ")
return strings.ReplaceAll(s, ":", "-")
}
var (
illegalReplace = strings.NewReplacer("/", "-", "\\", "-", "|", "-", "*", "-", "?", "", "\"", "'", "<", "", ">", "")
illegalRemove = strings.NewReplacer("/", "", "\\", "", "|", "", "*", "", "?", "", "\"", "", "<", "", ">", "")
)
// safeValue makes a tag value safe inside a file or folder name.
func safeValue(s string, c NamingConfig) string {
s = colonReplace(s, c.Colon)
if c.Illegal == "remove" {
s = illegalRemove.Replace(s)
} else {
s = illegalReplace.Replace(s)
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
var emptyBrackets = regexp.MustCompile(`\(\s*\)|\[\s*\]|\{\s*\}`)
// finishComponent tidies a rendered name: empty brackets and separators left
// by missing optional values are removed, and leading dots (hidden files)
// and trailing dots/spaces (invalid on Windows) are trimmed.
func finishComponent(s string) string {
s = emptyBrackets.ReplaceAllString(s, "")
s = spaces.ReplaceAllString(s, " ")
for _, dup := range []string{" - - ", " - - "} {
for strings.Contains(s, dup) {
s = strings.ReplaceAll(s, dup, " - ")
}
}
s = strings.Trim(s, " -._")
s = strings.TrimLeft(s, ".")
for len(s) > maxComponent {
_, size := utf8.DecodeLastRuneInString(s)
s = strings.TrimRight(s[:len(s)-size], ". ")
}
return s
}
// songValues are the raw values tokens draw from.
type songValues struct {
artist, trackArtist, album, title, year, genre string
track, disc, discTotal int
}
func valuesOf(f map[string]string) songValues {
v := songValues{
artist: strings.TrimSpace(f[tags.AlbumArtist]), trackArtist: strings.TrimSpace(f[tags.Artist]),
album: strings.TrimSpace(f[tags.Album]), title: strings.TrimSpace(f[tags.Title]), genre: strings.TrimSpace(f[tags.Genre]),
track: leadingNumber(f[tags.Track]), disc: leadingNumber(f[tags.Disc]), discTotal: leadingNumber(f[tags.DiscTotal]),
}
if v.artist == "" {
v.artist = v.trackArtist
}
if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 {
v.year = d[:4]
}
return v
}
func (v songValues) multiDisc() bool { return v.discTotal > 1 || v.disc > 1 }
// renderPath builds the organized relative path of a song at rel. Songs in
// a folder with its own naming stay in that folder. It returns why the song
// can't be renamed instead when a tag is missing or the new path would enter
// a folder that has its own naming.
func renderPath(c NamingConfig, rel string, f map[string]string) (string, string) {
ext := filepath.Ext(rel)
if rule, ok := c.folderOf(rel); ok {
to, why := renderFormats(c, rule.formats(), ext, f)
if why != "" {
return "", why
}
// Keep the folder as it is spelled on disk.
return filepath.Join(rel[:len(rule.Folder)], to), ""
}
to, why := renderFormats(c, c.formats(), ext, f)
if why != "" {
return "", why
}
for _, rule := range c.Folders {
if inFolder(to, rule.Folder) {
return "", "would move into " + rule.Folder + ", which has its own naming"
}
}
return to, ""
}
var artistTokens = []string{"|artistname|", "|artistnamethe|", "|artistcleanname|", "|artistnamefirstcharacter|"}
func renderFormats(c NamingConfig, fm formats, ext string, f map[string]string) (string, string) {
v := valuesOf(f)
track := fm.track
if v.multiDisc() {
track = fm.multiDisc
}
used := tokenKeys(fm.artist + fm.album + track)
for _, t := range artistTokens {
if v.artist == "" && strings.Contains(used, t) {
return "", "missing artist or album artist tag"
}
}
if v.album == "" {
return "", "missing album tag"
}
if v.title == "" {
return "", "missing title tag"
}
var parts []string
for _, f := range []string{fm.artist, fm.album, track} {
if f == "" {
continue // no artist folder
}
for _, part := range strings.Split(f, "/") {
out := tokenPat.ReplaceAllStringFunc(part, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) })
out = finishComponent(out)
if out == "" {
return "", "missing tags for " + part
}
parts = append(parts, out)
}
}
return filepath.Join(parts...) + strings.ToLower(ext), ""
}
func renderToken(t string, v songValues, c NamingConfig) string {
if m := numberTok.FindStringSubmatch(t); m != nil {
n := v.track
if strings.EqualFold(m[1], "medium") {
n = v.disc
if n == 0 {
n = 1
}
}
if n <= 0 {
return ""
}
width := len(m[2])
if strings.EqualFold(m[1], "track") && n >= 100 && width < 3 {
width = 3
}
return fmt.Sprintf("%0*d", width, n)
}
var val string
switch tokenKey(t) {
case "artistname":
val = v.artist
case "artistnamethe":
val = nameThe(v.artist)
case "artistcleanname":
val = cleanName(v.artist)
case "artistnamefirstcharacter":
val = firstChar(v.artist)
case "albumtitle":
val = v.album
case "albumtitlethe":
val = nameThe(v.album)
case "albumcleantitle":
val = cleanName(v.album)
case "releaseyear":
val = v.year
case "tracktitle":
val = v.title
case "trackcleantitle":
val = cleanName(v.title)
case "trackartistname":
val = v.trackArtist
case "mediumformat":
val = "Disc"
case "genre":
val = v.genre
}
val = safeValue(val, c)
sep, cased := styleOf(t)
if sep != " " {
val = strings.ReplaceAll(val, " ", sep)
}
switch cased {
case "upper":
val = strings.ToUpper(val)
case "lower":
val = strings.ToLower(val)
}
return val
}
// NamingExample is one sample rendering for the settings page.
type NamingExample struct {
Label string `json:"label"`
Path string `json:"path"`
}
// NamingExamples renders sample songs with a naming config, using exactly
// the code that renames real files.
func NamingExamples(c NamingConfig) []NamingExample {
samples := []struct {
label, ext string
f map[string]string
}{
{"Album track", ".flac", map[string]string{tags.Artist: "Avenged Sevenfold", tags.Album: "Nightmare", tags.Title: "Nightmare",
tags.Track: "1", tags.Date: "2010", tags.Genre: "Metal"}},
{"Multi-disc album, disc 1", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)",
tags.Title: "Back in the U.S.S.R.", tags.Track: "1", tags.Disc: "1", tags.DiscTotal: "2", tags.Date: "1968"}},
{"Multi-disc album, disc 2", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)",
tags.Title: "Birthday", tags.Track: "1", tags.Disc: "2", tags.DiscTotal: "2", tags.Date: "1968"}},
{"Colon and slash in names", ".mp3", map[string]string{tags.Artist: "AC/DC", tags.Album: "Who Made Who", tags.Title: "Hells Bells: Live",
tags.Track: "7", tags.Date: "1986"}},
}
out := []NamingExample{}
add := func(label, rel string, f map[string]string) {
p, why := renderPath(c, rel, f)
if why != "" {
p = "(skipped: " + why + ")"
}
out = append(out, NamingExample{Label: label, Path: p})
}
for _, s := range samples {
add(s.label, "x"+s.ext, s.f)
}
for _, rule := range c.Folders {
add("In "+rule.Folder, rule.Folder+"/x.flac", map[string]string{tags.Artist: "Koji Kondo", tags.Album: "Super Mario Galaxy",
tags.Title: "Gusty Garden Galaxy", tags.Track: "3", tags.Date: "2007"})
}
return out
}
-253
View File
@@ -1,253 +0,0 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func song(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
func TestRenderPath(t *testing.T) {
d := DefaultNaming()
with := func(mod func(*NamingConfig)) NamingConfig { c := DefaultNaming(); mod(&c); return c }
nightmare := song("artist", "Avenged Sevenfold", "album", "Nightmare", "title", "Nightmare", "track", "1", "date", "2010-07-27")
cases := []struct {
name string
c NamingConfig
f map[string]string
ext string
want string
}{
{"default", d, nightmare, ".FLAC", "Avenged Sevenfold/Nightmare/01 Nightmare.flac"},
{"multi-disc disc 1 also gets its folder", d, song("artist", "The Beatles", "album", "White Album", "title", "Back in the USSR", "track", "1", "disc", "1", "disc_total", "2"), ".flac",
"The Beatles/White Album/Disc 01/01 Back in the USSR.flac"},
{"multi-disc disc 2", d, song("artist", "The Beatles", "album", "White Album", "title", "Birthday", "track", "1", "disc", "2/2"), ".flac",
"The Beatles/White Album/Disc 02/01 Birthday.flac"},
{"album artist wins", d, song("artist", "Queen", "album_artist", "Various Artists", "album", "Hits", "title", "Bohemian Rhapsody", "track", "12"), ".mp3",
"Various Artists/Hits/12 Bohemian Rhapsody.mp3"},
{"year with empty brackets removed", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }),
song("artist", "A", "album", "B", "title", "S", "track", "3"), ".mp3", "A/B/03 S.mp3"},
{"year present", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }), nightmare, ".flac", "Avenged Sevenfold/Nightmare (2010)/01 Nightmare.flac"},
{"no track number", d, song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"separator left by missing number", with(func(c *NamingConfig) { c.TrackFormat = "{track:00} - {Track Title}" }), song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"three digit tracks", d, song("artist", "A", "album", "B", "title", "S", "track", "105"), ".mp3", "A/B/105 S.mp3"},
{"NameThe and first character", with(func(c *NamingConfig) {
c.ArtistFolder = "{Artist NameFirstCharacter}"
c.AlbumFolder = "{Artist NameThe}"
}),
song("artist", "The Beatles", "album", "X", "title", "S", "track", "1"), ".mp3", "B/Beatles, The/01 S.mp3"},
{"dotted and uppercase tokens", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist.Name}"; c.TrackFormat = "{track:0} {TRACK TITLE}" }), nightmare, ".flac",
"Avenged.Sevenfold/Nightmare/1 NIGHTMARE.flac"},
{"clean name", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist CleanName}" }), song("artist", "AC/DC", "album", "B", "title", "S", "track", "1"), ".mp3", "ACDC/B/01 S.mp3"},
{"illegal characters replaced, smart colon", d, song("artist", "AC/DC", "album", "Who Made Who?", "title", "Hells Bells: Live \"1986\"", "track", "7"), ".mp3",
"AC-DC/Who Made Who/07 Hells Bells - Live '1986'.mp3"},
{"illegal characters removed, colon deleted", with(func(c *NamingConfig) { c.Illegal = "remove"; c.Colon = "delete" }),
song("artist", "AC/DC", "album", "B", "title", "Hells Bells: Live", "track", "7"), ".mp3", "ACDC/B/07 Hells Bells Live.mp3"},
{"colon space-dash-space", with(func(c *NamingConfig) { c.Colon = "space_dash_space" }), song("artist", "A", "album", "B", "title", "Part:Two", "track", "1"), ".mp3", "A/B/01 Part - Two.mp3"},
{"no hidden names", d, song("artist", "...And Justice", "album", "B", "title", "S", "track", "1"), ".mp3", "And Justice/B/01 S.mp3"},
}
for _, c := range cases {
got, missing := renderPath(c.c, "in/x"+c.ext, c.f)
if got != c.want || missing != "" {
t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want)
}
}
for field, f := range map[string]map[string]string{
"artist or album artist": song("album", "B", "title", "S"),
"album": song("artist", "A", "title", "S"),
"title": song("artist", "A", "album", "B"),
} {
if _, why := renderPath(d, "x.mp3", f); why != "missing "+field+" tag" {
t.Errorf("want missing %q, got %q", field, why)
}
}
}
func TestNamingWithoutArtistFolder(t *testing.T) {
c := DefaultNaming()
c.ArtistFolder = ""
if err := ValidateNaming(c); err != nil {
t.Fatal(err)
}
// Soundtracks often have no artist tag; it isn't needed without the folder.
got, why := renderPath(c, "x.flac", song("album", "Super Mario Galaxy", "title", "Gusty Garden Galaxy", "track", "3"))
if got != "Super Mario Galaxy/03 Gusty Garden Galaxy.flac" || why != "" {
t.Fatalf("got %q %q", got, why)
}
got, _ = renderPath(c, "x.flac", song("album", "Halo 3", "title", "S", "track", "2", "disc", "2/2"))
if got != "Halo 3/Disc 02/02 S.flac" {
t.Fatalf("multi-disc: %q", got)
}
// Saving an empty artist folder keeps it empty; a missing one takes the default.
raw, _ := json.Marshal(c)
if got := NamingOf(store.Pair{OrganizeConfig: raw}); got.ArtistFolder != "" {
t.Fatalf("empty artist folder should stay empty: %+v", got)
}
p := store.Pair{OrganizeConfig: raw}
if err := normalizeOrganize(&p); err != nil || NamingOf(p).ArtistFolder != "" {
t.Fatalf("normalize: %v %s", err, p.OrganizeConfig)
}
}
func TestNamingFolderRules(t *testing.T) {
c := DefaultNaming()
c.Folders = []FolderNaming{SoundtrackFolder}
if err := ValidateNaming(c); err != nil {
t.Fatal(err)
}
vgm := song("artist", "Koji Kondo", "album", "Super Mario Galaxy", "title", "Gusty Garden Galaxy", "track", "3")
cases := []struct{ rel, want, why string }{
{"Video Games/mario/gusty.flac", "Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""},
{"video games/Mario/x.flac", "video games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""}, // spelled as on disk
{"Koji Kondo/x.flac", "Koji Kondo/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""},
{"Video Games Extra/x.flac", "Koji Kondo/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""},
}
for _, tc := range cases {
got, why := renderPath(c, tc.rel, vgm)
if got != tc.want || why != tc.why {
t.Errorf("%s: got %q %q, want %q", tc.rel, got, why, tc.want)
}
}
// A file outside the folder is never moved into it.
if _, why := renderPath(c, "x.flac", song("artist", "Video Games", "album", "B", "title", "S")); !strings.Contains(why, "own naming") {
t.Fatalf("should refuse to move into the rule folder: %q", why)
}
bad := []FolderNaming{
{Folder: "", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "../x", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: ".playlink-trash", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "Video Games/Nintendo", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "VIDEO GAMES", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "OST", AlbumFolder: "{Album Title}", TrackFormat: "{track:00}", MultiDiscFormat: "{Track Title}"},
}
for _, b := range bad {
cc := c
cc.Folders = []FolderNaming{SoundtrackFolder, b}
if ValidateNaming(cc) == nil {
t.Errorf("rule %+v should be rejected", b)
}
}
ex := NamingExamples(c)
if last := ex[len(ex)-1]; last.Label != "In Video Games" || last.Path != "Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac" {
t.Fatalf("folder example: %+v", last)
}
}
func TestValidateNaming(t *testing.T) {
bad := []func(*NamingConfig){
func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" },
func(c *NamingConfig) { c.TrackFormat = "{track:00}" },
func(c *NamingConfig) { c.TrackFormat = "{Bogus} {Track Title}" },
func(c *NamingConfig) { c.MultiDiscFormat = "../{Track Title}" },
func(c *NamingConfig) { c.AlbumFolder = "" },
func(c *NamingConfig) { c.Colon = "weird" },
func(c *NamingConfig) { c.AlbumFolder = "{Album Title" },
}
for i, mod := range bad {
c := DefaultNaming()
mod(&c)
if ValidateNaming(c) == nil {
t.Errorf("case %d should be rejected: %+v", i, c)
}
}
for _, p := range NamingPresets {
if err := ValidateNaming(p.Config); err != nil {
t.Errorf("preset %q invalid: %v", p.Name, err)
}
}
}
func TestNamingOfConvertsOldSettings(t *testing.T) {
c := NamingOf(store.Pair{OrganizeTemplate: "{artist}/{album} ({year})/{track} - {title}", OrganizeMultiDisc: "prefix", OrganizeDiscDigits: 1})
if c.ArtistFolder != "{Artist Name}" || c.AlbumFolder != "{Album Title} ({Release Year})" || c.TrackFormat != "{track:00} - {Track Title}" ||
c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" {
t.Fatalf("old settings not converted: %+v", c)
}
if got := NamingOf(store.Pair{}); !reflect.DeepEqual(got, DefaultNaming()) {
t.Fatalf("pairs without settings should use the defaults, got %+v", got)
}
got := NamingOf(store.Pair{OrganizeConfig: []byte(`{"track_format":"{track:0}. {Track Title}"}`)})
if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" {
t.Fatalf("stored settings should win and gaps take defaults, got %+v", got)
}
ex := NamingExamples(DefaultNaming())
if len(ex) != 4 || ex[1].Path != "The Beatles/The Beatles (White Album)/Disc 01/01 Back in the U.S.S.R.flac" {
t.Fatalf("examples: %+v", ex)
}
}
func TestRenameNowPreviewAndApply(t *testing.T) {
ev := tagEnv(t, false)
mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1")
mkTagged(t, filepath.Join(ev.slave, "in/b.mp3"), "title", "Two", "artist", "Band", "album", "LP", "track", "2")
if _, err := ev.e.StartOrganizePreview(ev.pair.ID, "slave"); err != nil {
t.Fatal(err)
}
ev.wait()
plan, items := ev.e.OrganizePreview(ev.pair.ID)
if plan == nil || plan.Moves != 2 || len(items) != 2 {
t.Fatalf("preview: %+v %+v", plan, items)
}
var first int
for _, it := range items {
if it.From == "in/a.mp3" {
first = it.ID
}
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, []int{first}); err != nil {
t.Fatal(err)
}
if j := ev.wait(); j.Results["renamed"] != 1 {
t.Fatalf("only the selected file should be renamed: %+v", j.Results)
}
if _, err := os.Stat(filepath.Join(ev.slave, "Band/LP/01 One.mp3")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ev.slave, "in/b.mp3")); err != nil {
t.Fatal("unselected files stay")
}
if p, _ := ev.e.OrganizePreview(ev.pair.ID); p != nil {
t.Fatal("the preview is stale after renaming and must be cleared")
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err == nil {
t.Fatal("applying without a fresh preview must be refused")
}
}
func TestOrganizeFolderRule(t *testing.T) {
ev := tagEnv(t, false)
c := DefaultNaming()
c.Folders = []FolderNaming{SoundtrackFolder}
ev.pair.OrganizeConfig, _ = json.Marshal(c)
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
mkTagged(t, filepath.Join(ev.slave, "Video Games/rip/g.flac"), "title", "Gusty Garden Galaxy", "album", "Super Mario Galaxy", "track", "3")
mkTagged(t, filepath.Join(ev.slave, "Video Games/Halo 3/01 Halo.flac"), "title", "Halo", "artist", "Martin O'Donnell", "album", "Halo 3", "track", "1")
mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1")
ev.e.StartOrganizePreview(ev.pair.ID, "slave")
ev.wait()
plan, items := ev.e.OrganizePreview(ev.pair.ID)
if plan == nil || plan.Moves != 2 || plan.Conforming != 1 {
t.Fatalf("preview: %+v %+v", plan, items)
}
ev.e.StartOrganizeApply(ev.pair.ID, nil)
ev.wait()
for _, rel := range []string{"Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", "Video Games/Halo 3/01 Halo.flac", "Band/LP/01 One.mp3"} {
if _, err := os.Stat(filepath.Join(ev.slave, rel)); err != nil {
t.Error(err)
}
}
}
+7 -62
View File
@@ -621,52 +621,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
if o.PurgedAt != nil {
return errors.New("the quarantined original was already purged")
}
switch o.Action {
case store.ActLink, store.ActPromote, store.ActBuild, store.ActRemove, store.ActRename:
default:
// History can hold operations of features that were removed (tag
// edits, file moves); undoing them as a link would be wrong.
return fmt.Errorf("%q operations can no longer be undone from playlink; their originals stay in the quarantine folder", o.Action)
}
l, err := e.loadPair(ctx, o.PairID)
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.ActMoveDir {
if err := undoMoveDir(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("folder move undone", "op", o.ID, "folder", o.RelNew)
_, _ = e.StartScan(o.PairID, "undo")
return nil
}
if o.Action == store.ActMove {
if err := undoMove(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("file move undone", "op", o.ID)
_, _ = e.StartScan(o.PairID, "undo")
return nil
}
if o.Action == store.ActTags {
if err := e.undoTags(ctx, l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("tag edit undone", "op", o.ID)
return nil
}
if o.Action == store.ActRename {
if err := undoRename(l, o); err != nil {
return err
@@ -890,26 +855,6 @@ func (e *Engine) Recover(ctx context.Context) {
return
}
for _, o := range ops {
if o.Action == store.ActMoveDir {
if l, err := e.loadPair(ctx, o.PairID); err == nil {
recoverMoveDir(l, o, e.log)
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
continue
}
if o.Action == store.ActMove {
if l, err := e.loadPair(ctx, o.PairID); err == nil {
recoverMove(l, o)
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
continue
}
if o.Action == store.ActTags {
msg := e.recoverTags(ctx, o)
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, msg)
e.log.Warn("recovered interrupted tag edit", "op", o.ID, "result", msg)
continue
}
if o.Action == store.ActRename {
if p, err := e.st.Pair(ctx, o.PairID); err == nil {
recoverRename(p, o, e.log)
-588
View File
@@ -1,588 +0,0 @@
package engine
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// The organizer renames audio files from their tags into one layout, by
// default Artist/Album/01 Title.ext. It only moves files within a library
// (same inode, so hardlinks survive), never rewrites them, and every move is
// an operation that can be undone.
// normalizeOrganize validates a pair's organize settings.
func normalizeOrganize(p *store.Pair) error {
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" {
c, err := parseNaming(p.OrganizeConfig)
if err != nil {
return fmt.Errorf("naming settings: %w", err)
}
if err := ValidateNaming(c); err != nil {
return err
}
p.OrganizeConfig, _ = json.Marshal(c)
} else {
p.OrganizeConfig = nil // derived from older settings or defaults (NamingOf)
}
switch p.AutoOrganize {
case "":
p.AutoOrganize = "off"
case "off", "slave", "master", "both":
default:
return errors.New("automatic organizing must be off, slave, master or both")
}
return nil
}
var spaces = regexp.MustCompile(`\s+`)
// maxComponent keeps names well below the 255-byte filesystem limit.
const maxComponent = 150
func leadingNumber(s string) int {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
n, _ := strconv.Atoi(s[:end])
return n
}
// ---- tag cache ----
type cachedTags struct {
size, mtime int64
fields map[string]string
err string
}
type tagCache struct {
mu sync.Mutex
m map[string]cachedTags
}
func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.Info) (map[string]string, error) {
c.mu.Lock()
if c.m == nil {
c.m = map[string]cachedTags{}
}
if v, ok := c.m[abs]; ok && v.size == in.Size && v.mtime == in.MtimeNs {
c.mu.Unlock()
if v.err != "" {
return nil, errors.New(v.err)
}
return v.fields, nil
}
c.mu.Unlock()
f, err := t.Read(ctx, abs)
v := cachedTags{size: in.Size, mtime: in.MtimeNs, fields: f.Fields}
if err != nil {
v.err = err.Error()
}
c.mu.Lock()
c.m[abs] = v
c.mu.Unlock()
return v.fields, err
}
// ---- planning ----
// organizeItem is one file the organizer looked at.
type organizeItem struct {
ID int `json:"id"`
Side string `json:"side"`
From string `json:"from"`
To string `json:"to,omitempty"`
Move bool `json:"move"`
Reason string `json:"reason,omitempty"` // why it is skipped
Sidecars []string `json:"sidecars,omitempty"` // e.g. lyrics files moving along
ino uint64
size, mtime int64
}
// organizePlan is what organizing would do right now.
type organizePlan struct {
Sides []string `json:"sides"`
CreatedAt int64 `json:"created_at"`
Scanned int `json:"scanned"`
Conforming int `json:"conforming"`
Moves int `json:"moves"`
Skips map[string]int `json:"skips"`
Items []organizeItem `json:"-"`
}
// sidecarExts move together with the song of the same name.
var sidecarExts = []string{".lrc"}
// folderArt follows an album folder whose songs all moved to one folder.
var folderArt = map[string]bool{"cover.jpg": true, "cover.png": true, "folder.jpg": true, "folder.png": true,
"front.jpg": true, "front.png": true, "albumart.jpg": true, "albumartsmall.jpg": true}
func sidesOf(s string) []string {
switch s {
case "both":
return []string{SideSlave, SideMaster}
case SideMaster, SideSlave:
return []string{s}
}
return nil
}
func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) {
plan := &organizePlan{Sides: sides, CreatedAt: time.Now().Unix(), Skips: map[string]int{}}
naming := NamingOf(l.pair)
id := 0
for _, side := range sides {
root, _ := sideRoot(l, side)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, root, files); err != nil {
return nil, err
}
j.phase("Reading tags ("+side+")", int64(len(files)))
claimed := map[string]string{} // lower(target) -> source
for _, rel := range sortedKeys(files) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
j.tick()
plan.Scanned++
in := files[rel]
item := organizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs}
if _, ok := tags.FormatOf(rel); !ok {
item.Reason = "file type without editable tags"
} else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil {
item.Reason = "tags could not be read"
} else if to, why := renderPath(naming, rel, fields); why != "" {
item.Reason = why
} else if to == rel {
plan.Conforming++
continue
} else {
item.To = to
key := strings.ToLower(to)
switch {
case claimed[key] != "":
item.Reason = "another file gets the same name (" + claimed[key] + ")"
case targetTaken(root, rel, to):
item.Reason = "a different file already has that name"
default:
claimed[key] = rel
item.Move = true
item.Sidecars = sidecarsOf(root, rel)
}
}
id++
item.ID = id
if item.Move {
plan.Moves++
} else {
plan.Skips[item.Reason]++
}
plan.Items = append(plan.Items, item)
}
}
return plan, nil
}
// targetTaken reports whether moving rel to to would overwrite another file.
// A case-only rename onto itself is fine.
func targetTaken(root fsops.Root, rel, to string) bool {
abs, err := root.Resolve(to)
if err != nil {
return true
}
ti, err := os.Lstat(abs)
if err != nil {
return !errors.Is(err, fs.ErrNotExist)
}
if strings.EqualFold(rel, to) {
si, err := os.Lstat(filepath.Join(root.Path, rel))
return err != nil || !os.SameFile(si, ti)
}
return true
}
func sidecarsOf(root fsops.Root, rel string) []string {
var out []string
stem := strings.TrimSuffix(rel, filepath.Ext(rel))
for _, ext := range sidecarExts {
if fi, err := os.Lstat(filepath.Join(root.Path, stem+ext)); err == nil && fi.Mode().IsRegular() {
out = append(out, stem+ext)
}
}
return out
}
type moveDetail struct {
Side string `json:"side"`
Sidecars [][2]string `json:"sidecars,omitempty"` // [from, to]
Art bool `json:"art,omitempty"` // a folder art file following its album
}
func (e *Engine) applyOrganize(ctx context.Context, j *Job, l *libs, items []organizeItem, trigger string) error {
j.phase("Renaming files", int64(len(items)))
// Remember where each source folder's songs went, to move cover art along.
type dirMove struct {
targets map[string]bool
side string
}
dirs := map[string]*dirMove{}
for _, it := range items {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.moveOne(ctx, l, it, trigger); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
j.count("renamed")
key := it.Side + "\x00" + filepath.Dir(it.From)
if dirs[key] == nil {
dirs[key] = &dirMove{targets: map[string]bool{}, side: it.Side}
}
dirs[key].targets[filepath.Dir(it.To)] = true
}
j.tick()
}
if e.st.DryRun(ctx) {
return nil
}
for key, dm := range dirs {
from := strings.SplitN(key, "\x00", 2)[1]
root, _ := sideRoot(l, dm.side)
if len(dm.targets) == 1 {
for to := range dm.targets {
e.moveFolderArt(ctx, l, root, dm.side, from, to, trigger)
}
}
if abs, err := root.Resolve(filepath.Join(from, "x")); err == nil {
pruneEmpty(filepath.Dir(abs), root.Path)
}
}
return nil
}
// moveFolderArt moves cover art out of a folder that has no songs left.
func (e *Engine) moveFolderArt(ctx context.Context, l *libs, root fsops.Root, side, from, to, trigger string) {
if from == to {
return
}
src := filepath.Join(root.Path, from)
ents, err := os.ReadDir(src)
if err != nil {
return
}
for _, en := range ents {
if !en.IsDir() && l.hasExt(en.Name()) {
return // songs remain: the art still belongs here
}
}
for _, en := range ents {
if !en.Type().IsRegular() || !folderArt[strings.ToLower(en.Name())] {
continue
}
item := organizeItem{Side: side, From: filepath.Join(from, en.Name()), To: filepath.Join(to, en.Name())}
e.mu.Lock()
dst, err := root.Resolve(item.To)
if err == nil {
if _, err := os.Lstat(dst); errors.Is(err, fs.ErrNotExist) {
o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: item.To, Trigger: trigger}
setSidePath(&o, side, item.From)
o.Detail, _ = json.Marshal(moveDetail{Side: side, Art: true})
if e.begin(ctx, &o) == nil && !o.DryRun {
_ = e.finish(ctx, &o, moveFile(filepath.Join(root.Path, item.From), dst))
}
}
}
e.mu.Unlock()
}
}
func setSidePath(o *store.Operation, side, rel string) {
if side == SideMaster {
o.RelMaster = rel
} else {
o.RelSlave = rel
}
}
func (e *Engine) moveOne(ctx context.Context, l *libs, it organizeItem, trigger string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, err := sideRoot(l, it.Side)
if err != nil {
return err
}
src, err := root.Resolve(it.From)
if err != nil {
return err
}
dst, err := root.Resolve(it.To)
if err != nil {
return err
}
in, err := fsops.RegularFile(src)
if err != nil {
return err
}
if in.Ino != it.ino || !in.Unchanged(it.size, it.mtime) {
return fmt.Errorf("changed since the preview: %w", fsops.ErrChanged)
}
if targetTaken(root, it.From, it.To) {
return fmt.Errorf("%s: %w", it.To, fsops.ErrExists)
}
d := moveDetail{Side: it.Side}
for _, sc := range it.Sidecars {
to := strings.TrimSuffix(it.To, filepath.Ext(it.To)) + filepath.Ext(sc)
if !targetTaken(root, sc, to) {
d.Sidecars = append(d.Sidecars, [2]string{sc, to})
}
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: it.To, Trigger: trigger, Bytes: 0}
setSidePath(&o, it.Side, it.From)
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
err = func() error {
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := moveFile(src, dst); err != nil {
pruneEmpty(filepath.Dir(dst), root.Path)
return err
}
for _, sc := range d.Sidecars {
_ = moveFile(filepath.Join(root.Path, sc[0]), filepath.Join(root.Path, sc[1]))
}
return nil
}()
return e.finish(ctx, &o, err)
}
// moveFile renames a file within a library. Case-only renames go through a
// temporary name so they also work on case-insensitive filesystems.
func moveFile(src, dst string) error {
if strings.EqualFold(src, dst) && src != dst {
var b [6]byte
_, _ = rand.Read(b[:])
tmp := filepath.Join(filepath.Dir(src), fsops.TmpPrefix+"mv-"+hex.EncodeToString(b[:]))
if err := os.Rename(src, tmp); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Rename(tmp, src)
return err
}
} else if err := os.Rename(src, dst); err != nil {
return err
}
if err := fsops.SyncDir(filepath.Dir(dst)); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(src))
}
// undoMove moves a file (and its sidecars) back.
func undoMove(l *libs, o store.Operation) error {
var d moveDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
cur, err := root.Resolve(o.RelNew)
if err != nil {
return err
}
old, err := root.Resolve(from)
if err != nil {
return err
}
if _, err := fsops.RegularFile(cur); err != nil {
return fmt.Errorf("%s is no longer there", o.RelNew)
}
if targetTaken(root, o.RelNew, from) {
return fmt.Errorf("%s: %w", from, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
return err
}
if err := moveFile(cur, old); err != nil {
return err
}
for _, sc := range d.Sidecars {
_ = moveFile(filepath.Join(root.Path, sc[1]), filepath.Join(root.Path, sc[0]))
}
pruneEmpty(filepath.Dir(cur), root.Path)
return nil
}
// recoverMove finishes a case-only file rename cut short by a crash.
func recoverMove(l *libs, o store.Operation) {
var d moveDetail
if json.Unmarshal(o.Detail, &d) != nil {
return
}
root, err := sideRoot(l, d.Side)
if err != nil {
return
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
if _, err := os.Lstat(filepath.Join(root.Path, from)); err == nil {
return // never moved
}
dst := filepath.Join(root.Path, o.RelNew)
if _, err := os.Lstat(dst); err == nil {
return // completed
}
dir := filepath.Dir(filepath.Join(root.Path, from))
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.Type().IsRegular() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"mv-") {
_ = os.Rename(filepath.Join(dir, en.Name()), dst)
return
}
}
}
// autoOrganize runs during a scan when the pair asks for it: every clean
// rename is applied, anything doubtful is left for the preview.
func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
sides := sidesOf(l.pair.AutoOrganize)
if sides == nil || !e.TagEditing() {
return nil
}
plan, err := e.planOrganize(ctx, j, l, sides)
if err != nil {
return err
}
var moves []organizeItem
for _, it := range plan.Items {
if it.Move {
moves = append(moves, it)
}
}
if len(moves) == 0 {
return nil
}
if err := e.BeforeAction(ctx, "auto-organize"); err != nil {
return err
}
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
}
// ---- rename now (preview, then apply) ----
// StartOrganizePreview checks which files would be renamed.
func (e *Engine) StartOrganizePreview(pairID int64, side string) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
sides := sidesOf(side)
if sides == nil {
return nil, fmt.Errorf("%w: side must be slave, master or both", ErrInvalidBulk)
}
return e.startJob(pairID, "organize-preview", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
plan, err := e.planOrganize(ctx, j, l, sides)
if err != nil {
return err
}
e.plansMu.Lock()
if e.plans == nil {
e.plans = map[int64]*organizePlan{}
}
e.plans[pairID] = plan
e.plansMu.Unlock()
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d to rename, %d already organized, %d skipped", plan.Moves, plan.Conforming, len(plan.Items)-plan.Moves)
})
return nil
})
}
// OrganizePreview returns the latest preview and its items.
func (e *Engine) OrganizePreview(pairID int64) (*organizePlan, []organizeItem) {
e.plansMu.Lock()
defer e.plansMu.Unlock()
p := e.plans[pairID]
if p == nil {
return nil, nil
}
return p, append([]organizeItem{}, p.Items...)
}
// StartOrganizeApply renames the selected files of the latest preview (all
// when ids is empty), then rescans. Each file is checked again first.
func (e *Engine) StartOrganizeApply(pairID int64, ids []int) (*Job, error) {
e.plansMu.Lock()
plan := e.plans[pairID]
e.plansMu.Unlock()
if plan == nil {
return nil, fmt.Errorf("%w: check the files first", ErrInvalidBulk)
}
want := map[int]bool{}
for _, id := range ids {
want[id] = true
}
var items []organizeItem
for _, it := range plan.Items {
if it.Move && (len(want) == 0 || want[it.ID]) {
items = append(items, it)
}
}
if len(items) == 0 {
return nil, fmt.Errorf("%w: nothing to rename", ErrInvalidBulk)
}
return e.startJob(pairID, "organize", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
if err := e.applyOrganize(ctx, j, l, items, "organize"); err != nil {
return err
}
e.plansMu.Lock()
delete(e.plans, pairID) // paths changed: the preview is stale
e.plansMu.Unlock()
if e.st.DryRun(ctx) {
j.update(func(j *Job) { j.Message = "Dry run: renames recorded as planned in Activity" })
return nil
}
return e.scan(ctx, j, pairID, "organize")
})
}
-167
View File
@@ -1,167 +0,0 @@
package engine
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func mkTagged(t *testing.T, path string, kv ...string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
args := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1"}
for i := 0; i < len(kv); i += 2 {
args = append(args, "-metadata", kv[i]+"="+kv[i+1])
}
if out, err := exec.Command("ffmpeg", append(args, path)...).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
// plan and apply run the same code as automatic organizing.
func (ev *env) plan(t *testing.T, sides ...string) *organizePlan {
t.Helper()
l, err := ev.e.loadPair(ctx, ev.pair.ID)
if err != nil {
t.Fatal(err)
}
p, err := ev.e.planOrganize(ctx, &Job{e: ev.e, Results: map[string]int{}}, l, sides)
if err != nil {
t.Fatal(err)
}
return p
}
func (ev *env) apply(t *testing.T, p *organizePlan) *Job {
t.Helper()
l, _ := ev.e.loadPair(ctx, ev.pair.ID)
j := &Job{e: ev.e, Results: map[string]int{}}
var moves []organizeItem
for _, it := range p.Items {
if it.Move {
moves = append(moves, it)
}
}
if err := ev.e.applyOrganize(ctx, j, l, moves, "test"); err != nil {
t.Fatal(err)
}
return j
}
func TestOrganizeSlaveLibrary(t *testing.T) {
ev := tagEnv(t, false)
s := ev.slave
// The user's example: a song named "Name - Artist.mp3".
mkTagged(t, filepath.Join(s, "Random/Hail to the King - Avenged Sevenfold.mp3"),
"title", "Hail to the King", "artist", "Avenged Sevenfold", "album", "Hail to the King", "track", "2/10")
os.WriteFile(filepath.Join(s, "Random/Hail to the King - Avenged Sevenfold.lrc"), []byte("[00:01]lyrics"), 0o644)
os.WriteFile(filepath.Join(s, "Random/cover.jpg"), []byte("jpg"), 0o644)
mkTagged(t, filepath.Join(s, "Avenged Sevenfold/Nightmare/01 Nightmare.mp3"), // already organized
"title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
mkTagged(t, filepath.Join(s, "loose/untagged.mp3"), "title", "No Album")
mkTagged(t, filepath.Join(s, "dupes/a.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1")
mkTagged(t, filepath.Join(s, "dupes/b.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1")
plan := ev.plan(t, SideSlave)
if plan.Conforming != 1 || plan.Moves != 2 {
t.Fatalf("want 1 conforming and 2 moves, got %+v", plan)
}
if plan.Skips["missing artist or album artist tag"] != 1 || len(plan.Skips) != 2 {
t.Fatalf("unexpected skips %+v", plan.Skips)
}
j := ev.apply(t, plan)
if j.Results["renamed"] != 2 {
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
}
for _, rel := range []string{"Avenged Sevenfold/Hail to the King/02 Hail to the King.mp3", "Avenged Sevenfold/Hail to the King/02 Hail to the King.lrc",
"Avenged Sevenfold/Hail to the King/cover.jpg", "X/Y/01 Same.mp3", "dupes/b.mp3"} {
if _, err := os.Stat(filepath.Join(s, rel)); err != nil {
t.Errorf("expected %s: %v", rel, err)
}
}
if _, err := os.Stat(filepath.Join(s, "Random")); !errors.Is(err, os.ErrNotExist) {
t.Error("the emptied folder should be removed")
}
// Undo the song move: it goes back, lyrics too.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActMove && o.RelSlave == "Random/Hail to the King - Avenged Sevenfold.mp3" {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
ev.wait()
}
}
for _, rel := range []string{"Random/Hail to the King - Avenged Sevenfold.mp3", "Random/Hail to the King - Avenged Sevenfold.lrc"} {
if _, err := os.Stat(filepath.Join(s, rel)); err != nil {
t.Errorf("undo should restore %s: %v", rel, err)
}
}
}
// Moving a hardlinked file keeps the link, and the scan still pairs it.
func TestOrganizeKeepsHardlinks(t *testing.T) {
ev := tagEnv(t, false)
m := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare/01 Nightmare.flac")
mkTagged(t, m, "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
s := filepath.Join(ev.slave, "Chris Picks/Nightmare.flac")
os.MkdirAll(filepath.Dir(s), 0o755)
if err := os.Link(m, s); err != nil {
t.Fatal(err)
}
ev.scan()
if got := ev.match("Chris Picks/Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindInode {
t.Fatalf("a hardlink at another path should be recognised, got %s/%s", got.Status, got.Kind)
}
ev.apply(t, ev.plan(t, SideSlave))
ev.scan()
moved := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.flac")
if !sameInode(t, m, moved) {
t.Fatal("the moved slave file must still be the master's file")
}
if got := ev.match("Avenged Sevenfold/Nightmare/01 Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindPath {
t.Fatalf("after organizing, the paths agree again: got %s/%s", got.Status, got.Kind)
}
}
func TestOrganizeDryRunAndChangedFile(t *testing.T) {
ev := tagEnv(t, true)
p := filepath.Join(ev.slave, "x/song.mp3")
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1")
if j := ev.apply(t, ev.plan(t, SideSlave)); j.Results["renamed"] != 1 {
t.Fatalf("dry run should plan the rename: %+v", j.Results)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("dry run must not move files")
}
ev.st.SetSetting(ctx, "dry_run", "0")
plan := ev.plan(t, SideSlave)
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1", "genre", "changed")
if j := ev.apply(t, plan); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 {
t.Fatalf("a file changed after planning must be skipped: %+v", j.Results)
}
}
func TestAutoOrganizeDuringScan(t *testing.T) {
ev := tagEnv(t, false)
ev.pair.AutoOrganize = "slave"
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
mkTagged(t, filepath.Join(ev.slave, "incoming/Nightmare - Avenged Sevenfold.mp3"),
"title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
ev.scan()
rel := "Avenged Sevenfold/Nightmare/01 Nightmare.mp3"
if _, err := os.Stat(filepath.Join(ev.slave, rel)); err != nil {
t.Fatalf("the scan should organize the file: %v", err)
}
if got := ev.match(rel); got.RelSlave != rel {
t.Fatal("the scan should see the organized path")
}
}
-19
View File
@@ -287,11 +287,6 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), nil)
return err
}
if l.pair.AutoOrganize != "" && l.pair.AutoOrganize != "off" {
if err := e.autoOrganize(ctx, j, l); err != nil {
e.log.Warn("auto-organize failed", "pair", l.pair.Name, "err", err)
}
}
warns := layoutWarnings(l)
j.phase("Indexing master library", 0)
@@ -309,15 +304,6 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
return err
}
ix := buildIndex(masterFiles, l.pair)
// The same file (hardlink) can sit at another path, e.g. after one library
// was organized; it is recognised as linked rather than unmatched.
byInode := map[[2]uint64]string{}
for _, rel := range sortedKeys(masterFiles) {
in := masterFiles[rel]
if _, dup := byInode[[2]uint64{in.Dev, in.Ino}]; !dup {
byInode[[2]uint64{in.Dev, in.Ino}] = rel
}
}
if ix.approx != nil {
ix.verify = e.artistVerifier(ctx, l)
}
@@ -336,11 +322,6 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
si := slaveFiles[rel]
m := store.Match{PairID: pairID, RelSlave: rel}
m.RelMaster, m.Kind, m.Reason = ix.find(rel)
if m.Kind != store.KindPath {
if mrel, ok := byInode[[2]uint64{si.Dev, si.Ino}]; ok {
m.RelMaster, m.Kind, m.Reason = mrel, store.KindInode, ""
}
}
var pm *store.Match
if p, ok := prev[rel]; ok {
pm = &p
-585
View File
@@ -1,585 +0,0 @@
package engine
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// Tag editing works on either library. A file used by only one library is
// rewritten to a new file that atomically replaces it (the old file goes to
// quarantine). A file hardlinked elsewhere (typically shared by master and
// slave) is updated in place so the link survives and every linked path sees
// the new tags; a verified backup copy goes to quarantine first. Choosing
// "separate" instead gives only this path its own edited copy.
const (
SideMaster = "master"
SideSlave = "slave"
tagModeReplace = "replace"
tagModeInPlace = "inplace"
maxTagFiles = 500
maxTagValue = 2000
)
var ErrNoTagTools = errors.New("tag editing needs ffmpeg and ffprobe, which were not found")
// SetTagTools enables tag editing.
func (e *Engine) SetTagTools(t tags.Tools) { e.tagTools = t }
func (e *Engine) TagEditing() bool { return e.tagTools.FFmpeg != "" && e.tagTools.FFprobe != "" }
func sideRoot(l *libs, side string) (fsops.Root, error) {
switch side {
case SideMaster:
return l.master, nil
case SideSlave:
return l.slave, nil
}
return fsops.Root{}, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// FileEntry is one row of the library browser.
type FileEntry struct {
Name string `json:"name"`
Rel string `json:"rel"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
Links int `json:"links,omitempty"` // >1: the file is shared (hardlinked)
Editable bool `json:"editable,omitempty"`
}
// ListFiles lists a folder of either library: subfolders and audio files.
func (e *Engine) ListFiles(ctx context.Context, pairID int64, side, rel string) ([]FileEntry, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
dir := root.Path
if rel != "" {
if dir, err = root.Resolve(rel); err != nil {
return nil, err
}
}
ents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := []FileEntry{}
for _, en := range ents {
name := en.Name()
abs := filepath.Join(dir, name)
if strings.HasPrefix(name, ".") || en.Type()&fs.ModeSymlink != 0 || abs == l.quarantine {
continue
}
child := name
if rel != "" {
child = filepath.Join(rel, name)
}
if en.IsDir() {
out = append(out, FileEntry{Name: name, Rel: child, Dir: true})
continue
}
if !l.hasExt(name) {
continue
}
in, err := fsops.Lstat(abs)
if err != nil || !in.Mode.IsRegular() {
continue
}
_, editable := tags.FormatOf(name)
out = append(out, FileEntry{Name: name, Rel: child, Size: in.Size, Links: int(in.Nlink), Editable: editable})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
// TagFile is the editable view of one file's tags.
type TagFile struct {
Rel string `json:"rel"`
Editable bool `json:"editable"`
Reason string `json:"reason,omitempty"`
Fields map[string]string `json:"fields"`
Other map[string]string `json:"other"`
Links int `json:"links"`
Size int64 `json:"size"`
}
func cleanRels(rels []string) ([]string, error) {
if len(rels) == 0 {
return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
}
if len(rels) > maxTagFiles {
return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles)
}
seen := map[string]bool{}
var out []string
for _, r := range rels {
c, err := fsops.CleanRel(r)
if err != nil {
return nil, err
}
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out, nil
}
// ReadTags reads the tags of files in one library.
func (e *Engine) ReadTags(ctx context.Context, pairID int64, side string, rels []string) ([]TagFile, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
rels, err := cleanRels(rels)
if err != nil {
return nil, err
}
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
out := make([]TagFile, 0, len(rels))
for _, rel := range rels {
tf := TagFile{Rel: rel, Fields: map[string]string{}, Other: map[string]string{}}
abs, err := root.Resolve(rel)
if err == nil {
var in fsops.Info
if in, err = fsops.RegularFile(abs); err == nil {
tf.Links, tf.Size = int(in.Nlink), in.Size
var f tags.File
if f, err = e.tagTools.Read(ctx, abs); err == nil {
tf.Editable, tf.Fields, tf.Other = true, f.Fields, f.Other
}
}
}
if err != nil {
tf.Reason = err.Error()
}
out = append(out, tf)
}
return out, nil
}
// TagEdit is one tag edit request.
type TagEdit struct {
Side string `json:"side"`
Paths []string `json:"paths"`
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 {
known := map[string]bool{}
for _, id := range tags.FieldIDs {
known[id] = true
}
for id, v := range set {
if !known[id] {
return fmt.Errorf("%w: unknown tag field %q", ErrInvalidBulk, id)
}
if len(v) > maxTagValue || !utf8.ValidString(v) || strings.ContainsRune(v, 0) {
return fmt.Errorf("%w: invalid value for %s", ErrInvalidBulk, id)
}
}
return nil
}
// StartTagEdit applies a tag edit to each selected file in a background job.
func (e *Engine) StartTagEdit(pairID int64, req TagEdit) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if req.Side != SideMaster && req.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// 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
}
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
}
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, 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
}
// 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)
abs, err := root.Resolve(rel)
if err != nil {
return err
}
info, err := fsops.RegularFile(abs)
if err != nil {
return err
}
f, err := e.tagTools.Read(ctx, abs)
if err != nil {
return err
}
changes := map[string]string{}
d := tagDetail{Side: side, Mode: tagModeReplace, Changes: map[string][2]string{}}
for id, v := range set {
if f.Fields[id] != v {
changes[id] = v
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
}
plan, err := f.Plan(changes)
if err != nil {
return err
}
if info.Nlink > 1 && !separate {
d.Mode = tagModeInPlace
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActTags, QuarantineSide: side, Trigger: "tags"}
if side == SideMaster {
o.RelMaster = rel
} else {
o.RelSlave = rel
}
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
j.count("planned")
return nil
}
var b [8]byte
_, _ = rand.Read(b[:])
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, cover); err != nil {
return err
}
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.
if now, err := fsops.RegularFile(abs); err != nil || !now.SameInode(info) || !now.Unchanged(info.Size, info.MtimeNs) {
return fmt.Errorf("%s %w", rel, fsops.ErrChanged)
}
q := quarantinePath(l, o.ID, side, rel)
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
if d.Mode == tagModeReplace {
// Keep the old file (as a hardlink, no extra space) and swap in
// the new one atomically, with the original's permissions.
if err := fsops.LinkNew(abs, info, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
if err := os.Chmod(tmp, info.Mode.Perm()); err != nil {
dropQuarantine(l, q, info)
e.clearQuarantine(ctx, o.ID)
return err
}
if err := os.Rename(tmp, abs); err != nil {
dropQuarantine(l, q, info)
e.clearQuarantine(ctx, o.ID)
return err
}
return fsops.SyncDir(filepath.Dir(abs))
}
// In place: a verified backup copy first, then overwrite the shared
// file's contents; restore from the backup if anything goes wrong.
if err := fsops.CopyNew(abs, q, info.Mode); err != nil {
return fmt.Errorf("backup: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
if err := fsops.OverwriteInPlace(abs, tmp); err != nil {
if rerr := fsops.OverwriteInPlace(abs, q); rerr != nil {
return fmt.Errorf("%v; restoring the original also failed (%v): the original is at %s", err, rerr, q)
}
_ = os.Remove(q)
e.clearQuarantine(ctx, o.ID)
return fmt.Errorf("could not write the new tags, the original was restored: %w", err)
}
return nil
}()
if err = e.finish(ctx, &o, err); err != nil {
return err
}
j.count("edited")
e.refreshTagged(ctx, l, side, rel)
return nil
}
func (e *Engine) setQuarantine(ctx context.Context, opID int64, q string) {
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, opID)
}
// refreshTagged updates the matches that involve an edited file.
func (e *Engine) refreshTagged(ctx context.Context, l *libs, side, rel string) {
if side == SideSlave {
e.refresh(ctx, l, rel)
return
}
ms, _ := e.st.MatchesByMaster(ctx, l.pair.ID, rel)
for _, m := range ms {
e.refresh(ctx, l, m.RelSlave, m)
}
}
func tagOpPath(l *libs, o store.Operation) (string, tagDetail, error) {
var d tagDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return "", d, fmt.Errorf("unreadable operation details: %w", err)
}
root, err := sideRoot(l, d.Side)
if err != nil {
return "", d, err
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
abs, err := root.Resolve(rel)
return abs, d, err
}
// undoTags puts the file back the way it was before the edit.
func (e *Engine) undoTags(ctx context.Context, l *libs, o store.Operation) error {
abs, d, err := tagOpPath(l, o)
if err != nil {
return err
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
cur, err := fsops.RegularFile(abs)
if err != nil {
return err
}
if d.Mode == tagModeReplace {
if cur.SameInode(qi) {
return errors.New("the file is already the original")
}
// The original inode comes back, rejoining any links it had.
if err := fsops.ReplaceWithLink(o.QuarantinePath, qi, abs); err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
} else {
if err := fsops.OverwriteInPlace(abs, o.QuarantinePath); err != nil {
return err
}
if err := os.Remove(o.QuarantinePath); err == nil {
pruneEmpty(filepath.Dir(o.QuarantinePath), l.quarantine)
}
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
e.refreshTagged(ctx, l, d.Side, rel)
return nil
}
// recoverTags handles a tag edit cut short by a crash. An in-place edit may
// have left a partially written file, so the backup is written back. A
// replace edit either happened (atomic rename) or did not; an unused
// quarantine link is dropped.
func (e *Engine) recoverTags(ctx context.Context, o store.Operation) string {
l, err := e.loadPair(ctx, o.PairID)
if err != nil || o.QuarantinePath == "" {
return "interrupted before any change"
}
abs, d, err := tagOpPath(l, o)
if err != nil {
return err.Error()
}
// The half-written new file is disposable.
if ents, err := os.ReadDir(filepath.Dir(abs)); err == nil {
for _, en := range ents {
if strings.HasPrefix(en.Name(), fsops.TmpPrefix) && en.Type().IsRegular() {
_ = os.Remove(filepath.Join(filepath.Dir(abs), en.Name()))
}
}
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return "interrupted; " + err.Error()
}
if d.Mode == tagModeInPlace {
if err := fsops.OverwriteInPlace(abs, o.QuarantinePath); err != nil {
return fmt.Sprintf("interrupted; could not restore the original (%v), it is kept at %s", err, o.QuarantinePath)
}
_ = os.Remove(o.QuarantinePath)
e.clearQuarantine(ctx, o.ID)
return "interrupted by shutdown; the original tags were restored"
}
if cur, err := fsops.Lstat(abs); err == nil && cur.SameInode(qi) {
dropQuarantine(l, o.QuarantinePath, qi) // rename never happened
e.clearQuarantine(ctx, o.ID)
return "interrupted by shutdown before the file was replaced; nothing changed"
}
return "interrupted by shutdown after the new tags were written"
}
-191
View File
@@ -1,191 +0,0 @@
package engine
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
func tagEnv(t *testing.T, dryRun bool) *env {
t.Helper()
ffmpeg, err1 := exec.LookPath("ffmpeg")
ffprobe, err2 := exec.LookPath("ffprobe")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
ev := setup(t, dryRun)
ev.e.SetTagTools(tags.Tools{FFmpeg: ffmpeg, FFprobe: ffprobe})
return ev
}
func mkSong(t *testing.T, path, title string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
if out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1",
"-metadata", "title="+title, "-metadata", "artist=Avenged Sevenfold", path).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
func (ev *env) title(t *testing.T, p string) string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields[tags.Title]
}
func (ev *env) editTags(t *testing.T, req TagEdit) *Job {
t.Helper()
if _, err := ev.e.StartTagEdit(ev.pair.ID, req); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func lastOp(t *testing.T, ev *env) store.Operation {
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 1})
if len(ops) == 0 {
t.Fatal("no operation recorded")
}
return ops[0]
}
// A file used by one library only is replaced by a new file; undo brings the
// original back.
func TestEditTagsReplace(t *testing.T) {
ev := tagEnv(t, false)
p := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.mp3")
mkSong(t, p, "Nightmar")
before, _ := os.ReadFile(p)
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3"}, Set: map[string]string{tags.Title: "Nightmare", tags.Genre: "Metal"}})
if j.Results["edited"] != 1 {
t.Fatalf("edit failed: %+v %v", j.Results, j.Skipped)
}
if ev.title(t, p) != "Nightmare" {
t.Fatal("title not written")
}
o := lastOp(t, ev)
var d tagDetail
json.Unmarshal(o.Detail, &d)
if d.Mode != tagModeReplace || d.Changes[tags.Title] != [2]string{"Nightmar", "Nightmare"} || o.QuarantinePath == "" {
t.Fatalf("unexpected op: %+v %+v", o, d)
}
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
if after, _ := os.ReadFile(p); string(after) != string(before) {
t.Fatal("undo should restore the original bytes")
}
}
// A file shared by master and slave is edited in place: the link survives
// and both libraries see the new tags. Undo restores the old tags for both.
func TestEditTagsSharedInPlace(t *testing.T) {
ev := tagEnv(t, false)
rel := "Avenged Sevenfold/Nightmare/01 Nightmare.flac"
m, s := filepath.Join(ev.master, rel), filepath.Join(ev.slave, rel)
mkSong(t, m, "Old")
os.MkdirAll(filepath.Dir(s), 0o755)
if err := os.Link(m, s); err != nil {
t.Fatal(err)
}
ev.scan()
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{rel}, Set: map[string]string{tags.Title: "Nightmare"}})
if j.Results["edited"] != 1 {
t.Fatalf("edit failed: %+v %v", j.Results, j.Skipped)
}
if !sameInode(t, m, s) || ev.title(t, m) != "Nightmare" {
t.Fatal("the shared file must stay linked and show the new tags in both libraries")
}
if got := ev.match(rel); got.Status != store.StatusLinked {
t.Fatalf("match should still be linked, got %s", got.Status)
}
o := lastOp(t, ev)
if b, err := os.ReadFile(o.QuarantinePath); err != nil || len(b) == 0 {
t.Fatal("a backup copy should be in quarantine")
}
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
if !sameInode(t, m, s) || ev.title(t, m) != "Old" {
t.Fatal("undo should restore the old tags and keep the link")
}
}
// "Only this library" gives the slave its own edited copy.
func TestEditTagsSeparateCopy(t *testing.T) {
ev := tagEnv(t, false)
rel := "Band/Album/01 Song.mp3"
m, s := filepath.Join(ev.master, rel), filepath.Join(ev.slave, rel)
mkSong(t, m, "Song")
os.MkdirAll(filepath.Dir(s), 0o755)
os.Link(m, s)
ev.scan()
ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{rel}, Set: map[string]string{tags.Title: "Song (My Tag)"}, Separate: true})
if sameInode(t, m, s) || ev.title(t, m) != "Song" || ev.title(t, s) != "Song (My Tag)" {
t.Fatal("only the slave copy should change, as a separate file")
}
if got := ev.match(rel); got.Status != store.StatusDifferent {
t.Fatalf("the pair is no longer linked, got %s", got.Status)
}
if err := ev.e.Undo(ctx, lastOp(t, ev).ID); err != nil {
t.Fatal(err)
}
if !sameInode(t, m, s) {
t.Fatal("undo should rejoin the original link")
}
}
func TestEditTagsDryRunAndUnchanged(t *testing.T) {
ev := tagEnv(t, true)
p := filepath.Join(ev.slave, "A/B/x.mp3")
mkSong(t, p, "Same")
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{tags.Title: "Changed"}})
if j.Results["planned"] != 1 || ev.title(t, p) != "Same" {
t.Fatalf("dry run must not write: %+v", j.Results)
}
j = ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{tags.Title: "Same"}})
if j.Results["unchanged"] != 1 {
t.Fatalf("identical values should be skipped: %+v", j.Results)
}
if _, err := ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{"../escape.mp3"}, Set: map[string]string{tags.Title: "x"}}); err == nil {
t.Fatal("paths outside the library must be refused")
}
if _, err := ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{"lyrics": "x"}}); err == nil {
t.Fatal("unknown fields must be refused")
}
}
// A crash during an in-place write leaves a half-written file; recovery
// writes the backup back.
func TestRecoverInterruptedInPlaceEdit(t *testing.T) {
ev := tagEnv(t, false)
rel := "A/B/song.flac"
p := filepath.Join(ev.slave, rel)
mkSong(t, p, "Original")
orig, _ := os.ReadFile(p)
q := filepath.Join(ev.pair.QuarantineDir, "99", "slave", rel)
os.MkdirAll(filepath.Dir(q), 0o755)
os.WriteFile(q, orig, 0o644)
os.WriteFile(p, orig[:len(orig)/3], 0o644) // truncated by the "crash"
d, _ := json.Marshal(tagDetail{Side: SideSlave, Mode: tagModeInPlace})
o := store.Operation{PairID: ev.pair.ID, Action: store.ActTags, RelSlave: rel, QuarantinePath: q, QuarantineSide: SideSlave, Status: store.OpRunning, Detail: d}
if err := ev.st.CreateOp(ctx, &o); err != nil {
t.Fatal(err)
}
ev.e.Recover(ctx)
if now, _ := os.ReadFile(p); string(now) != string(orig) {
t.Fatal("recovery should restore the original file")
}
got, _ := ev.st.Op(ctx, o.ID)
if got.Status != store.OpInterrupted {
t.Fatalf("op should be marked interrupted, got %s", got.Status)
}
}
-67
View File
@@ -403,70 +403,3 @@ func CopyReplace(src, dst string, mode fs.FileMode) error {
func MkdirAllLike(dir string) error {
return os.MkdirAll(dir, 0o775)
}
// CopyNew copies src to dst, which must not exist. The copy is fsynced and
// verified byte for byte. Used for backups that must not share an inode with
// the original (the original is about to be modified in place).
func CopyNew(src, dst string, mode fs.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm())
if err != nil {
return err
}
fail := func(e error) error { out.Close(); _ = os.Remove(dst); return e }
if _, err := io.Copy(out, in); err != nil {
return fail(err)
}
if err := out.Sync(); err != nil {
return fail(err)
}
if err := out.Close(); err != nil {
_ = os.Remove(dst)
return err
}
if same, err := SameContent(src, dst); err != nil || !same {
_ = os.Remove(dst)
if err == nil {
err = fmt.Errorf("%w: backup differs from the original", ErrVerifyFailed)
}
return err
}
return SyncDir(filepath.Dir(dst))
}
// OverwriteInPlace replaces the contents of dst with the bytes of src while
// keeping dst's inode, so every hardlink of dst sees the new contents. This is
// not atomic: callers keep a backup (CopyNew) to restore from on failure.
func OverwriteInPlace(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_TRUNC, 0)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if same, err := SameContent(src, dst); err != nil || !same {
if err == nil {
err = fmt.Errorf("%w: file does not match what was written", ErrVerifyFailed)
}
return err
}
return nil
}
-273
View File
@@ -1,273 +0,0 @@
// Package meta looks up album information (track lists, titles, dates) on
// MusicBrainz for the fixer's album tags step.
package meta
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cdnutter/playlink/internal/art"
)
// Client talks to MusicBrainz. Requests are spaced out to respect its limit
// of one request per second.
type Client struct {
HTTP *http.Client
UserAgent string
BaseURL string
Gap time.Duration
mu sync.Mutex
next time.Time
}
func New(version string) *Client {
return &Client{
HTTP: art.SafeHTTPClient(20 * time.Second),
UserAgent: "playlink/" + version + " (self-hosted music library tool)",
BaseURL: "https://musicbrainz.org",
Gap: 1100 * time.Millisecond,
}
}
// Release is one search result.
type Release struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
Type string `json:"type"` // Album, Single, EP, Soundtrack, ...
Disambiguation string `json:"disambiguation,omitempty"`
Formats string `json:"formats"` // "2×CD", "Digital Media"
TrackCount int `json:"track_count"`
Discs int `json:"discs"`
Score int `json:"score"`
}
// Track is one track of a release.
type Track struct {
Disc int `json:"disc"`
Position int `json:"position"`
DiscTracks int `json:"disc_tracks"`
Title string `json:"title"`
Artist string `json:"artist"`
LengthMs int `json:"length_ms"`
}
// ReleaseDetail is a release with its track list.
type ReleaseDetail struct {
Release
Tracks []Track `json:"tracks"`
}
var mbid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// ValidID reports whether s is a MusicBrainz id.
func ValidID(s string) bool { return mbid.MatchString(s) }
type artistCredit []struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
func (a artistCredit) String() string {
var b strings.Builder
for _, c := range a {
b.WriteString(c.Name + c.JoinPhrase)
}
return strings.TrimSpace(b.String())
}
type mbRelease struct {
ID string `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
Status string `json:"status"`
Date string `json:"date"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
TrackCount int `json:"track-count"`
ArtistCredit artistCredit `json:"artist-credit"`
ReleaseGroup struct {
PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
} `json:"release-group"`
Media []struct {
Position int `json:"position"`
Format string `json:"format"`
TrackCount int `json:"track-count"`
Tracks []struct {
Position int `json:"position"`
Title string `json:"title"`
Length int `json:"length"`
ArtistCredit artistCredit `json:"artist-credit"`
} `json:"tracks"`
} `json:"media"`
}
func (r mbRelease) summary() Release {
out := Release{ID: r.ID, Title: r.Title, Artist: r.ArtistCredit.String(), Date: r.Date, Country: r.Country,
Status: r.Status, Disambiguation: r.Disambiguation, TrackCount: r.TrackCount, Discs: len(r.Media), Score: r.Score}
out.Type = r.ReleaseGroup.PrimaryType
if len(r.ReleaseGroup.SecondaryTypes) > 0 {
out.Type = strings.TrimSpace(out.Type + " " + strings.Join(r.ReleaseGroup.SecondaryTypes, " "))
}
formats := map[string]int{}
var order []string
total := 0
for _, m := range r.Media {
f := m.Format
if f == "" {
f = "Unknown"
}
if formats[f] == 0 {
order = append(order, f)
}
formats[f]++
total += m.TrackCount
}
var parts []string
for _, f := range order {
if formats[f] > 1 {
parts = append(parts, fmt.Sprintf("%d×%s", formats[f], f))
} else {
parts = append(parts, f)
}
}
out.Formats = strings.Join(parts, " + ")
if out.TrackCount == 0 {
out.TrackCount = total
}
return out
}
func quote(s string) string {
// Lucene syntax: escape quotes and backslashes inside a phrase.
s = strings.ReplaceAll(s, `\`, `\\`)
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
// SearchReleases finds releases by album title and (optionally) artist.
func (c *Client) SearchReleases(ctx context.Context, artist, album string) ([]Release, error) {
if strings.TrimSpace(album) == "" {
return nil, errors.New("no album title to search for")
}
q := "release:" + quote(album)
if strings.TrimSpace(artist) != "" {
q += " AND artist:" + quote(artist)
}
var d struct {
Releases []mbRelease `json:"releases"`
}
u := c.BaseURL + "/ws/2/release/?" + url.Values{"query": {q}, "fmt": {"json"}, "limit": {"15"}}.Encode()
if err := c.get(ctx, u, &d); err != nil {
return nil, err
}
out := make([]Release, 0, len(d.Releases))
for _, r := range d.Releases {
out = append(out, r.summary())
}
return out, nil
}
// Release fetches a release with its track list.
func (c *Client) Release(ctx context.Context, id string) (*ReleaseDetail, error) {
if !ValidID(id) {
return nil, errors.New("not a MusicBrainz release id")
}
var r mbRelease
u := c.BaseURL + "/ws/2/release/" + id + "?" + url.Values{"inc": {"recordings artist-credits release-groups"}, "fmt": {"json"}}.Encode()
if err := c.get(ctx, u, &r); err != nil {
return nil, err
}
d := &ReleaseDetail{Release: r.summary()}
for _, m := range r.Media {
for _, t := range m.Tracks {
artist := t.ArtistCredit.String()
if artist == "" {
artist = d.Artist
}
d.Tracks = append(d.Tracks, Track{Disc: m.Position, Position: t.Position, DiscTracks: len(m.Tracks),
Title: t.Title, Artist: artist, LengthMs: t.Length})
}
}
return d, nil
}
// get fetches JSON, waiting for its turn and retrying once when MusicBrainz
// says it is busy.
func (c *Client) get(ctx context.Context, u string, v any) error {
for attempt := 0; ; attempt++ {
if err := c.wait(ctx); err != nil {
return err
}
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")
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20))
res.Body.Close()
if err != nil {
return err
}
if res.StatusCode == http.StatusServiceUnavailable && attempt < 2 {
c.backoff(res.Header.Get("Retry-After"))
continue
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("MusicBrainz: %s", res.Status)
}
return json.Unmarshal(body, v)
}
}
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
now := time.Now()
at := c.next
if at.Before(now) {
at = now
}
c.next = at.Add(c.Gap)
c.mu.Unlock()
if d := time.Until(at); d > 0 {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
return nil
}
func (c *Client) backoff(retryAfter string) {
d := 2 * time.Second
if n, err := strconv.Atoi(retryAfter); err == nil && n > 0 && n < 30 {
d = time.Duration(n) * time.Second
}
c.mu.Lock()
if t := time.Now().Add(d); t.After(c.next) {
c.next = t
}
c.mu.Unlock()
}
+57 -110
View File
@@ -137,46 +137,37 @@ func decodeWarnings(raw string) []Warning {
// ---- pairs ----
type Pair struct {
ID int64 `json:"id"`
Name string `json:"name"`
MasterRoot string `json:"master_root"`
SlaveRoot string `json:"slave_root"`
QuarantineDir string `json:"quarantine_dir"`
Extensions []string `json:"extensions"`
AutoLinkIdentical bool `json:"auto_link_identical"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
RetentionDays int `json:"retention_days"`
SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` // percent
OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
AutoOrganize string `json:"auto_organize"` // off, slave, master or both
OrganizeDiscDigits int `json:"-"` // superseded by OrganizeConfig; read to convert old settings
OrganizeConfig json.RawMessage `json:"-"` // naming settings (engine.NamingConfig)
LibraryType string `json:"library_type"` // music or soundtracks
FixerState json.RawMessage `json:"-"` // step-by-step fixer progress (engine.FixerState)
LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"`
CreatedAt int64 `json:"created_at"`
ID int64 `json:"id"`
Name string `json:"name"`
MasterRoot string `json:"master_root"`
SlaveRoot string `json:"slave_root"`
QuarantineDir string `json:"quarantine_dir"`
Extensions []string `json:"extensions"`
AutoLinkIdentical bool `json:"auto_link_identical"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
RetentionDays int `json:"retention_days"`
SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` // percent
LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"`
CreatedAt int64 `json:"created_at"`
}
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, organize_template,
organize_multidisc, auto_organize, organize_disc_digits, organize_config, last_scan_at, last_scan_error,
last_scan_warnings, created_at, library_type, fixer_state`
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, last_scan_at, last_scan_error,
last_scan_warnings, created_at`
type scanner interface{ Scan(...any) error }
func scanPair(r scanner) (Pair, error) {
var p Pair
var exts, warns, orgCfg, fixer string
var exts, warns string
var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &p.OrganizeTemplate,
&p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &orgCfg, &last, &p.LastScanError,
&warns, &p.CreatedAt, &p.LibraryType, &fixer)
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &last, &p.LastScanError,
&warns, &p.CreatedAt)
if err != nil {
return p, err
}
@@ -185,12 +176,6 @@ func scanPair(r scanner) (Pair, error) {
}
p.Extensions = strings.Split(exts, ",")
p.LastScanWarnings = decodeWarnings(warns)
if orgCfg != "" {
p.OrganizeConfig = json.RawMessage(orgCfg)
}
if fixer != "" {
p.FixerState = json.RawMessage(fixer)
}
return p, nil
}
@@ -223,12 +208,10 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
exts := strings.Join(p.Extensions, ",")
if p.ID == 0 {
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions,
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
organize_template, organize_multidisc, auto_organize, organize_disc_digits, organize_config, library_type, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.LibraryType, now())
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, now())
if err != nil {
return err
}
@@ -237,17 +220,9 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
}
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ?,
organize_disc_digits = ?, organize_config = ?, library_type = ? WHERE id = ?`,
approx_matching = ?, approx_threshold = ? WHERE id = ?`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, string(p.OrganizeConfig), p.LibraryType, p.ID)
return err
}
// SetFixerState stores the fixer's progress for a pair.
func (s *Store) SetFixerState(ctx context.Context, id int64, raw []byte) error {
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET fixer_state = ? WHERE id = ?`, string(raw), id)
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, p.ID)
return err
}
@@ -291,7 +266,6 @@ const (
KindFuzzy = "fuzzy" // same directory, normalized title matches
KindElsewhere = "elsewhere" // same top-level folder and title, different subfolder
KindApprox = "approximate" // opt-in: similar title, disc folders ignored
KindInode = "inode" // the same file (hardlink) at another path
KindNone = "none"
)
@@ -649,41 +623,36 @@ func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) {
// ---- operations ----
type Operation struct {
ID int64 `json:"id"`
PairID int64 `json:"pair_id"`
Action string `json:"action"`
RelMaster string `json:"rel_master"`
RelSlave string `json:"rel_slave"`
RelNew string `json:"rel_new"`
MatchKind string `json:"match_kind"`
MatchReason string `json:"match_reason"`
LinkPreexisted bool `json:"link_preexisted"`
Detail json.RawMessage `json:"detail,omitempty"`
QuarantinePath string `json:"quarantine_path"`
QuarantineSide string `json:"quarantine_side"`
TmpPath string `json:"-"`
Bytes int64 `json:"bytes"`
DryRun bool `json:"dry_run"`
Trigger string `json:"trigger"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt int64 `json:"created_at"`
FinishedAt *int64 `json:"finished_at"`
UndoneAt *int64 `json:"undone_at"`
PurgedAt *int64 `json:"purged_at"`
ID int64 `json:"id"`
PairID int64 `json:"pair_id"`
Action string `json:"action"`
RelMaster string `json:"rel_master"`
RelSlave string `json:"rel_slave"`
RelNew string `json:"rel_new"`
MatchKind string `json:"match_kind"`
MatchReason string `json:"match_reason"`
LinkPreexisted bool `json:"link_preexisted"`
QuarantinePath string `json:"quarantine_path"`
QuarantineSide string `json:"quarantine_side"`
TmpPath string `json:"-"`
Bytes int64 `json:"bytes"`
DryRun bool `json:"dry_run"`
Trigger string `json:"trigger"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt int64 `json:"created_at"`
FinishedAt *int64 `json:"finished_at"`
UndoneAt *int64 `json:"undone_at"`
PurgedAt *int64 `json:"purged_at"`
}
// Operation actions.
const (
ActLink = "link" // slave replaced by hardlink of master
ActPromote = "promote" // master replaced by slave's file, then linked
ActBuild = "build" // master file linked into the slave (fresh library)
ActRemove = "remove" // slave hardlink removed (master copy kept)
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
ActMoveDir = "move-dir" // folder moved by the fixer (structure or folder names)
ActLink = "link" // slave replaced by hardlink of master
ActPromote = "promote" // master replaced by slave's file, then linked
ActBuild = "build" // master file linked into the slave (fresh library)
ActRemove = "remove" // slave hardlink removed (master copy kept)
ActRename = "rename" // slave folder renamed to the master's spelling
)
// Operation statuses.
@@ -696,17 +665,13 @@ const (
)
const opCols = `id, pair_id, action, rel_master, rel_slave, rel_new, quarantine_path, quarantine_side, tmp_path, bytes,
dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted, detail`
dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted`
func scanOp(r scanner) (Operation, error) {
var o Operation
var fin, und, pur sql.NullInt64
var detail string
err := r.Scan(&o.ID, &o.PairID, &o.Action, &o.RelMaster, &o.RelSlave, &o.RelNew, &o.QuarantinePath, &o.QuarantineSide,
&o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted, &detail)
if detail != "" {
o.Detail = json.RawMessage(detail)
}
&o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted)
if fin.Valid {
o.FinishedAt = &fin.Int64
}
@@ -722,10 +687,10 @@ func scanOp(r scanner) (Operation, error) {
func (s *Store) CreateOp(ctx context.Context, o *Operation) error {
o.CreatedAt = now()
res, err := s.DB.ExecContext(ctx, `INSERT INTO operations (pair_id, action, rel_master, rel_slave, rel_new, quarantine_path,
quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
o.PairID, o.Action, o.RelMaster, o.RelSlave, o.RelNew, o.QuarantinePath, o.QuarantineSide, o.TmpPath, o.Bytes,
o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted, string(o.Detail))
o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted)
if err != nil {
return err
}
@@ -835,21 +800,3 @@ func (s *Store) ExpiredQuarantine(ctx context.Context, pairID, cutoff int64) ([]
}
return out, rows.Err()
}
// MatchesByMaster lists the matches of a pair that point at a master file.
func (s *Store) MatchesByMaster(ctx context.Context, pairID int64, relMaster string) ([]Match, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ? AND rel_master = ?`, pairID, relMaster)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Match
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
+4
View File
@@ -119,6 +119,10 @@ var migrations = []string{
// undo must then leave that target alone.
`ALTER TABLE operations ADD COLUMN link_preexisted INTEGER NOT NULL DEFAULT 0;`,
// 6-10 belong to features that were removed again (tag editing,
// organizing, the step-by-step fixer). They only add columns, which are
// unused now, and stay so databases that already ran them keep the same
// numbering and future migrations are never skipped.
// 6: operation-specific details as JSON (e.g. which tags an edit changed).
`ALTER TABLE operations ADD COLUMN detail TEXT NOT NULL DEFAULT '';`,
-378
View File
@@ -1,378 +0,0 @@
// Package tags reads and writes audio metadata with ffprobe/ffmpeg.
//
// Writing always produces a new file (the caller decides how it replaces the
// original) and is verified: the requested fields must read back as set, and
// the decoded audio must be bit-for-bit identical to the original's.
package tags
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// Field ids, in display order.
const (
Title = "title"
Artist = "artist"
Album = "album"
AlbumArtist = "album_artist"
Track = "track"
TrackTotal = "track_total"
Disc = "disc"
DiscTotal = "disc_total"
Date = "date"
Genre = "genre"
Composer = "composer"
Comment = "comment"
)
var FieldIDs = []string{Title, Artist, Album, AlbumArtist, Track, TrackTotal, Disc, DiscTotal, Date, Genre, Composer, Comment}
// aliases are the tag names (lower-cased, as ffprobe reports them) that hold
// each field. ffprobe already maps most format-specific names to these.
var aliases = map[string][]string{
Title: {"title"},
Artist: {"artist"},
Album: {"album"},
AlbumArtist: {"album_artist", "albumartist", "album artist"},
Track: {"track", "tracknumber"},
TrackTotal: {"tracktotal", "totaltracks"},
Disc: {"disc", "discnumber"},
DiscTotal: {"disctotal", "totaldiscs"},
Date: {"date", "year"},
Genre: {"genre"},
Composer: {"composer"},
Comment: {"comment", "description"},
}
// ignoredKeys are container/encoder details, not tags a user edits.
var ignoredKeys = map[string]bool{"encoder": true, "major_brand": true, "minor_version": true,
"compatible_brands": true, "handler_name": true, "language": true, "vendor_id": true}
// Format describes how a file stores its tags.
type Format struct {
Name string // mp3, flac, m4a, ogg, opus
Vorbis bool // Vorbis comments (flac, ogg, opus)
StreamTags bool // tags live on the audio stream (ogg, opus)
}
var formats = map[string]Format{
".mp3": {Name: "mp3"},
".flac": {Name: "flac", Vorbis: true},
".m4a": {Name: "m4a"},
".ogg": {Name: "ogg", Vorbis: true, StreamTags: true},
".oga": {Name: "ogg", Vorbis: true, StreamTags: true},
".opus": {Name: "opus", Vorbis: true, StreamTags: true},
}
// FormatOf returns the tag format for a file name, or false when editing
// that file type is not supported.
func FormatOf(path string) (Format, bool) {
f, ok := formats[strings.ToLower(filepath.Ext(path))]
return f, ok
}
// File is the metadata of one audio file.
type File struct {
Format Format `json:"-"`
Raw map[string]string `json:"-"` // tag name as reported -> value
Fields map[string]string `json:"fields"` // field id -> value (only present fields)
Other map[string]string `json:"other"` // tags that are not one of the fields
}
// Tools locates ffprobe and ffmpeg.
type Tools struct {
FFprobe string
FFmpeg string
}
func (t Tools) run(ctx context.Context, bin string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
var stderr bytes.Buffer
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if len(msg) > 300 {
msg = msg[:300] + "…"
}
return nil, fmt.Errorf("%s: %v: %s", filepath.Base(bin), err, msg)
}
return out, nil
}
type probeOut struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
// Read returns the tags of path.
func (t Tools) Read(ctx context.Context, path string) (File, error) {
fmtInfo, ok := FormatOf(path)
if !ok {
return File{}, fmt.Errorf("editing tags of %s files is not supported", strings.TrimPrefix(filepath.Ext(path), "."))
}
out, err := t.run(ctx, t.FFprobe, "-v", "error", "-show_entries", "format_tags:stream_tags:stream=codec_type,codec_name",
"-of", "json", "--", path)
if err != nil {
return File{}, err
}
var p probeOut
if err := json.Unmarshal(out, &p); err != nil {
return File{}, err
}
raw := p.Format.Tags
if fmtInfo.StreamTags {
raw = nil
for _, s := range p.Streams {
if s.CodecType == "audio" {
raw = s.Tags
break
}
}
}
return parse(fmtInfo, raw), nil
}
func parse(f Format, raw map[string]string) File {
file := File{Format: f, Raw: map[string]string{}, Fields: map[string]string{}, Other: map[string]string{}}
byAlias := map[string]string{}
for id, names := range aliases {
for _, n := range names {
byAlias[n] = id
}
}
for k, v := range raw {
file.Raw[k] = v
lk := strings.ToLower(k)
if ignoredKeys[lk] {
continue
}
id, ok := byAlias[lk]
if !ok {
file.Other[k] = v
continue
}
if _, dup := file.Fields[id]; !dup {
file.Fields[id] = v
}
}
// "3/12" in the track tag carries the total unless a total tag exists.
for _, pair := range [][2]string{{Track, TrackTotal}, {Disc, DiscTotal}} {
if n, total, ok := strings.Cut(file.Fields[pair[0]], "/"); ok {
file.Fields[pair[0]] = strings.TrimSpace(n)
if _, has := file.Fields[pair[1]]; !has && strings.TrimSpace(total) != "" {
file.Fields[pair[1]] = strings.TrimSpace(total)
}
}
}
return file
}
// keysFor returns the tag names in the file holding a field.
func (f File) keysFor(id string) []string {
var keys []string
for k := range f.Raw {
for _, a := range aliases[id] {
if strings.EqualFold(k, a) {
keys = append(keys, k)
}
}
}
sort.Strings(keys)
return keys
}
// canonical is the tag name for a field the file does not have yet. These are
// ffmpeg's own names, so ffmpeg writes the right frame/atom/comment for each
// format (e.g. album_artist -> TPE2 / aART / ALBUMARTIST).
func canonical(f Format, id string) string {
switch id {
case AlbumArtist, Track, Disc, Comment:
return id
case TrackTotal:
return "TRACKTOTAL"
case DiscTotal:
return "DISCTOTAL"
}
if f.Vorbis {
return strings.ToUpper(id)
}
return id
}
// Plan turns field changes into tag assignments for the file. A change to ""
// removes the field. The result maps tag name -> value ("" deletes).
func (f File) Plan(changes map[string]string) (map[string]string, error) {
set := map[string]string{}
assign := func(id, value string) {
keys := f.keysFor(id)
if len(keys) == 0 {
if value != "" {
set[canonical(f.Format, id)] = value
}
return
}
// The first spelling the file uses gets the value; duplicates under
// other spellings (DATE and YEAR) are removed so they cannot disagree.
set[keys[0]] = value
for _, k := range keys[1:] {
set[k] = ""
}
}
for id := range changes {
if _, ok := aliases[id]; !ok {
return nil, fmt.Errorf("unknown tag field %q", id)
}
}
for id, v := range changes {
if id == Track || id == TrackTotal || id == Disc || id == DiscTotal {
continue
}
assign(id, v)
}
// Number and total are written together, the way the file stores them:
// "3/12" in one tag, or a separate total tag (common in FLAC).
for _, pair := range [][2]string{{Track, TrackTotal}, {Disc, DiscTotal}} {
n, nChanged := changes[pair[0]]
total, tChanged := changes[pair[1]]
if !nChanged && !tChanged {
continue
}
if !nChanged {
n = f.Fields[pair[0]]
}
if !tChanged {
total = f.Fields[pair[1]]
}
separate := len(f.keysFor(pair[1])) > 0 || (f.Format.Vorbis && len(f.keysFor(pair[0])) == 0)
if separate {
assign(pair[0], n)
assign(pair[1], total)
continue
}
v := n
if n != "" && total != "" {
v = n + "/" + total
}
assign(pair[0], v)
// A stale separate total tag would contradict the combined value.
for _, k := range f.keysFor(pair[1]) {
set[k] = ""
}
}
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. 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")
target = "-metadata:s:a:0"
}
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
args = append(args, target, k+"="+set[k])
}
args = append(args, dst)
_, err := t.run(ctx, t.FFmpeg, args...)
return err
}
// ErrVerify means a written file did not pass verification.
var ErrVerify = errors.New("tag write verification failed")
// Verify checks that dst carries the requested field values and exactly the
// 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)
}
}
a, err := t.audioHash(ctx, src)
if err != nil {
return err
}
b, err := t.audioHash(ctx, dst)
if err != nil {
return err
}
if a != b {
return fmt.Errorf("%w: the audio changed", ErrVerify)
}
return nil
}
// audioHash is the MD5 of the file's decoded audio streams.
func (t Tools) audioHash(ctx context.Context, path string) (string, error) {
out, err := t.run(ctx, t.FFmpeg, "-v", "error", "-nostdin", "-i", path, "-map", "0:a", "-f", "md5", "-")
if err != nil {
return "", err
}
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", "-")
}
-177
View File
@@ -1,177 +0,0 @@
package tags
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var ctx = context.Background()
func tools(t *testing.T) Tools {
t.Helper()
ffprobe, err1 := exec.LookPath("ffprobe")
ffmpeg, err2 := exec.LookPath("ffmpeg")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
return Tools{FFprobe: ffprobe, FFmpeg: ffmpeg}
}
// makeFile creates a short tagged file; mp3, flac and m4a get cover art.
func makeFile(t *testing.T, tl Tools, dir, ext string) string {
t.Helper()
cover := filepath.Join(dir, "cover.png")
if _, err := os.Stat(cover); err != nil {
if out, err := exec.Command(tl.FFmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=red:s=32x32:d=1", "-frames:v", "1", cover).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
p := filepath.Join(dir, "song."+ext)
args := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1"}
if ext == "mp3" || ext == "flac" || ext == "m4a" {
args = append(args, "-i", cover, "-map", "0", "-map", "1", "-c:v", "png", "-disposition:v:0", "attached_pic")
}
if ext == "m4a" {
args = append(args, "-c:a", "aac")
}
for _, kv := range []string{"title=Old Title", "artist=Avenged Sevenfold", "album_artist=A7X", "track=3/12", "date=2010", "genre=Metal"} {
args = append(args, "-metadata", kv)
}
if out, err := exec.Command(tl.FFmpeg, append(args, p)...).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
return p
}
func streams(t *testing.T, tl Tools, p string) int {
out, err := exec.Command(tl.FFprobe, "-v", "error", "-show_entries", "stream=index", "-of", "csv=p=0", p).Output()
if err != nil {
t.Fatal(err)
}
return len(strings.Fields(string(out)))
}
func TestEditEveryFormat(t *testing.T) {
tl := tools(t)
for _, ext := range []string{"mp3", "flac", "m4a", "ogg", "opus"} {
t.Run(ext, func(t *testing.T) {
dir := t.TempDir()
src := makeFile(t, tl, dir, ext)
f, err := tl.Read(ctx, src)
if err != nil {
t.Fatal(err)
}
if f.Fields[Title] != "Old Title" || f.Fields[Track] != "3" || f.Fields[TrackTotal] != "12" || f.Fields[AlbumArtist] != "A7X" {
t.Fatalf("read: %+v", f.Fields)
}
changes := map[string]string{Title: "Nightmare", AlbumArtist: "Avenged Sevenfold", Genre: "", Track: "4", TrackTotal: "13", Album: "Nightmare"}
set, err := f.Plan(changes)
if err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, ".tmp-out."+ext)
if err := tl.Write(ctx, src, dst, f, set, ""); err != nil {
t.Fatal(err)
}
if err := tl.Verify(ctx, src, dst, changes, false); err != nil {
t.Fatal(err)
}
got, _ := tl.Read(ctx, dst)
for k, v := range got.Raw {
if strings.Contains(v, ";") {
t.Errorf("tag %s was duplicated: %q", k, v)
}
}
if _, has := got.Fields[Genre]; has {
t.Error("genre should be removed")
}
if got.Fields[Artist] != "Avenged Sevenfold" || got.Fields[Date] != "2010" {
t.Errorf("untouched fields must survive: %+v", got.Fields)
}
if streams(t, tl, src) != streams(t, tl, dst) {
t.Error("cover art or another stream was lost")
}
})
}
}
// FLACs tagged by other tools use raw Vorbis names; they must keep their
// spelling, their separate TRACKTOTAL and unrelated tags like ReplayGain.
func TestEditRawVorbisFLAC(t *testing.T) {
tl := tools(t)
metaflac, err := exec.LookPath("metaflac")
if err != nil {
t.Skip("metaflac not installed")
}
dir := t.TempDir()
src := makeFile(t, tl, dir, "flac")
cmd := exec.Command(metaflac, "--remove-all-tags", "--set-tag=TITLE=Raw", "--set-tag=ALBUMARTIST=A7X", "--set-tag=TRACKNUMBER=3",
"--set-tag=TRACKTOTAL=12", "--set-tag=REPLAYGAIN_TRACK_GAIN=-6.5 dB", "--set-tag=YEAR=2009", "--set-tag=DATE=2010", src)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
f, _ := tl.Read(ctx, src)
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 {
t.Fatal(err)
}
if err := tl.Verify(ctx, src, dst, changes, false); err != nil {
t.Fatal(err)
}
out, _ := exec.Command(metaflac, "--export-tags-to=-", dst).Output()
tagsText := string(out)
for _, want := range []string{"TITLE=Nightmare", "ALBUMARTIST=Avenged Sevenfold", "TRACKNUMBER=4", "TRACKTOTAL=12", "REPLAYGAIN_TRACK_GAIN=-6.5 dB", "DATE=2011"} {
if !strings.Contains(tagsText, want+"\n") {
t.Errorf("missing %q in:\n%s", want, tagsText)
}
}
if strings.Count(tagsText, "ALBUMARTIST=") != 1 || strings.Count(tagsText, "TRACKNUMBER=") != 1 || strings.Contains(tagsText, "YEAR=") {
t.Errorf("duplicate or stale tags:\n%s", tagsText)
}
if strings.Contains(strings.ToLower(tagsText), "encoder=") {
t.Errorf("no encoder tag should be added:\n%s", tagsText)
}
}
func TestVerifyRejectsChangedAudio(t *testing.T) {
tl := tools(t)
dir := t.TempDir()
a := makeFile(t, tl, dir, "flac")
b := filepath.Join(dir, "other.flac")
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"}, false); !errors.Is(err, ErrVerify) {
t.Fatalf("different audio must fail verification, got %v", err)
}
}
func TestPlanTotals(t *testing.T) {
combined := parse(Format{Name: "mp3"}, map[string]string{"track": "3/12"})
set, _ := combined.Plan(map[string]string{TrackTotal: "14"})
if set["track"] != "3/14" {
t.Errorf("mp3 total change should rewrite the combined tag, got %v", set)
}
set, _ = combined.Plan(map[string]string{Track: "", TrackTotal: ""})
if v, ok := set["track"]; !ok || v != "" {
t.Errorf("clearing both should delete the tag, got %v", set)
}
fresh := parse(Format{Name: "flac", Vorbis: true}, map[string]string{})
set, _ = fresh.Plan(map[string]string{Track: "1", TrackTotal: "9", Title: "x"})
if set["track"] != "1" || set["TRACKTOTAL"] != "9" || set["TITLE"] != "x" {
t.Errorf("new FLAC tags should use standard names, got %v", set)
}
if _, err := fresh.Plan(map[string]string{"bogus": "1"}); err == nil {
t.Error("unknown fields must be rejected")
}
if _, ok := FormatOf("x.dsf"); ok {
t.Error("dsf editing is not supported by ffmpeg and must be refused")
}
}