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:
1 parent
7b180e26db
commit
e0617047f7
44 files changed
+103
-10456
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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 '';`,
|
||||
|
||||
|
||||
@@ -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", "-")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user