diff --git a/README.md b/README.md
index 80e2019..08e030a 100644
--- a/README.md
+++ b/README.md
@@ -7,32 +7,6 @@ up disk space only once, while both libraries keep looking exactly the same to P
It ships as a single Go binary with an embedded React web UI and SQLite state, packaged as a small Docker image, in the style of
[qui](https://github.com/autobrr/qui).
-## Workflow: tune up, then link
-
-Each library pair has a **type**: **Music** (recommended layout `Artist/Album/01 Title.flac`) or **Soundtracks** for video game and film
-soundtracks (`Album/01 Title.flac`, named by game or film). The type sets the default naming; the *Organize* tab can change it.
-
-The **Tune-up** tab takes each library through the same steps, the **master first, then the slave**, and then links the two:
-
-1. **Folder structure.** Every album in its own folder at the right depth. Songs outside an album folder move into one named from their
- tags; extra folders around an album (a composer folder around a soundtrack, `Metal/Artist/Album`, a `FLAC` folder inside an album) are
- removed; songs in disc folders (`CD2`, `Disc 2`) get their disc number; missing titles, track numbers, albums and artists are filled in
- from the folder and file names (`Album (2010) [FLAC]` → `Album`). When several albums sit in the same extra folder it may be a category
- you want to keep (`Video Games/…`), so those moves are listed but not selected, with a hint to give that folder its own naming.
-2. **Album tags** (optional). Each album is looked up on MusicBrainz: the best few releases are compared with your songs' titles (so a promo
- single never beats the album it is named after), songs are matched to tracks by number or title, and the corrected titles, track and
- disc numbers, album artist and date are shown per album for review, with a confidence rating. You can pick another release or edit the
- album by hand. In the slave, albums that are also in the (fixed) master take the master's tags instead, so both libraries match.
- MusicBrainz allows about one request a second, so large libraries take a while; limit it to a folder or skip albums whose tags are already
- complete.
-3. **Folder names.** Artist and album folders are renamed from the tags. Whole album folders move, so covers and booklets stay with them.
-4. **File names.** Songs are renamed from the tags; multi-disc albums get `Disc 01`, `Disc 02` folders.
-5. **Link the libraries.** Scan, fix any folders still named differently, link the identical songs, then open **Review** for the rest.
-
-Every step is a check first: nothing changes until you apply the fixes you selected, each fix is an operation in History that can be undone,
-and dry run applies. Problems that need a person (a song without an album tag, a folder with songs from several albums) are listed with an
-**Edit album** button. A step is marked done when everything in it was fixed; you can also mark it done or skip it yourself.
-
## What it does
Files are matched by their **path relative to each library root**. Any folder layout works (`Artist/Album/Title.ext`,
@@ -97,59 +71,10 @@ anything runs, a preview shows how many songs will be processed and which are sk
downgrades, approximate matches, duration mismatches and files that move folders. Downgrades are skipped unless you tick a box, and
replacing master files needs an explicit acknowledgement. Each song then goes through the same checks as a single action.
-**Tag editor.** The *Tags* tab browses either library. Click a song to edit its tags (title, artist, album artist, album, track and
-disc numbers with totals, year, genre, composer, comment), or tick several, such as a whole album, to edit the tags they share. It
-works for MP3, FLAC, M4A, OGG and Opus; other tags and cover art are kept. Review's compare panel also has **Slave tags** / **Master
-tags** buttons. Edits are safe with hardlinks:
-
-- 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 to the other library is updated in place, so both libraries see the new tags and stay linked; a verified backup
- copy goes to quarantine first. You can instead choose to change only one library's copy, which breaks the link.
-- Every written file is verified: the new values must read back, and the decoded audio must be identical to the original.
-- Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on
- startup. Dry run applies.
-
-**Album editor.** Every folder in *Tags* has an **Edit album** button (like AudioShell's folder view). It shows the album fields
-once (album, album artist, year, genre, discs, composer, comment); a change there updates every song in the album, including songs in
-`Disc 01`/`Disc 02` subfolders. Below is a track grid to edit each song's disc, track number, title and artist, with **Number tracks
-in order** (fills track and disc totals too) and **Artist = album artist**. All songs are saved as one job with the same safety
-checks as above.
-
-**Album art.** The song and album editors can swap the cover. Search iTunes, Deezer, MusicBrainz (Cover Art Archive), DuckDuckGo
-or Google by artist and album or by free text; only square images are shown, and results under a minimum size are hidden. You can
-also paste an image address or upload a file. The chosen image is converted to JPEG at the saved size (1000 × 1000 by default) and
-can be embedded in the songs, saved as `cover.jpg` in the album folder, or both. OGG and Opus can't hold embedded art here, so they
-use the folder image. A replaced `cover.jpg` goes to quarantine, and every change can be undone from History.
-
-- **Auto-match art…** walks the current folder, looks up each album on iTunes, Deezer and MusicBrainz, and applies a cover only when
- the artist and album clearly match (release suffixes like "Deluxe Edition" are ignored). Albums that already have art can be
- skipped; albums without a confident match are listed and left untouched.
-- Sources are chosen in *Settings → Album art*. Google needs an API key with the Custom Search API and a Programmable Search Engine
- id (cx). DuckDuckGo has no official API and may stop working. Images are downloaded by playlink itself and never from addresses on
- your local network.
-
-**Organize (rename files from tags).** The *Organize* tab sets how files are named, in the style of Lidarr: an **artist folder**, an
-**album folder**, a **track format** and a separate **multi-disc track format**, with presets. The default is
-`{Artist Name}/{Album Title}/{track:00} {Track Title}`, and multi-disc albums get `{Medium Format} {medium:00}/…` → `Disc 01`, `Disc 02`.
-Leave the artist folder empty for `Album/01 Title`; songs then don't need an artist tag.
-
-**Folders with their own naming.** Part of a library can use different formats, such as game soundtracks in
-`Video Games/{Album Title}/{track:00} {Track Title}` while the rest stays `Artist/Album/01 Title`. Files inside such a folder are only
-renamed within it, and other files are never moved into it (they are skipped with a reason instead). Folder rules can't be nested.
-
-Tokens: `{Artist Name}`, `{Artist NameThe}` (The Beatles → Beatles, The), `{Artist CleanName}`, `{Artist NameFirstCharacter}`,
-`{Album Title}`, `{Album TitleThe}`, `{Album CleanTitle}`, `{Release Year}`, `{track:0}`/`{track:00}`, `{medium:0}`/`{medium:00}`,
-`{Medium Format}`, `{Track Title}`, `{Track CleanTitle}`, `{Track ArtistName}`, `{Genre}`. Like Lidarr, `{Artist.Name}` or `{Artist_Name}`
-puts dots or underscores between words and `{ARTIST NAME}` / `{artist name}` change the case. The artist is the album artist, falling back
-to the artist. Colons can be replaced smartly (`A: B` → `A - B`), with a dash, space dash, space dash space, or deleted; other characters
-Windows/SMB rejects are replaced (`AC/DC` → `AC-DC`) or removed. Brackets and separators left empty by a missing value are cleaned up
-(`Album ({Release Year})` without a year → `Album`). Examples are rendered by the same code that renames files.
-
-**Rename now…** checks the slave, the master or both, then shows every change grouped by destination folder (old → new, selectable per
-file or folder), plus skipped files with the reason and a *Fix tags* button. Files are only moved within their library, so contents and
-hardlinks never change; lyrics (`.lrc`) move with their song, cover art follows a whole folder, and emptied folders are removed. Each
-rename is in History and can be undone, and the library is rescanned afterwards. **Organize automatically** (off by default) applies the
-same renames during every scan. A slave file that is a hardlink of a master file at a different path is still recognised as linked.
+**Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan.
+Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate,
+bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
+(UTF-8, and cells a spreadsheet would treat as formulas are escaped).
**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and
history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7
@@ -159,11 +84,6 @@ are refused), then playlink restarts to swap the database in and keeps the repla
policy brings it back automatically. Optionally, **back up before every action in live mode**: one backup per action you start (and
before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept.
-**Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan.
-Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate,
-bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
-(UTF-8, and cells a spreadsheet would treat as formulas are escaped).
-
**Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the master library, tick the
folders or songs you want, and playlink hardlinks them in at the same relative path. **Remove** deletes slave files, but only ones that are hardlinks of the
master file.
@@ -245,8 +165,7 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions
| `PLAYLINK_DATA_DIR` | `/config` (Docker) or `./config` | Where the SQLite database lives. |
| `PLAYLINK_ALLOWED_ROOTS` | `/data` | Comma-separated folders that libraries and the quarantine must live in. `/` is refused. |
| `PLAYLINK_SECURE_COOKIES` | `false` | Set to `true` when served over HTTPS. |
-| `PLAYLINK_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison and tag editing are disabled. |
-| `PLAYLINK_FFMPEG` | `ffmpeg` | Path to ffmpeg, used by the tag editor. |
+| `PLAYLINK_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison is disabled but everything else works. |
| `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. |
**Hide quarantine from Plex** (Settings → Media servers) writes a `.plexignore` into each pair's quarantine folder so Plex does not
diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go
index 5e8a126..fd15704 100644
--- a/cmd/playlink/main.go
+++ b/cmd/playlink/main.go
@@ -15,13 +15,10 @@ import (
"time"
"github.com/cdnutter/playlink/internal/api"
- "github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine"
- "github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
- "github.com/cdnutter/playlink/internal/tags"
"github.com/cdnutter/playlink/web"
)
@@ -98,14 +95,6 @@ func main() {
defer st.DB.Close()
eng := engine.New(st, probe.Prober{Bin: ffprobe}, roots, log)
- // The tag editor writes with ffmpeg (verified with ffprobe).
- if ff, err := exec.LookPath(env("PLAYLINK_FFMPEG", "ffmpeg")); err == nil && ffprobe != "" {
- eng.SetTagTools(tags.Tools{FFmpeg: ff, FFprobe: ffprobe})
- eng.SetArt(art.New(version), &art.Store{Dir: filepath.Join(dataDir, "art-cache"), FFmpeg: ff})
- eng.SetMeta(meta.New(version))
- } else {
- log.Warn("ffmpeg not found; tag editing disabled")
- }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
diff --git a/internal/api/art.go b/internal/api/art.go
deleted file mode 100644
index aa69284..0000000
--- a/internal/api/art.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/api/export.go b/internal/api/export.go
index 4c86834..d5fc324 100644
--- a/internal/api/export.go
+++ b/internal/api/export.go
@@ -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",
}
diff --git a/internal/api/fixer.go b/internal/api/fixer.go
deleted file mode 100644
index 8727fcb..0000000
--- a/internal/api/fixer.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/api/server.go b/internal/api/server.go
index 3dada31..912cb87 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -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)
-}
diff --git a/internal/art/art.go b/internal/art/art.go
deleted file mode 100644
index 9b3f33d..0000000
--- a/internal/art/art.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/art/art_test.go b/internal/art/art_test.go
deleted file mode 100644
index a360f5f..0000000
--- a/internal/art/art_test.go
+++ /dev/null
@@ -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, ``)
- return
- }
- http.NotFound(w, r)
- })
- mux.HandleFunc("/i.js", func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Query().Get("vqd") != "4-12345" || !strings.Contains(r.URL.Query().Get("f"), "layout:Square") {
- http.Error(w, "bad", 400)
- return
- }
- fmt.Fprint(w, `{"results":[{"title":"near square","image":"https://x/a.jpg","thumbnail":"t","width":600,"height":594},
- {"title":"square","image":"https://x/b.jpg","thumbnail":"t","width":800,"height":800},
- {"title":"tiny","image":"https://x/c.jpg","thumbnail":"t","width":200,"height":200}]}`)
- })
- srv := httptest.NewServer(mux)
- t.Cleanup(srv.Close)
- base = srv.URL
- c := New("test")
- c.HTTP = srv.Client() // the safe client refuses loopback, which the fakes use
- c.ITunesURL, c.DeezerURL, c.MusicBrainzURL, c.CAAURL, c.DDGURL, c.GoogleURL = base, base, base, base, base, base
- return c
-}
-
-func TestSearchSquareOnly(t *testing.T) {
- c := fakeSources(t, false)
- res, errs := c.Search(ctx, Query{Artist: "Avenged Sevenfold", Album: "Nightmare"}, Config{Sources: AllSources})
- if len(errs) != 0 {
- t.Fatalf("errors: %v", errs)
- }
- got := map[string]Result{}
- for _, r := range res {
- got[r.Source+":"+r.Title] = r
- }
- for _, want := range []string{"itunes:Nightmare", "deezer:Nightmare", "musicbrainz:Nightmare", "duckduckgo:square"} {
- if _, ok := got[want]; !ok {
- t.Errorf("missing %s in %+v", want, res)
- }
- }
- for _, not := range []string{"duckduckgo:near square", "duckduckgo:tiny", "musicbrainz:Nightmare (Live)"} {
- if _, ok := got[not]; ok {
- t.Errorf("%s must be filtered out (not square or too small)", not)
- }
- }
- if r := got["itunes:Nightmare"]; !strings.HasSuffix(r.URL, "/img/1000x1000bb.jpg") || res[0].Source != ITunes {
- t.Errorf("iTunes should come first at 1000px: %+v", r)
- }
- for _, r := range res {
- if r.Source == Google {
- t.Error("Google must not be queried without an API key")
- }
- }
-}
-
-func TestOneFailingSourceDoesNotHideOthers(t *testing.T) {
- c := fakeSources(t, true)
- res, errs := c.Search(ctx, Query{Artist: "A", Album: "B"}, Config{Sources: []string{ITunes, Deezer}})
- if errs[Deezer] == "" || len(res) != 1 || res[0].Source != ITunes {
- t.Fatalf("want iTunes result plus a Deezer error, got %+v %v", res, errs)
- }
-}
-
-func TestSquare(t *testing.T) {
- for _, c := range []struct {
- w, h int
- want bool
- }{{1000, 1000, true}, {1000, 996, true}, {600, 594, false}, {500, 498, true}, {0, 0, false}, {1200, 1100, false}} {
- if Square(c.w, c.h) != c.want {
- t.Errorf("Square(%d,%d) != %v", c.w, c.h, c.want)
- }
- }
-}
-
-func TestSafeFetch(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/page" {
- w.Header().Set("Content-Type", "text/html")
- fmt.Fprint(w, "")
- return
- }
- w.Header().Set("Content-Type", "image/png")
- w.Write(pngBytes(10, 10))
- }))
- defer srv.Close()
- c := New("test") // the real, safe client
- if _, err := c.FetchImage(ctx, srv.URL+"/img.png"); !errors.Is(err, ErrBlockedAddress) {
- t.Fatalf("loopback must be refused, got %v", err)
- }
- for _, u := range []string{"file:///etc/passwd", "ftp://x/y.png", "gopher://x", "not a url"} {
- if _, err := c.FetchImage(ctx, u); err == nil {
- t.Errorf("%q must be refused", u)
- }
- }
- c.HTTP = srv.Client()
- if _, err := c.FetchImage(ctx, srv.URL+"/page"); err == nil || !strings.Contains(err.Error(), "not an image") {
- t.Fatalf("non-images must be refused, got %v", err)
- }
- if b, err := c.FetchImage(ctx, srv.URL+"/img.png"); err != nil || len(b) == 0 {
- t.Fatalf("image fetch: %v", err)
- }
- for _, ip := range []string{"10.0.0.1", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "::1", "fc00::1", "0.0.0.0"} {
- if !blockedIP(parseIP(ip)) {
- t.Errorf("%s should be blocked", ip)
- }
- }
- if blockedIP(parseIP("1.1.1.1")) {
- t.Error("public addresses are allowed")
- }
-}
-
-func TestPrepare(t *testing.T) {
- ff, err := exec.LookPath("ffmpeg")
- if err != nil {
- t.Skip("ffmpeg not installed")
- }
- s := &Store{Dir: t.TempDir(), FFmpeg: ff}
- p, err := s.Prepare(ctx, pngBytes(1600, 1600), 1000)
- if err != nil {
- t.Fatal(err)
- }
- if p.Width != 1000 || p.Height != 1000 || !p.Square {
- t.Fatalf("want 1000x1000 square, got %+v", p)
- }
- path, err := s.Path(p.Token)
- if err != nil {
- t.Fatal(err)
- }
- b, _ := os.ReadFile(path)
- if len(b) < 3 || b[0] != 0xFF || b[1] != 0xD8 {
- t.Fatal("prepared image should be a JPEG")
- }
- if small, _ := s.Prepare(ctx, pngBytes(300, 300), 1000); small.Width != 300 {
- t.Fatal("images are never enlarged")
- }
- if _, err := s.Prepare(ctx, []byte("not an image"), 1000); err == nil {
- t.Fatal("garbage must be refused")
- }
- for _, bad := range []string{"../etc", "abc", strings.Repeat("g", 32)} {
- if _, err := s.Path(bad); !errors.Is(err, ErrBadToken) {
- t.Errorf("token %q must be refused", bad)
- }
- }
-}
-
-func parseIP(s string) net.IP { return net.ParseIP(s) }
diff --git a/internal/art/prepared.go b/internal/art/prepared.go
deleted file mode 100644
index 9d5098e..0000000
--- a/internal/art/prepared.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/engine/artwork.go b/internal/engine/artwork.go
deleted file mode 100644
index 6a739c0..0000000
--- a/internal/engine/artwork.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/engine/artwork_test.go b/internal/engine/artwork_test.go
deleted file mode 100644
index 0cdcf36..0000000
--- a/internal/engine/artwork_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index be37142..4110913 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -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
}
diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go
index b02004f..4eaf4e4 100644
--- a/internal/engine/engine_test.go
+++ b/internal/engine/engine_test.go
@@ -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)
+ }
+}
diff --git a/internal/engine/fixer.go b/internal/engine/fixer.go
deleted file mode 100644
index e3dbba4..0000000
--- a/internal/engine/fixer.go
+++ /dev/null
@@ -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])
-}
diff --git a/internal/engine/fixer_steps.go b/internal/engine/fixer_steps.go
deleted file mode 100644
index 7dd7962..0000000
--- a/internal/engine/fixer_steps.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/engine/fixer_tags.go b/internal/engine/fixer_tags.go
deleted file mode 100644
index 1c14b31..0000000
--- a/internal/engine/fixer_tags.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/engine/fixer_test.go b/internal/engine/fixer_test.go
deleted file mode 100644
index 346dffc..0000000
--- a/internal/engine/fixer_test.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/internal/engine/naming.go b/internal/engine/naming.go
deleted file mode 100644
index 86ee93d..0000000
--- a/internal/engine/naming.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/engine/naming_test.go b/internal/engine/naming_test.go
deleted file mode 100644
index 9696d17..0000000
--- a/internal/engine/naming_test.go
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/internal/engine/ops.go b/internal/engine/ops.go
index 0d612f3..d5be608 100644
--- a/internal/engine/ops.go
+++ b/internal/engine/ops.go
@@ -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)
diff --git a/internal/engine/organize.go b/internal/engine/organize.go
deleted file mode 100644
index cfd1730..0000000
--- a/internal/engine/organize.go
+++ /dev/null
@@ -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")
- })
-}
diff --git a/internal/engine/organize_test.go b/internal/engine/organize_test.go
deleted file mode 100644
index 47bb927..0000000
--- a/internal/engine/organize_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/internal/engine/scan.go b/internal/engine/scan.go
index 318c74f..4a98a3b 100644
--- a/internal/engine/scan.go
+++ b/internal/engine/scan.go
@@ -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
diff --git a/internal/engine/tagedit.go b/internal/engine/tagedit.go
deleted file mode 100644
index d989026..0000000
--- a/internal/engine/tagedit.go
+++ /dev/null
@@ -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"
-}
diff --git a/internal/engine/tagedit_test.go b/internal/engine/tagedit_test.go
deleted file mode 100644
index 7589053..0000000
--- a/internal/engine/tagedit_test.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/internal/fsops/fsops.go b/internal/fsops/fsops.go
index afadb59..a587e57 100644
--- a/internal/fsops/fsops.go
+++ b/internal/fsops/fsops.go
@@ -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
-}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
deleted file mode 100644
index 6a9b73b..0000000
--- a/internal/meta/meta.go
+++ /dev/null
@@ -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()
-}
diff --git a/internal/store/models.go b/internal/store/models.go
index 90db3d1..2414a36 100644
--- a/internal/store/models.go
+++ b/internal/store/models.go
@@ -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()
-}
diff --git a/internal/store/store.go b/internal/store/store.go
index d10016e..0f4a0ea 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -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 '';`,
diff --git a/internal/tags/tags.go b/internal/tags/tags.go
deleted file mode 100644
index 67b5d9b..0000000
--- a/internal/tags/tags.go
+++ /dev/null
@@ -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", "-")
-}
diff --git a/internal/tags/tags_test.go b/internal/tags/tags_test.go
deleted file mode 100644
index 9d2cfa5..0000000
--- a/internal/tags/tags_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 5373f44..f63c761 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -65,7 +65,7 @@ function Shell({ username }: { username: string }) {
];
let page;
- if (route[0] === "pair" && route[1]) page = OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.
- Only tags and art change; the audio is verified to be identical. Previous versions go to quarantine, and every change can be undone
- from History.
- Where the tag editor searches for covers. Only square images are shown.
- Searching sends the artist and album (or your search text) to the selected services. Images are downloaded by playlink itself, never from
- addresses on your local network.
- How files are named from their tags, in the style of Lidarr. Only the name and folder change.
- For part of a library that is organized differently, such as game soundtracks in{" "}
- Video Games/Album/01 Title. Files in the folder stay in it, and other files are never moved into it.
-
- Write a token with dots or underscores to use them between words ( Check the library against the naming below and review every change before anything is renamed. Reading each file's tags. Nothing is renamed until you confirm. After fixing tags, run Rename now again to include those files. Whatever gets replaced is kept in quarantine and can be restored from History.
-
-
-
-
-
-
- {songs.map((s) => (
- Disc
- #
- Title
- Artist
- File
-
- {ROW_FIELDS.map((id) => {
- const v = rowValue(s, id);
- const changed = v !== (s.fields[id] ?? "");
- return (
-
- ))}
-
-
- setRow(s.rel, id, e.target.value)}
- />
-
- );
- })}
-
- {s.links > 1 &&
- setFailed(true)} />;
-}
-
-/**
- * Finds a new cover: search the art sources (square images only), paste an
- * image address, or upload a file. The chosen image is prepared on the server
- * (converted to JPEG, resized) and returned as a token to apply later.
- */
-export function ArtPicker({
- artist,
- album,
- onChoose,
-}: {
- artist: string;
- album: string;
- onChoose: (p: PreparedArt) => void;
-}) {
- const settings = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
- const [q, setQ] = useState({ artist, album, text: "" });
- const [free, setFree] = useState(false);
- const [sources, setSources] = useState
-
-
-
- Folders with their own naming
- {"{Artist.Name}"} → Avenged.Sevenfold),
- or in capitals or lower case to change the case ({"{ARTIST NAME}"}).
- Rename now
-
- {Object.entries(data.plan.skips).map(([why, n]) => (
-
-
-
- )}
- {dry && moves.length > 0 &&
- Click a song to edit its tags, tick several to edit the tags they share, or use Edit album to edit a whole album (album fields, - every track's number and title, and the cover). shared marks files that are hardlinked to the other library. -
- {editing && ( -| {k} | -{v} | -
OGG/Opus files can't hold embedded art; use Edit album to save a folder cover.jpg for them.
- )} -- Only the tags are rewritten; the audio is verified to be identical afterwards. The previous version goes to quarantine and the edit can - be undone from History. -
- {dry &&
- Finds covers for every album in {folder || `the ${side} library`} from iTunes, Deezer and MusicBrainz,
- using each album's artist and album tags. A cover is only applied when both clearly match, and only square covers are used.
-
- The album names are sent to those services to search. Every change goes through History and can be undone. -
- {dry &&Use Edit album → Change art… to search those by hand.
-{layoutOf(pair)};
- switch (step) {
- case "structure":
- return (
- <>
- Puts every album in its own folder at the right depth for {layout}. Songs outside an album folder are moved into one named from their tags;
- extra folders around an album (a composer folder around a soundtrack, a FLAC folder inside an album) are
- removed; songs in disc folders get their disc number; and missing titles, track numbers, albums and artists are filled in from the folder
- and file names. Names themselves are fixed in the next steps.
- >
- );
- case "tags":
- return (
- <>
- Looks each album up on MusicBrainz and shows corrected titles, track and disc numbers, album artist and date for you to review.
- {side === "slave" && " Albums that are also in the master take the master's tags instead, so both libraries match."} Nothing changes until
- you apply the albums you selected. This step is optional and can take a while: MusicBrainz allows about one lookup a second.
- >
- );
- case "folders":
- return (
- <>
- Renames artist and album folders from the tags, following {layout}. Whole album folders move, so covers, booklets and other files stay with
- their album.
- >
- );
- case "files":
- return (
- <>
- Renames each song from its tags ({pair.naming.track_format}). Albums with several discs get{" "}
- Disc 01, Disc 02 folders. Lyrics files move with their song.
- >
- );
- }
-}
-
-function StepPanel({ pair, side, step, fixer, onNext }: { pair: Pair; side: Side; step: FixStep; fixer?: Fixer; onNext: () => void }) {
- const qc = useQueryClient();
- const toast = useToast();
- const [folder, setFolder] = useState("");
- const [onlyIncomplete, setOnlyIncomplete] = useState(true);
- const meta = STEPS.find((s) => s.id === step)!;
- const status = pair.fixer[side][step];
- const plan = fixer?.plan && fixer.plan.side === side && fixer.plan.step === step ? fixer.plan : null;
- const running = pair.job?.state === "running";
- const ourJob = running && pair.job!.kind.startsWith("tune-up");
- const refresh = () => {
- qc.invalidateQueries({ queryKey: ["pair", pair.id] });
- qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
- };
- const check = useMutation({
- mutationFn: () => api.post(`/pairs/${pair.id}/fixer/check`, { side, step, folder: folder.trim(), only_incomplete: onlyIncomplete }),
- onSuccess: refresh,
- onError: (e) => toast("err", e.message),
- });
- const mark = useMutation({
- mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side, step, state }),
- onSuccess: (_, state) => {
- refresh();
- if (state) setTimeout(onNext, 300);
- },
- onError: (e) => toast("err", e.message),
- });
- const masterOpen = side === "slave" && STEPS.some((s) => !finished(pair.fixer.master[s.id]));
-
- return (
- {stepText(step, pair, side)}
- {masterOpen && ( -Complete: every song has a title, track number, album and artist, and they agree.
-| File | -Matched track | -Changes | -
|---|---|---|
| {f.path.slice(a.folder.length + 1)} | -{f.track ?? not matched} | -{Object.keys(own).length ? |
-
- With both libraries organized the same way, songs match by path. Identical songs are replaced in the slave with a hardlink to the master, so - they take up space only once. Songs that differ (another quality, other tags) are left for you in Review. -
- {open.length > 0 && ( -Upgrades, other formats and approximate matches are reviewed one album or song at a time.
- - - -