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

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

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

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

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

No files matched your search

+5 -86
View File
@@ -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
-11
View File
@@ -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()
-154
View File
@@ -1,154 +0,0 @@
package api
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/engine"
)
func (s *Server) handleArtSettings(w http.ResponseWriter, r *http.Request) {
cfg := s.eng.ArtSettings(r.Context())
writeJSON(w, 200, map[string]any{"settings": cfg, "all_sources": art.AllSources})
}
func (s *Server) handlePutArtSettings(w http.ResponseWriter, r *http.Request) {
var cfg engine.ArtSettings
if !readJSON(w, r, &cfg) {
return
}
if err := s.eng.SetArtSettings(r.Context(), cfg); err != nil {
writeErr(w, 400, err)
return
}
s.handleArtSettings(w, r)
}
func (s *Server) handleArtSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
query := art.Query{Artist: q.Get("artist"), Album: q.Get("album"), Text: q.Get("q")}
if strings.TrimSpace(query.Artist+query.Album+query.Text) == "" {
writeErr(w, 400, errors.New("enter an artist, album or search text"))
return
}
var sources []string
if src := q.Get("sources"); src != "" {
sources = strings.Split(src, ",")
}
res, errs, err := s.eng.SearchArt(r.Context(), query, sources)
if err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, map[string]any{"results": res, "errors": errs})
}
func (s *Server) handleArtPrepare(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
}
if !readJSON(w, r, &body) {
return
}
p, err := s.eng.PrepareArtURL(r.Context(), body.URL)
if err != nil {
writeErr(w, 400, err)
return
}
writeJSON(w, 200, p)
}
func (s *Server) handleArtUpload(w http.ResponseWriter, r *http.Request) {
f, _, err := r.FormFile("image")
if err != nil {
writeErr(w, 400, errors.New("choose an image file"))
return
}
defer f.Close()
p, err := s.eng.PrepareArtUpload(r.Context(), f)
if err != nil {
writeErr(w, 400, err)
return
}
writeJSON(w, 200, p)
}
func (s *Server) handleArtPrepared(w http.ResponseWriter, r *http.Request) {
p, err := s.eng.PreparedArtPath(chi.URLParam(r, "token"))
if err != nil {
writeErr(w, 404, err)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeFile(w, r, p)
}
func (s *Server) handleCover(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
b, err := s.eng.Cover(r.Context(), id, q.Get("side"), q.Get("path"))
if err != nil {
opErr(w, err)
return
}
if len(b) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", http.DetectContentType(b))
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "cover", time.Time{}, strings.NewReader(string(b)))
}
func (s *Server) handleAlbum(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
a, err := s.eng.AlbumSongs(r.Context(), id, q.Get("side"), q.Get("path"))
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, a)
}
func (s *Server) handleArtAuto(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var m engine.ArtMatch
if !readJSON(w, r, &m) {
return
}
if !s.preAction(w, r, id, "auto-match album art") {
return
}
j, err := s.eng.StartArtAutoMatch(id, m)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
// handleArtThumb shows a search result's thumbnail through the server, so
// the page keeps its strict content policy and the browser never contacts
// the image hosts directly. Fetching uses the local-network-safe client.
func (s *Server) handleArtThumb(w http.ResponseWriter, r *http.Request) {
b, err := s.eng.FetchArtThumb(r.Context(), r.URL.Query().Get("u"))
if err != nil {
writeErr(w, 502, err)
return
}
ct := http.DetectContentType(b)
if !strings.HasPrefix(ct, "image/") {
writeErr(w, 502, errors.New("not an image"))
return
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "private, max-age=86400")
w.Write(b)
}
-1
View File
@@ -23,7 +23,6 @@ var kindLabels = map[string]string{
store.KindFuzzy: "similar title",
store.KindElsewhere: "same top folder, other subfolder",
store.KindApprox: "approximate match",
store.KindInode: "same file, other path",
store.KindNone: "no match",
}
-94
View File
@@ -1,94 +0,0 @@
package api
import (
"net/http"
"github.com/cdnutter/playlink/internal/engine"
)
// ---- tune-up (step-by-step library fixing) ----
func (s *Server) handleFixer(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
p, err := s.st.Pair(r.Context(), id)
if err != nil {
opErr(w, err)
return
}
out := map[string]any{"state": engine.FixerStateOf(p), "steps": engine.FixerSteps, "plan": nil,
"items": []engine.FixItem{}, "albums": []*engine.AlbumFix{}}
if plan, items, albums := s.eng.FixerPlan(id); plan != nil {
out["plan"], out["items"], out["albums"] = plan, items, albums
}
writeJSON(w, 200, out)
}
func (s *Server) handleFixCheck(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.FixCheck
if !readJSON(w, r, &body) {
return
}
j, err := s.eng.StartFixCheck(id, body)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleFixApply(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.FixApply
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "tune-up") {
return
}
j, err := s.eng.StartFixApply(id, body)
if err != nil {
opErr(w, err)
return
}
s.log.Info("tune-up apply started", "pair", id, "selected", len(body.IDs))
writeJSON(w, 202, j)
}
func (s *Server) handleFixStep(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"` // master, slave, or "" for the link step
Step string `json:"step"`
State string `json:"state"` // done, skipped or "" (to do)
}
if !readJSON(w, r, &body) {
return
}
if err := s.eng.SetStep(r.Context(), id, body.Side, body.Step, body.State, ""); err != nil {
opErr(w, err)
return
}
s.handleFixer(w, r)
}
func (s *Server) handleFixRelease(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Album int `json:"album"`
Release string `json:"release"` // a MusicBrainz release id, or "search"
}
if !readJSON(w, r, &body) {
return
}
if j := s.eng.Job(id); j != nil && j.State == "running" {
writeErr(w, 409, engine.ErrBusy)
return
}
a, err := s.eng.ChooseRelease(r.Context(), id, body.Album, body.Release)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, a)
}
+2 -161
View File
@@ -23,7 +23,6 @@ import (
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
type Config struct {
@@ -58,9 +57,6 @@ func (s *Server) Handler() http.Handler {
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := int64(1 << 20)
if strings.HasSuffix(r.URL.Path, "/art/upload") {
limit = 21 << 20
}
if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") {
limit = 20 << 30 // backup zips can be large
}
@@ -82,15 +78,6 @@ func (s *Server) Handler() http.Handler {
r.Get("/settings", s.handleGetSettings)
r.Put("/settings", s.handlePutSettings)
r.Get("/browse", s.handleBrowse)
r.Get("/naming", s.handleNamingInfo)
r.Get("/art/settings", s.handleArtSettings)
r.Put("/art/settings", s.handlePutArtSettings)
r.Get("/art/search", s.handleArtSearch)
r.Post("/art/prepare", s.handleArtPrepare)
r.Post("/art/upload", s.handleArtUpload)
r.Get("/art/prepared/{token}", s.handleArtPrepared)
r.Get("/art/thumb", s.handleArtThumb)
r.Post("/naming/examples", s.handleNamingExamples)
if s.cfg.Backups != nil {
s.backupRoutes(r)
}
@@ -110,23 +97,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/groups", s.handleGroups)
r.Post("/bulk/preview", s.handleBulkPreview)
r.Post("/bulk", s.handleBulk)
r.Get("/files", s.handleFiles)
r.Get("/cover", s.handleCover)
r.Get("/album", s.handleAlbum)
r.Post("/art/auto", s.handleArtAuto)
r.Post("/tags/read", s.handleReadTags)
r.Post("/tags", s.handleEditTags)
r.Post("/organize/preview", s.handleOrganizePreview)
r.Get("/organize/plan", s.handleOrganizePlan)
r.Post("/organize/apply", s.handleOrganizeApply)
r.Get("/renames", s.handleRenamePreview)
r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical)
r.Get("/fixer", s.handleFixer)
r.Post("/fixer/check", s.handleFixCheck)
r.Post("/fixer/apply", s.handleFixApply)
r.Put("/fixer/step", s.handleFixStep)
r.Post("/fixer/release", s.handleFixRelease)
r.Get("/tree", s.handleTree)
r.Post("/build", s.handleBuild)
r.Post("/remove", s.handleRemove)
@@ -215,7 +188,7 @@ func opErr(w http.ResponseWriter, err error) {
errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists):
writeErr(w, 409, err)
case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice),
errors.Is(err, engine.ErrInvalidBulk), errors.Is(err, engine.ErrNoTagTools):
errors.Is(err, engine.ErrInvalidBulk):
writeErr(w, 400, err)
default:
writeErr(w, 500, err)
@@ -231,8 +204,6 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"default_extensions": engine.DefaultExtensions,
"dry_run": s.st.DryRun(r.Context()),
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1",
"tag_editing": s.eng.TagEditing(),
"tag_fields": tags.FieldIDs,
})
}
@@ -326,15 +297,13 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
type pairView struct {
store.Pair
Naming engine.NamingConfig `json:"naming"`
Fixer engine.FixerState `json:"fixer"`
Summary store.Summary `json:"summary"`
Job *engine.Job `json:"job"`
}
func (s *Server) view(r *http.Request, p store.Pair) pairView {
sum, _ := s.st.Summary(r.Context(), p.ID)
return pairView{Pair: p, Naming: engine.NamingOf(p), Fixer: engine.FixerStateOf(p), Summary: sum, Job: s.eng.Job(p.ID)}
return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)}
}
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
@@ -372,10 +341,6 @@ type pairInput struct {
SettleMinutes int `json:"settle_minutes"`
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"`
AutoOrganize string `json:"auto_organize"`
LibraryType string `json:"library_type"`
// Naming is optional: when absent, the pair keeps its current naming.
Naming *engine.NamingConfig `json:"naming,omitempty"`
}
func (in pairInput) apply(p *store.Pair) {
@@ -383,13 +348,6 @@ func (in pairInput) apply(p *store.Pair) {
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
p.AutoOrganize = in.AutoOrganize
if in.LibraryType != "" {
p.LibraryType = in.LibraryType
}
if in.Naming != nil {
p.OrganizeConfig, _ = json.Marshal(in.Naming)
}
}
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
@@ -430,8 +388,6 @@ func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) {
}
}
in.apply(&p)
engine.ChangeLibraryType(old, &p)
engine.ChangeLibraryType(old, &p)
warns, err := s.eng.NormalizePair(&p)
if err != nil {
writeErr(w, 400, err)
@@ -767,54 +723,6 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 202, j)
}
// ---- tag editor ----
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
ents, err := s.eng.ListFiles(r.Context(), id, q.Get("side"), q.Get("path"))
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, ents)
}
func (s *Server) handleReadTags(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"`
Paths []string `json:"paths"`
}
if !readJSON(w, r, &body) {
return
}
files, err := s.eng.ReadTags(r.Context(), id, body.Side, body.Paths)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 200, map[string]any{"files": files})
}
func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body engine.TagEdit
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "edit tags") {
return
}
j, err := s.eng.StartTagEdit(id, body)
if err != nil {
opErr(w, err)
return
}
s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate)
writeJSON(w, 202, j)
}
// preAction runs the optional database backup before an action that changes
// files (live mode only). It refuses up front when the pair is busy, so no
// backup is made for an action that could not start.
@@ -832,70 +740,3 @@ func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64,
}
return true
}
// ---- organizer ----
func (s *Server) handleNamingInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"defaults": engine.DefaultNaming(), "presets": engine.NamingPresets, "tokens": engine.TokenHelp,
"folder_default": engine.SoundtrackFolder})
}
// handleNamingExamples renders sample songs with unsaved settings, using the
// same code as real renames.
func (s *Server) handleNamingExamples(w http.ResponseWriter, r *http.Request) {
var c engine.NamingConfig
if !readJSON(w, r, &c) {
return
}
if err := engine.ValidateNaming(c); err != nil {
writeJSON(w, 200, map[string]any{"error": err.Error(), "examples": []engine.NamingExample{}})
return
}
writeJSON(w, 200, map[string]any{"examples": engine.NamingExamples(c)})
}
func (s *Server) handleOrganizePreview(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
Side string `json:"side"` // slave, master or both
}
if !readJSON(w, r, &body) {
return
}
j, err := s.eng.StartOrganizePreview(id, body.Side)
if err != nil {
opErr(w, err)
return
}
writeJSON(w, 202, j)
}
func (s *Server) handleOrganizePlan(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
plan, items := s.eng.OrganizePreview(id)
if plan == nil {
writeJSON(w, 200, map[string]any{"plan": nil, "items": []any{}})
return
}
writeJSON(w, 200, map[string]any{"plan": plan, "items": items})
}
func (s *Server) handleOrganizeApply(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
var body struct {
IDs []int `json:"ids"` // empty: every rename in the preview
}
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "rename files") {
return
}
j, err := s.eng.StartOrganizeApply(id, body.IDs)
if err != nil {
opErr(w, err)
return
}
s.log.Info("rename now started", "pair", id, "selected", len(body.IDs))
writeJSON(w, 202, j)
}
-462
View File
@@ -1,462 +0,0 @@
// Package art finds and prepares album art.
//
// Sources: iTunes, Deezer and MusicBrainz/Cover Art Archive (no key, made
// for album covers), DuckDuckGo images (unofficial endpoint, best effort) and
// Google Custom Search (needs an API key and search engine id). Only square
// images are returned.
//
// Images are fetched by the server, so all fetching goes through a client
// that refuses private, loopback and link-local addresses: a crafted result
// or pasted URL cannot reach the local network.
package art
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"syscall"
"time"
)
// Sources.
const (
ITunes = "itunes"
Deezer = "deezer"
MusicBrainz = "musicbrainz"
DuckDuckGo = "duckduckgo"
Google = "google"
)
var AllSources = []string{ITunes, Deezer, MusicBrainz, DuckDuckGo, Google}
// MatchSources are reliable enough for automatic matching.
var MatchSources = []string{ITunes, Deezer, MusicBrainz}
type Result struct {
Source string `json:"source"`
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
URL string `json:"url"`
Thumb string `json:"thumb"`
Width int `json:"width"`
Height int `json:"height"`
}
type Query struct {
Artist string `json:"artist"`
Album string `json:"album"`
Text string `json:"text"` // free-form search for the web engines
}
func (q Query) text() string {
if strings.TrimSpace(q.Text) != "" {
return strings.TrimSpace(q.Text)
}
return strings.TrimSpace(q.Artist + " " + q.Album)
}
type Config struct {
Sources []string
GoogleKey string
GoogleCX string
MinSize int
}
// Client talks to the art sources. The base URLs can be pointed at test
// servers.
type Client struct {
HTTP *http.Client
UserAgent string
ITunesURL, DeezerURL, MusicBrainzURL, CAAURL, DDGURL, GoogleURL string
}
func New(version string) *Client {
return &Client{
HTTP: SafeHTTPClient(20 * time.Second),
UserAgent: "playlink/" + version + " (self-hosted music library tool)",
ITunesURL: "https://itunes.apple.com",
DeezerURL: "https://api.deezer.com",
MusicBrainzURL: "https://musicbrainz.org",
CAAURL: "https://coverartarchive.org",
DDGURL: "https://duckduckgo.com",
GoogleURL: "https://www.googleapis.com",
}
}
// ---- safe fetching ----
var ErrBlockedAddress = errors.New("that address is on a private or local network and is not fetched")
func blockedIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() || ip.IsInterfaceLocalMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64 { // 100.64.0.0/10 (CGNAT)
return true
}
return false
}
// SafeHTTPClient refuses to connect to private/local addresses, including
// after redirects and DNS tricks (the check runs on the resolved address).
func SafeHTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 10 * time.Second, Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip == nil || blockedIP(ip) {
return ErrBlockedAddress
}
return nil
}}
tr := &http.Transport{Proxy: nil, DialContext: dialer.DialContext, TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 15 * time.Second, MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}
return &http.Client{Transport: tr, Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return errors.New("only http and https are fetched")
}
return nil
}}
}
// MaxImageBytes caps a downloaded image.
const MaxImageBytes = 20 << 20
// FetchImage downloads an image URL (http/https only, size capped).
func (c *Client) FetchImage(ctx context.Context, raw string) ([]byte, error) {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("enter an http or https image address")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("Accept", "image/*")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the image server answered %s", res.Status)
}
if ct := res.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "application/octet-stream") {
return nil, fmt.Errorf("that address is not an image (%s)", ct)
}
b, err := io.ReadAll(io.LimitReader(res.Body, MaxImageBytes+1))
if err != nil {
return nil, err
}
if len(b) > MaxImageBytes {
return nil, errors.New("the image is larger than 20 MB")
}
return b, nil
}
func (c *Client) getJSON(ctx context.Context, u string, header map[string]string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("Accept", "application/json")
for k, val := range header {
req.Header.Set(k, val)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%s", res.Status)
}
return json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(v)
}
// ---- search ----
// Square reports whether an image is square: within 0.5% or 2 pixels.
func Square(w, h int) bool {
if w <= 0 || h <= 0 {
return false
}
d := w - h
if d < 0 {
d = -d
}
return d <= max(2, max(w, h)/200)
}
// Search queries the enabled sources concurrently and returns square images
// of at least the minimum size, best first. Per-source errors are returned
// separately so one failing source does not hide the others.
func (c *Client) Search(ctx context.Context, q Query, cfg Config) ([]Result, map[string]string) {
if cfg.MinSize <= 0 {
cfg.MinSize = 300
}
type out struct {
source string
res []Result
err error
}
ch := make(chan out, len(cfg.Sources))
var wg sync.WaitGroup
for _, s := range cfg.Sources {
var fn func(context.Context, Query, Config) ([]Result, error)
switch s {
case ITunes:
fn = c.itunes
case Deezer:
fn = c.deezer
case MusicBrainz:
fn = c.musicbrainz
case DuckDuckGo:
fn = c.duckduckgo
case Google:
if cfg.GoogleKey == "" || cfg.GoogleCX == "" {
continue
}
fn = c.google
default:
continue
}
wg.Add(1)
go func(s string, fn func(context.Context, Query, Config) ([]Result, error)) {
defer wg.Done()
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
r, err := fn(ctx, q, cfg)
ch <- out{s, r, err}
}(s, fn)
}
wg.Wait()
close(ch)
errs := map[string]string{}
seen := map[string]bool{}
var all []Result
for o := range ch {
if o.err != nil {
errs[o.source] = o.err.Error()
}
for _, r := range o.res {
if seen[r.URL] || !Square(r.Width, r.Height) || min(r.Width, r.Height) < cfg.MinSize {
continue
}
seen[r.URL] = true
all = append(all, r)
}
}
rank := map[string]int{ITunes: 0, Deezer: 1, MusicBrainz: 2, Google: 3, DuckDuckGo: 4}
sort.SliceStable(all, func(i, j int) bool {
if rank[all[i].Source] != rank[all[j].Source] {
return rank[all[i].Source] < rank[all[j].Source]
}
return all[i].Width > all[j].Width
})
return all, errs
}
func (c *Client) itunes(ctx context.Context, q Query, _ Config) ([]Result, error) {
var d struct {
Results []struct {
ArtistName string `json:"artistName"`
CollectionName string `json:"collectionName"`
ArtworkURL100 string `json:"artworkUrl100"`
} `json:"results"`
}
u := c.ITunesURL + "/search?" + url.Values{"term": {q.text()}, "entity": {"album"}, "limit": {"25"}}.Encode()
if err := c.getJSON(ctx, u, nil, &d); err != nil {
return nil, err
}
var out []Result
for _, r := range d.Results {
if !strings.Contains(r.ArtworkURL100, "100x100") {
continue
}
// iTunes serves the same artwork at any requested size.
out = append(out, Result{Source: ITunes, Title: r.CollectionName, Artist: r.ArtistName, Width: 1000, Height: 1000,
URL: strings.Replace(r.ArtworkURL100, "100x100", "1000x1000", 1), Thumb: strings.Replace(r.ArtworkURL100, "100x100", "300x300", 1)})
}
return out, nil
}
func (c *Client) deezer(ctx context.Context, q Query, _ Config) ([]Result, error) {
query := q.text()
if q.Artist != "" && q.Album != "" && q.Text == "" {
query = fmt.Sprintf(`artist:"%s" album:"%s"`, q.Artist, q.Album)
}
var d struct {
Data []struct {
Title string `json:"title"`
CoverXL string `json:"cover_xl"`
CoverMedium string `json:"cover_medium"`
Artist struct {
Name string `json:"name"`
} `json:"artist"`
} `json:"data"`
}
if err := c.getJSON(ctx, c.DeezerURL+"/search/album?"+url.Values{"q": {query}, "limit": {"25"}}.Encode(), nil, &d); err != nil {
return nil, err
}
var out []Result
for _, r := range d.Data {
if r.CoverXL == "" {
continue
}
out = append(out, Result{Source: Deezer, Title: r.Title, Artist: r.Artist.Name, URL: r.CoverXL, Thumb: r.CoverMedium, Width: 1000, Height: 1000})
}
return out, nil
}
func (c *Client) musicbrainz(ctx context.Context, q Query, cfg Config) ([]Result, error) {
query := q.text()
if q.Album != "" && q.Text == "" {
query = fmt.Sprintf(`releasegroup:"%s"`, strings.ReplaceAll(q.Album, `"`, ""))
if q.Artist != "" {
query += fmt.Sprintf(` AND artist:"%s"`, strings.ReplaceAll(q.Artist, `"`, ""))
}
}
var d struct {
ReleaseGroups []struct {
ID string `json:"id"`
Title string `json:"title"`
ArtistCredit []struct {
Name string `json:"name"`
} `json:"artist-credit"`
} `json:"release-groups"`
}
u := c.MusicBrainzURL + "/ws/2/release-group/?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"8"}}.Encode()
if err := c.getJSON(ctx, u, nil, &d); err != nil {
return nil, err
}
// The Cover Art Archive does not report sizes: check each front cover's
// thumbnail to keep only square ones (and skip groups without art).
var mu sync.Mutex
var wg sync.WaitGroup
var out []Result
for _, rg := range d.ReleaseGroups {
artist := ""
if len(rg.ArtistCredit) > 0 {
artist = rg.ArtistCredit[0].Name
}
wg.Add(1)
go func(id, title, artist string) {
defer wg.Done()
thumb := c.CAAURL + "/release-group/" + id + "/front-250"
w, h, err := c.imageSize(ctx, thumb)
if err != nil || !Square(w, h) {
return
}
mu.Lock()
out = append(out, Result{Source: MusicBrainz, Title: title, Artist: artist, URL: c.CAAURL + "/release-group/" + id + "/front-1200",
Thumb: thumb, Width: 1200, Height: 1200})
mu.Unlock()
}(rg.ID, rg.Title, artist)
}
wg.Wait()
return out, nil
}
// imageSize reads just enough of an image to learn its dimensions.
func (c *Client) imageSize(ctx context.Context, u string) (int, int, error) {
b, err := c.FetchImage(ctx, u)
if err != nil {
return 0, 0, err
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(b))
return cfg.Width, cfg.Height, err
}
var vqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
func (c *Client) duckduckgo(ctx context.Context, q Query, _ Config) ([]Result, error) {
text := q.Text
if strings.TrimSpace(text) == "" {
text = q.text() + " album cover"
}
// DuckDuckGo has no official image API: a page request yields the token
// its own image search uses. This can break if they change their site.
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.DDGURL+"/?"+url.Values{"q": {text}, "iax": {"images"}, "ia": {"images"}}.Encode(), nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) playlink")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
page, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20))
res.Body.Close()
m := vqdRe.FindSubmatch(page)
if m == nil {
return nil, errors.New("DuckDuckGo did not return a search token (their site may have changed)")
}
var d struct {
Results []struct {
Title string `json:"title"`
Image string `json:"image"`
Thumbnail string `json:"thumbnail"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"results"`
}
u := c.DDGURL + "/i.js?" + url.Values{"q": {text}, "vqd": {string(m[1])}, "o": {"json"}, "l": {"us-en"}, "f": {",,,,,layout:Square"}, "p": {"1"}}.Encode()
if err := c.getJSON(ctx, u, map[string]string{"Referer": c.DDGURL + "/", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) playlink"}, &d); err != nil {
return nil, err
}
var out []Result
for _, r := range d.Results {
out = append(out, Result{Source: DuckDuckGo, Title: r.Title, URL: r.Image, Thumb: r.Thumbnail, Width: r.Width, Height: r.Height})
}
return out, nil
}
func (c *Client) google(ctx context.Context, q Query, cfg Config) ([]Result, error) {
text := q.Text
if strings.TrimSpace(text) == "" {
text = q.text() + " album cover"
}
var d struct {
Items []struct {
Title string `json:"title"`
Link string `json:"link"`
Image struct {
Width int `json:"width"`
Height int `json:"height"`
ThumbnailLink string `json:"thumbnailLink"`
} `json:"image"`
} `json:"items"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
u := c.GoogleURL + "/customsearch/v1?" + url.Values{"key": {cfg.GoogleKey}, "cx": {cfg.GoogleCX}, "q": {text},
"searchType": {"image"}, "num": {"10"}, "imgSize": {"large"}}.Encode()
if err := c.getJSON(ctx, u, nil, &d); err != nil {
return nil, fmt.Errorf("Google: %v (check the API key and search engine id)", err)
}
var out []Result
for _, it := range d.Items {
out = append(out, Result{Source: Google, Title: it.Title, URL: it.Link, Thumb: it.Image.ThumbnailLink, Width: it.Image.Width, Height: it.Image.Height})
}
return out, nil
}
-204
View File
@@ -1,204 +0,0 @@
package art
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
)
var ctx = context.Background()
func pngBytes(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for x := 0; x < w; x++ {
img.Set(x, 0, color.RGBA{200, 0, 0, 255})
}
var b bytes.Buffer
png.Encode(&b, img)
return b.Bytes()
}
// fakeSources serves canned answers for every source.
func fakeSources(t *testing.T, failDeezer bool) *Client {
t.Helper()
mux := http.NewServeMux()
var base string
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { // iTunes
fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base)
})
mux.HandleFunc("/search/album", func(w http.ResponseWriter, r *http.Request) { // Deezer
if failDeezer {
http.Error(w, "down", 500)
return
}
fmt.Fprintf(w, `{"data":[{"title":"Nightmare","cover_xl":"%s/dz/xl.jpg","cover_medium":"%s/dz/m.jpg","artist":{"name":"Avenged Sevenfold"}}]}`, base, base)
})
mux.HandleFunc("/ws/2/release-group/", func(w http.ResponseWriter, r *http.Request) { // MusicBrainz
fmt.Fprint(w, `{"release-groups":[{"id":"sq","title":"Nightmare","artist-credit":[{"name":"Avenged Sevenfold"}]},
{"id":"wide","title":"Nightmare (Live)","artist-credit":[{"name":"Avenged Sevenfold"}]},{"id":"none","title":"X"}]}`)
})
mux.HandleFunc("/release-group/sq/front-250", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes(250, 250))
})
mux.HandleFunc("/release-group/wide/front-250", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes(250, 180))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // DDG page with token
if r.URL.Path == "/" {
fmt.Fprint(w, `<html><script>vqd="4-12345";</script></html>`)
return
}
http.NotFound(w, r)
})
mux.HandleFunc("/i.js", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("vqd") != "4-12345" || !strings.Contains(r.URL.Query().Get("f"), "layout:Square") {
http.Error(w, "bad", 400)
return
}
fmt.Fprint(w, `{"results":[{"title":"near square","image":"https://x/a.jpg","thumbnail":"t","width":600,"height":594},
{"title":"square","image":"https://x/b.jpg","thumbnail":"t","width":800,"height":800},
{"title":"tiny","image":"https://x/c.jpg","thumbnail":"t","width":200,"height":200}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
base = srv.URL
c := New("test")
c.HTTP = srv.Client() // the safe client refuses loopback, which the fakes use
c.ITunesURL, c.DeezerURL, c.MusicBrainzURL, c.CAAURL, c.DDGURL, c.GoogleURL = base, base, base, base, base, base
return c
}
func TestSearchSquareOnly(t *testing.T) {
c := fakeSources(t, false)
res, errs := c.Search(ctx, Query{Artist: "Avenged Sevenfold", Album: "Nightmare"}, Config{Sources: AllSources})
if len(errs) != 0 {
t.Fatalf("errors: %v", errs)
}
got := map[string]Result{}
for _, r := range res {
got[r.Source+":"+r.Title] = r
}
for _, want := range []string{"itunes:Nightmare", "deezer:Nightmare", "musicbrainz:Nightmare", "duckduckgo:square"} {
if _, ok := got[want]; !ok {
t.Errorf("missing %s in %+v", want, res)
}
}
for _, not := range []string{"duckduckgo:near square", "duckduckgo:tiny", "musicbrainz:Nightmare (Live)"} {
if _, ok := got[not]; ok {
t.Errorf("%s must be filtered out (not square or too small)", not)
}
}
if r := got["itunes:Nightmare"]; !strings.HasSuffix(r.URL, "/img/1000x1000bb.jpg") || res[0].Source != ITunes {
t.Errorf("iTunes should come first at 1000px: %+v", r)
}
for _, r := range res {
if r.Source == Google {
t.Error("Google must not be queried without an API key")
}
}
}
func TestOneFailingSourceDoesNotHideOthers(t *testing.T) {
c := fakeSources(t, true)
res, errs := c.Search(ctx, Query{Artist: "A", Album: "B"}, Config{Sources: []string{ITunes, Deezer}})
if errs[Deezer] == "" || len(res) != 1 || res[0].Source != ITunes {
t.Fatalf("want iTunes result plus a Deezer error, got %+v %v", res, errs)
}
}
func TestSquare(t *testing.T) {
for _, c := range []struct {
w, h int
want bool
}{{1000, 1000, true}, {1000, 996, true}, {600, 594, false}, {500, 498, true}, {0, 0, false}, {1200, 1100, false}} {
if Square(c.w, c.h) != c.want {
t.Errorf("Square(%d,%d) != %v", c.w, c.h, c.want)
}
}
}
func TestSafeFetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/page" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, "<html>")
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes(10, 10))
}))
defer srv.Close()
c := New("test") // the real, safe client
if _, err := c.FetchImage(ctx, srv.URL+"/img.png"); !errors.Is(err, ErrBlockedAddress) {
t.Fatalf("loopback must be refused, got %v", err)
}
for _, u := range []string{"file:///etc/passwd", "ftp://x/y.png", "gopher://x", "not a url"} {
if _, err := c.FetchImage(ctx, u); err == nil {
t.Errorf("%q must be refused", u)
}
}
c.HTTP = srv.Client()
if _, err := c.FetchImage(ctx, srv.URL+"/page"); err == nil || !strings.Contains(err.Error(), "not an image") {
t.Fatalf("non-images must be refused, got %v", err)
}
if b, err := c.FetchImage(ctx, srv.URL+"/img.png"); err != nil || len(b) == 0 {
t.Fatalf("image fetch: %v", err)
}
for _, ip := range []string{"10.0.0.1", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "::1", "fc00::1", "0.0.0.0"} {
if !blockedIP(parseIP(ip)) {
t.Errorf("%s should be blocked", ip)
}
}
if blockedIP(parseIP("1.1.1.1")) {
t.Error("public addresses are allowed")
}
}
func TestPrepare(t *testing.T) {
ff, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not installed")
}
s := &Store{Dir: t.TempDir(), FFmpeg: ff}
p, err := s.Prepare(ctx, pngBytes(1600, 1600), 1000)
if err != nil {
t.Fatal(err)
}
if p.Width != 1000 || p.Height != 1000 || !p.Square {
t.Fatalf("want 1000x1000 square, got %+v", p)
}
path, err := s.Path(p.Token)
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(path)
if len(b) < 3 || b[0] != 0xFF || b[1] != 0xD8 {
t.Fatal("prepared image should be a JPEG")
}
if small, _ := s.Prepare(ctx, pngBytes(300, 300), 1000); small.Width != 300 {
t.Fatal("images are never enlarged")
}
if _, err := s.Prepare(ctx, []byte("not an image"), 1000); err == nil {
t.Fatal("garbage must be refused")
}
for _, bad := range []string{"../etc", "abc", strings.Repeat("g", 32)} {
if _, err := s.Path(bad); !errors.Is(err, ErrBadToken) {
t.Errorf("token %q must be refused", bad)
}
}
}
func parseIP(s string) net.IP { return net.ParseIP(s) }
-121
View File
@@ -1,121 +0,0 @@
package art
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"image"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"time"
)
// Prepared images are downloaded or uploaded covers converted to a clean
// JPEG, waiting to be applied. They live in a cache folder under a random
// token and expire after a few hours.
type Prepared struct {
Token string `json:"token"`
Width int `json:"width"`
Height int `json:"height"`
Size int64 `json:"size"`
Square bool `json:"square"`
}
type Store struct {
Dir string
FFmpeg string
}
var tokenRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
var ErrBadToken = errors.New("unknown or expired image; choose it again")
const preparedTTL = 3 * time.Hour
// Prepare converts image bytes to a JPEG no larger than maxSize pixels on
// its longest side (0 keeps the original size).
func (s *Store) Prepare(ctx context.Context, data []byte, maxSize int) (Prepared, error) {
if s.FFmpeg == "" {
return Prepared{}, errors.New("preparing images needs ffmpeg")
}
if err := os.MkdirAll(s.Dir, 0o750); err != nil {
return Prepared{}, err
}
s.cleanup()
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return Prepared{}, err
}
token := hex.EncodeToString(b[:])
raw := filepath.Join(s.Dir, token+".src")
out := filepath.Join(s.Dir, token+".jpg")
defer os.Remove(raw)
if err := os.WriteFile(raw, data, 0o640); err != nil {
return Prepared{}, err
}
scale := "scale=iw:ih"
if maxSize > 0 {
m := strconv.Itoa(maxSize)
scale = "scale='if(gt(iw,ih),min(" + m + ",iw),-2)':'if(gt(iw,ih),-2,min(" + m + ",ih))'"
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.FFmpeg, "-v", "error", "-nostdin", "-y", "-i", raw, "-frames:v", "1",
"-vf", scale+",format=yuvj420p", "-q:v", "2", "-f", "image2", out)
if msg, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(out)
return Prepared{}, fmt.Errorf("that file is not a usable image: %s", firstLine(string(msg)))
}
f, err := os.Open(out)
if err != nil {
return Prepared{}, err
}
cfg, _, err := image.DecodeConfig(f)
f.Close()
if err != nil {
_ = os.Remove(out)
return Prepared{}, fmt.Errorf("converted image unreadable: %w", err)
}
fi, _ := os.Stat(out)
return Prepared{Token: token, Width: cfg.Width, Height: cfg.Height, Size: fi.Size(), Square: Square(cfg.Width, cfg.Height)}, nil
}
// Path returns the prepared JPEG for a token.
func (s *Store) Path(token string) (string, error) {
if !tokenRe.MatchString(token) {
return "", ErrBadToken
}
p := filepath.Join(s.Dir, token+".jpg")
fi, err := os.Stat(p)
if err != nil || time.Since(fi.ModTime()) > preparedTTL {
return "", ErrBadToken
}
return p, nil
}
func (s *Store) cleanup() {
ents, _ := os.ReadDir(s.Dir)
for _, en := range ents {
if fi, err := en.Info(); err == nil && time.Since(fi.ModTime()) > preparedTTL {
_ = os.Remove(filepath.Join(s.Dir, en.Name()))
}
}
}
func firstLine(s string) string {
for i, r := range s {
if r == '\n' {
return s[:i]
}
}
if len(s) > 200 {
return s[:200]
}
return s
}
-565
View File
@@ -1,565 +0,0 @@
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// FolderCoverName is the album-folder image playlink writes (read by Plex,
// Jellyfin, Navidrome and most players).
const FolderCoverName = "cover.jpg"
var folderCoverNames = []string{"cover.jpg", "folder.jpg", "cover.png", "folder.png", "front.jpg"}
// SetArt enables album art search and preparation.
func (e *Engine) SetArt(c *art.Client, s *art.Store) { e.artClient, e.artStore = c, s }
func (e *Engine) preparedArt(token string) (string, error) {
if e.artStore == nil {
return "", errors.New("album art is not available")
}
return e.artStore.Path(token)
}
// ---- settings ----
// ArtSettings are the album art options (Settings -> Album art).
type ArtSettings struct {
Sources []string `json:"sources"`
GoogleKey string `json:"google_key"`
GoogleCX string `json:"google_cx"`
MaxSize int `json:"max_size"` // longest side of prepared covers; 0 keeps the original
MinSize int `json:"min_size"` // smallest search result shown
}
func (e *Engine) ArtSettings(ctx context.Context) ArtSettings {
s := ArtSettings{
GoogleKey: e.st.Setting(ctx, "art_google_key", ""),
GoogleCX: e.st.Setting(ctx, "art_google_cx", ""),
}
src := e.st.Setting(ctx, "art_sources", strings.Join([]string{art.ITunes, art.Deezer, art.MusicBrainz, art.DuckDuckGo, art.Google}, ","))
for _, x := range strings.Split(src, ",") {
if x != "" {
s.Sources = append(s.Sources, x)
}
}
s.MaxSize, _ = strconv.Atoi(e.st.Setting(ctx, "art_max_size", "1000"))
s.MinSize, _ = strconv.Atoi(e.st.Setting(ctx, "art_min_size", "500"))
return s
}
func (e *Engine) SetArtSettings(ctx context.Context, s ArtSettings) error {
known := map[string]bool{}
for _, x := range art.AllSources {
known[x] = true
}
var src []string
for _, x := range s.Sources {
if !known[x] {
return fmt.Errorf("unknown art source %q", x)
}
src = append(src, x)
}
if s.MaxSize != 0 && (s.MaxSize < 300 || s.MaxSize > 4000) {
return errors.New("cover size must be 300-4000 pixels, or 0 to keep the original")
}
if s.MinSize < 100 || s.MinSize > 3000 {
return errors.New("minimum search result size must be 100-3000 pixels")
}
for k, v := range map[string]string{"art_sources": strings.Join(src, ","), "art_google_key": strings.TrimSpace(s.GoogleKey),
"art_google_cx": strings.TrimSpace(s.GoogleCX), "art_max_size": strconv.Itoa(s.MaxSize), "art_min_size": strconv.Itoa(s.MinSize)} {
if err := e.st.SetSetting(ctx, k, v); err != nil {
return err
}
}
return nil
}
func (e *Engine) artConfig(ctx context.Context, sources []string) art.Config {
s := e.ArtSettings(ctx)
enabled := map[string]bool{}
for _, x := range s.Sources {
enabled[x] = true
}
var use []string
for _, x := range sources {
if enabled[x] {
use = append(use, x)
}
}
if len(sources) == 0 {
use = s.Sources
}
return art.Config{Sources: use, GoogleKey: s.GoogleKey, GoogleCX: s.GoogleCX, MinSize: s.MinSize}
}
// SearchArt searches album art (square images only).
func (e *Engine) SearchArt(ctx context.Context, q art.Query, sources []string) ([]art.Result, map[string]string, error) {
if e.artClient == nil {
return nil, nil, errors.New("album art is not available")
}
res, errs := e.artClient.Search(ctx, q, e.artConfig(ctx, sources))
if res == nil {
res = []art.Result{}
}
return res, errs, nil
}
// PrepareArtURL downloads and prepares an image for applying.
func (e *Engine) PrepareArtURL(ctx context.Context, u string) (art.Prepared, error) {
if e.artClient == nil || e.artStore == nil {
return art.Prepared{}, errors.New("album art is not available")
}
b, err := e.artClient.FetchImage(ctx, u)
if err != nil {
return art.Prepared{}, err
}
return e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
}
// PrepareArtUpload prepares an uploaded image.
func (e *Engine) PrepareArtUpload(ctx context.Context, r io.Reader) (art.Prepared, error) {
if e.artStore == nil {
return art.Prepared{}, errors.New("album art is not available")
}
b, err := io.ReadAll(io.LimitReader(r, art.MaxImageBytes+1))
if err != nil {
return art.Prepared{}, err
}
if len(b) > art.MaxImageBytes {
return art.Prepared{}, errors.New("the image is larger than 20 MB")
}
return e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
}
func (e *Engine) PreparedArtPath(token string) (string, error) { return e.preparedArt(token) }
// FetchArtThumb downloads a search result thumbnail for display.
func (e *Engine) FetchArtThumb(ctx context.Context, u string) ([]byte, error) {
if e.artClient == nil {
return nil, errors.New("album art is not available")
}
return e.artClient.FetchImage(ctx, u)
}
// ---- reading covers ----
// Cover returns the art for a song (embedded) or an album folder (its image
// file), as image bytes.
func (e *Engine) Cover(ctx context.Context, pairID int64, side, rel string) ([]byte, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
abs := root.Path
if rel != "" {
if abs, err = root.Resolve(rel); err != nil {
return nil, err
}
}
fi, err := os.Lstat(abs)
if err != nil {
return nil, err
}
if fi.IsDir() {
for _, n := range folderCoverNames {
p := filepath.Join(abs, n)
if in, err := fsops.RegularFile(p); err == nil && in.Size < art.MaxImageBytes {
return os.ReadFile(p)
}
}
return nil, nil
}
if !fi.Mode().IsRegular() {
return nil, fsops.ErrNotRegular
}
return e.tagTools.Cover(ctx, abs)
}
// AlbumSong is one song in the album editor.
type AlbumSong struct {
TagFile
HasCover bool `json:"has_cover"`
EmbedCover bool `json:"embed_cover"` // the format can hold embedded art
}
// Album is an album folder with its songs (disc folders included).
type Album struct {
Folder string `json:"folder"`
Songs []AlbumSong `json:"songs"`
FolderCover string `json:"folder_cover,omitempty"` // name of the folder image, if any
}
func (e *Engine) AlbumSongs(ctx context.Context, pairID int64, side, folder string) (Album, error) {
a := Album{Folder: folder, Songs: []AlbumSong{}}
if !e.TagEditing() {
return a, ErrNoTagTools
}
l, err := e.loadPair(ctx, pairID)
if err != nil {
return a, err
}
root, err := sideRoot(l, side)
if err != nil {
return a, err
}
dir := root.Path
if folder != "" {
if dir, err = root.Resolve(folder); err != nil {
return a, err
}
}
for _, n := range folderCoverNames {
if _, err := fsops.RegularFile(filepath.Join(dir, n)); err == nil {
a.FolderCover = n
break
}
}
var rels []string
collect := func(d, prefix string) {
ents, _ := os.ReadDir(d)
for _, en := range ents {
if en.Type().IsRegular() && l.hasExt(en.Name()) && !strings.HasPrefix(en.Name(), ".") {
rels = append(rels, filepath.Join(prefix, en.Name()))
}
}
}
collect(dir, folder)
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.IsDir() && discFolder.MatchString(en.Name()) {
collect(filepath.Join(dir, en.Name()), filepath.Join(folder, en.Name()))
}
}
if len(rels) == 0 {
return a, nil
}
if len(rels) > maxTagFiles {
return a, fmt.Errorf("%w: the folder has more than %d songs", ErrInvalidBulk, maxTagFiles)
}
files, err := e.ReadTags(ctx, pairID, side, rels)
if err != nil {
return a, err
}
for _, f := range files {
s := AlbumSong{TagFile: f}
if fm, ok := tags.FormatOf(f.Rel); ok {
s.EmbedCover = fm.EmbedsCover()
}
if f.Editable {
if abs, err := root.Resolve(f.Rel); err == nil {
s.HasCover = e.tagTools.HasCover(ctx, abs)
}
}
a.Songs = append(a.Songs, s)
}
return a, nil
}
// ---- folder image ----
type coverDetail struct {
Side string `json:"side"`
Existed bool `json:"existed"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
}
// writeFolderCover writes cover.jpg into an album folder. An existing one is
// kept in quarantine first; the operation can be undone.
func (e *Engine) writeFolderCover(ctx context.Context, l *libs, side, folder, src string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, _ := sideRoot(l, side)
rel := filepath.Join(folder, FolderCoverName)
abs, err := root.Resolve(rel)
if err != nil {
return err
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActCover, QuarantineSide: side, Trigger: "art"}
setSidePath(&o, side, rel)
old, oldErr := fsops.RegularFile(abs)
d := coverDetail{Side: side, Existed: oldErr == nil}
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
tmp := filepath.Join(filepath.Dir(abs), fsops.TmpPrefix+"cover.jpg")
_ = os.Remove(tmp)
err = func() error {
if d.Existed {
q := quarantinePath(l, o.ID, side, rel)
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
if err := fsops.LinkNew(abs, old, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
}
if err := fsops.CopyNew(src, tmp, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, abs); err != nil {
_ = os.Remove(tmp)
return err
}
in, _ := fsops.Lstat(abs)
d.Size, d.Mtime = in.Size, in.MtimeNs
det, _ := json.Marshal(d)
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET detail = ? WHERE id = ?`, string(det), o.ID)
return fsops.SyncDir(filepath.Dir(abs))
}()
return e.finish(ctx, &o, err)
}
// undoCover restores the previous folder image, or removes the new one.
func (e *Engine) undoCover(l *libs, o store.Operation) error {
var d coverDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
abs, err := root.Resolve(rel)
if err != nil {
return err
}
cur, err := fsops.RegularFile(abs)
if err != nil {
return err
}
if !cur.Unchanged(d.Size, d.Mtime) {
return fmt.Errorf("%s changed after it was written; refusing to undo", rel)
}
if d.Existed {
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
if err := fsops.ReplaceWithLink(o.QuarantinePath, qi, abs); err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
return nil
}
if err := os.Remove(abs); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(abs))
}
// ---- automatic matching ----
// ArtMatch are the options of an automatic match.
type ArtMatch struct {
Side string `json:"side"`
Folder string `json:"folder"`
OnlyMissing bool `json:"only_missing"`
Embed bool `json:"embed"`
FolderFile bool `json:"folder_file"`
}
// matchKey normalizes an artist or album name for comparison: case,
// punctuation, a leading "The" and release labels such as "(Deluxe)" are
// ignored.
func matchKey(s string) string {
s = stripRelease(s)
s = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s)), "the ")
var b strings.Builder
for _, r := range accentFold.Replace(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// bestArt picks a confident match: artist and album must both closely match.
func bestArt(results []art.Result, artist, album string) (art.Result, float64, bool) {
ak, bk := matchKey(artist), matchKey(album)
var best art.Result
bestScore := 0.0
for _, r := range results {
if r.Artist == "" || r.Title == "" {
continue
}
sa := levenshteinRatio(matchKey(r.Artist), ak)
sb := levenshteinRatio(matchKey(r.Title), bk)
if sa < 0.85 || sb < 0.85 {
continue
}
score := sa*0.4 + sb*0.6 + float64(min(r.Width, 3000))/100000
if score > bestScore {
best, bestScore = r, score
}
}
return best, bestScore, bestScore > 0
}
// StartArtAutoMatch finds and applies covers for the albums in a folder.
func (e *Engine) StartArtAutoMatch(pairID int64, m ArtMatch) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if e.artClient == nil || e.artStore == nil {
return nil, errors.New("album art is not available")
}
if m.Side != SideMaster && m.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
if !m.Embed && !m.FolderFile {
return nil, fmt.Errorf("%w: choose where the art goes", ErrInvalidBulk)
}
return e.startJob(pairID, "art-auto-match", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
root, _ := sideRoot(l, m.Side)
start := root
if m.Folder != "" {
abs, err := root.Resolve(m.Folder)
if err != nil {
return err
}
if start, err = fsops.NewRoot(abs); err != nil {
return err
}
}
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, start, files); err != nil {
return err
}
type album struct {
artist, title string
rels []string
}
albums := map[string]*album{}
var order []string
j.phase("Reading tags", int64(len(files)))
for _, rel := range sortedKeys(files) {
j.tick()
full := filepath.Join(m.Folder, rel)
fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(start.Path, rel), files[rel])
if err != nil {
continue
}
v := valuesOf(fields)
if v.artist == "" || v.album == "" {
j.skip(full + ": missing artist or album tag")
continue
}
key := matchKey(v.artist) + "|" + matchKey(v.album)
if albums[key] == nil {
albums[key] = &album{artist: v.artist, title: v.album}
order = append(order, key)
}
albums[key].rels = append(albums[key].rels, full)
}
j.phase("Finding covers", int64(len(order)))
for _, key := range order {
if ctx.Err() != nil {
return ctx.Err()
}
a := albums[key]
label := a.artist + " – " + a.title
j.tick()
need := a.rels
if m.OnlyMissing {
need = nil
for _, rel := range a.rels {
abs, _ := root.Resolve(rel)
if fm, ok := tags.FormatOf(rel); ok && fm.EmbedsCover() && m.Embed && !e.tagTools.HasCover(ctx, abs) {
need = append(need, rel)
}
}
folderMissing := false
if m.FolderFile {
for _, f := range albumFolders(a.rels) {
if abs, err := root.Resolve(f); err == nil && !hasFolderCover(abs) {
folderMissing = true
}
}
}
if len(need) == 0 && !folderMissing {
j.count("already have art")
continue
}
}
res, _ := e.artClient.Search(ctx, art.Query{Artist: a.artist, Album: a.title}, e.artConfig(ctx, art.MatchSources))
best, _, ok := bestArt(res, a.artist, a.title)
if !ok {
j.skip(label + ": no confident match")
continue
}
b, err := e.artClient.FetchImage(ctx, best.URL)
if err != nil {
j.skip(label + ": download failed: " + err.Error())
continue
}
p, err := e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
if err != nil {
j.skip(label + ": " + err.Error())
continue
}
cover, _ := e.artStore.Path(p.Token)
if m.Embed {
for _, rel := range need {
if err := e.editTagsOne(ctx, j, l, m.Side, rel, nil, cover, false); err != nil {
j.skip(rel + ": " + err.Error())
}
}
}
if m.FolderFile {
for _, f := range albumFolders(a.rels) {
abs, _ := root.Resolve(f)
if m.OnlyMissing && hasFolderCover(abs) {
continue
}
if err := e.writeFolderCover(ctx, l, m.Side, f, cover); err != nil {
j.skip(f + " cover.jpg: " + err.Error())
}
}
}
j.count("albums matched")
j.note(fmt.Sprintf("%s: %s (%dx%d)", label, best.Source, p.Width, p.Height))
}
return nil
})
}
func hasFolderCover(dir string) bool {
for _, n := range folderCoverNames {
if _, err := fsops.RegularFile(filepath.Join(dir, n)); err == nil {
return true
}
}
return false
}
-181
View File
@@ -1,181 +0,0 @@
package engine
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
func squarePNG(n int) []byte {
img := image.NewRGBA(image.Rect(0, 0, n, n))
for i := 0; i < n; i++ {
img.Set(i, i, color.RGBA{0, 0, 200, 255})
}
var b bytes.Buffer
png.Encode(&b, img)
return b.Bytes()
}
// artEnv is a tag-editing env with an art store and a fake iTunes that knows
// one album: Avenged Sevenfold - Nightmare.
func artEnv(t *testing.T) *env {
t.Helper()
ev := tagEnv(t, false)
var base string
mux := http.NewServeMux()
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"results":[{"artistName":"Avenged Sevenfold","collectionName":"Nightmare (Deluxe Edition)","artworkUrl100":"%s/img/100x100bb.jpg"}]}`, base)
})
mux.HandleFunc("/img/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(squarePNG(1200))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
base = srv.URL
c := art.New("test")
c.HTTP = srv.Client()
c.ITunesURL = base
ff, _ := exec.LookPath("ffmpeg")
ev.e.SetArt(c, &art.Store{Dir: filepath.Join(ev.root, "art-cache"), FFmpeg: ff})
ev.st.SetSetting(ctx, "art_sources", "itunes")
return ev
}
func prepared(t *testing.T, ev *env) string {
t.Helper()
p, err := ev.e.artStore.Prepare(ctx, squarePNG(800), 1000)
if err != nil {
t.Fatal(err)
}
return p.Token
}
func TestAlbumEditWithArt(t *testing.T) {
ev := artEnv(t)
dir := "Avenged Sevenfold/Nightmare"
mkTagged(t, filepath.Join(ev.slave, dir, "a.mp3"), "title", "x", "artist", "Avenged Sevenfold", "album", "Nightmar")
mkTagged(t, filepath.Join(ev.slave, dir, "Disc 2/b.flac"), "title", "y", "artist", "Avenged Sevenfold", "album", "Nightmar")
mkTagged(t, filepath.Join(ev.slave, dir, "c.ogg"), "title", "z", "artist", "Avenged Sevenfold", "album", "Nightmar")
a, err := ev.e.AlbumSongs(ctx, ev.pair.ID, SideSlave, dir)
if err != nil || len(a.Songs) != 3 {
t.Fatalf("album should include the disc folder: %+v %v", a, err)
}
req := TagEdit{Side: SideSlave, Set: map[string]string{tags.Album: "Nightmare", tags.Genre: "Metal", tags.TrackTotal: "3"},
Files: []FileEdit{
{Path: dir + "/a.mp3", Set: map[string]string{tags.Title: "Nightmare", tags.Track: "1"}},
{Path: dir + "/Disc 2/b.flac", Set: map[string]string{tags.Title: "Welcome to the Family", tags.Track: "2"}},
{Path: dir + "/c.ogg", Set: map[string]string{tags.Title: "Danger Line", tags.Track: "3"}},
},
Art: &ArtApply{Token: prepared(t, ev), Embed: true, FolderFile: true}}
if _, err := ev.e.StartTagEdit(ev.pair.ID, req); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["edited"] != 3 || j.Results["folder art"] != 1 {
t.Fatalf("results %+v %v", j.Results, j.Skipped)
}
for rel, title := range map[string]string{"a.mp3": "Nightmare", "Disc 2/b.flac": "Welcome to the Family", "c.ogg": "Danger Line"} {
p := filepath.Join(ev.slave, dir, rel)
f, _ := ev.e.tagTools.Read(ctx, p)
if f.Fields[tags.Title] != title || f.Fields[tags.Album] != "Nightmare" || f.Fields[tags.Genre] != "Metal" {
t.Errorf("%s: fields %+v", rel, f.Fields)
}
wantCover := !strings.HasSuffix(rel, ".ogg")
if ev.e.tagTools.HasCover(ctx, p) != wantCover {
t.Errorf("%s: embedded cover should be %v", rel, wantCover)
}
}
if _, err := os.Stat(filepath.Join(ev.slave, dir, "cover.jpg")); err != nil {
t.Fatal("the album folder should get cover.jpg")
}
// Undo the folder image: it is removed again.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActCover {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
}
}
if _, err := os.Stat(filepath.Join(ev.slave, dir, "cover.jpg")); !os.IsNotExist(err) {
t.Fatal("undo should remove the new cover.jpg")
}
}
func TestFolderCoverReplaceKeepsOld(t *testing.T) {
ev := artEnv(t)
dir := "A/B"
mkTagged(t, filepath.Join(ev.slave, dir, "s.flac"), "title", "S", "artist", "A", "album", "B")
old := filepath.Join(ev.slave, dir, "cover.jpg")
os.WriteFile(old, []byte("old cover"), 0o644)
ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{dir + "/s.flac"}, Art: &ArtApply{Token: prepared(t, ev), FolderFile: true}})
ev.wait()
if b, _ := os.ReadFile(old); string(b) == "old cover" {
t.Fatal("cover.jpg should be replaced")
}
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 1})
if ops[0].Action != store.ActCover || ops[0].QuarantinePath == "" {
t.Fatalf("old cover should be quarantined: %+v", ops[0])
}
if err := ev.e.Undo(ctx, ops[0].ID); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(old); string(b) != "old cover" {
t.Fatal("undo should restore the old cover.jpg")
}
}
func TestAutoMatchArt(t *testing.T) {
ev := artEnv(t)
mkTagged(t, filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(ev.slave, "Other/Record/01.mp3"), "title", "Song", "artist", "Other Band", "album", "Record")
m := ArtMatch{Side: SideSlave, OnlyMissing: true, Embed: true, FolderFile: true}
if _, err := ev.e.StartArtAutoMatch(ev.pair.ID, m); err != nil {
t.Fatal(err)
}
j := ev.wait()
if j.Results["albums matched"] != 1 || len(j.Notes) != 1 || !strings.Contains(strings.Join(j.Skipped, "\n"), "Other Band – Record: no confident match") {
t.Fatalf("results %+v notes %v skipped %v", j.Results, j.Notes, j.Skipped)
}
if !ev.e.tagTools.HasCover(ctx, filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01.mp3")) {
t.Fatal("the matched album should get embedded art")
}
if ev.e.tagTools.HasCover(ctx, filepath.Join(ev.slave, "Other/Record/01.mp3")) {
t.Fatal("an unmatched album must not get art")
}
// Running again: the matched album already has art.
ev.e.StartArtAutoMatch(ev.pair.ID, m)
if j := ev.wait(); j.Results["already have art"] != 1 {
t.Fatalf("second run: %+v", j.Results)
}
}
func TestBestArt(t *testing.T) {
res := []art.Result{
{Source: "itunes", Artist: "Avenged Sevenfold", Title: "Nightmare (Deluxe Edition)", Width: 1000},
{Source: "deezer", Artist: "Avenged Sevenfold", Title: "Hail to the King", Width: 1000},
{Source: "deezer", Artist: "Someone Else", Title: "Nightmare", Width: 1000},
}
best, _, ok := bestArt(res, "Avenged Sevenfold", "Nightmare")
if !ok || best.Title != "Nightmare (Deluxe Edition)" {
t.Fatalf("got %+v", best)
}
if _, _, ok := bestArt(res, "The Beatles", "Abbey Road"); ok {
t.Fatal("no confident match expected")
}
}
-37
View File
@@ -13,12 +13,9 @@ import (
"sync"
"time"
"github.com/cdnutter/playlink/internal/art"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// DefaultExtensions are the audio file types considered by default.
@@ -44,20 +41,7 @@ type Engine struct {
// the same files.
mu sync.Mutex
tagTools tags.Tools
beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
artClient *art.Client
artStore *art.Store
meta *meta.Client
fixMu sync.Mutex
fixPlans map[int64]*FixPlan // latest fixer check per pair
fixSeq int64
plansMu sync.Mutex
plans map[int64]*organizePlan // latest "rename now" preview per pair
jobsMu sync.Mutex
jobs map[int64]*Job
@@ -84,7 +68,6 @@ type Job struct {
Error string `json:"error"`
Results map[string]int `json:"results"`
Skipped []string `json:"skipped"`
Notes []string `json:"notes"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at,omitempty"`
@@ -106,15 +89,6 @@ func (j *Job) tick() { j.update(func(j *Job) { j.Done++ }) }
func (j *Job) count(key string) { j.update(func(j *Job) { j.Results[key]++ }) }
// note records an informational line (e.g. which cover was chosen).
func (j *Job) note(msg string) {
j.update(func(j *Job) {
if len(j.Notes) < 300 {
j.Notes = append(j.Notes, msg)
}
})
}
func (j *Job) skip(msg string) {
j.update(func(j *Job) {
j.Results["skipped"]++
@@ -138,7 +112,6 @@ func (e *Engine) Job(pairID int64) *Job {
cp.Results[k] = v
}
cp.Skipped = append([]string{}, j.Skipped...) // never nil: the UI expects an array
cp.Notes = append([]string{}, j.Notes...)
return &cp
}
@@ -316,16 +289,6 @@ func (e *Engine) NormalizePair(p *store.Pair) ([]store.Warning, error) {
if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 {
return nil, errors.New("intervals must not be negative")
}
switch p.LibraryType {
case "":
p.LibraryType = LibraryMusic
case LibraryMusic, LibrarySoundtracks:
default:
return nil, errors.New("library type must be music or soundtracks")
}
if err := normalizeOrganize(p); err != nil {
return nil, err
}
if p.ApproxThreshold == 0 {
p.ApproxThreshold = 85
}
+16
View File
@@ -565,3 +565,19 @@ func TestBeforeActionOncePerScan(t *testing.T) {
t.Fatal("a failed backup must stop the action")
}
}
// History can hold operations of removed features; they must never be
// undone as if they were links.
func TestUndoRefusesUnknownActions(t *testing.T) {
ev := setup(t, false)
o := store.Operation{PairID: ev.pair.ID, Action: "tags", RelSlave: "A/B/x.flac"}
if err := ev.st.CreateOp(ctx, &o); err != nil {
t.Fatal(err)
}
if err := ev.st.FinishOp(ctx, o.ID, store.OpDone, ""); err != nil {
t.Fatal(err)
}
if err := ev.e.Undo(ctx, o.ID); err == nil || !strings.Contains(err.Error(), "can no longer be undone") {
t.Fatalf("got %v", err)
}
}
-599
View File
@@ -1,599 +0,0 @@
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
)
// The tune-up takes each library through the same steps, the master first and
// then the slave:
//
// 1. structure: every album in its own folder at the right depth (Artist/
// Album for music, Album for soundtracks), disc folders tagged, and missing
// tags filled from folder and file names;
// 2. album tags (optional): look albums up online and review the changes;
// 3. folder names: album and artist folders renamed from the tags;
// 4. file names: songs renamed from the tags.
//
// Once both libraries are fixed they are linked. Every step is a check that
// produces a plan; nothing changes until the user applies the fixes they
// selected, and every change is an operation that can be undone.
const (
StepStructure = "structure"
StepTags = "tags"
StepFolders = "folders"
StepFiles = "files"
StepLink = "link"
)
// FixerSteps are the steps each library goes through, in order.
var FixerSteps = []string{StepStructure, StepTags, StepFolders, StepFiles}
// Kinds of fix.
const (
FixMoveFolder = "move-folder" // move or rename a folder with everything in it
FixMoveFile = "move-file" // move or rename one song
FixTags = "tags" // change one song's tags
FixAlbumTags = "album-tags" // change the tags of an album's songs (online lookup)
FixProblem = "problem" // needs a person: shown, never applied
)
type StepStatus struct {
State string `json:"state"` // "" (to do), done or skipped
At int64 `json:"at,omitempty"`
Note string `json:"note,omitempty"`
}
type FixerState struct {
Master map[string]StepStatus `json:"master"`
Slave map[string]StepStatus `json:"slave"`
Link StepStatus `json:"link"`
}
// FixerStateOf reads a pair's tune-up progress.
func FixerStateOf(p store.Pair) FixerState {
var s FixerState
if len(p.FixerState) > 0 {
_ = json.Unmarshal(p.FixerState, &s)
}
if s.Master == nil {
s.Master = map[string]StepStatus{}
}
if s.Slave == nil {
s.Slave = map[string]StepStatus{}
}
return s
}
func validStep(side, step string) error {
if step == StepLink && side == "" {
return nil
}
if side != SideMaster && side != SideSlave {
return fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
for _, s := range FixerSteps {
if s == step {
return nil
}
}
return fmt.Errorf("%w: unknown step %q", ErrInvalidBulk, step)
}
// SetStep records a step as done, skipped or to do ("").
func (e *Engine) SetStep(ctx context.Context, pairID int64, side, step, state, note string) error {
if err := validStep(side, step); err != nil {
return err
}
if state != "" && state != "done" && state != "skipped" {
return fmt.Errorf("%w: state must be done, skipped or empty", ErrInvalidBulk)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
p, err := e.st.Pair(ctx, pairID)
if err != nil {
return err
}
s := FixerStateOf(p)
st := StepStatus{State: state, Note: note}
if state != "" {
st.At = time.Now().Unix()
}
switch side {
case SideMaster:
s.Master[step] = st
case SideSlave:
s.Slave[step] = st
default:
s.Link = st
}
raw, _ := json.Marshal(s)
return e.st.SetFixerState(ctx, pairID, raw)
}
// FixItem is one proposed fix (or a problem that needs a person).
type FixItem struct {
ID int `json:"id"`
Kind string `json:"kind"`
Group string `json:"group"` // album folder, for grouping
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Why string `json:"why"`
Blocked string `json:"blocked,omitempty"` // why it can't be fixed automatically
Changes map[string][2]string `json:"changes,omitempty"` // tags: field -> [old, new]
// Unsure fixes are listed but not selected by default.
Unsure bool `json:"unsure,omitempty"`
org *organizeItem // moves: the file as it was when checked
}
// FixPlan is the result of checking one step of one library.
type FixPlan struct {
ID int64 `json:"id"` // unique per check, so selections never carry over
Side string `json:"side"`
Step string `json:"step"`
CreatedAt int64 `json:"created_at"`
Songs int `json:"songs"`
Albums int `json:"albums"`
Fixable int `json:"fixable"`
Blocked int `json:"blocked"`
Notices []string `json:"notices"`
Items []FixItem `json:"-"`
AlbumFixes []*AlbumFix `json:"-"`
Applied bool `json:"applied"`
}
func (p *FixPlan) add(it FixItem) {
it.ID = len(p.Items) + 1
if it.Kind == FixProblem || it.Blocked != "" {
p.Blocked++
} else {
p.Fixable++
}
p.Items = append(p.Items, it)
}
// FixerPlan returns the latest plan of a pair and its items.
func (e *Engine) FixerPlan(pairID int64) (*FixPlan, []FixItem, []*AlbumFix) {
e.fixMu.Lock()
defer e.fixMu.Unlock()
p := e.fixPlans[pairID]
if p == nil {
return nil, nil, nil
}
return p, append([]FixItem{}, p.Items...), append([]*AlbumFix{}, p.AlbumFixes...)
}
// FixCheck asks for one step of one library to be checked.
type FixCheck struct {
Side string `json:"side"`
Step string `json:"step"`
Folder string `json:"folder"` // optional: only this folder (album tags)
// OnlyIncomplete skips albums whose tags already look complete (album tags).
OnlyIncomplete bool `json:"only_incomplete"`
}
// StartFixCheck checks a step in a background job; the plan is kept for review.
func (e *Engine) StartFixCheck(pairID int64, c FixCheck) (*Job, error) {
if err := validStep(c.Side, c.Step); err != nil {
return nil, err
}
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if c.Folder != "" {
f, err := fsops.CleanRel(c.Folder)
if err != nil {
return nil, err
}
c.Folder = f
}
if c.Step == StepTags && e.meta == nil {
return nil, errors.New("online lookup is not available")
}
return e.startJob(pairID, "tune-up "+c.Step, func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
plan := &FixPlan{Side: c.Side, Step: c.Step, CreatedAt: time.Now().Unix(), Notices: []string{}}
switch c.Step {
case StepStructure:
err = e.checkStructure(ctx, j, l, plan)
case StepTags:
err = e.checkAlbumTags(ctx, j, l, plan, c)
case StepFolders:
err = e.checkFolders(ctx, j, l, plan)
case StepFiles:
err = e.checkFiles(ctx, j, l, plan)
}
if err != nil {
return err
}
e.fixMu.Lock()
if e.fixPlans == nil {
e.fixPlans = map[int64]*FixPlan{}
}
e.fixSeq++
plan.ID = e.fixSeq
e.fixPlans[pairID] = plan
e.fixMu.Unlock()
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d to fix, %d need attention", plan.Fixable, plan.Blocked)
})
return nil
})
}
// FixApply selects fixes of the latest plan.
type FixApply struct {
Plan int64 `json:"plan"` // the plan the ids belong to
IDs []int `json:"ids"`
}
// StartFixApply applies the selected fixes of the latest plan. Tags are
// changed first (at the paths that were checked), then folders move, then
// files. When the step has nothing left to fix it is marked done.
func (e *Engine) StartFixApply(pairID int64, req FixApply) (*Job, error) {
e.fixMu.Lock()
plan := e.fixPlans[pairID]
e.fixMu.Unlock()
if plan == nil {
return nil, fmt.Errorf("%w: check the step first", ErrInvalidBulk)
}
if req.Plan != plan.ID {
return nil, fmt.Errorf("%w: the check was replaced by a newer one; review it and apply again", ErrInvalidBulk)
}
if plan.Applied {
return nil, fmt.Errorf("%w: the check is out of date; check again", ErrInvalidBulk)
}
want := map[int]bool{}
for _, id := range req.IDs {
want[id] = true
}
applied := 0
var tagItems, folderItems []FixItem
var moves []organizeItem
var albums []*AlbumFix
for _, it := range plan.Items {
if !want[it.ID] || it.Kind == FixProblem || it.Blocked != "" {
continue
}
applied++
switch it.Kind {
case FixTags:
tagItems = append(tagItems, it)
case FixMoveFolder:
folderItems = append(folderItems, it)
case FixMoveFile:
moves = append(moves, *it.org)
case FixAlbumTags:
for _, a := range plan.AlbumFixes {
if a.ID == it.ID {
albums = append(albums, a)
}
}
}
}
if len(tagItems)+len(folderItems)+len(moves)+len(albums) == 0 {
return nil, fmt.Errorf("%w: nothing selected", ErrInvalidBulk)
}
side, step := plan.Side, plan.Step
return e.startJob(pairID, "tune-up "+step+" (apply)", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
e.fixMu.Lock()
plan.Applied = true // paths and tags change: the plan is stale
e.fixMu.Unlock()
trigger := "tune-up"
edited := map[string]bool{}
if n := len(tagItems) + songsIn(albums); n > 0 {
j.phase("Updating tags", int64(n))
for _, it := range tagItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.editTagsOne(ctx, j, l, side, it.From, newValues(it.Changes), "", false); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
edited[it.From] = true
}
j.tick()
}
for _, a := range albums {
for _, f := range a.Files {
if ctx.Err() != nil {
return ctx.Err()
}
if len(f.Changes) > 0 {
if err := e.editTagsOne(ctx, j, l, side, f.Path, newValues(f.Changes), "", false); err != nil {
j.skip(f.Path + ": " + err.Error())
}
}
j.tick()
}
j.count("albums updated")
}
}
if len(folderItems) > 0 {
j.phase("Moving folders", int64(len(folderItems)))
for _, it := range folderItems {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.moveDirOp(ctx, l, side, it.From, it.To, trigger); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
j.count("folders moved")
}
j.tick()
}
}
if len(moves) > 0 {
// A song whose tags were just rewritten is a new file: take it as it is now.
root, _ := sideRoot(l, side)
for i, m := range moves {
if edited[m.From] {
if in, err := fsops.RegularFile(filepath.Join(root.Path, m.From)); err == nil {
moves[i].ino, moves[i].size, moves[i].mtime = in.Ino, in.Size, in.MtimeNs
}
}
}
if err := e.applyOrganize(ctx, j, l, moves, trigger); err != nil {
return err
}
}
dry := e.st.DryRun(ctx)
skipped := 0
j.update(func(j *Job) {
skipped = j.Results["skipped"]
if dry {
j.Message = "Dry run: changes recorded as planned in Activity"
}
})
// Done when every fix was applied and nothing needs a person.
if !dry && skipped == 0 && applied >= plan.Fixable && plan.Blocked == 0 {
if err := e.SetStep(ctx, pairID, side, step, "done", fmt.Sprintf("%d fixed", applied)); err != nil {
e.log.Warn("tune-up step not recorded", "err", err)
}
}
return nil
})
}
func songsIn(albums []*AlbumFix) int {
n := 0
for _, a := range albums {
n += len(a.Files)
}
return n
}
func newValues(changes map[string][2]string) map[string]string {
set := map[string]string{}
for k, v := range changes {
set[k] = v[1]
}
return set
}
// ---- moving folders ----
type dirMoveDetail struct {
Side string `json:"side"`
}
// moveDirOp moves a folder (with everything in it) within one library.
func (e *Engine) moveDirOp(ctx context.Context, l *libs, side, from, to, trigger string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, err := sideRoot(l, side)
if err != nil {
return err
}
if why := checkDirMove(l, root, from, to); why != "" {
return errors.New(why)
}
src, _ := root.Resolve(from)
dst, _ := root.Resolve(to)
o := store.Operation{PairID: l.pair.ID, Action: store.ActMoveDir, RelNew: to, Trigger: trigger}
setSidePath(&o, side, from)
o.Detail, _ = json.Marshal(dirMoveDetail{Side: side})
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
err = func() error {
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := renameDir(src, dst); err != nil {
pruneEmpty(filepath.Dir(dst), root.Path)
return err
}
_ = fsops.SyncDir(filepath.Dir(src))
pruneEmpty(filepath.Dir(src), root.Path)
return nil
}()
return e.finish(ctx, &o, err)
}
// checkDirMove returns why a folder can't be moved, or "".
func checkDirMove(l *libs, root fsops.Root, from, to string) string {
src, err := root.Resolve(from)
if err != nil {
return err.Error()
}
dst, err := root.Resolve(to)
if err != nil {
return err.Error()
}
if src == root.Path || dst == root.Path {
return "the library folder itself can't be moved"
}
fi, err := os.Lstat(src)
switch {
case err != nil:
return "the folder no longer exists (check again)"
case fi.Mode()&os.ModeSymlink != 0:
return "is a symlink"
case !fi.IsDir():
return "is not a folder"
case src == l.quarantine || fsops.Within(src, l.quarantine):
return "contains the quarantine folder"
case fsops.Within(src, dst):
return "can't move a folder into itself"
}
if di, err := os.Lstat(dst); err == nil && !(strings.EqualFold(from, to) && os.SameFile(fi, di)) {
return fmt.Sprintf("%q already exists; merge the two by hand", to)
}
return ""
}
// undoMoveDir moves a folder back.
func undoMoveDir(l *libs, o store.Operation) error {
var d dirMoveDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
cur, err := root.Resolve(o.RelNew)
if err != nil {
return err
}
old, err := root.Resolve(from)
if err != nil {
return err
}
fi, err := os.Lstat(cur)
if err != nil || !fi.IsDir() {
return fmt.Errorf("the folder %q no longer exists", o.RelNew)
}
if di, err := os.Lstat(old); err == nil && !(strings.EqualFold(o.RelNew, from) && os.SameFile(fi, di)) {
return fmt.Errorf("%q exists again: %w", from, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
return err
}
if err := renameDir(cur, old); err != nil {
return err
}
pruneEmpty(filepath.Dir(cur), root.Path)
return nil
}
// recoverMoveDir finishes a case-only folder move cut short by a crash.
func recoverMoveDir(l *libs, o store.Operation, log *slog.Logger) {
var d dirMoveDetail
if json.Unmarshal(o.Detail, &d) != nil {
return
}
root, err := sideRoot(l, d.Side)
if err != nil {
return
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
src, dst := filepath.Join(root.Path, from), filepath.Join(root.Path, o.RelNew)
if _, err := os.Lstat(src); err == nil {
return // never moved
}
if _, err := os.Lstat(dst); err == nil {
return // completed
}
dir := filepath.Dir(src)
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.IsDir() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"dir-") {
if err := os.Rename(filepath.Join(dir, en.Name()), dst); err == nil {
log.Warn("finished interrupted folder move", "op", o.ID, "folder", o.RelNew)
}
return
}
}
}
// ---- library layout ----
// albumInfo is one album folder of a library as found on disk.
type albumInfo struct {
Dir string // the album folder (disc folders folded in)
Songs []string // every song, including those in disc folders
Discs map[string]int // disc folder -> disc number
Loose bool // songs directly in the album folder
Nested bool // other album folders live inside this one
}
// albumsOf groups a library's songs into album folders.
func albumsOf(files map[string]fsops.Info) []*albumInfo {
byDir := map[string]*albumInfo{}
for _, rel := range sortedKeys(files) {
dir := filepath.Dir(rel)
album, disc := dir, 0
if dir != "." {
if n := discNumber(filepath.Base(dir)); n > 0 && filepath.Dir(dir) != "." {
album, disc = filepath.Dir(dir), n
}
}
a := byDir[album]
if a == nil {
a = &albumInfo{Dir: album, Discs: map[string]int{}}
byDir[album] = a
}
a.Songs = append(a.Songs, rel)
if disc > 0 {
a.Discs[dir] = disc
} else {
a.Loose = true
}
}
out := make([]*albumInfo, 0, len(byDir))
for _, a := range byDir {
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return out[i].Dir < out[j].Dir })
for _, a := range out {
for _, b := range out {
if a != b && (a.Dir == "." || strings.HasPrefix(b.Dir, a.Dir+"/")) {
a.Nested = true
break
}
}
}
return out
}
// discNumber reads "Disc 2", "CD02", "disk_3" or "Disc 1 - Title" as a disc number.
func discNumber(name string) int {
m := discNum.FindStringSubmatch(name)
if m == nil {
return 0
}
return leadingNumber(m[1])
}
-500
View File
@@ -1,500 +0,0 @@
package engine
import (
"context"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/tags"
)
// discNum recognizes disc folders. "Vol. 2" is left out on purpose: volumes
// are usually albums of their own.
var discNum = regexp.MustCompile(`(?i)^(?:disc|disk|cd|dvd|part)[\s._-]*(\d{1,2})(?:\b|$)`)
// junkFolder matches extra folders inside an album that only describe the
// files ("FLAC", "MP3 320", "24bit"), whose songs belong in the album folder.
var junkFolder = regexp.MustCompile(`(?i)^[\[(]?\s*(?:flac|mp3|aac|m4a|alac|ogg|opus|wav|lossless|lossy|hi-?res|web|` +
`(?:mp3\s*)?(?:320|256|192|v0|v2)(?:\s*kbps)?|\d{2}\s*-?\s*bit.*|audio|tracks?|files?|songs?)\s*[\])]?$`)
// folderNoise is release information in folder names: "Nightmare (2010)",
// "Nightmare [FLAC]", "2010 - Nightmare".
var (
folderNoise = regexp.MustCompile(`(?i)\s*[\[({]\s*(?:\d{4}(?:[-.]\d{2}){0,2}|flac|mp3|aac|alac|ogg|opus|web|cd|vinyl|lossless|` +
`deluxe|remaster(?:ed)?|\d{2}\s*-?\s*bit[^\])}]*|\d+(?:\.\d+)?\s*k(?:hz|bps)[^\])}]*|v0|v2|320|\d{2}-\d{2,3})\s*[\])}]\s*$`)
yearPrefix = regexp.MustCompile(`^\s*[\[(]?\d{4}[\])]?\s*[-–.]\s+`)
)
// cleanFolderName turns a folder name into an album or artist name.
func cleanFolderName(s string) string {
for {
t := folderNoise.ReplaceAllString(s, "")
t = yearPrefix.ReplaceAllString(t, "")
t = strings.TrimSpace(t)
if t == s || t == "" {
return s
}
s = t
}
}
// layoutFor returns the formats that apply at rel and the folder they are
// relative to ("" for the library, or a folder with its own naming).
func (c NamingConfig) layoutFor(rel string) (string, formats) {
if rule, ok := c.folderOf(rel); ok {
return rel[:len(rule.Folder)], rule.formats()
}
return "", c.formats()
}
// albumDepth is how many folders deep albums sit: Artist/Album or Album.
func (f formats) albumDepth() int {
if f.artist == "" {
return 1
}
return 2
}
// renderComponent renders a one-folder format from a song's tags.
func renderComponent(c NamingConfig, format string, f map[string]string) string {
v := valuesOf(f)
return finishComponent(tokenPat.ReplaceAllStringFunc(format, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) }))
}
// renderAlbumDir is the album folder a song at rel belongs in, from its tags.
func renderAlbumDir(c NamingConfig, rel string, f map[string]string) (string, string) {
flat := c
flat.TrackFormat, flat.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders = make([]FolderNaming, len(c.Folders))
for i, r := range c.Folders {
r.TrackFormat, r.MultiDiscFormat = "{Track Title}", "{Track Title}"
flat.Folders[i] = r
}
g := map[string]string{}
for k, v := range f {
g[k] = v
}
if strings.TrimSpace(g[tags.Title]) == "" {
g[tags.Title] = "x" // the title doesn't name folders
}
p, why := renderPath(flat, rel, g)
if why != "" {
return "", why
}
return filepath.Dir(p), ""
}
func depthOf(rel string) int {
if rel == "" || rel == "." {
return 0
}
return strings.Count(rel, "/") + 1
}
// majority is the most common non-empty value of a field.
func majority(fields map[string]map[string]string, songs []string, keys ...string) string {
for _, key := range keys {
count := map[string]int{}
best, bestN := "", 0
for _, s := range songs {
v := strings.TrimSpace(fields[s][key])
if v == "" {
continue
}
count[v]++
if count[v] > bestN || (count[v] == bestN && v < best) {
best, bestN = v, count[v]
}
}
if best != "" {
return best
}
}
return ""
}
func similar(a, b string) bool {
ka, kb := matchKey(a), matchKey(b)
return ka != "" && kb != "" && (ka == kb || levenshteinRatio(ka, kb) >= 0.8)
}
// readLibrary lists a library's songs and reads their tags.
func (e *Engine) readLibrary(ctx context.Context, j *Job, l *libs, side string) (fsops.Root, map[string]fsops.Info, map[string]map[string]string, error) {
root, err := sideRoot(l, side)
if err != nil {
return root, nil, nil, err
}
j.phase("Listing "+side+" files", 0)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, root, files); err != nil {
return root, nil, nil, err
}
j.phase("Reading "+side+" tags", int64(len(files)))
fields := make(map[string]map[string]string, len(files))
for _, rel := range sortedKeys(files) {
if ctx.Err() != nil {
return root, nil, nil, ctx.Err()
}
f := map[string]string{}
if _, ok := tags.FormatOf(rel); ok {
if got, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), files[rel]); err == nil && got != nil {
f = got
}
}
fields[rel] = f
j.tick()
}
return root, files, fields, nil
}
func (e *Engine) moveItem(root fsops.Root, side, from, to string, in fsops.Info) *organizeItem {
return &organizeItem{Side: side, From: from, To: to, Move: true, ino: in.Ino, size: in.Size, mtime: in.MtimeNs,
Sidecars: sidecarsOf(root, from)}
}
// ---- step 1: structure ----
func (e *Engine) checkStructure(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{} // lower(new path) -> old path
claim := func(from, to string) string {
k := strings.ToLower(to)
if c := claimed[k]; c != "" && c != from {
return "another item gets the same name (" + c + ")"
}
claimed[k] = from
return ""
}
// Songs that change tags here, so a move of the same song can re-read it.
type deep struct {
item int
extra string
}
var deeps []deep
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
prefix, fm := naming.layoutFor(filepath.Join(a.Dir, "x"))
rest := strings.TrimPrefix(strings.TrimPrefix(a.Dir, prefix), "/")
if rest == "." {
rest = ""
}
want, depth := fm.albumDepth(), depthOf(rest)
target := a.Dir // where the album will be, for filling tags from names
// looseInto moves songs into album folders named from their tags,
// under base (or where the naming puts them when base is "").
looseInto := func(songs []string, base, why string) {
for _, s := range songs {
var dir, blocked string
if base == "" {
dir, blocked = renderAlbumDir(naming, s, fields[s])
} else if strings.TrimSpace(fields[s][tags.Album]) == "" {
blocked = "missing album tag"
} else {
dir = filepath.Join(base, renderComponent(naming, fm.album, fields[s]))
}
it := FixItem{Kind: FixMoveFile, From: s, Why: why, Group: dir}
if blocked != "" {
it.Blocked = blocked + ": set it in the Tags tab, then check again"
it.Group = filepath.Dir(s)
} else {
it.To = filepath.Join(dir, filepath.Base(s))
if targetTaken(root, s, it.To) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, it.To); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, it.To, files[s])
}
}
plan.add(it)
}
}
switch {
case depth == 0:
where := "the top of the library"
if prefix != "" {
where = "the top of " + prefix
}
looseInto(a.Songs, "", "Not in an album folder (at "+where+")")
continue
case depth < want:
// Music with one folder: an album without an artist folder, or an
// artist folder with songs directly inside it.
album := majority(fields, a.Songs, tags.Album)
artist := majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
name := filepath.Base(a.Dir)
isArtist := a.Nested || (artist != "" && similar(artist, cleanFolderName(name)) && !similar(album, cleanFolderName(name)))
if isArtist {
looseInto(a.Songs, a.Dir, "Songs directly in the artist folder, not in an album folder")
continue
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, Why: "Album folder is not inside an artist folder"}
if artist == "" {
it.Blocked = "its songs have no artist tag: set the album artist (Edit album in the Tags tab), then check again"
it.Group = a.Dir
} else {
f := map[string]string{tags.AlbumArtist: artist}
it.To = filepath.Join(prefix, renderComponent(naming, fm.artist, f), name)
it.Group = it.To
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
target = it.To
}
plan.add(it)
case depth > want:
comps := strings.Split(rest, "/")
last := comps[len(comps)-1]
if junkFolder.MatchString(last) && !a.Nested {
parent := filepath.Dir(a.Dir)
why := fmt.Sprintf("Extra folder %q inside the album", last)
for _, s := range a.Songs {
to := filepath.Join(parent, strings.TrimPrefix(s, a.Dir+"/"))
it := FixItem{Kind: FixMoveFile, From: s, To: to, Why: why, Group: parent}
if targetTaken(root, s, to) {
it.Blocked = "a different file already has that name"
} else if c := claim(s, to); c != "" {
it.Blocked = c
} else {
it.org = e.moveItem(root, plan.Side, s, to, files[s])
}
plan.add(it)
}
target = parent
break
}
extra := comps[:len(comps)-want]
to := filepath.Join(prefix, filepath.Join(comps[len(comps)-want:]...))
kind := "Album"
if want == 1 {
kind = "Soundtrack"
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to,
Why: fmt.Sprintf("%s is inside extra folders (%s)", kind, strings.Join(extra, "/"))}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if why := checkDirMove(l, root, it.From, it.To); why != "" {
it.Blocked = why
} else if c := claim(it.From, it.To); c != "" {
it.Blocked = c
}
}
plan.add(it)
deeps = append(deeps, deep{item: len(plan.Items) - 1, extra: filepath.Join(prefix, extra[0])})
target = to
}
// Disc folders and tags filled from folder and file names.
if len(a.Discs) > 0 && a.Loose {
plan.add(FixItem{Kind: FixProblem, Group: a.Dir, From: a.Dir, Why: "Songs are both in the album folder and in disc folders",
Blocked: "move them into the right disc folder by hand"})
}
discTotal := 0
for _, n := range a.Discs {
discTotal = max(discTotal, n)
}
albumName := cleanFolderName(filepath.Base(target))
artistName := ""
if want == 2 && depthOf(strings.TrimPrefix(strings.TrimPrefix(target, prefix), "/")) == 2 {
artistName = cleanFolderName(filepath.Base(filepath.Dir(target)))
}
for _, s := range a.Songs {
f := fields[s]
if _, ok := tags.FormatOf(s); !ok {
continue
}
ch := map[string][2]string{}
var why []string
set := func(k, v string) {
if v != "" && strings.TrimSpace(f[k]) != v {
ch[k] = [2]string{f[k], v}
}
}
if n := a.Discs[filepath.Dir(s)]; n > 0 {
if leadingNumber(f[tags.Disc]) != n {
set(tags.Disc, strconv.Itoa(n))
}
if leadingNumber(f[tags.DiscTotal]) != discTotal {
set(tags.DiscTotal, strconv.Itoa(discTotal))
}
if len(ch) > 0 {
why = append(why, "disc number from its disc folder")
}
}
filled := len(ch)
if strings.TrimSpace(f[tags.Album]) == "" {
set(tags.Album, albumName)
}
if artistName != "" && strings.TrimSpace(f[tags.Artist]) == "" && strings.TrimSpace(f[tags.AlbumArtist]) == "" {
set(tags.Artist, artistName)
}
stem := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))
if strings.TrimSpace(f[tags.Title]) == "" {
if t := strings.TrimSpace(trackPrefix.ReplaceAllString(stem, "")); t != "" {
set(tags.Title, t)
} else {
set(tags.Title, stem)
}
}
if strings.TrimSpace(f[tags.Track]) == "" {
if m := trackNum.FindStringSubmatch(stem); m != nil {
set(tags.Track, strconv.Itoa(leadingNumber(m[1])))
}
}
if len(ch) > filled {
why = append(why, "missing tags filled from the folder and file names")
}
if len(ch) > 0 {
w := strings.Join(why, "; ")
plan.add(FixItem{Kind: FixTags, From: s, Group: target, Changes: ch, Why: strings.ToUpper(w[:1]) + w[1:]})
}
}
}
// Several albums inside the same extra folder look like a category
// ("Video Games/…", "Rock/…") rather than a mistake: don't select those.
byExtra := map[string]int{}
for _, d := range deeps {
byExtra[d.extra]++
}
var cats []string
for _, d := range deeps {
if byExtra[d.extra] >= 3 {
plan.Items[d.item].Unsure = true
}
}
for x, n := range byExtra {
if n >= 3 {
cats = append(cats, fmt.Sprintf("%s (%d albums)", x, n))
}
}
if len(cats) > 0 {
sort.Strings(cats)
plan.Notices = append(plan.Notices, "Some folders hold several albums one level deeper than expected: "+strings.Join(cats, ", ")+
". If they are categories you want to keep (like Video Games or Movies), add them under Organize → Folders with their own naming "+
"instead of moving the albums out. Those moves are not selected.")
}
return nil
}
// ---- step 3: folder names ----
func (e *Engine) checkFolders(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
root, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
albums := albumsOf(files)
plan.Songs, plan.Albums = len(files), len(albums)
claimed := map[string]string{}
for _, a := range albums {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // loose songs: the file step puts them in album folders
}
targets := map[string]string{}
var missing string
untagged := 0
for _, s := range a.Songs {
t, why := renderAlbumDir(naming, s, fields[s])
if why != "" {
missing = why
untagged++
continue
}
targets[strings.ToLower(t)] = t
}
switch {
case len(targets) == 0:
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir, Why: "Can't name this album folder",
Blocked: missing + " (fix it with Edit album in the Tags tab, then check again)"})
continue
case len(targets) > 1:
var names []string
for _, t := range targets {
names = append(names, t)
}
sort.Strings(names)
plan.add(FixItem{Kind: FixProblem, From: a.Dir, Group: a.Dir,
Why: fmt.Sprintf("Songs belong to %d different album folders (%s)", len(names), strings.Join(names, ", ")),
Blocked: "the file step sorts them into their own folders; or make the album tags agree with Edit album"})
continue
}
var to string
for _, t := range targets {
to = t
}
if to == a.Dir {
continue
}
why := "Renamed from its tags"
if filepath.Dir(to) != filepath.Dir(a.Dir) {
why = "Moved to the folder its tags name"
}
if untagged > 0 {
why += fmt.Sprintf(" (%d songs without tags move along)", untagged)
}
it := FixItem{Kind: FixMoveFolder, From: a.Dir, To: to, Group: to, Why: why}
switch {
case a.Nested:
it.Blocked = "other album folders are inside it"
default:
if b := checkDirMove(l, root, a.Dir, to); b != "" {
it.Blocked = b
if strings.Contains(b, "already exists") {
it.Blocked = "that folder already exists; its songs are moved into it in the file step"
}
} else if c := claimed[strings.ToLower(to)]; c != "" {
it.Blocked = "another folder gets the same name (" + c + ")"
} else {
claimed[strings.ToLower(to)] = a.Dir
}
}
plan.add(it)
}
return nil
}
// ---- step 4: file names ----
func (e *Engine) checkFiles(ctx context.Context, j *Job, l *libs, plan *FixPlan) error {
op, err := e.planOrganize(ctx, j, l, []string{plan.Side})
if err != nil {
return err
}
plan.Songs = op.Scanned
for _, it := range op.Items {
fi := FixItem{Kind: FixMoveFile, From: it.From, To: it.To, Why: "Renamed from its tags"}
if it.Move {
org := it
fi.org = &org
fi.Group = filepath.Dir(it.To)
if filepath.Dir(it.To) != filepath.Dir(it.From) {
fi.Why = "Moved and renamed from its tags"
}
} else {
fi.Kind, fi.Blocked, fi.Why, fi.Group = FixProblem, it.Reason, "Can't be renamed", filepath.Dir(it.From)
}
plan.add(fi)
}
return nil
}
-552
View File
@@ -1,552 +0,0 @@
package engine
import (
"context"
"errors"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/tags"
)
// The album tags step looks each album up and proposes corrected tags for
// review. The master is looked up on MusicBrainz; the slave uses the fixed
// master album when there is one (so both libraries agree), and MusicBrainz
// otherwise.
// SetMeta enables online album lookups.
func (e *Engine) SetMeta(c *meta.Client) { e.meta = c }
// Sources of album tags.
const (
SourceMusicBrainz = "musicbrainz"
SourceMaster = "master"
)
// AlbumFix is the lookup result for one album folder.
type AlbumFix struct {
ID int `json:"id"` // the plan item's id
Folder string `json:"folder"`
Artist string `json:"artist"` // what was searched for
Album string `json:"album"`
Source string `json:"source"`
Release *meta.Release `json:"release,omitempty"`
Candidates []meta.Release `json:"candidates"`
Confidence float64 `json:"confidence"` // 0..1
Matched int `json:"matched"` // songs matched to a track
Files []FileFix `json:"files"`
Shared map[string][2]string `json:"album_changes"` // changes to album-wide fields
Error string `json:"error,omitempty"`
songs []string
fields map[string]map[string]string
discs map[string]int
}
// FileFix is the proposed change of one song.
type FileFix struct {
Path string `json:"path"`
Track string `json:"track,omitempty"` // the matched track, "1-03 Title"
Changes map[string][2]string `json:"changes"`
}
// albumComplete reports whether an album's tags already look right: every
// song has a title, track number, album and artist, and they agree.
func albumComplete(songs []string, fields map[string]map[string]string) bool {
albums, artists, tracks := map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, s := range songs {
f := fields[s]
artist := strings.TrimSpace(f[tags.AlbumArtist])
if artist == "" {
artist = strings.TrimSpace(f[tags.Artist])
}
if strings.TrimSpace(f[tags.Title]) == "" || leadingNumber(f[tags.Track]) == 0 || strings.TrimSpace(f[tags.Album]) == "" || artist == "" {
return false
}
key := strconv.Itoa(leadingNumber(f[tags.Disc])) + "-" + strconv.Itoa(leadingNumber(f[tags.Track]))
if tracks[key] {
return false // two songs with the same number
}
tracks[key] = true
albums[f[tags.Album]] = true
artists[artist] = true
}
return len(albums) == 1 && len(artists) == 1
}
func (e *Engine) checkAlbumTags(ctx context.Context, j *Job, l *libs, plan *FixPlan, c FixCheck) error {
_, files, fields, err := e.readLibrary(ctx, j, l, plan.Side)
if err != nil {
return err
}
naming := NamingOf(l.pair)
var albums []*albumInfo
complete := 0
for _, a := range albumsOf(files) {
prefix, _ := naming.layoutFor(filepath.Join(a.Dir, "x"))
if a.Dir == "." || a.Dir == prefix {
continue // not in an album folder: the structure step handles those
}
if c.Folder != "" && a.Dir != c.Folder && !strings.HasPrefix(a.Dir, c.Folder+"/") {
continue
}
if c.OnlyIncomplete && albumComplete(a.Songs, fields) {
complete++
continue
}
albums = append(albums, a)
}
plan.Songs = len(files)
plan.Albums = len(albums)
if complete > 0 {
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already have complete tags and were not looked up.", complete))
}
var master map[string]*masterAlbum
if plan.Side == SideSlave {
master, err = e.masterAlbums(ctx, j, l)
if err != nil {
return err
}
}
j.phase("Looking up albums", int64(len(albums)))
correct := 0
for _, a := range albums {
if ctx.Err() != nil {
return ctx.Err()
}
af := &AlbumFix{Folder: a.Dir, songs: a.Songs, fields: fields, discs: map[string]int{}}
for _, s := range a.Songs {
af.discs[s] = a.Discs[filepath.Dir(s)]
}
af.Album = majority(fields, a.Songs, tags.Album)
if af.Album == "" {
af.Album = cleanFolderName(filepath.Base(a.Dir))
}
af.Artist = majority(fields, a.Songs, tags.AlbumArtist, tags.Artist)
if m := master[fixAlbumKey(af.Artist, af.Album)]; m != nil {
af.useMaster(m)
} else {
e.lookupAlbum(ctx, af, "")
}
if ctx.Err() != nil {
return ctx.Err()
}
j.tick()
if af.Error == "" && !af.hasChanges() {
correct++
continue
}
it := FixItem{Kind: FixAlbumTags, From: a.Dir, Group: a.Dir, Why: af.summary(), Blocked: af.Error, Unsure: af.Confidence < 0.75}
plan.add(it)
af.ID = plan.Items[len(plan.Items)-1].ID
plan.AlbumFixes = append(plan.AlbumFixes, af)
}
if correct > 0 {
plan.Notices = append(plan.Notices, fmt.Sprintf("%d albums already match their lookup.", correct))
}
return nil
}
func (a *AlbumFix) hasChanges() bool {
for _, f := range a.Files {
if len(f.Changes) > 0 {
return true
}
}
return false
}
func (a *AlbumFix) summary() string {
if a.Error != "" {
return "No confident match"
}
n := 0
for _, f := range a.Files {
if len(f.Changes) > 0 {
n++
}
}
src := "MusicBrainz"
if a.Source == SourceMaster {
src = "the master library"
}
return fmt.Sprintf("%d of %d songs change (from %s)", n, len(a.Files), src)
}
func fixAlbumKey(artist, album string) string { return matchKey(artist) + "|" + matchKey(album) }
// ---- the master as reference (slave only) ----
type masterAlbum struct {
folder string
songs []string
fields map[string]map[string]string
}
// masterAlbums indexes the master's albums by artist and album tags.
func (e *Engine) masterAlbums(ctx context.Context, j *Job, l *libs) (map[string]*masterAlbum, error) {
_, files, fields, err := e.readLibrary(ctx, j, l, SideMaster)
if err != nil {
return nil, err
}
out := map[string]*masterAlbum{}
for _, a := range albumsOf(files) {
if a.Dir == "." || !albumComplete(a.Songs, fields) {
continue
}
k := fixAlbumKey(majority(fields, a.Songs, tags.AlbumArtist, tags.Artist), majority(fields, a.Songs, tags.Album))
if out[k] == nil {
out[k] = &masterAlbum{folder: a.Dir, songs: a.Songs, fields: fields}
}
}
return out, nil
}
// useMaster takes the tags of the same album in the master.
func (a *AlbumFix) useMaster(m *masterAlbum) {
a.Source = SourceMaster
d := &meta.ReleaseDetail{Release: meta.Release{ID: "master:" + m.folder, Title: majority(m.fields, m.songs, tags.Album),
Artist: majority(m.fields, m.songs, tags.AlbumArtist, tags.Artist), Date: majority(m.fields, m.songs, tags.Date)}}
discs := map[int]bool{}
for _, s := range m.songs {
f := m.fields[s]
disc := max(leadingNumber(f[tags.Disc]), 1)
discs[disc] = true
d.Tracks = append(d.Tracks, meta.Track{Disc: disc, Position: leadingNumber(f[tags.Track]), DiscTracks: leadingNumber(f[tags.TrackTotal]),
Title: f[tags.Title], Artist: f[tags.Artist]})
}
d.Discs = len(discs)
d.TrackCount = len(d.Tracks)
d.Formats = "master: " + m.folder
a.Candidates = []meta.Release{d.Release}
a.apply(d, 1)
}
// ---- MusicBrainz ----
// lookupAlbum searches MusicBrainz and applies the best release, or the one
// given by id.
func (e *Engine) lookupAlbum(ctx context.Context, a *AlbumFix, id string) {
a.Source, a.Error, a.Release, a.Files, a.Matched, a.Confidence = SourceMusicBrainz, "", nil, nil, 0, 0
if id == "" {
res, err := e.meta.SearchReleases(ctx, a.Artist, a.Album)
if err == nil && len(res) == 0 && a.Artist != "" {
res, err = e.meta.SearchReleases(ctx, "", a.Album) // soundtracks: the artist is often missing or a composer
}
if err != nil {
a.Error = "lookup failed: " + err.Error()
return
}
scored := make([]struct {
r meta.Release
s float64
}, len(res))
for i, r := range res {
scored[i].r, scored[i].s = r, a.releaseScore(r)
}
sort.SliceStable(scored, func(i, j int) bool { return scored[i].s > scored[j].s })
a.Candidates = a.Candidates[:0]
for i, s := range scored {
if i == 8 {
break
}
a.Candidates = append(a.Candidates, s.r)
}
// The search can't tell a promo single from the album it is named
// after: compare the track lists of the best few with the songs.
var best *meta.ReleaseDetail
bestScore := 0.0
for i, c := range scored {
if i == 3 || c.s < 0.45 {
break
}
d, err := e.meta.Release(ctx, c.r.ID)
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
fit := a.trackFit(d)
if score := 0.5*c.s + 0.5*fit; score > bestScore {
best, bestScore = d, score
}
if fit == 1 && c.s >= 0.75 {
break // every song is on this release
}
}
if best == nil || bestScore < 0.6 {
a.Error = "no confident match on MusicBrainz; pick a release or edit the album by hand"
return
}
a.apply(best, bestScore)
return
}
d, err := e.meta.Release(ctx, id)
if err != nil {
a.Error = "lookup failed: " + err.Error()
return
}
a.apply(d, 0.5*a.releaseScore(d.Release)+0.5*a.trackFit(d))
}
// releaseScore rates a search result before its track list is known (0..1).
func (a *AlbumFix) releaseScore(r meta.Release) float64 {
title := levenshteinRatio(matchKey(r.Title), matchKey(a.Album))
artist := 0.6
if a.Artist != "" {
artist = levenshteinRatio(matchKey(r.Artist), matchKey(a.Artist))
}
count := 0.0
switch n := len(a.songs); {
case r.TrackCount == n:
count = 1
case r.TrackCount > n:
count = 0.7 // a partial copy of an album is common
}
s := 0.45*title + 0.2*artist + 0.15*count + 0.2*typeFit(r.Type)
if r.Status != "" && r.Status != "Official" {
s -= 0.05
}
return s
}
// typeFit prefers albums and soundtracks over singles and EPs of the same name.
func typeFit(t string) float64 {
t = strings.ToLower(t)
switch {
case strings.Contains(t, "album"), strings.Contains(t, "soundtrack"):
return 1
case strings.Contains(t, "compilation"):
return 0.8
case strings.Contains(t, "ep"), t == "":
return 0.6
case strings.Contains(t, "single"):
return 0.3
}
return 0.5
}
// trackFit is the share of the album's titled songs found on a release.
func (a *AlbumFix) trackFit(d *meta.ReleaseDetail) float64 {
n, hit := 0, 0
for _, s := range a.songs {
t := matchKey(a.fields[s][tags.Title])
if t == "" {
continue
}
n++
for _, tr := range d.Tracks {
if k := matchKey(tr.Title); k == t || levenshteinRatio(k, t) >= 0.8 {
hit++
break
}
}
}
if n == 0 {
if len(d.Tracks) == len(a.songs) {
return 0.8
}
return 0.4
}
return float64(hit) / float64(n)
}
// ChooseRelease switches an album of the latest plan to another release (a
// MusicBrainz id), or to a new MusicBrainz search ("search").
func (e *Engine) ChooseRelease(ctx context.Context, pairID int64, albumID int, id string) (*AlbumFix, error) {
if e.meta == nil {
return nil, errors.New("online lookup is not available")
}
if id != "search" && !meta.ValidID(id) {
return nil, fmt.Errorf("%w: not a MusicBrainz release id", ErrInvalidBulk)
}
e.fixMu.Lock()
plan := e.fixPlans[pairID]
var af *AlbumFix
if plan != nil && plan.Step == StepTags && !plan.Applied {
for _, a := range plan.AlbumFixes {
if a.ID == albumID {
af = a
}
}
}
e.fixMu.Unlock()
if af == nil {
return nil, fmt.Errorf("%w: that album is not in the latest check", ErrInvalidBulk)
}
// Work on a copy so a failed or slow lookup never leaves a half-updated album.
cp := *af
if id == "search" {
id = ""
}
e.lookupAlbum(ctx, &cp, id)
if cp.Error != "" && cp.Release == nil && id != "" {
return nil, errors.New(cp.Error)
}
e.fixMu.Lock()
defer e.fixMu.Unlock()
*af = cp
for i := range plan.Items {
if plan.Items[i].ID == albumID {
wasBlocked := plan.Items[i].Blocked != ""
plan.Items[i].Blocked, plan.Items[i].Why, plan.Items[i].Unsure = af.Error, af.summary(), af.Confidence < 0.75
if wasBlocked && af.Error == "" {
plan.Blocked--
plan.Fixable++
} else if !wasBlocked && af.Error != "" {
plan.Blocked++
plan.Fixable--
}
}
}
return af, nil
}
// apply matches the album's songs to a release's tracks and works out the
// tag changes.
func (a *AlbumFix) apply(d *meta.ReleaseDetail, confidence float64) {
rel := d.Release
a.Release, a.Confidence, a.Error = &rel, confidence, ""
match := matchTracks(a.songs, a.fields, a.discs, d.Tracks)
a.Files, a.Matched = nil, 0
shared := map[string][2]string{}
for i, s := range a.songs {
f := a.fields[s]
want := map[string]string{tags.Album: d.Title, tags.AlbumArtist: d.Artist}
if d.Date != "" && !(len(f[tags.Date]) >= 4 && len(d.Date) >= 4 && f[tags.Date][:4] == d.Date[:4]) {
want[tags.Date] = d.Date
}
ff := FileFix{Path: s}
if t := match[i]; t >= 0 {
tr := d.Tracks[t]
a.Matched++
want[tags.Title], want[tags.Artist] = tr.Title, tr.Artist
if tr.Position > 0 {
want[tags.Track] = strconv.Itoa(tr.Position)
}
if tr.DiscTracks > 0 {
want[tags.TrackTotal] = strconv.Itoa(tr.DiscTracks)
}
if d.Discs > 1 || leadingNumber(f[tags.Disc]) > 1 {
want[tags.Disc] = strconv.Itoa(tr.Disc)
want[tags.DiscTotal] = strconv.Itoa(d.Discs)
}
ff.Track = fmt.Sprintf("%d-%02d %s", tr.Disc, tr.Position, tr.Title)
}
ff.Changes = map[string][2]string{}
for k, v := range want {
if strings.TrimSpace(v) == "" || sameTag(k, f[k], v) {
continue
}
ff.Changes[k] = [2]string{f[k], v}
if k == tags.Album || k == tags.AlbumArtist || k == tags.Date {
shared[k] = [2]string{f[k], v}
}
}
a.Files = append(a.Files, ff)
}
a.Shared = shared
if len(a.songs) > 0 && a.Matched*2 < len(a.songs) {
a.Confidence = min(a.Confidence, 0.5)
}
}
func sameTag(k, old, new string) bool {
switch k {
case tags.Track, tags.TrackTotal, tags.Disc, tags.DiscTotal:
return leadingNumber(old) == leadingNumber(new)
}
return strings.TrimSpace(old) == strings.TrimSpace(new)
}
// matchTracks pairs each song with a track of the release (-1: none). Track
// and disc numbers are trusted when the titles mostly agree with them;
// otherwise songs are matched by title, and failing that by file order when
// the counts are equal.
func matchTracks(songs []string, fields map[string]map[string]string, discs map[string]int, tracks []meta.Track) []int {
out := make([]int, len(songs))
for i := range out {
out[i] = -1
}
titleOf := func(i int) string {
if t := strings.TrimSpace(fields[songs[i]][tags.Title]); t != "" {
return matchKey(t)
}
return normTitle(filepath.Base(songs[i]))
}
sim := func(i, t int) float64 { return levenshteinRatio(titleOf(i), matchKey(tracks[t].Title)) }
// 1. by disc and track number
byNum := map[[2]int]int{}
multi := false
for t, tr := range tracks {
byNum[[2]int{tr.Disc, tr.Position}] = t
multi = multi || tr.Disc > 1
}
used := map[int]bool{}
agree, tried := 0, 0
for i, s := range songs {
n := leadingNumber(fields[s][tags.Track])
disc := leadingNumber(fields[s][tags.Disc])
if disc == 0 {
disc = discs[s]
}
if disc == 0 && !multi {
disc = 1
}
if t, ok := byNum[[2]int{disc, n}]; ok && n > 0 && !used[t] {
out[i], used[t] = t, true
tried++
if sim(i, t) >= 0.6 {
agree++
}
}
}
if tried > 0 && agree*10 < tried*7 {
for i := range out { // numbers don't fit these titles
out[i] = -1
}
used = map[int]bool{}
}
// 2. by title, best pairs first
type pair struct {
i, t int
s float64
}
var pairs []pair
for i := range songs {
if out[i] >= 0 {
continue
}
for t := range tracks {
if !used[t] {
if s := sim(i, t); s >= 0.6 {
pairs = append(pairs, pair{i, t, s})
}
}
}
}
sort.Slice(pairs, func(x, y int) bool { return pairs[x].s > pairs[y].s })
for _, p := range pairs {
if out[p.i] < 0 && !used[p.t] {
out[p.i], used[p.t] = p.t, true
}
}
// 3. by file order when nothing matched and the counts agree
if len(used) == 0 && len(songs) == len(tracks) {
order := make([]int, len(songs))
for i := range order {
order[i] = i
}
sort.Slice(order, func(x, y int) bool { return songs[order[x]] < songs[order[y]] })
for t, i := range order {
out[i] = t
}
}
return out
}
-384
View File
@@ -1,384 +0,0 @@
package engine
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/meta"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// fixCheck runs a check and returns its plan and items.
func (ev *env) fixCheck(t *testing.T, c FixCheck) (*FixPlan, []FixItem, []*AlbumFix) {
t.Helper()
if _, err := ev.e.StartFixCheck(ev.pair.ID, c); err != nil {
t.Fatal(err)
}
ev.wait()
p, items, albums := ev.e.FixerPlan(ev.pair.ID)
if p == nil {
t.Fatal("no plan")
}
return p, items, albums
}
// fixAll applies every fix that isn't blocked (unsure ones included).
func (ev *env) fixAll(t *testing.T, items []FixItem) *Job {
t.Helper()
var ids []int
for _, it := range items {
if it.Blocked == "" && it.Kind != FixProblem {
ids = append(ids, it.ID)
}
}
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: ids}); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func byFrom(items []FixItem) map[string]FixItem {
m := map[string]FixItem{}
for _, it := range items {
if _, ok := m[it.From]; !ok || it.Kind != FixTags {
m[it.From] = it
}
}
return m
}
func (ev *env) fields(t *testing.T, p string) map[string]string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields
}
func TestFixStructureMusic(t *testing.T) {
ev := tagEnv(t, false)
s := ev.slave
mkTagged(t, filepath.Join(s, "Loose.mp3"), "title", "Loose", "artist", "A", "album", "B")
mkTagged(t, filepath.Join(s, "Nightmare/01 Nightmare.flac"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
os.WriteFile(filepath.Join(s, "Nightmare/cover.jpg"), []byte("art"), 0o644)
mkTagged(t, filepath.Join(s, "Band/song.mp3"), "title", "Song", "artist", "Band", "album", "Record")
mkTagged(t, filepath.Join(s, "X/Y/FLAC/01 a.flac"), "title", "a", "artist", "X", "album", "Y")
mkTagged(t, filepath.Join(s, "Metal/Artist2/Album2/01 s.mp3"), "title", "s", "artist", "Artist2", "album", "Album2")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD1/01 t.mp3"), "title", "t", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3"), "title", "u", "artist", "Artist3", "album", "Album3", "track", "1")
mkTagged(t, filepath.Join(s, "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"))
mkTagged(t, filepath.Join(s, "NoTags.mp3"))
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
want := map[string]string{
"Loose.mp3": "A/B/Loose.mp3",
"Nightmare": "Avenged Sevenfold/Nightmare",
"Band/song.mp3": "Band/Record/song.mp3",
"X/Y/FLAC/01 a.flac": "X/Y/01 a.flac",
"Metal/Artist2/Album2": "Artist2/Album2",
"Artist3/Album3/CD2/01 u.mp3": "",
}
for from, to := range want {
it, ok := m[from]
if !ok || it.To != to || it.Blocked != "" {
t.Errorf("%s: got %+v, want to %q", from, it, to)
}
}
if it := m["Artist3/Album3/CD2/01 u.mp3"]; it.Changes[tags.Disc][1] != "2" || it.Changes[tags.DiscTotal][1] != "2" {
t.Errorf("disc tags from the disc folder: %+v", it.Changes)
}
fill := m["Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"].Changes
if fill[tags.Album][1] != "Album4" || fill[tags.Artist][1] != "Artist4" || fill[tags.Title][1] != "Song Name" || fill[tags.Track][1] != "5" {
t.Errorf("tags from names: %+v", fill)
}
if it := m["NoTags.mp3"]; it.Blocked == "" {
t.Errorf("a loose song without tags can't be placed: %+v", it)
}
if plan.Blocked != 1 {
t.Errorf("blocked %d", plan.Blocked)
}
j := ev.fixAll(t, items)
if j.Results["skipped"] != 0 {
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
}
for _, p := range []string{"A/B/Loose.mp3", "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold/Nightmare/cover.jpg",
"Band/Record/song.mp3", "X/Y/01 a.flac", "Artist2/Album2/01 s.mp3", "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"} {
if !exists(filepath.Join(s, p)) {
t.Errorf("missing %s", p)
}
}
for _, p := range []string{"Nightmare", "X/Y/FLAC", "Metal"} {
if exists(filepath.Join(s, p)) {
t.Errorf("%s should be gone", p)
}
}
if f := ev.fields(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3")); f[tags.Disc] != "2" || f[tags.DiscTotal] != "2" {
t.Errorf("disc tags not written: %+v", f)
}
// A folder move can be undone.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActMoveDir && o.RelNew == "Avenged Sevenfold/Nightmare" {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
}
}
waitIdle(ev)
if !exists(filepath.Join(s, "Nightmare/cover.jpg")) || exists(filepath.Join(s, "Avenged Sevenfold")) {
t.Fatal("undo should move the folder back and remove the empty artist folder")
}
// Not everything was fixable, so the step isn't marked done.
p, _ := ev.st.Pair(ctx, ev.pair.ID)
if FixerStateOf(p).Slave[StepStructure].State != "" {
t.Fatal("step marked done although a song still needs attention")
}
}
func waitIdle(ev *env) {
for i := 0; i < 500; i++ {
if j := ev.e.Job(ev.pair.ID); j == nil || j.State != "running" {
return
}
time.Sleep(10 * time.Millisecond)
}
}
func TestFixStructureSoundtracks(t *testing.T) {
ev := tagEnv(t, false)
ev.pair.LibraryType = LibrarySoundtracks
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
s := ev.slave
mkTagged(t, filepath.Join(s, "Nobuo Uematsu/Final Fantasy VII/01 Prelude.flac"), "title", "Prelude", "album", "Final Fantasy VII")
for _, g := range []string{"Halo 3", "Super Mario Galaxy", "Portal 2"} {
mkTagged(t, filepath.Join(s, "Video Games", g, "01 x.flac"), "title", "x", "album", g)
}
mkTagged(t, filepath.Join(s, "Journey/01 Nascence.flac"), "title", "Nascence", "album", "Journey")
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
m := byFrom(items)
if it := m["Nobuo Uematsu/Final Fantasy VII"]; it.To != "Final Fantasy VII" || it.Unsure || !strings.Contains(it.Why, "Soundtrack") {
t.Errorf("composer folder: %+v", it)
}
if it := m["Video Games/Halo 3"]; it.To != "Halo 3" || !it.Unsure {
t.Errorf("a category folder's albums should be listed but not selected: %+v", it)
}
if len(plan.Notices) != 1 || !strings.Contains(plan.Notices[0], "Video Games (3 albums)") {
t.Errorf("notices %v", plan.Notices)
}
if _, ok := m["Journey"]; ok {
t.Error("a soundtrack at the top of the library is fine")
}
}
func TestFixFoldersAndFiles(t *testing.T) {
ev := tagEnv(t, false)
m := ev.master
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/a.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/b.mp3"), "title", "Welcome to the Family", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "2")
mkTagged(t, filepath.Join(m, "Mix/1.mp3"), "title", "One", "artist", "A", "album", "First")
mkTagged(t, filepath.Join(m, "Mix/2.mp3"), "title", "Two", "artist", "B", "album", "Second")
_, items, _ := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFolders})
byF := byFrom(items)
if it := byF["avenged sevenfold/nightmare (2010)"]; it.Kind != FixMoveFolder || it.To != "Avenged Sevenfold/Nightmare" {
t.Fatalf("folder rename: %+v", it)
}
if it := byF["Mix"]; it.Kind != FixProblem || !strings.Contains(it.Why, "2 different album folders") {
t.Fatalf("mixed folder: %+v", it)
}
ev.fixAll(t, items)
if !exists(filepath.Join(m, "Avenged Sevenfold/Nightmare/a.mp3")) {
t.Fatal("folder not renamed")
}
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFolders].State != "" {
t.Fatal("a folder that needs attention keeps the step open")
}
_, items, _ = ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFiles})
ev.fixAll(t, items)
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFiles].State != "done" {
t.Fatal("applying every fix marks the step done")
}
for _, p := range []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3", "Avenged Sevenfold/Nightmare/02 Welcome to the Family.mp3", "A/First/One.mp3", "B/Second/Two.mp3"} {
if !exists(filepath.Join(m, p)) {
t.Errorf("missing %s", p)
}
}
// Applying the same plan twice is refused: paths changed.
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID - 1, IDs: []int{1}}); err == nil {
t.Fatal("a stale plan must not be applied again")
}
}
// fakeMB serves one release: Avenged Sevenfold - Nightmare, 3 tracks.
func fakeMB(t *testing.T) *meta.Client {
const id = "85cb4213-ffc2-38b1-a256-bd6e5ce2e0af"
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, `{"id":"`+id+`","title":"Nightmare","date":"2010-07-26","status":"Official","artist-credit":[{"name":"Avenged Sevenfold"}],
"release-group":{"primary-type":"Album"},"media":[{"position":1,"format":"CD","track-count":3,"tracks":[
{"position":1,"title":"Nightmare","length":374000},{"position":2,"title":"Welcome to the Family","length":245000},
{"position":3,"title":"Danger Line","length":329000}]}]}`)
return
}
if !strings.Contains(r.URL.Query().Get("query"), "Nightmare") {
fmt.Fprint(w, `{"releases":[]}`)
return
}
fmt.Fprint(w, `{"releases":[{"id":"`+id+`","score":100,"title":"Nightmare","status":"Official","date":"2010-07-26",
"track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":3}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
return c
}
func TestFixAlbumTags(t *testing.T) {
ev := tagEnv(t, false)
ev.e.SetMeta(fakeMB(t))
m, s := ev.master, ev.slave
dir := "Avenged Sevenfold/Nightmare"
// Titles are slightly off and numbers are missing; one song has no title.
mkTagged(t, filepath.Join(m, dir, "a.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "b.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, dir, "03 Danger Line.mp3"), "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(m, "Nobody/Unknown/x.mp3"), "title", "x", "artist", "Nobody", "album", "Unknown")
plan, items, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 2 || plan.Fixable != 1 || plan.Blocked != 1 {
t.Fatalf("plan %+v albums %d", plan, len(albums))
}
var nm *AlbumFix
for _, a := range albums {
if a.Folder == dir {
nm = a
}
}
if nm == nil || nm.Matched != 3 || nm.Release.Title != "Nightmare" || nm.Confidence < 0.9 {
t.Fatalf("album fix %+v", nm)
}
ev.fixAll(t, items)
f := ev.fields(t, filepath.Join(m, dir, "b.mp3"))
if f[tags.Title] != "Welcome to the Family" || f[tags.Track] != "2" || f[tags.TrackTotal] != "3" || f[tags.AlbumArtist] != "Avenged Sevenfold" || f[tags.Date] != "2010-07-26" {
t.Fatalf("tags not updated: %+v", f)
}
if f := ev.fields(t, filepath.Join(m, dir, "03 Danger Line.mp3")); f[tags.Title] != "Danger Line" || f[tags.Track] != "3" {
t.Fatalf("untitled song matched by position: %+v", f)
}
// The slave copy takes its tags from the fixed master, not MusicBrainz.
mkTagged(t, filepath.Join(s, "A7X/Nightmare/x.mp3"), "title", "Welcome to the family", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums = ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepTags})
if len(albums) != 1 || albums[0].Source != SourceMaster || albums[0].Files[0].Changes[tags.Track][1] != "2" {
t.Fatalf("slave from master: %+v", albums)
}
// Choosing MusicBrainz instead.
a, err := ev.e.ChooseRelease(ctx, ev.pair.ID, albums[0].ID, "search")
if err != nil || a.Source != SourceMusicBrainz || a.Matched != 1 {
t.Fatalf("switch to MusicBrainz: %+v %v", a, err)
}
}
func TestFixerStepState(t *testing.T) {
ev := setup(t, false)
if err := ev.e.SetStep(ctx, ev.pair.ID, SideMaster, StepTags, "skipped", ""); err != nil {
t.Fatal(err)
}
if err := ev.e.SetStep(ctx, ev.pair.ID, "", StepLink, "done", ""); err != nil {
t.Fatal(err)
}
p, _ := ev.st.Pair(ctx, ev.pair.ID)
s := FixerStateOf(p)
if s.Master[StepTags].State != "skipped" || s.Link.State != "done" || s.Slave[StepTags].State != "" {
t.Fatalf("%+v", s)
}
for _, bad := range [][3]string{{"left", StepTags, "done"}, {SideMaster, "bogus", "done"}, {SideMaster, StepTags, "maybe"}} {
if ev.e.SetStep(ctx, ev.pair.ID, bad[0], bad[1], bad[2], "") == nil {
t.Errorf("%v should be refused", bad)
}
}
}
func TestCleanFolderName(t *testing.T) {
for in, want := range map[string]string{
"Nightmare (2010)": "Nightmare",
"Nightmare [FLAC]": "Nightmare",
"2010 - Nightmare": "Nightmare",
"Nightmare (2010) [24-96]": "Nightmare",
"Hail to the King (Deluxe)": "Hail to the King",
"(What's the Story) Morning Glory?": "(What's the Story) Morning Glory?",
"1989": "1989",
} {
if got := cleanFolderName(in); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
for in, want := range map[string]int{"CD2": 2, "Disc 01": 1, "disk_3": 3, "Disc 1 - The Beginning": 1, "Vol. 2": 0, "CDs": 0} {
if got := discNumber(in); got != want {
t.Errorf("disc %q: got %d, want %d", in, got, want)
}
}
}
// A promo single named after the album, with exactly as many tracks as the
// local (partial) album, must not beat the album itself.
func TestFixAlbumTagsPrefersTheAlbum(t *testing.T) {
ev := tagEnv(t, false)
const promo, album = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
tracks := func(titles ...string) string {
var b []string
for i, t := range titles {
b = append(b, fmt.Sprintf(`{"position":%d,"title":%q}`, i+1, t))
}
return strings.Join(b, ",")
}
release := map[string]string{
promo: `{"id":"` + promo + `","title":"Nightmare","status":"Promotion","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare (album version) (explicit)", "Nightmare (radio edit)", "Nightmare (instrumental)") + `]}]}`,
album: `{"id":"` + album + `","title":"Nightmare","status":"Official","date":"2010-07-26","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},
"media":[{"position":1,"tracks":[` + tracks("Nightmare", "Welcome to the Family", "Danger Line", "Buried Alive", "Natural Born Killer") + `]}]}`,
}
mux := http.NewServeMux()
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
for id, body := range release {
if strings.HasSuffix(r.URL.Path, id) {
fmt.Fprint(w, body)
return
}
}
fmt.Fprint(w, `{"releases":[
{"id":"`+promo+`","score":100,"title":"Nightmare","status":"Promotion","track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},"media":[{"format":"CD-R","track-count":3}]},
{"id":"`+album+`","score":95,"title":"Nightmare","status":"Official","track-count":5,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":5}]}]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := meta.New("test")
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
ev.e.SetMeta(c)
dir := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare")
mkTagged(t, filepath.Join(dir, "01.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "02.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
mkTagged(t, filepath.Join(dir, "03.mp3"), "title", "danger line", "artist", "Avenged Sevenfold", "album", "Nightmare")
_, _, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
if len(albums) != 1 || albums[0].Release == nil || albums[0].Release.ID != album || albums[0].Matched != 3 || albums[0].Confidence < 0.75 {
t.Fatalf("want the album: %+v", albums)
}
}
-643
View File
@@ -1,643 +0,0 @@
package engine
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"reflect"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// Naming follows Lidarr's model: an artist folder, an album folder and a
// track format (with a separate format for multi-disc albums), built from
// tokens such as {Artist Name}, {Album Title} and {track:00}.
// An empty ArtistFolder means no artist folder: Album/01 Title.
type NamingConfig struct {
ArtistFolder string `json:"artist_folder"`
AlbumFolder string `json:"album_folder"`
TrackFormat string `json:"track_format"`
MultiDiscFormat string `json:"multi_disc_format"`
Colon string `json:"colon"` // smart, delete, dash, space_dash, space_dash_space
Illegal string `json:"illegal"` // replace or remove
// Folders give part of a library its own formats, such as soundtracks in
// "Video Games" named Album/01 Title. Files inside such a folder stay
// inside it, and other files are never moved into it.
Folders []FolderNaming `json:"folders"`
}
// FolderNaming is the naming for one folder of a library (relative to its root).
type FolderNaming struct {
Folder string `json:"folder"`
ArtistFolder string `json:"artist_folder"`
AlbumFolder string `json:"album_folder"`
TrackFormat string `json:"track_format"`
MultiDiscFormat string `json:"multi_disc_format"`
}
// formats are the four path formats in effect for one file.
type formats struct{ artist, album, track, multiDisc string }
func (c NamingConfig) formats() formats {
return formats{c.ArtistFolder, c.AlbumFolder, c.TrackFormat, c.MultiDiscFormat}
}
func (f FolderNaming) formats() formats {
return formats{f.ArtistFolder, f.AlbumFolder, f.TrackFormat, f.MultiDiscFormat}
}
// folderOf returns the folder rule a library path falls under, if any.
func (c NamingConfig) folderOf(rel string) (FolderNaming, bool) {
for _, f := range c.Folders {
if inFolder(rel, f.Folder) {
return f, true
}
}
return FolderNaming{}, false
}
// inFolder reports whether rel is inside folder (compared case-insensitively).
func inFolder(rel, folder string) bool {
return strings.HasPrefix(strings.ToLower(rel)+"/", strings.ToLower(folder)+"/") && len(rel) > len(folder)
}
func DefaultNaming() NamingConfig {
return NamingConfig{
ArtistFolder: "{Artist Name}",
AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}",
MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}",
Colon: "smart",
Illegal: "replace",
Folders: []FolderNaming{},
}
}
// Library types. Music is organized Artist/Album/01 Title; soundtracks (games,
// films) Album/01 Title, since their "artist" is often a composer or empty.
const (
LibraryMusic = "music"
LibrarySoundtracks = "soundtracks"
)
// DefaultNamingFor returns the recommended naming for a library type.
func DefaultNamingFor(libraryType string) NamingConfig {
c := DefaultNaming()
if libraryType == LibrarySoundtracks {
c.ArtistFolder = ""
}
return c
}
// SoundtrackFolder is the suggested folder rule for game and film soundtracks.
var SoundtrackFolder = FolderNaming{Folder: "Video Games", AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}"}
// NamingPresets are offered in the UI.
var NamingPresets = []struct {
Name string `json:"name"`
Config NamingConfig `json:"config"`
}{
{"Artist/Album/01 Title", DefaultNaming()},
{"Artist/Album (Year)/01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title} ({Release Year})",
TrackFormat: "{track:00} - {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}},
{"Artist/Album/Artist - Album - 01 - Title", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}",
TrackFormat: "{Artist Name} - {Album Title} - {track:00} - {Track Title}",
MultiDiscFormat: "{Artist Name} - {Album Title} - {medium:00}-{track:00} - {Track Title}", Colon: "smart", Illegal: "replace"}},
{"Artist/Album/1-01 Title (disc in name)", NamingConfig{ArtistFolder: "{Artist Name}", AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{medium:0}-{track:00} {Track Title}", Colon: "smart", Illegal: "replace"}},
{"Album/01 Title (no artist folder)", NamingConfig{AlbumFolder: "{Album Title}",
TrackFormat: "{track:00} {Track Title}", MultiDiscFormat: "{Medium Format} {medium:00}/{track:00} {Track Title}", Colon: "smart", Illegal: "replace"}},
}
// NamingOf returns a pair's naming, converting the settings of earlier
// versions (one path pattern plus a disc style) when none is stored yet.
func NamingOf(p store.Pair) NamingConfig {
c := DefaultNamingFor(p.LibraryType)
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" {
if stored, err := parseNaming(p.OrganizeConfig); err == nil {
return stored
}
}
old := map[string]string{"{artist}": "{Artist Name}", "{album}": "{Album Title}", "{track}": "{track:00}", "{title}": "{Track Title}",
"{year}": "{Release Year}", "{track_artist}": "{Track ArtistName}", "{genre}": "{Genre}"}
medium := "{medium:00}"
if p.OrganizeDiscDigits == 1 {
medium = "{medium:0}"
}
old["{disc}"] = medium
if parts := strings.Split(p.OrganizeTemplate, "/"); len(parts) == 3 {
conv := func(s string) string {
for k, v := range old {
s = strings.ReplaceAll(s, k, v)
}
return s
}
c.ArtistFolder, c.AlbumFolder, c.TrackFormat = conv(parts[0]), conv(parts[1]), conv(parts[2])
}
switch p.OrganizeMultiDisc {
case "prefix":
c.MultiDiscFormat = strings.Replace(c.TrackFormat, "{track:00}", medium+"-{track:00}", 1)
case "none":
c.MultiDiscFormat = c.TrackFormat
default:
c.MultiDiscFormat = "{Medium Format} " + medium + "/" + c.TrackFormat
}
return c
}
// parseNaming reads stored settings. Fields missing from the JSON take the
// defaults; an artist folder that is present but empty stays empty.
func parseNaming(raw []byte) (NamingConfig, error) {
c := DefaultNaming() // missing keys: only very old or hand-made settings
if err := json.Unmarshal(raw, &c); err != nil {
return c, err
}
fill(&c, DefaultNaming())
return c, nil
}
// ChangeLibraryType switches a pair's naming to the new type's recommended
// naming when it still uses the old type's, so custom naming is kept.
func ChangeLibraryType(old store.Pair, p *store.Pair) {
if old.ID == 0 || old.LibraryType == p.LibraryType {
return
}
if reflect.DeepEqual(NamingOf(*p), DefaultNamingFor(old.LibraryType)) {
p.OrganizeConfig = nil
}
}
func fill(c *NamingConfig, d NamingConfig) {
c.ArtistFolder = strings.TrimSpace(c.ArtistFolder)
if c.Folders == nil {
c.Folders = []FolderNaming{}
}
for i := range c.Folders {
f := &c.Folders[i]
f.Folder = strings.Trim(strings.TrimSpace(f.Folder), "/")
f.ArtistFolder = strings.TrimSpace(f.ArtistFolder)
if strings.TrimSpace(f.MultiDiscFormat) == "" {
f.MultiDiscFormat = "{Medium Format} {medium:00}/" + f.TrackFormat
}
}
if strings.TrimSpace(c.AlbumFolder) == "" {
c.AlbumFolder = d.AlbumFolder
}
if strings.TrimSpace(c.TrackFormat) == "" {
c.TrackFormat = d.TrackFormat
}
if strings.TrimSpace(c.MultiDiscFormat) == "" {
c.MultiDiscFormat = d.MultiDiscFormat
}
if c.Colon == "" {
c.Colon = d.Colon
}
if c.Illegal == "" {
c.Illegal = d.Illegal
}
}
// ---- tokens ----
var (
tokenPat = regexp.MustCompile(`\{([^{}]+)\}`)
numberTok = regexp.MustCompile(`(?i)^(track|medium):(0+)$`)
)
// TokenHelp documents the tokens (with example values) for the UI.
var TokenHelp = [][2]string{
{"{Artist Name}", "Avenged Sevenfold"}, {"{Artist NameThe}", "Beatles, The"}, {"{Artist CleanName}", "ACDC"},
{"{Artist NameFirstCharacter}", "A"}, {"{Album Title}", "Nightmare"}, {"{Album TitleThe}", "White Album, The"},
{"{Album CleanTitle}", "Nightmare"}, {"{Release Year}", "2010"}, {"{track:00}", "01"}, {"{track:0}", "1"},
{"{medium:00}", "01"}, {"{medium:0}", "1"}, {"{Medium Format}", "Disc"}, {"{Track Title}", "Nightmare"},
{"{Track CleanTitle}", "Nightmare"}, {"{Track ArtistName}", "Avenged Sevenfold"}, {"{Genre}", "Metal"},
}
var knownTokens = map[string]bool{
"artistname": true, "artistnamethe": true, "artistcleanname": true, "artistnamefirstcharacter": true,
"albumtitle": true, "albumtitlethe": true, "albumcleantitle": true, "releaseyear": true,
"tracktitle": true, "trackcleantitle": true, "trackartistname": true, "mediumformat": true, "genre": true,
}
// tokenKey normalizes "Artist Name", "Artist.Name" and "ARTIST_NAME".
func tokenKey(t string) string {
var b strings.Builder
for _, r := range strings.ToLower(t) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// styleOf returns the word separator and case a token is written in.
func styleOf(t string) (sep string, cased string) {
sep = " "
switch {
case strings.Contains(t, "."):
sep = "."
case strings.Contains(t, "_"):
sep = "_"
}
letters := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) {
return r
}
return -1
}, t)
switch {
case letters != "" && letters == strings.ToUpper(letters):
cased = "upper"
case letters != "" && letters == strings.ToLower(letters):
cased = "lower"
}
return
}
// ValidateNaming checks the formats.
func ValidateNaming(c NamingConfig) error {
if err := checkFormats("", c.formats()); err != nil {
return err
}
seen := map[string]bool{}
for _, f := range c.Folders {
label := fmt.Sprintf("Folder %q", f.Folder)
switch {
case f.Folder == "":
return errors.New("a folder rule has no folder")
case strings.Contains(f.Folder, "\\") || strings.HasPrefix(f.Folder, "/") || filepath.Clean(f.Folder) != f.Folder ||
f.Folder == "." || strings.HasPrefix(f.Folder, ".."):
return fmt.Errorf("%s must be a folder inside the library, like Video Games", label)
case strings.HasPrefix(filepath.Base(f.Folder), "."):
return fmt.Errorf("%s: hidden folders can't have naming rules", label)
case seen[strings.ToLower(f.Folder)]:
return fmt.Errorf("%s is listed twice", label)
}
seen[strings.ToLower(f.Folder)] = true
for _, o := range c.Folders {
if inFolder(f.Folder, o.Folder) {
return fmt.Errorf("%s is inside %q; folder rules can't be nested", label, o.Folder)
}
}
if err := checkFormats(label+": ", f.formats()); err != nil {
return err
}
}
switch c.Colon {
case "smart", "delete", "dash", "space_dash", "space_dash_space":
default:
return errors.New("unknown colon replacement")
}
if c.Illegal != "replace" && c.Illegal != "remove" {
return errors.New("illegal characters must be replace or remove")
}
return nil
}
// checkFormats validates one set of formats. The artist folder may be empty.
func checkFormats(prefix string, f formats) error {
if f.artist != "" {
if err := checkFormat(prefix+"Artist folder format", f.artist, true); err != nil {
return err
}
}
if err := checkFormat(prefix+"Album folder format", f.album, true); err != nil {
return err
}
for _, t := range []struct{ label, v string }{{"Track format", f.track}, {"Multi-disc track format", f.multiDisc}} {
if err := checkFormat(prefix+t.label, t.v, false); err != nil {
return err
}
if !strings.Contains(tokenKeys(t.v), "|tracktitle|") && !strings.Contains(tokenKeys(t.v), "|trackcleantitle|") {
return fmt.Errorf("%s%s must contain {Track Title}", prefix, t.label)
}
}
return nil
}
func checkFormat(label, f string, folder bool) error {
if strings.TrimSpace(f) == "" {
return fmt.Errorf("%s is empty", label)
}
if strings.Contains(f, "..") || strings.Contains(f, "\\") || strings.HasPrefix(f, "/") || strings.HasSuffix(f, "/") {
return fmt.Errorf("%s must be a relative name without .. or backslashes", label)
}
if folder && strings.Contains(f, "/") {
return fmt.Errorf("%s must be a single folder name (no /)", label)
}
for _, m := range tokenPat.FindAllStringSubmatch(f, -1) {
if numberTok.MatchString(m[1]) {
continue
}
if !knownTokens[tokenKey(m[1])] {
return fmt.Errorf("%s: unknown token {%s}", label, m[1])
}
}
if strings.Count(f, "{") != strings.Count(f, "}") {
return fmt.Errorf("%s has an unclosed {", label)
}
return nil
}
func tokenKeys(f string) string {
var b strings.Builder
b.WriteString("|")
for _, m := range tokenPat.FindAllStringSubmatch(f, -1) {
b.WriteString(tokenKey(m[1]) + "|")
}
return b.String()
}
// ---- value transforms ----
var articles = []string{"The ", "A ", "An "}
// nameThe moves a leading article to the end: "The Beatles" -> "Beatles, The".
func nameThe(s string) string {
for _, a := range articles {
if len(s) > len(a) && strings.EqualFold(s[:len(a)], a) {
return strings.TrimSpace(s[len(a):]) + ", " + strings.TrimSpace(s[:len(a)])
}
}
return s
}
// cleanName keeps letters, digits and spaces: "AC/DC" -> "ACDC".
func cleanName(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r), r == ' ':
return r
case r == '&':
return r
}
return -1
}, s)
return strings.TrimSpace(spaces.ReplaceAllString(s, " "))
}
func firstChar(s string) string {
for _, r := range nameThe(s) {
if unicode.IsLetter(r) {
return string(unicode.ToUpper(r))
}
if unicode.IsDigit(r) {
return "#"
}
}
return ""
}
func colonReplace(s, mode string) string {
switch mode {
case "delete":
return strings.ReplaceAll(s, ":", "")
case "dash":
return strings.ReplaceAll(s, ":", "-")
case "space_dash":
return strings.ReplaceAll(s, ":", " -")
case "space_dash_space":
return strings.ReplaceAll(strings.ReplaceAll(s, " :", ":"), ":", " - ")
}
// smart: "Title: Sub" -> "Title - Sub", "10:30" -> "10-30"
s = strings.ReplaceAll(s, ": ", " - ")
return strings.ReplaceAll(s, ":", "-")
}
var (
illegalReplace = strings.NewReplacer("/", "-", "\\", "-", "|", "-", "*", "-", "?", "", "\"", "'", "<", "", ">", "")
illegalRemove = strings.NewReplacer("/", "", "\\", "", "|", "", "*", "", "?", "", "\"", "", "<", "", ">", "")
)
// safeValue makes a tag value safe inside a file or folder name.
func safeValue(s string, c NamingConfig) string {
s = colonReplace(s, c.Colon)
if c.Illegal == "remove" {
s = illegalRemove.Replace(s)
} else {
s = illegalReplace.Replace(s)
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
var emptyBrackets = regexp.MustCompile(`\(\s*\)|\[\s*\]|\{\s*\}`)
// finishComponent tidies a rendered name: empty brackets and separators left
// by missing optional values are removed, and leading dots (hidden files)
// and trailing dots/spaces (invalid on Windows) are trimmed.
func finishComponent(s string) string {
s = emptyBrackets.ReplaceAllString(s, "")
s = spaces.ReplaceAllString(s, " ")
for _, dup := range []string{" - - ", " - - "} {
for strings.Contains(s, dup) {
s = strings.ReplaceAll(s, dup, " - ")
}
}
s = strings.Trim(s, " -._")
s = strings.TrimLeft(s, ".")
for len(s) > maxComponent {
_, size := utf8.DecodeLastRuneInString(s)
s = strings.TrimRight(s[:len(s)-size], ". ")
}
return s
}
// songValues are the raw values tokens draw from.
type songValues struct {
artist, trackArtist, album, title, year, genre string
track, disc, discTotal int
}
func valuesOf(f map[string]string) songValues {
v := songValues{
artist: strings.TrimSpace(f[tags.AlbumArtist]), trackArtist: strings.TrimSpace(f[tags.Artist]),
album: strings.TrimSpace(f[tags.Album]), title: strings.TrimSpace(f[tags.Title]), genre: strings.TrimSpace(f[tags.Genre]),
track: leadingNumber(f[tags.Track]), disc: leadingNumber(f[tags.Disc]), discTotal: leadingNumber(f[tags.DiscTotal]),
}
if v.artist == "" {
v.artist = v.trackArtist
}
if d := strings.TrimSpace(f[tags.Date]); len(d) >= 4 && leadingNumber(d[:4]) > 0 {
v.year = d[:4]
}
return v
}
func (v songValues) multiDisc() bool { return v.discTotal > 1 || v.disc > 1 }
// renderPath builds the organized relative path of a song at rel. Songs in
// a folder with its own naming stay in that folder. It returns why the song
// can't be renamed instead when a tag is missing or the new path would enter
// a folder that has its own naming.
func renderPath(c NamingConfig, rel string, f map[string]string) (string, string) {
ext := filepath.Ext(rel)
if rule, ok := c.folderOf(rel); ok {
to, why := renderFormats(c, rule.formats(), ext, f)
if why != "" {
return "", why
}
// Keep the folder as it is spelled on disk.
return filepath.Join(rel[:len(rule.Folder)], to), ""
}
to, why := renderFormats(c, c.formats(), ext, f)
if why != "" {
return "", why
}
for _, rule := range c.Folders {
if inFolder(to, rule.Folder) {
return "", "would move into " + rule.Folder + ", which has its own naming"
}
}
return to, ""
}
var artistTokens = []string{"|artistname|", "|artistnamethe|", "|artistcleanname|", "|artistnamefirstcharacter|"}
func renderFormats(c NamingConfig, fm formats, ext string, f map[string]string) (string, string) {
v := valuesOf(f)
track := fm.track
if v.multiDisc() {
track = fm.multiDisc
}
used := tokenKeys(fm.artist + fm.album + track)
for _, t := range artistTokens {
if v.artist == "" && strings.Contains(used, t) {
return "", "missing artist or album artist tag"
}
}
if v.album == "" {
return "", "missing album tag"
}
if v.title == "" {
return "", "missing title tag"
}
var parts []string
for _, f := range []string{fm.artist, fm.album, track} {
if f == "" {
continue // no artist folder
}
for _, part := range strings.Split(f, "/") {
out := tokenPat.ReplaceAllStringFunc(part, func(tok string) string { return renderToken(tok[1:len(tok)-1], v, c) })
out = finishComponent(out)
if out == "" {
return "", "missing tags for " + part
}
parts = append(parts, out)
}
}
return filepath.Join(parts...) + strings.ToLower(ext), ""
}
func renderToken(t string, v songValues, c NamingConfig) string {
if m := numberTok.FindStringSubmatch(t); m != nil {
n := v.track
if strings.EqualFold(m[1], "medium") {
n = v.disc
if n == 0 {
n = 1
}
}
if n <= 0 {
return ""
}
width := len(m[2])
if strings.EqualFold(m[1], "track") && n >= 100 && width < 3 {
width = 3
}
return fmt.Sprintf("%0*d", width, n)
}
var val string
switch tokenKey(t) {
case "artistname":
val = v.artist
case "artistnamethe":
val = nameThe(v.artist)
case "artistcleanname":
val = cleanName(v.artist)
case "artistnamefirstcharacter":
val = firstChar(v.artist)
case "albumtitle":
val = v.album
case "albumtitlethe":
val = nameThe(v.album)
case "albumcleantitle":
val = cleanName(v.album)
case "releaseyear":
val = v.year
case "tracktitle":
val = v.title
case "trackcleantitle":
val = cleanName(v.title)
case "trackartistname":
val = v.trackArtist
case "mediumformat":
val = "Disc"
case "genre":
val = v.genre
}
val = safeValue(val, c)
sep, cased := styleOf(t)
if sep != " " {
val = strings.ReplaceAll(val, " ", sep)
}
switch cased {
case "upper":
val = strings.ToUpper(val)
case "lower":
val = strings.ToLower(val)
}
return val
}
// NamingExample is one sample rendering for the settings page.
type NamingExample struct {
Label string `json:"label"`
Path string `json:"path"`
}
// NamingExamples renders sample songs with a naming config, using exactly
// the code that renames real files.
func NamingExamples(c NamingConfig) []NamingExample {
samples := []struct {
label, ext string
f map[string]string
}{
{"Album track", ".flac", map[string]string{tags.Artist: "Avenged Sevenfold", tags.Album: "Nightmare", tags.Title: "Nightmare",
tags.Track: "1", tags.Date: "2010", tags.Genre: "Metal"}},
{"Multi-disc album, disc 1", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)",
tags.Title: "Back in the U.S.S.R.", tags.Track: "1", tags.Disc: "1", tags.DiscTotal: "2", tags.Date: "1968"}},
{"Multi-disc album, disc 2", ".flac", map[string]string{tags.Artist: "The Beatles", tags.Album: "The Beatles (White Album)",
tags.Title: "Birthday", tags.Track: "1", tags.Disc: "2", tags.DiscTotal: "2", tags.Date: "1968"}},
{"Colon and slash in names", ".mp3", map[string]string{tags.Artist: "AC/DC", tags.Album: "Who Made Who", tags.Title: "Hells Bells: Live",
tags.Track: "7", tags.Date: "1986"}},
}
out := []NamingExample{}
add := func(label, rel string, f map[string]string) {
p, why := renderPath(c, rel, f)
if why != "" {
p = "(skipped: " + why + ")"
}
out = append(out, NamingExample{Label: label, Path: p})
}
for _, s := range samples {
add(s.label, "x"+s.ext, s.f)
}
for _, rule := range c.Folders {
add("In "+rule.Folder, rule.Folder+"/x.flac", map[string]string{tags.Artist: "Koji Kondo", tags.Album: "Super Mario Galaxy",
tags.Title: "Gusty Garden Galaxy", tags.Track: "3", tags.Date: "2007"})
}
return out
}
-253
View File
@@ -1,253 +0,0 @@
package engine
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func song(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
func TestRenderPath(t *testing.T) {
d := DefaultNaming()
with := func(mod func(*NamingConfig)) NamingConfig { c := DefaultNaming(); mod(&c); return c }
nightmare := song("artist", "Avenged Sevenfold", "album", "Nightmare", "title", "Nightmare", "track", "1", "date", "2010-07-27")
cases := []struct {
name string
c NamingConfig
f map[string]string
ext string
want string
}{
{"default", d, nightmare, ".FLAC", "Avenged Sevenfold/Nightmare/01 Nightmare.flac"},
{"multi-disc disc 1 also gets its folder", d, song("artist", "The Beatles", "album", "White Album", "title", "Back in the USSR", "track", "1", "disc", "1", "disc_total", "2"), ".flac",
"The Beatles/White Album/Disc 01/01 Back in the USSR.flac"},
{"multi-disc disc 2", d, song("artist", "The Beatles", "album", "White Album", "title", "Birthday", "track", "1", "disc", "2/2"), ".flac",
"The Beatles/White Album/Disc 02/01 Birthday.flac"},
{"album artist wins", d, song("artist", "Queen", "album_artist", "Various Artists", "album", "Hits", "title", "Bohemian Rhapsody", "track", "12"), ".mp3",
"Various Artists/Hits/12 Bohemian Rhapsody.mp3"},
{"year with empty brackets removed", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }),
song("artist", "A", "album", "B", "title", "S", "track", "3"), ".mp3", "A/B/03 S.mp3"},
{"year present", with(func(c *NamingConfig) { c.AlbumFolder = "{Album Title} ({Release Year})" }), nightmare, ".flac", "Avenged Sevenfold/Nightmare (2010)/01 Nightmare.flac"},
{"no track number", d, song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"separator left by missing number", with(func(c *NamingConfig) { c.TrackFormat = "{track:00} - {Track Title}" }), song("artist", "A", "album", "B", "title", "Song"), ".mp3", "A/B/Song.mp3"},
{"three digit tracks", d, song("artist", "A", "album", "B", "title", "S", "track", "105"), ".mp3", "A/B/105 S.mp3"},
{"NameThe and first character", with(func(c *NamingConfig) {
c.ArtistFolder = "{Artist NameFirstCharacter}"
c.AlbumFolder = "{Artist NameThe}"
}),
song("artist", "The Beatles", "album", "X", "title", "S", "track", "1"), ".mp3", "B/Beatles, The/01 S.mp3"},
{"dotted and uppercase tokens", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist.Name}"; c.TrackFormat = "{track:0} {TRACK TITLE}" }), nightmare, ".flac",
"Avenged.Sevenfold/Nightmare/1 NIGHTMARE.flac"},
{"clean name", with(func(c *NamingConfig) { c.ArtistFolder = "{Artist CleanName}" }), song("artist", "AC/DC", "album", "B", "title", "S", "track", "1"), ".mp3", "ACDC/B/01 S.mp3"},
{"illegal characters replaced, smart colon", d, song("artist", "AC/DC", "album", "Who Made Who?", "title", "Hells Bells: Live \"1986\"", "track", "7"), ".mp3",
"AC-DC/Who Made Who/07 Hells Bells - Live '1986'.mp3"},
{"illegal characters removed, colon deleted", with(func(c *NamingConfig) { c.Illegal = "remove"; c.Colon = "delete" }),
song("artist", "AC/DC", "album", "B", "title", "Hells Bells: Live", "track", "7"), ".mp3", "ACDC/B/07 Hells Bells Live.mp3"},
{"colon space-dash-space", with(func(c *NamingConfig) { c.Colon = "space_dash_space" }), song("artist", "A", "album", "B", "title", "Part:Two", "track", "1"), ".mp3", "A/B/01 Part - Two.mp3"},
{"no hidden names", d, song("artist", "...And Justice", "album", "B", "title", "S", "track", "1"), ".mp3", "And Justice/B/01 S.mp3"},
}
for _, c := range cases {
got, missing := renderPath(c.c, "in/x"+c.ext, c.f)
if got != c.want || missing != "" {
t.Errorf("%s: got %q (missing %q), want %q", c.name, got, missing, c.want)
}
}
for field, f := range map[string]map[string]string{
"artist or album artist": song("album", "B", "title", "S"),
"album": song("artist", "A", "title", "S"),
"title": song("artist", "A", "album", "B"),
} {
if _, why := renderPath(d, "x.mp3", f); why != "missing "+field+" tag" {
t.Errorf("want missing %q, got %q", field, why)
}
}
}
func TestNamingWithoutArtistFolder(t *testing.T) {
c := DefaultNaming()
c.ArtistFolder = ""
if err := ValidateNaming(c); err != nil {
t.Fatal(err)
}
// Soundtracks often have no artist tag; it isn't needed without the folder.
got, why := renderPath(c, "x.flac", song("album", "Super Mario Galaxy", "title", "Gusty Garden Galaxy", "track", "3"))
if got != "Super Mario Galaxy/03 Gusty Garden Galaxy.flac" || why != "" {
t.Fatalf("got %q %q", got, why)
}
got, _ = renderPath(c, "x.flac", song("album", "Halo 3", "title", "S", "track", "2", "disc", "2/2"))
if got != "Halo 3/Disc 02/02 S.flac" {
t.Fatalf("multi-disc: %q", got)
}
// Saving an empty artist folder keeps it empty; a missing one takes the default.
raw, _ := json.Marshal(c)
if got := NamingOf(store.Pair{OrganizeConfig: raw}); got.ArtistFolder != "" {
t.Fatalf("empty artist folder should stay empty: %+v", got)
}
p := store.Pair{OrganizeConfig: raw}
if err := normalizeOrganize(&p); err != nil || NamingOf(p).ArtistFolder != "" {
t.Fatalf("normalize: %v %s", err, p.OrganizeConfig)
}
}
func TestNamingFolderRules(t *testing.T) {
c := DefaultNaming()
c.Folders = []FolderNaming{SoundtrackFolder}
if err := ValidateNaming(c); err != nil {
t.Fatal(err)
}
vgm := song("artist", "Koji Kondo", "album", "Super Mario Galaxy", "title", "Gusty Garden Galaxy", "track", "3")
cases := []struct{ rel, want, why string }{
{"Video Games/mario/gusty.flac", "Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""},
{"video games/Mario/x.flac", "video games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""}, // spelled as on disk
{"Koji Kondo/x.flac", "Koji Kondo/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""},
{"Video Games Extra/x.flac", "Koji Kondo/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", ""},
}
for _, tc := range cases {
got, why := renderPath(c, tc.rel, vgm)
if got != tc.want || why != tc.why {
t.Errorf("%s: got %q %q, want %q", tc.rel, got, why, tc.want)
}
}
// A file outside the folder is never moved into it.
if _, why := renderPath(c, "x.flac", song("artist", "Video Games", "album", "B", "title", "S")); !strings.Contains(why, "own naming") {
t.Fatalf("should refuse to move into the rule folder: %q", why)
}
bad := []FolderNaming{
{Folder: "", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "../x", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: ".playlink-trash", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "Video Games/Nintendo", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "VIDEO GAMES", AlbumFolder: "{Album Title}", TrackFormat: "{Track Title}", MultiDiscFormat: "{Track Title}"},
{Folder: "OST", AlbumFolder: "{Album Title}", TrackFormat: "{track:00}", MultiDiscFormat: "{Track Title}"},
}
for _, b := range bad {
cc := c
cc.Folders = []FolderNaming{SoundtrackFolder, b}
if ValidateNaming(cc) == nil {
t.Errorf("rule %+v should be rejected", b)
}
}
ex := NamingExamples(c)
if last := ex[len(ex)-1]; last.Label != "In Video Games" || last.Path != "Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac" {
t.Fatalf("folder example: %+v", last)
}
}
func TestValidateNaming(t *testing.T) {
bad := []func(*NamingConfig){
func(c *NamingConfig) { c.ArtistFolder = "{Artist Name}/{Album Title}" },
func(c *NamingConfig) { c.TrackFormat = "{track:00}" },
func(c *NamingConfig) { c.TrackFormat = "{Bogus} {Track Title}" },
func(c *NamingConfig) { c.MultiDiscFormat = "../{Track Title}" },
func(c *NamingConfig) { c.AlbumFolder = "" },
func(c *NamingConfig) { c.Colon = "weird" },
func(c *NamingConfig) { c.AlbumFolder = "{Album Title" },
}
for i, mod := range bad {
c := DefaultNaming()
mod(&c)
if ValidateNaming(c) == nil {
t.Errorf("case %d should be rejected: %+v", i, c)
}
}
for _, p := range NamingPresets {
if err := ValidateNaming(p.Config); err != nil {
t.Errorf("preset %q invalid: %v", p.Name, err)
}
}
}
func TestNamingOfConvertsOldSettings(t *testing.T) {
c := NamingOf(store.Pair{OrganizeTemplate: "{artist}/{album} ({year})/{track} - {title}", OrganizeMultiDisc: "prefix", OrganizeDiscDigits: 1})
if c.ArtistFolder != "{Artist Name}" || c.AlbumFolder != "{Album Title} ({Release Year})" || c.TrackFormat != "{track:00} - {Track Title}" ||
c.MultiDiscFormat != "{medium:0}-{track:00} - {Track Title}" {
t.Fatalf("old settings not converted: %+v", c)
}
if got := NamingOf(store.Pair{}); !reflect.DeepEqual(got, DefaultNaming()) {
t.Fatalf("pairs without settings should use the defaults, got %+v", got)
}
got := NamingOf(store.Pair{OrganizeConfig: []byte(`{"track_format":"{track:0}. {Track Title}"}`)})
if got.TrackFormat != "{track:0}. {Track Title}" || got.ArtistFolder != "{Artist Name}" {
t.Fatalf("stored settings should win and gaps take defaults, got %+v", got)
}
ex := NamingExamples(DefaultNaming())
if len(ex) != 4 || ex[1].Path != "The Beatles/The Beatles (White Album)/Disc 01/01 Back in the U.S.S.R.flac" {
t.Fatalf("examples: %+v", ex)
}
}
func TestRenameNowPreviewAndApply(t *testing.T) {
ev := tagEnv(t, false)
mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1")
mkTagged(t, filepath.Join(ev.slave, "in/b.mp3"), "title", "Two", "artist", "Band", "album", "LP", "track", "2")
if _, err := ev.e.StartOrganizePreview(ev.pair.ID, "slave"); err != nil {
t.Fatal(err)
}
ev.wait()
plan, items := ev.e.OrganizePreview(ev.pair.ID)
if plan == nil || plan.Moves != 2 || len(items) != 2 {
t.Fatalf("preview: %+v %+v", plan, items)
}
var first int
for _, it := range items {
if it.From == "in/a.mp3" {
first = it.ID
}
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, []int{first}); err != nil {
t.Fatal(err)
}
if j := ev.wait(); j.Results["renamed"] != 1 {
t.Fatalf("only the selected file should be renamed: %+v", j.Results)
}
if _, err := os.Stat(filepath.Join(ev.slave, "Band/LP/01 One.mp3")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ev.slave, "in/b.mp3")); err != nil {
t.Fatal("unselected files stay")
}
if p, _ := ev.e.OrganizePreview(ev.pair.ID); p != nil {
t.Fatal("the preview is stale after renaming and must be cleared")
}
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err == nil {
t.Fatal("applying without a fresh preview must be refused")
}
}
func TestOrganizeFolderRule(t *testing.T) {
ev := tagEnv(t, false)
c := DefaultNaming()
c.Folders = []FolderNaming{SoundtrackFolder}
ev.pair.OrganizeConfig, _ = json.Marshal(c)
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
mkTagged(t, filepath.Join(ev.slave, "Video Games/rip/g.flac"), "title", "Gusty Garden Galaxy", "album", "Super Mario Galaxy", "track", "3")
mkTagged(t, filepath.Join(ev.slave, "Video Games/Halo 3/01 Halo.flac"), "title", "Halo", "artist", "Martin O'Donnell", "album", "Halo 3", "track", "1")
mkTagged(t, filepath.Join(ev.slave, "in/a.mp3"), "title", "One", "artist", "Band", "album", "LP", "track", "1")
ev.e.StartOrganizePreview(ev.pair.ID, "slave")
ev.wait()
plan, items := ev.e.OrganizePreview(ev.pair.ID)
if plan == nil || plan.Moves != 2 || plan.Conforming != 1 {
t.Fatalf("preview: %+v %+v", plan, items)
}
ev.e.StartOrganizeApply(ev.pair.ID, nil)
ev.wait()
for _, rel := range []string{"Video Games/Super Mario Galaxy/03 Gusty Garden Galaxy.flac", "Video Games/Halo 3/01 Halo.flac", "Band/LP/01 One.mp3"} {
if _, err := os.Stat(filepath.Join(ev.slave, rel)); err != nil {
t.Error(err)
}
}
}
+7 -62
View File
@@ -621,52 +621,17 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error {
if o.PurgedAt != nil {
return errors.New("the quarantined original was already purged")
}
switch o.Action {
case store.ActLink, store.ActPromote, store.ActBuild, store.ActRemove, store.ActRename:
default:
// History can hold operations of features that were removed (tag
// edits, file moves); undoing them as a link would be wrong.
return fmt.Errorf("%q operations can no longer be undone from playlink; their originals stay in the quarantine folder", o.Action)
}
l, err := e.loadPair(ctx, o.PairID)
if err != nil {
return err
}
if o.Action == store.ActCover {
if err := e.undoCover(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("folder cover undone", "op", o.ID)
return nil
}
if o.Action == store.ActMoveDir {
if err := undoMoveDir(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("folder move undone", "op", o.ID, "folder", o.RelNew)
_, _ = e.StartScan(o.PairID, "undo")
return nil
}
if o.Action == store.ActMove {
if err := undoMove(l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("file move undone", "op", o.ID)
_, _ = e.StartScan(o.PairID, "undo")
return nil
}
if o.Action == store.ActTags {
if err := e.undoTags(ctx, l, o); err != nil {
return err
}
if err := e.st.MarkUndone(ctx, o.ID); err != nil {
return err
}
e.log.Info("tag edit undone", "op", o.ID)
return nil
}
if o.Action == store.ActRename {
if err := undoRename(l, o); err != nil {
return err
@@ -890,26 +855,6 @@ func (e *Engine) Recover(ctx context.Context) {
return
}
for _, o := range ops {
if o.Action == store.ActMoveDir {
if l, err := e.loadPair(ctx, o.PairID); err == nil {
recoverMoveDir(l, o, e.log)
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
continue
}
if o.Action == store.ActMove {
if l, err := e.loadPair(ctx, o.PairID); err == nil {
recoverMove(l, o)
}
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state")
continue
}
if o.Action == store.ActTags {
msg := e.recoverTags(ctx, o)
_ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, msg)
e.log.Warn("recovered interrupted tag edit", "op", o.ID, "result", msg)
continue
}
if o.Action == store.ActRename {
if p, err := e.st.Pair(ctx, o.PairID); err == nil {
recoverRename(p, o, e.log)
-588
View File
@@ -1,588 +0,0 @@
package engine
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// The organizer renames audio files from their tags into one layout, by
// default Artist/Album/01 Title.ext. It only moves files within a library
// (same inode, so hardlinks survive), never rewrites them, and every move is
// an operation that can be undone.
// normalizeOrganize validates a pair's organize settings.
func normalizeOrganize(p *store.Pair) error {
if len(p.OrganizeConfig) > 0 && string(p.OrganizeConfig) != "{}" && string(p.OrganizeConfig) != "null" {
c, err := parseNaming(p.OrganizeConfig)
if err != nil {
return fmt.Errorf("naming settings: %w", err)
}
if err := ValidateNaming(c); err != nil {
return err
}
p.OrganizeConfig, _ = json.Marshal(c)
} else {
p.OrganizeConfig = nil // derived from older settings or defaults (NamingOf)
}
switch p.AutoOrganize {
case "":
p.AutoOrganize = "off"
case "off", "slave", "master", "both":
default:
return errors.New("automatic organizing must be off, slave, master or both")
}
return nil
}
var spaces = regexp.MustCompile(`\s+`)
// maxComponent keeps names well below the 255-byte filesystem limit.
const maxComponent = 150
func leadingNumber(s string) int {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
n, _ := strconv.Atoi(s[:end])
return n
}
// ---- tag cache ----
type cachedTags struct {
size, mtime int64
fields map[string]string
err string
}
type tagCache struct {
mu sync.Mutex
m map[string]cachedTags
}
func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.Info) (map[string]string, error) {
c.mu.Lock()
if c.m == nil {
c.m = map[string]cachedTags{}
}
if v, ok := c.m[abs]; ok && v.size == in.Size && v.mtime == in.MtimeNs {
c.mu.Unlock()
if v.err != "" {
return nil, errors.New(v.err)
}
return v.fields, nil
}
c.mu.Unlock()
f, err := t.Read(ctx, abs)
v := cachedTags{size: in.Size, mtime: in.MtimeNs, fields: f.Fields}
if err != nil {
v.err = err.Error()
}
c.mu.Lock()
c.m[abs] = v
c.mu.Unlock()
return v.fields, err
}
// ---- planning ----
// organizeItem is one file the organizer looked at.
type organizeItem struct {
ID int `json:"id"`
Side string `json:"side"`
From string `json:"from"`
To string `json:"to,omitempty"`
Move bool `json:"move"`
Reason string `json:"reason,omitempty"` // why it is skipped
Sidecars []string `json:"sidecars,omitempty"` // e.g. lyrics files moving along
ino uint64
size, mtime int64
}
// organizePlan is what organizing would do right now.
type organizePlan struct {
Sides []string `json:"sides"`
CreatedAt int64 `json:"created_at"`
Scanned int `json:"scanned"`
Conforming int `json:"conforming"`
Moves int `json:"moves"`
Skips map[string]int `json:"skips"`
Items []organizeItem `json:"-"`
}
// sidecarExts move together with the song of the same name.
var sidecarExts = []string{".lrc"}
// folderArt follows an album folder whose songs all moved to one folder.
var folderArt = map[string]bool{"cover.jpg": true, "cover.png": true, "folder.jpg": true, "folder.png": true,
"front.jpg": true, "front.png": true, "albumart.jpg": true, "albumartsmall.jpg": true}
func sidesOf(s string) []string {
switch s {
case "both":
return []string{SideSlave, SideMaster}
case SideMaster, SideSlave:
return []string{s}
}
return nil
}
func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) {
plan := &organizePlan{Sides: sides, CreatedAt: time.Now().Unix(), Skips: map[string]int{}}
naming := NamingOf(l.pair)
id := 0
for _, side := range sides {
root, _ := sideRoot(l, side)
files := map[string]fsops.Info{}
if _, err := walk(ctx, l, root, files); err != nil {
return nil, err
}
j.phase("Reading tags ("+side+")", int64(len(files)))
claimed := map[string]string{} // lower(target) -> source
for _, rel := range sortedKeys(files) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
j.tick()
plan.Scanned++
in := files[rel]
item := organizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs}
if _, ok := tags.FormatOf(rel); !ok {
item.Reason = "file type without editable tags"
} else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil {
item.Reason = "tags could not be read"
} else if to, why := renderPath(naming, rel, fields); why != "" {
item.Reason = why
} else if to == rel {
plan.Conforming++
continue
} else {
item.To = to
key := strings.ToLower(to)
switch {
case claimed[key] != "":
item.Reason = "another file gets the same name (" + claimed[key] + ")"
case targetTaken(root, rel, to):
item.Reason = "a different file already has that name"
default:
claimed[key] = rel
item.Move = true
item.Sidecars = sidecarsOf(root, rel)
}
}
id++
item.ID = id
if item.Move {
plan.Moves++
} else {
plan.Skips[item.Reason]++
}
plan.Items = append(plan.Items, item)
}
}
return plan, nil
}
// targetTaken reports whether moving rel to to would overwrite another file.
// A case-only rename onto itself is fine.
func targetTaken(root fsops.Root, rel, to string) bool {
abs, err := root.Resolve(to)
if err != nil {
return true
}
ti, err := os.Lstat(abs)
if err != nil {
return !errors.Is(err, fs.ErrNotExist)
}
if strings.EqualFold(rel, to) {
si, err := os.Lstat(filepath.Join(root.Path, rel))
return err != nil || !os.SameFile(si, ti)
}
return true
}
func sidecarsOf(root fsops.Root, rel string) []string {
var out []string
stem := strings.TrimSuffix(rel, filepath.Ext(rel))
for _, ext := range sidecarExts {
if fi, err := os.Lstat(filepath.Join(root.Path, stem+ext)); err == nil && fi.Mode().IsRegular() {
out = append(out, stem+ext)
}
}
return out
}
type moveDetail struct {
Side string `json:"side"`
Sidecars [][2]string `json:"sidecars,omitempty"` // [from, to]
Art bool `json:"art,omitempty"` // a folder art file following its album
}
func (e *Engine) applyOrganize(ctx context.Context, j *Job, l *libs, items []organizeItem, trigger string) error {
j.phase("Renaming files", int64(len(items)))
// Remember where each source folder's songs went, to move cover art along.
type dirMove struct {
targets map[string]bool
side string
}
dirs := map[string]*dirMove{}
for _, it := range items {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.moveOne(ctx, l, it, trigger); err != nil {
j.skip(it.From + ": " + err.Error())
} else {
j.count("renamed")
key := it.Side + "\x00" + filepath.Dir(it.From)
if dirs[key] == nil {
dirs[key] = &dirMove{targets: map[string]bool{}, side: it.Side}
}
dirs[key].targets[filepath.Dir(it.To)] = true
}
j.tick()
}
if e.st.DryRun(ctx) {
return nil
}
for key, dm := range dirs {
from := strings.SplitN(key, "\x00", 2)[1]
root, _ := sideRoot(l, dm.side)
if len(dm.targets) == 1 {
for to := range dm.targets {
e.moveFolderArt(ctx, l, root, dm.side, from, to, trigger)
}
}
if abs, err := root.Resolve(filepath.Join(from, "x")); err == nil {
pruneEmpty(filepath.Dir(abs), root.Path)
}
}
return nil
}
// moveFolderArt moves cover art out of a folder that has no songs left.
func (e *Engine) moveFolderArt(ctx context.Context, l *libs, root fsops.Root, side, from, to, trigger string) {
if from == to {
return
}
src := filepath.Join(root.Path, from)
ents, err := os.ReadDir(src)
if err != nil {
return
}
for _, en := range ents {
if !en.IsDir() && l.hasExt(en.Name()) {
return // songs remain: the art still belongs here
}
}
for _, en := range ents {
if !en.Type().IsRegular() || !folderArt[strings.ToLower(en.Name())] {
continue
}
item := organizeItem{Side: side, From: filepath.Join(from, en.Name()), To: filepath.Join(to, en.Name())}
e.mu.Lock()
dst, err := root.Resolve(item.To)
if err == nil {
if _, err := os.Lstat(dst); errors.Is(err, fs.ErrNotExist) {
o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: item.To, Trigger: trigger}
setSidePath(&o, side, item.From)
o.Detail, _ = json.Marshal(moveDetail{Side: side, Art: true})
if e.begin(ctx, &o) == nil && !o.DryRun {
_ = e.finish(ctx, &o, moveFile(filepath.Join(root.Path, item.From), dst))
}
}
}
e.mu.Unlock()
}
}
func setSidePath(o *store.Operation, side, rel string) {
if side == SideMaster {
o.RelMaster = rel
} else {
o.RelSlave = rel
}
}
func (e *Engine) moveOne(ctx context.Context, l *libs, it organizeItem, trigger string) error {
e.mu.Lock()
defer e.mu.Unlock()
root, err := sideRoot(l, it.Side)
if err != nil {
return err
}
src, err := root.Resolve(it.From)
if err != nil {
return err
}
dst, err := root.Resolve(it.To)
if err != nil {
return err
}
in, err := fsops.RegularFile(src)
if err != nil {
return err
}
if in.Ino != it.ino || !in.Unchanged(it.size, it.mtime) {
return fmt.Errorf("changed since the preview: %w", fsops.ErrChanged)
}
if targetTaken(root, it.From, it.To) {
return fmt.Errorf("%s: %w", it.To, fsops.ErrExists)
}
d := moveDetail{Side: it.Side}
for _, sc := range it.Sidecars {
to := strings.TrimSuffix(it.To, filepath.Ext(it.To)) + filepath.Ext(sc)
if !targetTaken(root, sc, to) {
d.Sidecars = append(d.Sidecars, [2]string{sc, to})
}
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActMove, RelNew: it.To, Trigger: trigger, Bytes: 0}
setSidePath(&o, it.Side, it.From)
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
return nil
}
err = func() error {
if err := fsops.MkdirAllLike(filepath.Dir(dst)); err != nil {
return err
}
if err := moveFile(src, dst); err != nil {
pruneEmpty(filepath.Dir(dst), root.Path)
return err
}
for _, sc := range d.Sidecars {
_ = moveFile(filepath.Join(root.Path, sc[0]), filepath.Join(root.Path, sc[1]))
}
return nil
}()
return e.finish(ctx, &o, err)
}
// moveFile renames a file within a library. Case-only renames go through a
// temporary name so they also work on case-insensitive filesystems.
func moveFile(src, dst string) error {
if strings.EqualFold(src, dst) && src != dst {
var b [6]byte
_, _ = rand.Read(b[:])
tmp := filepath.Join(filepath.Dir(src), fsops.TmpPrefix+"mv-"+hex.EncodeToString(b[:]))
if err := os.Rename(src, tmp); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Rename(tmp, src)
return err
}
} else if err := os.Rename(src, dst); err != nil {
return err
}
if err := fsops.SyncDir(filepath.Dir(dst)); err != nil {
return err
}
return fsops.SyncDir(filepath.Dir(src))
}
// undoMove moves a file (and its sidecars) back.
func undoMove(l *libs, o store.Operation) error {
var d moveDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return err
}
root, err := sideRoot(l, d.Side)
if err != nil {
return err
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
cur, err := root.Resolve(o.RelNew)
if err != nil {
return err
}
old, err := root.Resolve(from)
if err != nil {
return err
}
if _, err := fsops.RegularFile(cur); err != nil {
return fmt.Errorf("%s is no longer there", o.RelNew)
}
if targetTaken(root, o.RelNew, from) {
return fmt.Errorf("%s: %w", from, fsops.ErrExists)
}
if err := fsops.MkdirAllLike(filepath.Dir(old)); err != nil {
return err
}
if err := moveFile(cur, old); err != nil {
return err
}
for _, sc := range d.Sidecars {
_ = moveFile(filepath.Join(root.Path, sc[1]), filepath.Join(root.Path, sc[0]))
}
pruneEmpty(filepath.Dir(cur), root.Path)
return nil
}
// recoverMove finishes a case-only file rename cut short by a crash.
func recoverMove(l *libs, o store.Operation) {
var d moveDetail
if json.Unmarshal(o.Detail, &d) != nil {
return
}
root, err := sideRoot(l, d.Side)
if err != nil {
return
}
from := o.RelSlave
if d.Side == SideMaster {
from = o.RelMaster
}
if _, err := os.Lstat(filepath.Join(root.Path, from)); err == nil {
return // never moved
}
dst := filepath.Join(root.Path, o.RelNew)
if _, err := os.Lstat(dst); err == nil {
return // completed
}
dir := filepath.Dir(filepath.Join(root.Path, from))
ents, _ := os.ReadDir(dir)
for _, en := range ents {
if en.Type().IsRegular() && strings.HasPrefix(en.Name(), fsops.TmpPrefix+"mv-") {
_ = os.Rename(filepath.Join(dir, en.Name()), dst)
return
}
}
}
// autoOrganize runs during a scan when the pair asks for it: every clean
// rename is applied, anything doubtful is left for the preview.
func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
sides := sidesOf(l.pair.AutoOrganize)
if sides == nil || !e.TagEditing() {
return nil
}
plan, err := e.planOrganize(ctx, j, l, sides)
if err != nil {
return err
}
var moves []organizeItem
for _, it := range plan.Items {
if it.Move {
moves = append(moves, it)
}
}
if len(moves) == 0 {
return nil
}
if err := e.BeforeAction(ctx, "auto-organize"); err != nil {
return err
}
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
}
// ---- rename now (preview, then apply) ----
// StartOrganizePreview checks which files would be renamed.
func (e *Engine) StartOrganizePreview(pairID int64, side string) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
sides := sidesOf(side)
if sides == nil {
return nil, fmt.Errorf("%w: side must be slave, master or both", ErrInvalidBulk)
}
return e.startJob(pairID, "organize-preview", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
plan, err := e.planOrganize(ctx, j, l, sides)
if err != nil {
return err
}
e.plansMu.Lock()
if e.plans == nil {
e.plans = map[int64]*organizePlan{}
}
e.plans[pairID] = plan
e.plansMu.Unlock()
j.update(func(j *Job) {
j.Message = fmt.Sprintf("%d to rename, %d already organized, %d skipped", plan.Moves, plan.Conforming, len(plan.Items)-plan.Moves)
})
return nil
})
}
// OrganizePreview returns the latest preview and its items.
func (e *Engine) OrganizePreview(pairID int64) (*organizePlan, []organizeItem) {
e.plansMu.Lock()
defer e.plansMu.Unlock()
p := e.plans[pairID]
if p == nil {
return nil, nil
}
return p, append([]organizeItem{}, p.Items...)
}
// StartOrganizeApply renames the selected files of the latest preview (all
// when ids is empty), then rescans. Each file is checked again first.
func (e *Engine) StartOrganizeApply(pairID int64, ids []int) (*Job, error) {
e.plansMu.Lock()
plan := e.plans[pairID]
e.plansMu.Unlock()
if plan == nil {
return nil, fmt.Errorf("%w: check the files first", ErrInvalidBulk)
}
want := map[int]bool{}
for _, id := range ids {
want[id] = true
}
var items []organizeItem
for _, it := range plan.Items {
if it.Move && (len(want) == 0 || want[it.ID]) {
items = append(items, it)
}
}
if len(items) == 0 {
return nil, fmt.Errorf("%w: nothing to rename", ErrInvalidBulk)
}
return e.startJob(pairID, "organize", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
if err := e.applyOrganize(ctx, j, l, items, "organize"); err != nil {
return err
}
e.plansMu.Lock()
delete(e.plans, pairID) // paths changed: the preview is stale
e.plansMu.Unlock()
if e.st.DryRun(ctx) {
j.update(func(j *Job) { j.Message = "Dry run: renames recorded as planned in Activity" })
return nil
}
return e.scan(ctx, j, pairID, "organize")
})
}
-167
View File
@@ -1,167 +0,0 @@
package engine
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func mkTagged(t *testing.T, path string, kv ...string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
args := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1"}
for i := 0; i < len(kv); i += 2 {
args = append(args, "-metadata", kv[i]+"="+kv[i+1])
}
if out, err := exec.Command("ffmpeg", append(args, path)...).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
// plan and apply run the same code as automatic organizing.
func (ev *env) plan(t *testing.T, sides ...string) *organizePlan {
t.Helper()
l, err := ev.e.loadPair(ctx, ev.pair.ID)
if err != nil {
t.Fatal(err)
}
p, err := ev.e.planOrganize(ctx, &Job{e: ev.e, Results: map[string]int{}}, l, sides)
if err != nil {
t.Fatal(err)
}
return p
}
func (ev *env) apply(t *testing.T, p *organizePlan) *Job {
t.Helper()
l, _ := ev.e.loadPair(ctx, ev.pair.ID)
j := &Job{e: ev.e, Results: map[string]int{}}
var moves []organizeItem
for _, it := range p.Items {
if it.Move {
moves = append(moves, it)
}
}
if err := ev.e.applyOrganize(ctx, j, l, moves, "test"); err != nil {
t.Fatal(err)
}
return j
}
func TestOrganizeSlaveLibrary(t *testing.T) {
ev := tagEnv(t, false)
s := ev.slave
// The user's example: a song named "Name - Artist.mp3".
mkTagged(t, filepath.Join(s, "Random/Hail to the King - Avenged Sevenfold.mp3"),
"title", "Hail to the King", "artist", "Avenged Sevenfold", "album", "Hail to the King", "track", "2/10")
os.WriteFile(filepath.Join(s, "Random/Hail to the King - Avenged Sevenfold.lrc"), []byte("[00:01]lyrics"), 0o644)
os.WriteFile(filepath.Join(s, "Random/cover.jpg"), []byte("jpg"), 0o644)
mkTagged(t, filepath.Join(s, "Avenged Sevenfold/Nightmare/01 Nightmare.mp3"), // already organized
"title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
mkTagged(t, filepath.Join(s, "loose/untagged.mp3"), "title", "No Album")
mkTagged(t, filepath.Join(s, "dupes/a.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1")
mkTagged(t, filepath.Join(s, "dupes/b.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1")
plan := ev.plan(t, SideSlave)
if plan.Conforming != 1 || plan.Moves != 2 {
t.Fatalf("want 1 conforming and 2 moves, got %+v", plan)
}
if plan.Skips["missing artist or album artist tag"] != 1 || len(plan.Skips) != 2 {
t.Fatalf("unexpected skips %+v", plan.Skips)
}
j := ev.apply(t, plan)
if j.Results["renamed"] != 2 {
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
}
for _, rel := range []string{"Avenged Sevenfold/Hail to the King/02 Hail to the King.mp3", "Avenged Sevenfold/Hail to the King/02 Hail to the King.lrc",
"Avenged Sevenfold/Hail to the King/cover.jpg", "X/Y/01 Same.mp3", "dupes/b.mp3"} {
if _, err := os.Stat(filepath.Join(s, rel)); err != nil {
t.Errorf("expected %s: %v", rel, err)
}
}
if _, err := os.Stat(filepath.Join(s, "Random")); !errors.Is(err, os.ErrNotExist) {
t.Error("the emptied folder should be removed")
}
// Undo the song move: it goes back, lyrics too.
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
for _, o := range ops {
if o.Action == store.ActMove && o.RelSlave == "Random/Hail to the King - Avenged Sevenfold.mp3" {
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
ev.wait()
}
}
for _, rel := range []string{"Random/Hail to the King - Avenged Sevenfold.mp3", "Random/Hail to the King - Avenged Sevenfold.lrc"} {
if _, err := os.Stat(filepath.Join(s, rel)); err != nil {
t.Errorf("undo should restore %s: %v", rel, err)
}
}
}
// Moving a hardlinked file keeps the link, and the scan still pairs it.
func TestOrganizeKeepsHardlinks(t *testing.T) {
ev := tagEnv(t, false)
m := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare/01 Nightmare.flac")
mkTagged(t, m, "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
s := filepath.Join(ev.slave, "Chris Picks/Nightmare.flac")
os.MkdirAll(filepath.Dir(s), 0o755)
if err := os.Link(m, s); err != nil {
t.Fatal(err)
}
ev.scan()
if got := ev.match("Chris Picks/Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindInode {
t.Fatalf("a hardlink at another path should be recognised, got %s/%s", got.Status, got.Kind)
}
ev.apply(t, ev.plan(t, SideSlave))
ev.scan()
moved := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.flac")
if !sameInode(t, m, moved) {
t.Fatal("the moved slave file must still be the master's file")
}
if got := ev.match("Avenged Sevenfold/Nightmare/01 Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindPath {
t.Fatalf("after organizing, the paths agree again: got %s/%s", got.Status, got.Kind)
}
}
func TestOrganizeDryRunAndChangedFile(t *testing.T) {
ev := tagEnv(t, true)
p := filepath.Join(ev.slave, "x/song.mp3")
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1")
if j := ev.apply(t, ev.plan(t, SideSlave)); j.Results["renamed"] != 1 {
t.Fatalf("dry run should plan the rename: %+v", j.Results)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("dry run must not move files")
}
ev.st.SetSetting(ctx, "dry_run", "0")
plan := ev.plan(t, SideSlave)
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1", "genre", "changed")
if j := ev.apply(t, plan); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 {
t.Fatalf("a file changed after planning must be skipped: %+v", j.Results)
}
}
func TestAutoOrganizeDuringScan(t *testing.T) {
ev := tagEnv(t, false)
ev.pair.AutoOrganize = "slave"
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
t.Fatal(err)
}
mkTagged(t, filepath.Join(ev.slave, "incoming/Nightmare - Avenged Sevenfold.mp3"),
"title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
ev.scan()
rel := "Avenged Sevenfold/Nightmare/01 Nightmare.mp3"
if _, err := os.Stat(filepath.Join(ev.slave, rel)); err != nil {
t.Fatalf("the scan should organize the file: %v", err)
}
if got := ev.match(rel); got.RelSlave != rel {
t.Fatal("the scan should see the organized path")
}
}
-19
View File
@@ -287,11 +287,6 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
_ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), nil)
return err
}
if l.pair.AutoOrganize != "" && l.pair.AutoOrganize != "off" {
if err := e.autoOrganize(ctx, j, l); err != nil {
e.log.Warn("auto-organize failed", "pair", l.pair.Name, "err", err)
}
}
warns := layoutWarnings(l)
j.phase("Indexing master library", 0)
@@ -309,15 +304,6 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
return err
}
ix := buildIndex(masterFiles, l.pair)
// The same file (hardlink) can sit at another path, e.g. after one library
// was organized; it is recognised as linked rather than unmatched.
byInode := map[[2]uint64]string{}
for _, rel := range sortedKeys(masterFiles) {
in := masterFiles[rel]
if _, dup := byInode[[2]uint64{in.Dev, in.Ino}]; !dup {
byInode[[2]uint64{in.Dev, in.Ino}] = rel
}
}
if ix.approx != nil {
ix.verify = e.artistVerifier(ctx, l)
}
@@ -336,11 +322,6 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string)
si := slaveFiles[rel]
m := store.Match{PairID: pairID, RelSlave: rel}
m.RelMaster, m.Kind, m.Reason = ix.find(rel)
if m.Kind != store.KindPath {
if mrel, ok := byInode[[2]uint64{si.Dev, si.Ino}]; ok {
m.RelMaster, m.Kind, m.Reason = mrel, store.KindInode, ""
}
}
var pm *store.Match
if p, ok := prev[rel]; ok {
pm = &p
-585
View File
@@ -1,585 +0,0 @@
package engine
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
// Tag editing works on either library. A file used by only one library is
// rewritten to a new file that atomically replaces it (the old file goes to
// quarantine). A file hardlinked elsewhere (typically shared by master and
// slave) is updated in place so the link survives and every linked path sees
// the new tags; a verified backup copy goes to quarantine first. Choosing
// "separate" instead gives only this path its own edited copy.
const (
SideMaster = "master"
SideSlave = "slave"
tagModeReplace = "replace"
tagModeInPlace = "inplace"
maxTagFiles = 500
maxTagValue = 2000
)
var ErrNoTagTools = errors.New("tag editing needs ffmpeg and ffprobe, which were not found")
// SetTagTools enables tag editing.
func (e *Engine) SetTagTools(t tags.Tools) { e.tagTools = t }
func (e *Engine) TagEditing() bool { return e.tagTools.FFmpeg != "" && e.tagTools.FFprobe != "" }
func sideRoot(l *libs, side string) (fsops.Root, error) {
switch side {
case SideMaster:
return l.master, nil
case SideSlave:
return l.slave, nil
}
return fsops.Root{}, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// FileEntry is one row of the library browser.
type FileEntry struct {
Name string `json:"name"`
Rel string `json:"rel"`
Dir bool `json:"dir"`
Size int64 `json:"size,omitempty"`
Links int `json:"links,omitempty"` // >1: the file is shared (hardlinked)
Editable bool `json:"editable,omitempty"`
}
// ListFiles lists a folder of either library: subfolders and audio files.
func (e *Engine) ListFiles(ctx context.Context, pairID int64, side, rel string) ([]FileEntry, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
dir := root.Path
if rel != "" {
if dir, err = root.Resolve(rel); err != nil {
return nil, err
}
}
ents, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := []FileEntry{}
for _, en := range ents {
name := en.Name()
abs := filepath.Join(dir, name)
if strings.HasPrefix(name, ".") || en.Type()&fs.ModeSymlink != 0 || abs == l.quarantine {
continue
}
child := name
if rel != "" {
child = filepath.Join(rel, name)
}
if en.IsDir() {
out = append(out, FileEntry{Name: name, Rel: child, Dir: true})
continue
}
if !l.hasExt(name) {
continue
}
in, err := fsops.Lstat(abs)
if err != nil || !in.Mode.IsRegular() {
continue
}
_, editable := tags.FormatOf(name)
out = append(out, FileEntry{Name: name, Rel: child, Size: in.Size, Links: int(in.Nlink), Editable: editable})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
// TagFile is the editable view of one file's tags.
type TagFile struct {
Rel string `json:"rel"`
Editable bool `json:"editable"`
Reason string `json:"reason,omitempty"`
Fields map[string]string `json:"fields"`
Other map[string]string `json:"other"`
Links int `json:"links"`
Size int64 `json:"size"`
}
func cleanRels(rels []string) ([]string, error) {
if len(rels) == 0 {
return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
}
if len(rels) > maxTagFiles {
return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles)
}
seen := map[string]bool{}
var out []string
for _, r := range rels {
c, err := fsops.CleanRel(r)
if err != nil {
return nil, err
}
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out, nil
}
// ReadTags reads the tags of files in one library.
func (e *Engine) ReadTags(ctx context.Context, pairID int64, side string, rels []string) ([]TagFile, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
rels, err := cleanRels(rels)
if err != nil {
return nil, err
}
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, err
}
out := make([]TagFile, 0, len(rels))
for _, rel := range rels {
tf := TagFile{Rel: rel, Fields: map[string]string{}, Other: map[string]string{}}
abs, err := root.Resolve(rel)
if err == nil {
var in fsops.Info
if in, err = fsops.RegularFile(abs); err == nil {
tf.Links, tf.Size = int(in.Nlink), in.Size
var f tags.File
if f, err = e.tagTools.Read(ctx, abs); err == nil {
tf.Editable, tf.Fields, tf.Other = true, f.Fields, f.Other
}
}
}
if err != nil {
tf.Reason = err.Error()
}
out = append(out, tf)
}
return out, nil
}
// TagEdit is one tag edit request.
type TagEdit struct {
Side string `json:"side"`
Paths []string `json:"paths"`
Set map[string]string `json:"set"` // field -> new value ("" removes the tag), for every file
// Files adds per-song changes (an album's track titles and numbers).
Files []FileEdit `json:"files,omitempty"`
// Art applies album art to the edited files and/or their album folder.
Art *ArtApply `json:"art,omitempty"`
// Separate gives a hardlinked file its own edited copy instead of
// updating it in place for every linked path.
Separate bool `json:"separate"`
}
type FileEdit struct {
Path string `json:"path"`
Set map[string]string `json:"set"`
}
// ArtApply is a prepared cover (see art.Store) and where it goes.
type ArtApply struct {
Token string `json:"token"`
Embed bool `json:"embed"` // into each song (MP3, FLAC, M4A)
FolderFile bool `json:"folder_file"` // as cover.jpg in the album folder
}
func validTagChanges(set map[string]string) error {
known := map[string]bool{}
for _, id := range tags.FieldIDs {
known[id] = true
}
for id, v := range set {
if !known[id] {
return fmt.Errorf("%w: unknown tag field %q", ErrInvalidBulk, id)
}
if len(v) > maxTagValue || !utf8.ValidString(v) || strings.ContainsRune(v, 0) {
return fmt.Errorf("%w: invalid value for %s", ErrInvalidBulk, id)
}
}
return nil
}
// StartTagEdit applies a tag edit to each selected file in a background job.
func (e *Engine) StartTagEdit(pairID int64, req TagEdit) (*Job, error) {
if !e.TagEditing() {
return nil, ErrNoTagTools
}
if req.Side != SideMaster && req.Side != SideSlave {
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
}
// Every file gets the shared changes plus its own.
perFile := map[string]map[string]string{}
var order []string
add := func(p string, set map[string]string) error {
c, err := fsops.CleanRel(p)
if err != nil {
return err
}
if _, ok := perFile[c]; !ok {
perFile[c] = map[string]string{}
order = append(order, c)
}
for k, v := range set {
perFile[c][k] = v
}
return nil
}
for _, p := range req.Paths {
if err := add(p, req.Set); err != nil {
return nil, err
}
}
for _, f := range req.Files {
if err := add(f.Path, req.Set); err != nil {
return nil, err
}
if err := add(f.Path, f.Set); err != nil {
return nil, err
}
}
if len(order) == 0 {
return nil, fmt.Errorf("%w: no files selected", ErrInvalidBulk)
}
if len(order) > maxTagFiles {
return nil, fmt.Errorf("%w: select at most %d files at a time", ErrInvalidBulk, maxTagFiles)
}
changed := false
for _, set := range perFile {
if len(set) > 0 {
if err := validTagChanges(set); err != nil {
return nil, err
}
changed = true
}
}
var cover string
if req.Art != nil && (req.Art.Embed || req.Art.FolderFile) {
p, err := e.preparedArt(req.Art.Token)
if err != nil {
return nil, err
}
cover = p
}
if !changed && cover == "" {
return nil, fmt.Errorf("%w: nothing changed", ErrInvalidBulk)
}
return e.startJob(pairID, "edit-tags", func(ctx context.Context, j *Job) error {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return err
}
embed := ""
if cover != "" && req.Art.Embed {
embed = cover
}
j.phase("Editing tags", int64(len(order)))
for _, rel := range order {
if ctx.Err() != nil {
return ctx.Err()
}
if err := e.editTagsOne(ctx, j, l, req.Side, rel, perFile[rel], embed, req.Separate); err != nil {
j.skip(rel + ": " + err.Error())
}
j.tick()
}
if cover != "" && req.Art.FolderFile {
for _, folder := range albumFolders(order) {
if err := e.writeFolderCover(ctx, l, req.Side, folder, cover); err != nil {
j.skip(folder + " cover.jpg: " + err.Error())
} else {
j.count("folder art")
}
}
}
return nil
})
}
// albumFolders lists the distinct album folders of some songs (disc folders
// folded into their album).
func albumFolders(rels []string) []string {
seen := map[string]bool{}
var out []string
for _, r := range rels {
f := AlbumFolder(r)
if f != "" && !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
type tagDetail struct {
Side string `json:"side"`
Mode string `json:"mode"`
Changes map[string][2]string `json:"changes"` // field -> [old, new]
Cover bool `json:"cover,omitempty"` // new art embedded
}
// editTagsOne applies tag changes and/or a new embedded cover to one file.
func (e *Engine) editTagsOne(ctx context.Context, j *Job, l *libs, side, rel string, set map[string]string, cover string, separate bool) error {
e.mu.Lock()
defer e.mu.Unlock()
root, _ := sideRoot(l, side)
abs, err := root.Resolve(rel)
if err != nil {
return err
}
info, err := fsops.RegularFile(abs)
if err != nil {
return err
}
f, err := e.tagTools.Read(ctx, abs)
if err != nil {
return err
}
changes := map[string]string{}
d := tagDetail{Side: side, Mode: tagModeReplace, Changes: map[string][2]string{}}
for id, v := range set {
if f.Fields[id] != v {
changes[id] = v
d.Changes[id] = [2]string{f.Fields[id], v}
}
}
if cover != "" && !f.Format.EmbedsCover() {
cover = "" // Ogg-based files: the folder image carries their art
if len(changes) == 0 {
j.count("art in folder only")
return nil
}
}
d.Cover = cover != ""
if len(changes) == 0 && cover == "" {
j.count("unchanged")
return nil
}
plan, err := f.Plan(changes)
if err != nil {
return err
}
if info.Nlink > 1 && !separate {
d.Mode = tagModeInPlace
}
o := store.Operation{PairID: l.pair.ID, Action: store.ActTags, QuarantineSide: side, Trigger: "tags"}
if side == SideMaster {
o.RelMaster = rel
} else {
o.RelSlave = rel
}
o.Detail, _ = json.Marshal(d)
if err := e.begin(ctx, &o); err != nil {
return err
}
if o.DryRun {
j.count("planned")
return nil
}
var b [8]byte
_, _ = rand.Read(b[:])
tmp := filepath.Join(filepath.Dir(abs), fsops.TmpPrefix+hex.EncodeToString(b[:])+filepath.Ext(abs))
defer os.Remove(tmp)
err = func() error {
if err := e.tagTools.Write(ctx, abs, tmp, f, plan, cover); err != nil {
return err
}
if err := e.tagTools.Verify(ctx, abs, tmp, changes, cover != ""); err != nil {
return err
}
// Nothing may have touched the original while the new file was made.
if now, err := fsops.RegularFile(abs); err != nil || !now.SameInode(info) || !now.Unchanged(info.Size, info.MtimeNs) {
return fmt.Errorf("%s %w", rel, fsops.ErrChanged)
}
q := quarantinePath(l, o.ID, side, rel)
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
return err
}
if d.Mode == tagModeReplace {
// Keep the old file (as a hardlink, no extra space) and swap in
// the new one atomically, with the original's permissions.
if err := fsops.LinkNew(abs, info, q); err != nil {
return fmt.Errorf("quarantine: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
if err := os.Chmod(tmp, info.Mode.Perm()); err != nil {
dropQuarantine(l, q, info)
e.clearQuarantine(ctx, o.ID)
return err
}
if err := os.Rename(tmp, abs); err != nil {
dropQuarantine(l, q, info)
e.clearQuarantine(ctx, o.ID)
return err
}
return fsops.SyncDir(filepath.Dir(abs))
}
// In place: a verified backup copy first, then overwrite the shared
// file's contents; restore from the backup if anything goes wrong.
if err := fsops.CopyNew(abs, q, info.Mode); err != nil {
return fmt.Errorf("backup: %w", err)
}
e.setQuarantine(ctx, o.ID, q)
if err := fsops.OverwriteInPlace(abs, tmp); err != nil {
if rerr := fsops.OverwriteInPlace(abs, q); rerr != nil {
return fmt.Errorf("%v; restoring the original also failed (%v): the original is at %s", err, rerr, q)
}
_ = os.Remove(q)
e.clearQuarantine(ctx, o.ID)
return fmt.Errorf("could not write the new tags, the original was restored: %w", err)
}
return nil
}()
if err = e.finish(ctx, &o, err); err != nil {
return err
}
j.count("edited")
e.refreshTagged(ctx, l, side, rel)
return nil
}
func (e *Engine) setQuarantine(ctx context.Context, opID int64, q string) {
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, opID)
}
// refreshTagged updates the matches that involve an edited file.
func (e *Engine) refreshTagged(ctx context.Context, l *libs, side, rel string) {
if side == SideSlave {
e.refresh(ctx, l, rel)
return
}
ms, _ := e.st.MatchesByMaster(ctx, l.pair.ID, rel)
for _, m := range ms {
e.refresh(ctx, l, m.RelSlave, m)
}
}
func tagOpPath(l *libs, o store.Operation) (string, tagDetail, error) {
var d tagDetail
if err := json.Unmarshal(o.Detail, &d); err != nil {
return "", d, fmt.Errorf("unreadable operation details: %w", err)
}
root, err := sideRoot(l, d.Side)
if err != nil {
return "", d, err
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
abs, err := root.Resolve(rel)
return abs, d, err
}
// undoTags puts the file back the way it was before the edit.
func (e *Engine) undoTags(ctx context.Context, l *libs, o store.Operation) error {
abs, d, err := tagOpPath(l, o)
if err != nil {
return err
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return err
}
cur, err := fsops.RegularFile(abs)
if err != nil {
return err
}
if d.Mode == tagModeReplace {
if cur.SameInode(qi) {
return errors.New("the file is already the original")
}
// The original inode comes back, rejoining any links it had.
if err := fsops.ReplaceWithLink(o.QuarantinePath, qi, abs); err != nil {
return err
}
dropQuarantine(l, o.QuarantinePath, qi)
} else {
if err := fsops.OverwriteInPlace(abs, o.QuarantinePath); err != nil {
return err
}
if err := os.Remove(o.QuarantinePath); err == nil {
pruneEmpty(filepath.Dir(o.QuarantinePath), l.quarantine)
}
}
rel := o.RelSlave
if d.Side == SideMaster {
rel = o.RelMaster
}
e.refreshTagged(ctx, l, d.Side, rel)
return nil
}
// recoverTags handles a tag edit cut short by a crash. An in-place edit may
// have left a partially written file, so the backup is written back. A
// replace edit either happened (atomic rename) or did not; an unused
// quarantine link is dropped.
func (e *Engine) recoverTags(ctx context.Context, o store.Operation) string {
l, err := e.loadPair(ctx, o.PairID)
if err != nil || o.QuarantinePath == "" {
return "interrupted before any change"
}
abs, d, err := tagOpPath(l, o)
if err != nil {
return err.Error()
}
// The half-written new file is disposable.
if ents, err := os.ReadDir(filepath.Dir(abs)); err == nil {
for _, en := range ents {
if strings.HasPrefix(en.Name(), fsops.TmpPrefix) && en.Type().IsRegular() {
_ = os.Remove(filepath.Join(filepath.Dir(abs), en.Name()))
}
}
}
qi, err := e.quarantined(l, o.QuarantinePath)
if err != nil {
return "interrupted; " + err.Error()
}
if d.Mode == tagModeInPlace {
if err := fsops.OverwriteInPlace(abs, o.QuarantinePath); err != nil {
return fmt.Sprintf("interrupted; could not restore the original (%v), it is kept at %s", err, o.QuarantinePath)
}
_ = os.Remove(o.QuarantinePath)
e.clearQuarantine(ctx, o.ID)
return "interrupted by shutdown; the original tags were restored"
}
if cur, err := fsops.Lstat(abs); err == nil && cur.SameInode(qi) {
dropQuarantine(l, o.QuarantinePath, qi) // rename never happened
e.clearQuarantine(ctx, o.ID)
return "interrupted by shutdown before the file was replaced; nothing changed"
}
return "interrupted by shutdown after the new tags were written"
}
-191
View File
@@ -1,191 +0,0 @@
package engine
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/internal/tags"
)
func tagEnv(t *testing.T, dryRun bool) *env {
t.Helper()
ffmpeg, err1 := exec.LookPath("ffmpeg")
ffprobe, err2 := exec.LookPath("ffprobe")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
ev := setup(t, dryRun)
ev.e.SetTagTools(tags.Tools{FFmpeg: ffmpeg, FFprobe: ffprobe})
return ev
}
func mkSong(t *testing.T, path, title string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
if out, err := exec.Command("ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1",
"-metadata", "title="+title, "-metadata", "artist=Avenged Sevenfold", path).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
func (ev *env) title(t *testing.T, p string) string {
t.Helper()
f, err := ev.e.tagTools.Read(ctx, p)
if err != nil {
t.Fatal(err)
}
return f.Fields[tags.Title]
}
func (ev *env) editTags(t *testing.T, req TagEdit) *Job {
t.Helper()
if _, err := ev.e.StartTagEdit(ev.pair.ID, req); err != nil {
t.Fatal(err)
}
return ev.wait()
}
func lastOp(t *testing.T, ev *env) store.Operation {
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 1})
if len(ops) == 0 {
t.Fatal("no operation recorded")
}
return ops[0]
}
// A file used by one library only is replaced by a new file; undo brings the
// original back.
func TestEditTagsReplace(t *testing.T) {
ev := tagEnv(t, false)
p := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.mp3")
mkSong(t, p, "Nightmar")
before, _ := os.ReadFile(p)
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3"}, Set: map[string]string{tags.Title: "Nightmare", tags.Genre: "Metal"}})
if j.Results["edited"] != 1 {
t.Fatalf("edit failed: %+v %v", j.Results, j.Skipped)
}
if ev.title(t, p) != "Nightmare" {
t.Fatal("title not written")
}
o := lastOp(t, ev)
var d tagDetail
json.Unmarshal(o.Detail, &d)
if d.Mode != tagModeReplace || d.Changes[tags.Title] != [2]string{"Nightmar", "Nightmare"} || o.QuarantinePath == "" {
t.Fatalf("unexpected op: %+v %+v", o, d)
}
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
if after, _ := os.ReadFile(p); string(after) != string(before) {
t.Fatal("undo should restore the original bytes")
}
}
// A file shared by master and slave is edited in place: the link survives
// and both libraries see the new tags. Undo restores the old tags for both.
func TestEditTagsSharedInPlace(t *testing.T) {
ev := tagEnv(t, false)
rel := "Avenged Sevenfold/Nightmare/01 Nightmare.flac"
m, s := filepath.Join(ev.master, rel), filepath.Join(ev.slave, rel)
mkSong(t, m, "Old")
os.MkdirAll(filepath.Dir(s), 0o755)
if err := os.Link(m, s); err != nil {
t.Fatal(err)
}
ev.scan()
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{rel}, Set: map[string]string{tags.Title: "Nightmare"}})
if j.Results["edited"] != 1 {
t.Fatalf("edit failed: %+v %v", j.Results, j.Skipped)
}
if !sameInode(t, m, s) || ev.title(t, m) != "Nightmare" {
t.Fatal("the shared file must stay linked and show the new tags in both libraries")
}
if got := ev.match(rel); got.Status != store.StatusLinked {
t.Fatalf("match should still be linked, got %s", got.Status)
}
o := lastOp(t, ev)
if b, err := os.ReadFile(o.QuarantinePath); err != nil || len(b) == 0 {
t.Fatal("a backup copy should be in quarantine")
}
if err := ev.e.Undo(ctx, o.ID); err != nil {
t.Fatal(err)
}
if !sameInode(t, m, s) || ev.title(t, m) != "Old" {
t.Fatal("undo should restore the old tags and keep the link")
}
}
// "Only this library" gives the slave its own edited copy.
func TestEditTagsSeparateCopy(t *testing.T) {
ev := tagEnv(t, false)
rel := "Band/Album/01 Song.mp3"
m, s := filepath.Join(ev.master, rel), filepath.Join(ev.slave, rel)
mkSong(t, m, "Song")
os.MkdirAll(filepath.Dir(s), 0o755)
os.Link(m, s)
ev.scan()
ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{rel}, Set: map[string]string{tags.Title: "Song (My Tag)"}, Separate: true})
if sameInode(t, m, s) || ev.title(t, m) != "Song" || ev.title(t, s) != "Song (My Tag)" {
t.Fatal("only the slave copy should change, as a separate file")
}
if got := ev.match(rel); got.Status != store.StatusDifferent {
t.Fatalf("the pair is no longer linked, got %s", got.Status)
}
if err := ev.e.Undo(ctx, lastOp(t, ev).ID); err != nil {
t.Fatal(err)
}
if !sameInode(t, m, s) {
t.Fatal("undo should rejoin the original link")
}
}
func TestEditTagsDryRunAndUnchanged(t *testing.T) {
ev := tagEnv(t, true)
p := filepath.Join(ev.slave, "A/B/x.mp3")
mkSong(t, p, "Same")
j := ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{tags.Title: "Changed"}})
if j.Results["planned"] != 1 || ev.title(t, p) != "Same" {
t.Fatalf("dry run must not write: %+v", j.Results)
}
j = ev.editTags(t, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{tags.Title: "Same"}})
if j.Results["unchanged"] != 1 {
t.Fatalf("identical values should be skipped: %+v", j.Results)
}
if _, err := ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{"../escape.mp3"}, Set: map[string]string{tags.Title: "x"}}); err == nil {
t.Fatal("paths outside the library must be refused")
}
if _, err := ev.e.StartTagEdit(ev.pair.ID, TagEdit{Side: SideSlave, Paths: []string{"A/B/x.mp3"}, Set: map[string]string{"lyrics": "x"}}); err == nil {
t.Fatal("unknown fields must be refused")
}
}
// A crash during an in-place write leaves a half-written file; recovery
// writes the backup back.
func TestRecoverInterruptedInPlaceEdit(t *testing.T) {
ev := tagEnv(t, false)
rel := "A/B/song.flac"
p := filepath.Join(ev.slave, rel)
mkSong(t, p, "Original")
orig, _ := os.ReadFile(p)
q := filepath.Join(ev.pair.QuarantineDir, "99", "slave", rel)
os.MkdirAll(filepath.Dir(q), 0o755)
os.WriteFile(q, orig, 0o644)
os.WriteFile(p, orig[:len(orig)/3], 0o644) // truncated by the "crash"
d, _ := json.Marshal(tagDetail{Side: SideSlave, Mode: tagModeInPlace})
o := store.Operation{PairID: ev.pair.ID, Action: store.ActTags, RelSlave: rel, QuarantinePath: q, QuarantineSide: SideSlave, Status: store.OpRunning, Detail: d}
if err := ev.st.CreateOp(ctx, &o); err != nil {
t.Fatal(err)
}
ev.e.Recover(ctx)
if now, _ := os.ReadFile(p); string(now) != string(orig) {
t.Fatal("recovery should restore the original file")
}
got, _ := ev.st.Op(ctx, o.ID)
if got.Status != store.OpInterrupted {
t.Fatalf("op should be marked interrupted, got %s", got.Status)
}
}
-67
View File
@@ -403,70 +403,3 @@ func CopyReplace(src, dst string, mode fs.FileMode) error {
func MkdirAllLike(dir string) error {
return os.MkdirAll(dir, 0o775)
}
// CopyNew copies src to dst, which must not exist. The copy is fsynced and
// verified byte for byte. Used for backups that must not share an inode with
// the original (the original is about to be modified in place).
func CopyNew(src, dst string, mode fs.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm())
if err != nil {
return err
}
fail := func(e error) error { out.Close(); _ = os.Remove(dst); return e }
if _, err := io.Copy(out, in); err != nil {
return fail(err)
}
if err := out.Sync(); err != nil {
return fail(err)
}
if err := out.Close(); err != nil {
_ = os.Remove(dst)
return err
}
if same, err := SameContent(src, dst); err != nil || !same {
_ = os.Remove(dst)
if err == nil {
err = fmt.Errorf("%w: backup differs from the original", ErrVerifyFailed)
}
return err
}
return SyncDir(filepath.Dir(dst))
}
// OverwriteInPlace replaces the contents of dst with the bytes of src while
// keeping dst's inode, so every hardlink of dst sees the new contents. This is
// not atomic: callers keep a backup (CopyNew) to restore from on failure.
func OverwriteInPlace(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_TRUNC, 0)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Sync(); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if same, err := SameContent(src, dst); err != nil || !same {
if err == nil {
err = fmt.Errorf("%w: file does not match what was written", ErrVerifyFailed)
}
return err
}
return nil
}
-273
View File
@@ -1,273 +0,0 @@
// Package meta looks up album information (track lists, titles, dates) on
// MusicBrainz for the fixer's album tags step.
package meta
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cdnutter/playlink/internal/art"
)
// Client talks to MusicBrainz. Requests are spaced out to respect its limit
// of one request per second.
type Client struct {
HTTP *http.Client
UserAgent string
BaseURL string
Gap time.Duration
mu sync.Mutex
next time.Time
}
func New(version string) *Client {
return &Client{
HTTP: art.SafeHTTPClient(20 * time.Second),
UserAgent: "playlink/" + version + " (self-hosted music library tool)",
BaseURL: "https://musicbrainz.org",
Gap: 1100 * time.Millisecond,
}
}
// Release is one search result.
type Release struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
Type string `json:"type"` // Album, Single, EP, Soundtrack, ...
Disambiguation string `json:"disambiguation,omitempty"`
Formats string `json:"formats"` // "2×CD", "Digital Media"
TrackCount int `json:"track_count"`
Discs int `json:"discs"`
Score int `json:"score"`
}
// Track is one track of a release.
type Track struct {
Disc int `json:"disc"`
Position int `json:"position"`
DiscTracks int `json:"disc_tracks"`
Title string `json:"title"`
Artist string `json:"artist"`
LengthMs int `json:"length_ms"`
}
// ReleaseDetail is a release with its track list.
type ReleaseDetail struct {
Release
Tracks []Track `json:"tracks"`
}
var mbid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// ValidID reports whether s is a MusicBrainz id.
func ValidID(s string) bool { return mbid.MatchString(s) }
type artistCredit []struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
func (a artistCredit) String() string {
var b strings.Builder
for _, c := range a {
b.WriteString(c.Name + c.JoinPhrase)
}
return strings.TrimSpace(b.String())
}
type mbRelease struct {
ID string `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
Status string `json:"status"`
Date string `json:"date"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
TrackCount int `json:"track-count"`
ArtistCredit artistCredit `json:"artist-credit"`
ReleaseGroup struct {
PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
} `json:"release-group"`
Media []struct {
Position int `json:"position"`
Format string `json:"format"`
TrackCount int `json:"track-count"`
Tracks []struct {
Position int `json:"position"`
Title string `json:"title"`
Length int `json:"length"`
ArtistCredit artistCredit `json:"artist-credit"`
} `json:"tracks"`
} `json:"media"`
}
func (r mbRelease) summary() Release {
out := Release{ID: r.ID, Title: r.Title, Artist: r.ArtistCredit.String(), Date: r.Date, Country: r.Country,
Status: r.Status, Disambiguation: r.Disambiguation, TrackCount: r.TrackCount, Discs: len(r.Media), Score: r.Score}
out.Type = r.ReleaseGroup.PrimaryType
if len(r.ReleaseGroup.SecondaryTypes) > 0 {
out.Type = strings.TrimSpace(out.Type + " " + strings.Join(r.ReleaseGroup.SecondaryTypes, " "))
}
formats := map[string]int{}
var order []string
total := 0
for _, m := range r.Media {
f := m.Format
if f == "" {
f = "Unknown"
}
if formats[f] == 0 {
order = append(order, f)
}
formats[f]++
total += m.TrackCount
}
var parts []string
for _, f := range order {
if formats[f] > 1 {
parts = append(parts, fmt.Sprintf("%d×%s", formats[f], f))
} else {
parts = append(parts, f)
}
}
out.Formats = strings.Join(parts, " + ")
if out.TrackCount == 0 {
out.TrackCount = total
}
return out
}
func quote(s string) string {
// Lucene syntax: escape quotes and backslashes inside a phrase.
s = strings.ReplaceAll(s, `\`, `\\`)
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
// SearchReleases finds releases by album title and (optionally) artist.
func (c *Client) SearchReleases(ctx context.Context, artist, album string) ([]Release, error) {
if strings.TrimSpace(album) == "" {
return nil, errors.New("no album title to search for")
}
q := "release:" + quote(album)
if strings.TrimSpace(artist) != "" {
q += " AND artist:" + quote(artist)
}
var d struct {
Releases []mbRelease `json:"releases"`
}
u := c.BaseURL + "/ws/2/release/?" + url.Values{"query": {q}, "fmt": {"json"}, "limit": {"15"}}.Encode()
if err := c.get(ctx, u, &d); err != nil {
return nil, err
}
out := make([]Release, 0, len(d.Releases))
for _, r := range d.Releases {
out = append(out, r.summary())
}
return out, nil
}
// Release fetches a release with its track list.
func (c *Client) Release(ctx context.Context, id string) (*ReleaseDetail, error) {
if !ValidID(id) {
return nil, errors.New("not a MusicBrainz release id")
}
var r mbRelease
u := c.BaseURL + "/ws/2/release/" + id + "?" + url.Values{"inc": {"recordings artist-credits release-groups"}, "fmt": {"json"}}.Encode()
if err := c.get(ctx, u, &r); err != nil {
return nil, err
}
d := &ReleaseDetail{Release: r.summary()}
for _, m := range r.Media {
for _, t := range m.Tracks {
artist := t.ArtistCredit.String()
if artist == "" {
artist = d.Artist
}
d.Tracks = append(d.Tracks, Track{Disc: m.Position, Position: t.Position, DiscTracks: len(m.Tracks),
Title: t.Title, Artist: artist, LengthMs: t.Length})
}
}
return d, nil
}
// get fetches JSON, waiting for its turn and retrying once when MusicBrainz
// says it is busy.
func (c *Client) get(ctx context.Context, u string, v any) error {
for attempt := 0; ; attempt++ {
if err := c.wait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20))
res.Body.Close()
if err != nil {
return err
}
if res.StatusCode == http.StatusServiceUnavailable && attempt < 2 {
c.backoff(res.Header.Get("Retry-After"))
continue
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("MusicBrainz: %s", res.Status)
}
return json.Unmarshal(body, v)
}
}
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
now := time.Now()
at := c.next
if at.Before(now) {
at = now
}
c.next = at.Add(c.Gap)
c.mu.Unlock()
if d := time.Until(at); d > 0 {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
return nil
}
func (c *Client) backoff(retryAfter string) {
d := 2 * time.Second
if n, err := strconv.Atoi(retryAfter); err == nil && n > 0 && n < 30 {
d = time.Duration(n) * time.Second
}
c.mu.Lock()
if t := time.Now().Add(d); t.After(c.next) {
c.next = t
}
c.mu.Unlock()
}
+15 -68
View File
@@ -149,13 +149,6 @@ type Pair struct {
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"`
@@ -163,20 +156,18 @@ type Pair struct {
}
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"
)
@@ -658,7 +632,6 @@ type Operation struct {
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:"-"`
@@ -680,10 +653,6 @@ const (
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)
)
// Operation statuses.
@@ -696,17 +665,13 @@ const (
)
const opCols = `id, pair_id, action, rel_master, rel_slave, rel_new, quarantine_path, quarantine_side, tmp_path, bytes,
dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted, detail`
dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at, match_kind, match_reason, link_preexisted`
func scanOp(r scanner) (Operation, error) {
var o Operation
var fin, und, pur sql.NullInt64
var detail string
err := r.Scan(&o.ID, &o.PairID, &o.Action, &o.RelMaster, &o.RelSlave, &o.RelNew, &o.QuarantinePath, &o.QuarantineSide,
&o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted, &detail)
if detail != "" {
o.Detail = json.RawMessage(detail)
}
&o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur, &o.MatchKind, &o.MatchReason, &o.LinkPreexisted)
if fin.Valid {
o.FinishedAt = &fin.Int64
}
@@ -722,10 +687,10 @@ func scanOp(r scanner) (Operation, error) {
func (s *Store) CreateOp(ctx context.Context, o *Operation) error {
o.CreatedAt = now()
res, err := s.DB.ExecContext(ctx, `INSERT INTO operations (pair_id, action, rel_master, rel_slave, rel_new, quarantine_path,
quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at, match_kind, match_reason, link_preexisted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
o.PairID, o.Action, o.RelMaster, o.RelSlave, o.RelNew, o.QuarantinePath, o.QuarantineSide, o.TmpPath, o.Bytes,
o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted, string(o.Detail))
o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt, o.MatchKind, o.MatchReason, o.LinkPreexisted)
if err != nil {
return err
}
@@ -835,21 +800,3 @@ func (s *Store) ExpiredQuarantine(ctx context.Context, pairID, cutoff int64) ([]
}
return out, rows.Err()
}
// MatchesByMaster lists the matches of a pair that point at a master file.
func (s *Store) MatchesByMaster(ctx context.Context, pairID int64, relMaster string) ([]Match, error) {
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ? AND rel_master = ?`, pairID, relMaster)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Match
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
+4
View File
@@ -119,6 +119,10 @@ var migrations = []string{
// undo must then leave that target alone.
`ALTER TABLE operations ADD COLUMN link_preexisted INTEGER NOT NULL DEFAULT 0;`,
// 6-10 belong to features that were removed again (tag editing,
// organizing, the step-by-step fixer). They only add columns, which are
// unused now, and stay so databases that already ran them keep the same
// numbering and future migrations are never skipped.
// 6: operation-specific details as JSON (e.g. which tags an edit changed).
`ALTER TABLE operations ADD COLUMN detail TEXT NOT NULL DEFAULT '';`,
-378
View File
@@ -1,378 +0,0 @@
// Package tags reads and writes audio metadata with ffprobe/ffmpeg.
//
// Writing always produces a new file (the caller decides how it replaces the
// original) and is verified: the requested fields must read back as set, and
// the decoded audio must be bit-for-bit identical to the original's.
package tags
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// Field ids, in display order.
const (
Title = "title"
Artist = "artist"
Album = "album"
AlbumArtist = "album_artist"
Track = "track"
TrackTotal = "track_total"
Disc = "disc"
DiscTotal = "disc_total"
Date = "date"
Genre = "genre"
Composer = "composer"
Comment = "comment"
)
var FieldIDs = []string{Title, Artist, Album, AlbumArtist, Track, TrackTotal, Disc, DiscTotal, Date, Genre, Composer, Comment}
// aliases are the tag names (lower-cased, as ffprobe reports them) that hold
// each field. ffprobe already maps most format-specific names to these.
var aliases = map[string][]string{
Title: {"title"},
Artist: {"artist"},
Album: {"album"},
AlbumArtist: {"album_artist", "albumartist", "album artist"},
Track: {"track", "tracknumber"},
TrackTotal: {"tracktotal", "totaltracks"},
Disc: {"disc", "discnumber"},
DiscTotal: {"disctotal", "totaldiscs"},
Date: {"date", "year"},
Genre: {"genre"},
Composer: {"composer"},
Comment: {"comment", "description"},
}
// ignoredKeys are container/encoder details, not tags a user edits.
var ignoredKeys = map[string]bool{"encoder": true, "major_brand": true, "minor_version": true,
"compatible_brands": true, "handler_name": true, "language": true, "vendor_id": true}
// Format describes how a file stores its tags.
type Format struct {
Name string // mp3, flac, m4a, ogg, opus
Vorbis bool // Vorbis comments (flac, ogg, opus)
StreamTags bool // tags live on the audio stream (ogg, opus)
}
var formats = map[string]Format{
".mp3": {Name: "mp3"},
".flac": {Name: "flac", Vorbis: true},
".m4a": {Name: "m4a"},
".ogg": {Name: "ogg", Vorbis: true, StreamTags: true},
".oga": {Name: "ogg", Vorbis: true, StreamTags: true},
".opus": {Name: "opus", Vorbis: true, StreamTags: true},
}
// FormatOf returns the tag format for a file name, or false when editing
// that file type is not supported.
func FormatOf(path string) (Format, bool) {
f, ok := formats[strings.ToLower(filepath.Ext(path))]
return f, ok
}
// File is the metadata of one audio file.
type File struct {
Format Format `json:"-"`
Raw map[string]string `json:"-"` // tag name as reported -> value
Fields map[string]string `json:"fields"` // field id -> value (only present fields)
Other map[string]string `json:"other"` // tags that are not one of the fields
}
// Tools locates ffprobe and ffmpeg.
type Tools struct {
FFprobe string
FFmpeg string
}
func (t Tools) run(ctx context.Context, bin string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
var stderr bytes.Buffer
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if len(msg) > 300 {
msg = msg[:300] + "…"
}
return nil, fmt.Errorf("%s: %v: %s", filepath.Base(bin), err, msg)
}
return out, nil
}
type probeOut struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Duration string `json:"duration"`
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
// Read returns the tags of path.
func (t Tools) Read(ctx context.Context, path string) (File, error) {
fmtInfo, ok := FormatOf(path)
if !ok {
return File{}, fmt.Errorf("editing tags of %s files is not supported", strings.TrimPrefix(filepath.Ext(path), "."))
}
out, err := t.run(ctx, t.FFprobe, "-v", "error", "-show_entries", "format_tags:stream_tags:stream=codec_type,codec_name",
"-of", "json", "--", path)
if err != nil {
return File{}, err
}
var p probeOut
if err := json.Unmarshal(out, &p); err != nil {
return File{}, err
}
raw := p.Format.Tags
if fmtInfo.StreamTags {
raw = nil
for _, s := range p.Streams {
if s.CodecType == "audio" {
raw = s.Tags
break
}
}
}
return parse(fmtInfo, raw), nil
}
func parse(f Format, raw map[string]string) File {
file := File{Format: f, Raw: map[string]string{}, Fields: map[string]string{}, Other: map[string]string{}}
byAlias := map[string]string{}
for id, names := range aliases {
for _, n := range names {
byAlias[n] = id
}
}
for k, v := range raw {
file.Raw[k] = v
lk := strings.ToLower(k)
if ignoredKeys[lk] {
continue
}
id, ok := byAlias[lk]
if !ok {
file.Other[k] = v
continue
}
if _, dup := file.Fields[id]; !dup {
file.Fields[id] = v
}
}
// "3/12" in the track tag carries the total unless a total tag exists.
for _, pair := range [][2]string{{Track, TrackTotal}, {Disc, DiscTotal}} {
if n, total, ok := strings.Cut(file.Fields[pair[0]], "/"); ok {
file.Fields[pair[0]] = strings.TrimSpace(n)
if _, has := file.Fields[pair[1]]; !has && strings.TrimSpace(total) != "" {
file.Fields[pair[1]] = strings.TrimSpace(total)
}
}
}
return file
}
// keysFor returns the tag names in the file holding a field.
func (f File) keysFor(id string) []string {
var keys []string
for k := range f.Raw {
for _, a := range aliases[id] {
if strings.EqualFold(k, a) {
keys = append(keys, k)
}
}
}
sort.Strings(keys)
return keys
}
// canonical is the tag name for a field the file does not have yet. These are
// ffmpeg's own names, so ffmpeg writes the right frame/atom/comment for each
// format (e.g. album_artist -> TPE2 / aART / ALBUMARTIST).
func canonical(f Format, id string) string {
switch id {
case AlbumArtist, Track, Disc, Comment:
return id
case TrackTotal:
return "TRACKTOTAL"
case DiscTotal:
return "DISCTOTAL"
}
if f.Vorbis {
return strings.ToUpper(id)
}
return id
}
// Plan turns field changes into tag assignments for the file. A change to ""
// removes the field. The result maps tag name -> value ("" deletes).
func (f File) Plan(changes map[string]string) (map[string]string, error) {
set := map[string]string{}
assign := func(id, value string) {
keys := f.keysFor(id)
if len(keys) == 0 {
if value != "" {
set[canonical(f.Format, id)] = value
}
return
}
// The first spelling the file uses gets the value; duplicates under
// other spellings (DATE and YEAR) are removed so they cannot disagree.
set[keys[0]] = value
for _, k := range keys[1:] {
set[k] = ""
}
}
for id := range changes {
if _, ok := aliases[id]; !ok {
return nil, fmt.Errorf("unknown tag field %q", id)
}
}
for id, v := range changes {
if id == Track || id == TrackTotal || id == Disc || id == DiscTotal {
continue
}
assign(id, v)
}
// Number and total are written together, the way the file stores them:
// "3/12" in one tag, or a separate total tag (common in FLAC).
for _, pair := range [][2]string{{Track, TrackTotal}, {Disc, DiscTotal}} {
n, nChanged := changes[pair[0]]
total, tChanged := changes[pair[1]]
if !nChanged && !tChanged {
continue
}
if !nChanged {
n = f.Fields[pair[0]]
}
if !tChanged {
total = f.Fields[pair[1]]
}
separate := len(f.keysFor(pair[1])) > 0 || (f.Format.Vorbis && len(f.keysFor(pair[0])) == 0)
if separate {
assign(pair[0], n)
assign(pair[1], total)
continue
}
v := n
if n != "" && total != "" {
v = n + "/" + total
}
assign(pair[0], v)
// A stale separate total tag would contradict the combined value.
for _, k := range f.keysFor(pair[1]) {
set[k] = ""
}
}
return set, nil
}
// EmbedsCover reports whether ffmpeg can embed cover art in this format
// (Ogg-based formats cannot; their art goes into a folder image instead).
func (f Format) EmbedsCover() bool { return !f.StreamTags }
// Write copies src to dst (which must not exist and must keep src's
// extension) with the planned tags applied. Audio and other streams, such
// as cover art, are copied untouched. A non-empty cover replaces any
// embedded art with that JPEG.
func (t Tools) Write(ctx context.Context, src, dst string, f File, set map[string]string, cover string) error {
args := []string{"-v", "error", "-nostdin", "-n", "-i", src}
if cover != "" {
if !f.Format.EmbedsCover() {
return fmt.Errorf("%s files cannot hold embedded cover art", f.Format.Name)
}
args = append(args, "-i", cover, "-map", "0:a", "-map", "1:v", "-disposition:v:0", "attached_pic")
if f.Format.Name == "mp3" {
args = append(args, "-metadata:s:v:0", "title=Album cover", "-metadata:s:v:0", "comment=Cover (front)")
}
} else {
args = append(args, "-map", "0")
}
args = append(args, "-c", "copy", "-map_metadata", "0", "-fflags", "+bitexact")
target := "-metadata"
if f.Format.StreamTags {
args = append(args, "-map_metadata:s:a", "0:s:a")
target = "-metadata:s:a:0"
}
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
args = append(args, target, k+"="+set[k])
}
args = append(args, dst)
_, err := t.run(ctx, t.FFmpeg, args...)
return err
}
// ErrVerify means a written file did not pass verification.
var ErrVerify = errors.New("tag write verification failed")
// Verify checks that dst carries the requested field values and exactly the
// same audio as src, and embedded art when wantCover is set.
func (t Tools) Verify(ctx context.Context, src, dst string, changes map[string]string, wantCover bool) error {
got, err := t.Read(ctx, dst)
if err != nil {
return fmt.Errorf("%w: %v", ErrVerify, err)
}
if wantCover && !t.HasCover(ctx, dst) {
return fmt.Errorf("%w: the cover art was not embedded", ErrVerify)
}
for id, want := range changes {
if got.Fields[id] != want {
return fmt.Errorf("%w: %s reads back as %q, not %q", ErrVerify, id, got.Fields[id], want)
}
}
a, err := t.audioHash(ctx, src)
if err != nil {
return err
}
b, err := t.audioHash(ctx, dst)
if err != nil {
return err
}
if a != b {
return fmt.Errorf("%w: the audio changed", ErrVerify)
}
return nil
}
// audioHash is the MD5 of the file's decoded audio streams.
func (t Tools) audioHash(ctx context.Context, path string) (string, error) {
out, err := t.run(ctx, t.FFmpeg, "-v", "error", "-nostdin", "-i", path, "-map", "0:a", "-f", "md5", "-")
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// HasCover reports whether a file has embedded cover art.
func (t Tools) HasCover(ctx context.Context, path string) bool {
out, err := t.run(ctx, t.FFprobe, "-v", "error", "-select_streams", "v", "-show_entries", "stream=index", "-of", "csv=p=0", "--", path)
return err == nil && strings.TrimSpace(string(out)) != ""
}
// Cover returns a file's embedded cover art as a JPEG, or nil when it has none.
func (t Tools) Cover(ctx context.Context, path string) ([]byte, error) {
if !t.HasCover(ctx, path) {
return nil, nil
}
return t.run(ctx, t.FFmpeg, "-v", "error", "-nostdin", "-i", path, "-map", "0:v:0", "-frames:v", "1",
"-vf", "scale='min(600,iw)':-2", "-c:v", "mjpeg", "-q:v", "3", "-f", "image2pipe", "-")
}
-177
View File
@@ -1,177 +0,0 @@
package tags
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var ctx = context.Background()
func tools(t *testing.T) Tools {
t.Helper()
ffprobe, err1 := exec.LookPath("ffprobe")
ffmpeg, err2 := exec.LookPath("ffmpeg")
if err1 != nil || err2 != nil {
t.Skip("ffmpeg/ffprobe not installed")
}
return Tools{FFprobe: ffprobe, FFmpeg: ffmpeg}
}
// makeFile creates a short tagged file; mp3, flac and m4a get cover art.
func makeFile(t *testing.T, tl Tools, dir, ext string) string {
t.Helper()
cover := filepath.Join(dir, "cover.png")
if _, err := os.Stat(cover); err != nil {
if out, err := exec.Command(tl.FFmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=red:s=32x32:d=1", "-frames:v", "1", cover).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
}
p := filepath.Join(dir, "song."+ext)
args := []string{"-v", "error", "-y", "-f", "lavfi", "-i", "sine=d=1"}
if ext == "mp3" || ext == "flac" || ext == "m4a" {
args = append(args, "-i", cover, "-map", "0", "-map", "1", "-c:v", "png", "-disposition:v:0", "attached_pic")
}
if ext == "m4a" {
args = append(args, "-c:a", "aac")
}
for _, kv := range []string{"title=Old Title", "artist=Avenged Sevenfold", "album_artist=A7X", "track=3/12", "date=2010", "genre=Metal"} {
args = append(args, "-metadata", kv)
}
if out, err := exec.Command(tl.FFmpeg, append(args, p)...).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
return p
}
func streams(t *testing.T, tl Tools, p string) int {
out, err := exec.Command(tl.FFprobe, "-v", "error", "-show_entries", "stream=index", "-of", "csv=p=0", p).Output()
if err != nil {
t.Fatal(err)
}
return len(strings.Fields(string(out)))
}
func TestEditEveryFormat(t *testing.T) {
tl := tools(t)
for _, ext := range []string{"mp3", "flac", "m4a", "ogg", "opus"} {
t.Run(ext, func(t *testing.T) {
dir := t.TempDir()
src := makeFile(t, tl, dir, ext)
f, err := tl.Read(ctx, src)
if err != nil {
t.Fatal(err)
}
if f.Fields[Title] != "Old Title" || f.Fields[Track] != "3" || f.Fields[TrackTotal] != "12" || f.Fields[AlbumArtist] != "A7X" {
t.Fatalf("read: %+v", f.Fields)
}
changes := map[string]string{Title: "Nightmare", AlbumArtist: "Avenged Sevenfold", Genre: "", Track: "4", TrackTotal: "13", Album: "Nightmare"}
set, err := f.Plan(changes)
if err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, ".tmp-out."+ext)
if err := tl.Write(ctx, src, dst, f, set, ""); err != nil {
t.Fatal(err)
}
if err := tl.Verify(ctx, src, dst, changes, false); err != nil {
t.Fatal(err)
}
got, _ := tl.Read(ctx, dst)
for k, v := range got.Raw {
if strings.Contains(v, ";") {
t.Errorf("tag %s was duplicated: %q", k, v)
}
}
if _, has := got.Fields[Genre]; has {
t.Error("genre should be removed")
}
if got.Fields[Artist] != "Avenged Sevenfold" || got.Fields[Date] != "2010" {
t.Errorf("untouched fields must survive: %+v", got.Fields)
}
if streams(t, tl, src) != streams(t, tl, dst) {
t.Error("cover art or another stream was lost")
}
})
}
}
// FLACs tagged by other tools use raw Vorbis names; they must keep their
// spelling, their separate TRACKTOTAL and unrelated tags like ReplayGain.
func TestEditRawVorbisFLAC(t *testing.T) {
tl := tools(t)
metaflac, err := exec.LookPath("metaflac")
if err != nil {
t.Skip("metaflac not installed")
}
dir := t.TempDir()
src := makeFile(t, tl, dir, "flac")
cmd := exec.Command(metaflac, "--remove-all-tags", "--set-tag=TITLE=Raw", "--set-tag=ALBUMARTIST=A7X", "--set-tag=TRACKNUMBER=3",
"--set-tag=TRACKTOTAL=12", "--set-tag=REPLAYGAIN_TRACK_GAIN=-6.5 dB", "--set-tag=YEAR=2009", "--set-tag=DATE=2010", src)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
f, _ := tl.Read(ctx, src)
changes := map[string]string{Title: "Nightmare", AlbumArtist: "Avenged Sevenfold", Track: "4", Date: "2011"}
set, _ := f.Plan(changes)
dst := filepath.Join(dir, ".tmp-out.flac")
if err := tl.Write(ctx, src, dst, f, set, ""); err != nil {
t.Fatal(err)
}
if err := tl.Verify(ctx, src, dst, changes, false); err != nil {
t.Fatal(err)
}
out, _ := exec.Command(metaflac, "--export-tags-to=-", dst).Output()
tagsText := string(out)
for _, want := range []string{"TITLE=Nightmare", "ALBUMARTIST=Avenged Sevenfold", "TRACKNUMBER=4", "TRACKTOTAL=12", "REPLAYGAIN_TRACK_GAIN=-6.5 dB", "DATE=2011"} {
if !strings.Contains(tagsText, want+"\n") {
t.Errorf("missing %q in:\n%s", want, tagsText)
}
}
if strings.Count(tagsText, "ALBUMARTIST=") != 1 || strings.Count(tagsText, "TRACKNUMBER=") != 1 || strings.Contains(tagsText, "YEAR=") {
t.Errorf("duplicate or stale tags:\n%s", tagsText)
}
if strings.Contains(strings.ToLower(tagsText), "encoder=") {
t.Errorf("no encoder tag should be added:\n%s", tagsText)
}
}
func TestVerifyRejectsChangedAudio(t *testing.T) {
tl := tools(t)
dir := t.TempDir()
a := makeFile(t, tl, dir, "flac")
b := filepath.Join(dir, "other.flac")
if out, err := exec.Command(tl.FFmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "sine=f=900:d=1", "-metadata", "title=Old Title", b).CombinedOutput(); err != nil {
t.Fatal(err, string(out))
}
if err := tl.Verify(ctx, a, b, map[string]string{Title: "Old Title"}, false); !errors.Is(err, ErrVerify) {
t.Fatalf("different audio must fail verification, got %v", err)
}
}
func TestPlanTotals(t *testing.T) {
combined := parse(Format{Name: "mp3"}, map[string]string{"track": "3/12"})
set, _ := combined.Plan(map[string]string{TrackTotal: "14"})
if set["track"] != "3/14" {
t.Errorf("mp3 total change should rewrite the combined tag, got %v", set)
}
set, _ = combined.Plan(map[string]string{Track: "", TrackTotal: ""})
if v, ok := set["track"]; !ok || v != "" {
t.Errorf("clearing both should delete the tag, got %v", set)
}
fresh := parse(Format{Name: "flac", Vorbis: true}, map[string]string{})
set, _ = fresh.Plan(map[string]string{Track: "1", TrackTotal: "9", Title: "x"})
if set["track"] != "1" || set["TRACKTOTAL"] != "9" || set["TITLE"] != "x" {
t.Errorf("new FLAC tags should use standard names, got %v", set)
}
if _, err := fresh.Plan(map[string]string{"bogus": "1"}); err == nil {
t.Error("unknown fields must be rejected")
}
if _, ok := FormatOf("x.dsf"); ok {
t.Error("dsf editing is not supported by ffmpeg and must be refused")
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ function Shell({ username }: { username: string }) {
];
let page;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={!route[2] || route[2] === "fixer" ? "tune-up" : route[2]} />;
if (route[0] === "pair" && route[1]) page = <PairPage id={Number(route[1])} tab={route[2] ?? "review"} />;
else if (route[0] === "activity") page = <ActivityPage />;
else if (route[0] === "settings") page = <SettingsPage />;
else page = <Dashboard />;
+2 -131
View File
@@ -34,8 +34,6 @@ export type Info = {
default_extensions: string[];
dry_run: boolean;
plex_ignore: boolean;
tag_editing: boolean;
tag_fields: string[];
};
export type Job = {
@@ -50,7 +48,6 @@ export type Job = {
error: string;
results: Record<string, number>;
skipped: string[];
notes?: string[];
started_at: number;
finished_at?: number;
};
@@ -77,10 +74,6 @@ export type Pair = {
settle_minutes: number;
approx_matching: boolean;
approx_threshold: number;
auto_organize: "off" | "slave" | "master" | "both";
library_type: LibraryType;
naming: Naming;
fixer: FixerState;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: ScanWarning[];
@@ -101,75 +94,8 @@ export type PairInput = Pick<
| "settle_minutes"
| "approx_matching"
| "approx_threshold"
| "auto_organize"
| "library_type"
>;
export type LibraryType = "music" | "soundtracks";
// ---- tune-up (step-by-step library fixing) ----
export type FixStep = "structure" | "tags" | "folders" | "files";
export type StepStatus = { state: "" | "done" | "skipped"; at?: number; note?: string };
export type FixerState = { master: Partial<Record<FixStep, StepStatus>>; slave: Partial<Record<FixStep, StepStatus>>; link: StepStatus };
export type FixPlan = {
id: number;
side: Side;
step: FixStep;
created_at: number;
songs: number;
albums: number;
fixable: number;
blocked: number;
notices: string[];
applied: boolean;
};
export type FixItem = {
id: number;
kind: "move-folder" | "move-file" | "tags" | "album-tags" | "problem";
group: string;
from?: string;
to?: string;
why: string;
blocked?: string;
changes?: Record<string, [string, string]>;
unsure?: boolean;
};
export type MbRelease = {
id: string;
title: string;
artist: string;
date: string;
country: string;
status: string;
type: string;
disambiguation?: string;
formats: string;
track_count: number;
discs: number;
score: number;
};
export type AlbumFix = {
id: number;
folder: string;
artist: string;
album: string;
source: "musicbrainz" | "master";
release?: MbRelease;
candidates: MbRelease[];
confidence: number;
matched: number;
files: { path: string; track?: string; changes: Record<string, [string, string]> }[];
album_changes: Record<string, [string, string]>;
error?: string;
};
export type Fixer = { state: FixerState; steps: FixStep[]; plan: FixPlan | null; items: FixItem[]; albums: AlbumFix[] };
export type ProbeInfo = {
codec: string;
codec_long?: string;
@@ -195,7 +121,7 @@ export type Match = {
pair_id: number;
rel_slave: string;
rel_master: string;
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "inode" | "none";
kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "approximate" | "none";
status: Status;
master_size: number;
master_mtime: number;
@@ -213,7 +139,7 @@ export type Match = {
export type Operation = {
id: number;
pair_id: number;
action: "link" | "promote" | "build" | "remove" | "rename" | "tags" | "move";
action: "link" | "promote" | "build" | "remove" | "rename";
rel_master: string;
rel_slave: string;
rel_new: string;
@@ -228,13 +154,6 @@ export type Operation = {
finished_at: number | null;
undone_at: number | null;
purged_at: number | null;
detail?: {
side?: "master" | "slave";
mode?: "replace" | "inplace";
changes?: Record<string, [string, string]>;
sidecars?: [string, string][];
art?: boolean;
};
};
export type TreeEntry = {
@@ -293,53 +212,5 @@ export type ScanWarning = {
export type FolderRename = { slave: string; master: string; ready: boolean; reason?: string };
export type Side = "master" | "slave";
export type FileEntry = { name: string; rel: string; dir: boolean; size?: number; links?: number; editable?: boolean };
export type TagFile = {
rel: string;
editable: boolean;
reason?: string;
fields: Record<string, string>;
other: Record<string, string>;
links: number;
size: number;
};
export type BackupConfig = { folder: string; interval_days: number; retention_days: number; before_actions: boolean };
export type Backup = { name: string; type: "manual" | "scheduled" | "action"; size: number; created: number };
export type Naming = {
artist_folder: string;
album_folder: string;
track_format: string;
multi_disc_format: string;
colon: "smart" | "delete" | "dash" | "space_dash" | "space_dash_space";
illegal: "replace" | "remove";
folders: FolderNaming[];
};
/** Its own naming for one folder of a library, e.g. soundtracks in "Video Games". */
export type FolderNaming = {
folder: string;
artist_folder: string;
album_folder: string;
track_format: string;
multi_disc_format: string;
};
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
export type OrganizePlan = { sides: Side[]; created_at: number; scanned: number; conforming: number; moves: number; skips: Record<string, number> };
export type ArtResult = { source: string; title: string; artist?: string; url: string; thumb: string; width: number; height: number };
export type PreparedArt = { token: string; width: number; height: number; size: number; square: boolean };
export type ArtSettings = { sources: string[]; google_key: string; google_cx: string; max_size: number; min_size: number };
export type AlbumSong = TagFile & { has_cover: boolean; embed_cover: boolean };
export type Album = { folder: string; songs: AlbumSong[]; folder_cover?: string };
export type ArtApply = { token: string; embed: boolean; folder_file: boolean };
export const artThumb = (u: string) => `/api/art/thumb?u=${encodeURIComponent(u)}`;
export const coverURL = (pairId: number, side: Side, path: string, bust = "") =>
`/api/pairs/${pairId}/cover?side=${side}&path=${encodeURIComponent(path)}${bust ? `&v=${bust}` : ""}`;
+2 -20
View File
@@ -37,8 +37,6 @@ const actionLabel: Record<Operation["action"], string> = {
build: "Built into slave",
remove: "Removed from slave",
rename: "Renamed slave folder",
tags: "Edited tags",
move: "Renamed file",
};
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
@@ -97,19 +95,9 @@ export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?:
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave || o.rel_master}>
{(o.action === "tags" || o.action === "move") && <span className="mr-1 font-sans text-[11px] text-muted">{o.detail?.side}</span>}
{o.rel_slave || o.rel_master}
<div className="truncate mono text-[13px]" title={o.rel_slave}>
{o.rel_slave}
</div>
{o.detail?.changes && (
<div className="text-xs text-muted">
{Object.entries(o.detail.changes).map(([k, [from, to]]) => (
<div key={k} className="truncate">
<span className="text-gray-400">{k.replace("_", " ")}</span>: {from || "(none)"} → <span className="text-gray-200">{to || "(removed)"}</span>
</div>
))}
</div>
)}
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
)}
@@ -190,12 +178,6 @@ function undoText(o: Operation) {
return "The hardlink is removed from the slave library. The master file is not affected.";
case "remove":
return "The hardlink is recreated in the slave library.";
case "move":
return `The file is moved back from "${o.rel_new}" to "${o.rel_slave || o.rel_master}"${o.detail?.sidecars?.length ? ", with its lyrics file" : ""}, and the library is rescanned.`;
case "tags":
return o.detail?.mode === "inplace"
? "The previous version of the file is written back from quarantine, restoring the old tags everywhere the file is linked."
: "The previous file is put back from quarantine, restoring the old tags.";
case "rename":
return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`;
}
-287
View File
@@ -1,287 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Image as ImageIcon, Link2, ListOrdered, RotateCcw, UserRound } from "lucide-react";
import { api, coverURL, type Album, type AlbumSong, type PreparedArt, type Side } from "../api";
import { useInfo } from "../App";
import { Alert, Button, Modal, Spinner, Toggle, cx, inputCls } from "../ui";
import { useToast } from "../toast";
import { ArtPicker, ChosenArt, CoverImage } from "./Art";
// Album-level fields apply to every song; row fields are per song.
const ALBUM_FIELDS: { id: string; label: string; wide?: boolean }[] = [
{ id: "album", label: "Album", wide: true },
{ id: "album_artist", label: "Album artist", wide: true },
{ id: "date", label: "Year / date" },
{ id: "genre", label: "Genre" },
{ id: "disc_total", label: "Discs" },
{ id: "composer", label: "Composer", wide: true },
{ id: "comment", label: "Comment", wide: true },
];
const ROW_FIELDS = ["disc", "track", "title", "artist"] as const;
const num = (s?: string) => parseInt(s ?? "", 10) || 0;
/**
* Edits a whole album at once, AudioShell-style: album fields for every song,
* a grid for each song's number, title and artist, and the album art.
*/
export function AlbumEditor({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const data = useQuery({
queryKey: ["album", pairId, side, folder],
queryFn: () => api.get<Album>(`/pairs/${pairId}/album?side=${side}&path=${encodeURIComponent(folder)}`),
gcTime: 0,
});
const [albumEdits, setAlbumEdits] = useState<Record<string, string>>({});
const [rowEdits, setRowEdits] = useState<Record<string, Record<string, string>>>({});
const [art, setArt] = useState<PreparedArt | null>(null);
const [picking, setPicking] = useState(false);
const [embed, setEmbed] = useState(true);
const [folderFile, setFolderFile] = useState(true);
const [separate, setSeparate] = useState(false);
const songs = useMemo(() => {
const s = (data.data?.songs ?? []).filter((x) => x.editable);
return [...s].sort(
(a, b) =>
num(a.fields.disc) - num(b.fields.disc) || num(a.fields.track) - num(b.fields.track) || a.rel.localeCompare(b.rel),
);
}, [data.data]);
const blocked = (data.data?.songs ?? []).filter((x) => !x.editable);
const common = useMemo(() => {
const c: Record<string, { value: string; mixed: boolean }> = {};
for (const f of ALBUM_FIELDS) {
const vals = songs.map((s) => s.fields[f.id] ?? "");
c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]) };
}
return c;
}, [songs]);
const albumValue = (id: string) => albumEdits[id] ?? (common[id]?.mixed ? "" : common[id]?.value ?? "");
const rowValue = (s: AlbumSong, id: string) => rowEdits[s.rel]?.[id] ?? s.fields[id] ?? "";
const setRow = (rel: string, id: string, v: string) => setRowEdits((r) => ({ ...r, [rel]: { ...r[rel], [id]: v } }));
const albumChanged = Object.keys(albumEdits).filter((id) => common[id]?.mixed || albumEdits[id] !== common[id]?.value);
const rowChanges = songs
.map((s) => ({
path: s.rel,
set: Object.fromEntries(Object.entries(rowEdits[s.rel] ?? {}).filter(([id, v]) => v !== (s.fields[id] ?? ""))),
}))
.filter((r) => Object.keys(r.set).length > 0);
const hasChanges = albumChanged.length > 0 || rowChanges.length > 0 || !!art;
// "Number tracks in order": 1..n within each disc, with the disc's total.
const numberTracks = () => {
const byDisc = new Map<number, AlbumSong[]>();
for (const s of songs) {
const d = num(rowValue(s, "disc")) || 1;
byDisc.set(d, [...(byDisc.get(d) ?? []), s]);
}
const next: Record<string, Record<string, string>> = { ...rowEdits };
for (const list of byDisc.values()) {
list.forEach((s, i) => {
next[s.rel] = { ...next[s.rel], track: String(i + 1), track_total: String(list.length) };
});
}
setRowEdits(next);
if (byDisc.size > 1) setAlbumEdits((a) => ({ ...a, disc_total: String(byDisc.size) }));
};
const artistFromAlbumArtist = () => {
const aa = albumValue("album_artist");
if (!aa) return;
setRowEdits((r) => Object.fromEntries(songs.map((s) => [s.rel, { ...r[s.rel], artist: aa }])));
};
const save = useMutation({
mutationFn: () =>
api.post(`/pairs/${pairId}/tags`, {
side,
paths: songs.map((s) => s.rel),
set: Object.fromEntries(albumChanged.map((id) => [id, albumEdits[id].trim()])),
files: rowChanges.map((r) => ({ path: r.path, set: Object.fromEntries(Object.entries(r.set).map(([k, v]) => [k, v.trim()])) })),
art: art ? { token: art.token, embed, folder_file: folderFile } : undefined,
separate,
}),
onSuccess: () => {
toast("ok", dry ? "Started (dry run): recorded as planned only" : `Saving the album (${songs.length} songs)…`);
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["pair", pairId] });
onClose();
},
onError: (e) => toast("err", e.message),
});
const shared = songs.filter((s) => s.links > 1).length;
const embeddable = songs.filter((s) => s.embed_cover).length;
const currentCover = data.data
? data.data.folder_cover
? coverURL(pairId, side, folder)
: songs.find((s) => s.has_cover)
? coverURL(pairId, side, songs.find((s) => s.has_cover)!.rel)
: null
: null;
useEffect(() => setPicking(false), [art]);
return (
<Modal open onClose={onClose} title={`Edit album: ${folder.split("/").pop() || folder}`} wide>
{data.isLoading && <Spinner />}
{data.error && <Alert tone="red">{data.error.message}</Alert>}
{data.data && songs.length === 0 && <Alert tone="amber">No editable songs in this folder.</Alert>}
{data.data && songs.length > 0 && (
<div className="space-y-4 text-sm">
<div className="text-xs text-muted">
<span className="mono">{folder}</span> · {songs.length} songs{blocked.length > 0 && `, ${blocked.length} not editable`}
</div>
<div className="grid gap-4 md:grid-cols-[11rem_1fr]">
<div className="space-y-2">
{art ? <ChosenArt art={art} onClear={() => setArt(null)} /> : <CoverImage src={currentCover} className="aspect-square w-full" />}
{!art && (
<Button size="sm" className="w-full" onClick={() => setPicking(!picking)}>
<ImageIcon className="size-3.5" /> {picking ? "Close search" : "Change art…"}
</Button>
)}
{art && (
<div className="space-y-1.5 text-xs">
<Toggle checked={embed} onChange={setEmbed} label={`Embed in songs (${embeddable})`} />
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in folder" />
{embeddable < songs.length && (
<p className="text-[11px] text-muted">OGG/Opus songs can't hold embedded art; they use the folder's cover.jpg.</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 md:grid-cols-4">
{ALBUM_FIELDS.map((f) => {
const changed = albumChanged.includes(f.id);
return (
<label key={f.id} className={cx("block space-y-1", f.wide ? "col-span-2" : "col-span-1")}>
<span className="flex justify-between text-xs font-medium text-gray-300">
{f.label}
{changed && (
<button type="button" className="text-muted hover:text-white cursor-pointer" onClick={() => setAlbumEdits(({ [f.id]: _, ...r }) => r)}>
<RotateCcw className="size-3" />
</button>
)}
</span>
<input
className={cx(inputCls, "h-8 text-xs", changed && "border-accent ring-1 ring-accent/40")}
value={albumValue(f.id)}
placeholder={common[f.id]?.mixed ? "(differs per song: keep)" : ""}
onChange={(e) => setAlbumEdits((a) => ({ ...a, [f.id]: e.target.value }))}
/>
</label>
);
})}
</div>
</div>
{picking && !art && (
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
<ArtPicker artist={albumValue("album_artist") || songs[0]?.fields.artist || ""} album={albumValue("album")} onChoose={setArt} />
</div>
)}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-gray-300">Tracks</span>
<div className="flex gap-1.5">
<Button size="sm" variant="ghost" onClick={numberTracks} title="Number the tracks 1, 2, 3… in the order shown, per disc">
<ListOrdered className="size-3.5" /> Number tracks in order
</Button>
<Button size="sm" variant="ghost" onClick={artistFromAlbumArtist} disabled={!albumValue("album_artist")}>
<UserRound className="size-3.5" /> Artist = album artist
</Button>
{rowChanges.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => setRowEdits({})}>
<RotateCcw className="size-3.5" /> Revert tracks
</Button>
)}
</div>
</div>
<div className="max-h-[40vh] overflow-auto rounded-lg border border-line">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-panel-2 text-left text-muted">
<tr>
<th className="w-14 px-2 py-1.5 font-medium">Disc</th>
<th className="w-14 px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">Title</th>
<th className="w-44 px-2 py-1.5 font-medium">Artist</th>
<th className="w-40 px-2 py-1.5 font-medium">File</th>
</tr>
</thead>
<tbody>
{songs.map((s) => (
<tr key={s.rel} className="border-t border-line/60">
{ROW_FIELDS.map((id) => {
const v = rowValue(s, id);
const changed = v !== (s.fields[id] ?? "");
return (
<td key={id} className="px-1 py-0.5">
<input
className={cx(
"w-full rounded border border-transparent bg-transparent px-1.5 py-1 outline-none hover:border-line focus:border-accent",
changed && "border-accent/60 bg-accent/10",
(id === "disc" || id === "track") && "text-right tabular-nums",
)}
value={v}
onChange={(e) => setRow(s.rel, id, e.target.value)}
/>
</td>
);
})}
<td className="max-w-40 truncate px-2 py-1 mono text-[11px] text-muted" title={s.rel}>
{s.links > 1 && <Link2 className="mr-1 inline size-3 text-emerald-400" />}
{s.rel.split("/").pop()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{shared > 0 && (
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
<div>
{shared} of these songs are <b>hardlinked</b> to the {side === "slave" ? "master" : "slave"} library, so edits show up in both
libraries and the files stay linked.
</div>
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
<span>Change only the {side} library's copies instead (breaks the links and uses extra disk space).</span>
</label>
</Alert>
)}
<p className="text-xs text-muted">
Only tags and art change; the audio is verified to be identical. Previous versions go to quarantine, and every change can be undone
from History.
</p>
{dry && <Alert tone="blue">Dry run is on, so the changes are recorded as planned in Activity. No files change.</Alert>}
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
{hasChanges && (
<span className="mr-auto text-xs text-muted">
{[
albumChanged.length && `${albumChanged.length} album field${albumChanged.length > 1 ? "s" : ""}`,
rowChanges.length && `${rowChanges.length} song${rowChanges.length > 1 ? "s" : ""} changed`,
art && "new cover",
]
.filter(Boolean)
.join(" · ")}
</span>
)}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!hasChanges || (!!art && !embed && !folderFile && albumChanged.length + rowChanges.length === 0)} loading={save.isPending} onClick={() => save.mutate()}>
Save album
</Button>
</div>
</Modal>
);
}
-229
View File
@@ -1,229 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ImageOff, Link2, Search, Upload } from "lucide-react";
import { api, artThumb, type ArtResult, type ArtSettings, type PreparedArt } from "../api";
import { Alert, Badge, Button, Spinner, cx, formatBytes, inputCls } from "../ui";
const SOURCE_LABEL: Record<string, string> = {
itunes: "iTunes",
deezer: "Deezer",
musicbrainz: "MusicBrainz",
duckduckgo: "DuckDuckGo",
google: "Google",
};
/** Shows a cover image, or a placeholder when there is none. */
export function CoverImage({ src, className }: { src?: string | null; className?: string }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
if (!src || failed)
return (
<div className={cx("grid place-items-center rounded-lg border border-line bg-panel-2 text-muted", className)}>
<ImageOff className="size-6" />
</div>
);
return <img src={src} alt="" className={cx("rounded-lg border border-line object-cover", className)} onError={() => setFailed(true)} />;
}
/**
* Finds a new cover: search the art sources (square images only), paste an
* image address, or upload a file. The chosen image is prepared on the server
* (converted to JPEG, resized) and returned as a token to apply later.
*/
export function ArtPicker({
artist,
album,
onChoose,
}: {
artist: string;
album: string;
onChoose: (p: PreparedArt) => void;
}) {
const settings = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
const [q, setQ] = useState({ artist, album, text: "" });
const [free, setFree] = useState(false);
const [sources, setSources] = useState<string[] | null>(null);
const [url, setUrl] = useState("");
const [broken, setBroken] = useState<Set<string>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const enabled = settings.data?.settings.sources ?? [];
const googleReady = !!settings.data?.settings.google_key && !!settings.data?.settings.google_cx;
const usable = enabled.filter((s) => s !== "google" || googleReady);
const active = sources ?? usable;
const search = useMutation({
mutationFn: () => {
const p = new URLSearchParams({ sources: active.join(",") });
if (free) p.set("q", q.text);
else {
p.set("artist", q.artist);
p.set("album", q.album);
}
return api.get<{ results: ArtResult[]; errors: Record<string, string> }>(`/art/search?${p}`);
},
});
// Search right away with the album's own tags.
useEffect(() => {
if (settings.data && (artist || album)) search.mutate();
}, [settings.data]); // eslint-disable-line react-hooks/exhaustive-deps
const prepare = useMutation({
mutationFn: (u: string) => api.post<PreparedArt>("/art/prepare", { url: u }),
onSuccess: onChoose,
});
const upload = useMutation({
mutationFn: async (f: File) => {
const form = new FormData();
form.append("image", f);
const res = await fetch("/api/art/upload", { method: "POST", credentials: "same-origin", headers: { "X-Playlink-Request": "1" }, body: form });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? res.statusText);
return data as PreparedArt;
},
onSuccess: onChoose,
});
const busy = prepare.isPending || upload.isPending;
return (
<div className="space-y-3">
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
search.mutate();
}}
>
{free ? (
<input className={inputCls + " min-w-0 flex-1"} placeholder="Search text" value={q.text} onChange={(e) => setQ({ ...q, text: e.target.value })} />
) : (
<>
<input className={inputCls + " min-w-0 flex-1"} placeholder="Artist" value={q.artist} onChange={(e) => setQ({ ...q, artist: e.target.value })} />
<input className={inputCls + " min-w-0 flex-1"} placeholder="Album" value={q.album} onChange={(e) => setQ({ ...q, album: e.target.value })} />
</>
)}
<Button type="submit" variant="primary" loading={search.isPending}>
<Search className="size-4" /> Search
</Button>
</form>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<button type="button" className="mr-2 text-accent hover:underline cursor-pointer" onClick={() => setFree(!free)}>
{free ? "Search by artist and album" : "Free-text search"}
</button>
{enabled.map((s) => {
const disabled = s === "google" && !googleReady;
const on = active.includes(s) && !disabled;
return (
<button
key={s}
type="button"
disabled={disabled}
title={disabled ? "Add a Google API key and search engine id in Settings → Album art" : undefined}
onClick={() => setSources(on ? active.filter((x) => x !== s) : [...active, s])}
className={cx(
"rounded-full border px-2 py-0.5",
on ? "border-accent bg-accent/15 text-white" : "border-line text-muted",
disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
)}
>
{SOURCE_LABEL[s] ?? s}
</button>
);
})}
<span className="text-muted">· square images only</span>
</div>
{search.data && Object.keys(search.data.errors).length > 0 && (
<Alert tone="amber">
{Object.entries(search.data.errors).map(([s, e]) => (
<div key={s} className="text-xs">
{SOURCE_LABEL[s] ?? s}: {e}
</div>
))}
</Alert>
)}
{search.isPending && <Spinner />}
{search.data && search.data.results.length === 0 && <div className="py-4 text-center text-sm text-muted">No square covers found. Try other words.</div>}
{search.data && search.data.results.length > 0 && (
<div className="grid max-h-[45vh] grid-cols-3 gap-2 overflow-y-auto sm:grid-cols-4 md:grid-cols-5">
{search.data.results.filter((r) => !broken.has(r.url)).map((r) => (
<button
key={r.url}
type="button"
disabled={busy}
onClick={() => prepare.mutate(r.url)}
title={[r.artist, r.title].filter(Boolean).join(" – ")}
className="group relative overflow-hidden rounded-lg border border-line text-left hover:border-accent cursor-pointer disabled:opacity-50"
>
<img
src={artThumb(r.thumb || r.url)}
alt=""
loading="lazy"
className="aspect-square w-full object-cover"
// Some web images vanish or refuse hotlinking; drop them from the grid.
onError={() => setBroken((b) => new Set(b).add(r.url))}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-1.5 py-1 text-[10px] leading-tight">
<div className="truncate text-gray-100">{r.title || "untitled"}</div>
<div className="flex justify-between text-gray-400">
<span>{SOURCE_LABEL[r.source] ?? r.source}</span>
<span>
{r.width}×{r.height}
</span>
</div>
</div>
</button>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-3">
<div className="relative min-w-0 flex-1">
<Link2 className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8 text-xs"} placeholder="Or paste an image address (https://…)" value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<Button disabled={!url.trim() || busy} onClick={() => prepare.mutate(url.trim())}>
Use address
</Button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = "";
}}
/>
<Button disabled={busy} onClick={() => fileRef.current?.click()}>
<Upload className="size-4" /> Upload…
</Button>
</div>
{busy && (
<div className="flex items-center gap-2 text-xs text-muted">
<Spinner className="size-3.5" /> Downloading and preparing the image…
</div>
)}
{(prepare.error || upload.error) && <Alert tone="red">{(prepare.error || upload.error)!.message}</Alert>}
</div>
);
}
/** A chosen cover waiting to be saved. */
export function ChosenArt({ art, onClear }: { art: PreparedArt; onClear: () => void }) {
return (
<div className="flex items-center gap-3">
<CoverImage src={`/api/art/prepared/${art.token}`} className="size-20" />
<div className="text-xs">
<div className="font-medium text-gray-100">New cover</div>
<div className="text-muted">
{art.width}×{art.height} · {formatBytes(art.size)} JPEG
</div>
{!art.square && <Badge tone="amber">not square</Badge>}
<button className="mt-1 block text-accent hover:underline cursor-pointer" onClick={onClear}>
Keep the current cover
</button>
</div>
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Image as ImageIcon } from "lucide-react";
import { api, type ArtSettings } from "../api";
import { Button, Card, Field, cx, inputCls } from "../ui";
import { useToast } from "../toast";
const SOURCES: { id: string; label: string; hint: string }[] = [
{ id: "itunes", label: "iTunes", hint: "Album covers, 1000 px, no key needed" },
{ id: "deezer", label: "Deezer", hint: "Album covers, 1000 px, no key needed" },
{ id: "musicbrainz", label: "MusicBrainz", hint: "Cover Art Archive, no key needed" },
{ id: "duckduckgo", label: "DuckDuckGo", hint: "Web images; unofficial, may stop working" },
{ id: "google", label: "Google", hint: "Web images; needs an API key and search engine id" },
];
export function ArtSettingsCard() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["art-settings"], queryFn: () => api.get<{ settings: ArtSettings }>("/art/settings") });
const [s, setS] = useState<ArtSettings | null>(null);
useEffect(() => {
if (data.data) setS(data.data.settings);
}, [data.data]);
const dirty = s && data.data && JSON.stringify(s) !== JSON.stringify(data.data.settings);
const save = useMutation({
mutationFn: () => api.put("/art/settings", s),
onSuccess: () => {
toast("ok", "Album art settings saved");
qc.invalidateQueries({ queryKey: ["art-settings"] });
},
onError: (e) => toast("err", e.message),
});
if (!s) return null;
const toggle = (id: string) => setS({ ...s, sources: s.sources.includes(id) ? s.sources.filter((x) => x !== id) : [...s.sources, id] });
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 font-semibold">
<ImageIcon className="size-4 text-accent" /> Album art
</h2>
<p className="text-sm text-muted">Where the tag editor searches for covers. Only square images are shown.</p>
</div>
{dirty && (
<Button size="sm" variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{SOURCES.map((src) => (
<label key={src.id} className={cx("flex items-start gap-2 rounded-lg border border-line p-2.5 text-sm cursor-pointer", s.sources.includes(src.id) && "border-accent/50 bg-accent/5")}>
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={s.sources.includes(src.id)} onChange={() => toggle(src.id)} />
<span>
<span className="font-medium">{src.label}</span>
<span className="block text-xs text-muted">{src.hint}</span>
</span>
</label>
))}
</div>
{s.sources.includes("google") && (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Google API key" hint="From Google Cloud, with the Custom Search API enabled.">
<input className={inputCls + " mono text-xs"} type="password" autoComplete="off" value={s.google_key} onChange={(e) => setS({ ...s, google_key: e.target.value })} />
</Field>
<Field label="Search engine id (cx)" hint="A Programmable Search Engine with image search on.">
<input className={inputCls + " mono text-xs"} value={s.google_cx} onChange={(e) => setS({ ...s, google_cx: e.target.value })} />
</Field>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<Field label="Saved cover size" hint="Covers are converted to JPEG at this size.">
<select className={inputCls} value={s.max_size} onChange={(e) => setS({ ...s, max_size: Number(e.target.value) })}>
<option value={600}>600 × 600</option>
<option value={1000}>1000 × 1000 (recommended)</option>
<option value={1400}>1400 × 1400</option>
<option value={3000}>3000 × 3000</option>
<option value={0}>Original size</option>
</select>
</Field>
<Field label="Smallest search result" hint="Hide covers smaller than this.">
<select className={inputCls} value={s.min_size} onChange={(e) => setS({ ...s, min_size: Number(e.target.value) })}>
<option value={300}>300 px</option>
<option value={500}>500 px</option>
<option value={800}>800 px</option>
<option value={1000}>1000 px</option>
</select>
</Field>
</div>
<p className="text-xs text-muted">
Searching sends the artist and album (or your search text) to the selected services. Images are downloaded by playlink itself, never from
addresses on your local network.
</p>
</Card>
);
}
-585
View File
@@ -1,585 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Folder, FolderPlus, FolderTree, Tags as TagsIcon, Trash2 } from "lucide-react";
import { api, type FolderNaming, type Job, type Naming, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, cx, inputCls } from "../ui";
import { useToast } from "../toast";
import { TagEditor } from "./Tags";
type NamingInfo = { defaults: Naming; presets: { name: string; config: Naming }[]; tokens: [string, string][]; folder_default: FolderNaming };
type FormatKey = "artist_folder" | "album_folder" | "track_format" | "multi_disc_format";
// Where a token is inserted: a main format, or one of a folder rule's (rule = its index).
type FieldRef = { rule: number | null; key: FormatKey; el: HTMLInputElement };
type Scope = Side | "both";
const FORMAT_FIELDS: { key: FormatKey; label: string; hint: string }[] = [
{ key: "artist_folder", label: "Artist folder", hint: "One folder name. Leave empty for no artist folder (Album/01 Title)." },
{ key: "album_folder", label: "Album folder", hint: "One folder name, inside the artist folder if there is one." },
{ key: "track_format", label: "Track", hint: "File name inside the album folder. May add subfolders with /." },
{ key: "multi_disc_format", label: "Multi-disc track", hint: "Used when an album has more than one disc." },
];
export function OrganizeTab({ pair }: { pair: Pair }) {
const info = useInfo();
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">Organizing reads tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
return (
<div className="space-y-5">
<RenameNowCard pair={pair} />
<NamingCard pair={pair} />
</div>
);
}
// ---- naming settings ----
function NamingCard({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const info = useQuery({ queryKey: ["naming-info"], queryFn: () => api.get<NamingInfo>("/naming"), staleTime: Infinity });
const [cfg, setCfg] = useState<Naming>(pair.naming);
const [auto, setAuto] = useState(pair.auto_organize);
const [showTokens, setShowTokens] = useState(false);
const focused = useRef<FieldRef | null>(null);
const saved = JSON.stringify(pair.naming);
useEffect(() => {
setCfg(pair.naming);
setAuto(pair.auto_organize);
}, [pair.id, saved, pair.auto_organize]); // eslint-disable-line react-hooks/exhaustive-deps
const dirty = JSON.stringify(cfg) !== saved || auto !== pair.auto_organize;
// Examples are rendered by the server with the same code as real renames.
const [debounced, setDebounced] = useState(cfg);
useEffect(() => {
const t = setTimeout(() => setDebounced(cfg), 250);
return () => clearTimeout(t);
}, [cfg]);
const examples = useQuery({
queryKey: ["naming-examples", JSON.stringify(debounced)],
queryFn: () => api.post<{ examples: { label: string; path: string }[]; error?: string }>("/naming/examples", debounced),
});
const save = useMutation({
mutationFn: () =>
api.put(`/pairs/${pair.id}`, {
name: pair.name,
master_root: pair.master_root,
slave_root: pair.slave_root,
quarantine_dir: pair.quarantine_dir,
extensions: pair.extensions,
auto_link_identical: pair.auto_link_identical,
scan_interval_minutes: pair.scan_interval_minutes,
retention_days: pair.retention_days,
settle_minutes: pair.settle_minutes,
approx_matching: pair.approx_matching,
approx_threshold: pair.approx_threshold,
auto_organize: auto,
naming: cfg,
}),
onSuccess: () => {
toast("ok", "Naming saved");
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
},
onError: (e) => toast("err", e.message),
});
const folders = cfg.folders ?? [];
const setField = (rule: number | null, key: FormatKey, v: string) =>
setCfg(rule === null ? { ...cfg, [key]: v } : { ...cfg, folders: folders.map((r, i) => (i === rule ? { ...r, [key]: v } : r)) });
const insert = (token: string) => {
const f = focused.current;
if (!f) return;
const v = f.rule === null ? cfg[f.key] : (folders[f.rule]?.[f.key] ?? "");
const start = f.el.selectionStart ?? v.length;
const end = f.el.selectionEnd ?? v.length;
setField(f.rule, f.key, v.slice(0, start) + token + v.slice(end));
requestAnimationFrame(() => {
f.el.focus();
f.el.setSelectionRange(start + token.length, start + token.length);
});
};
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 font-semibold">
<FolderTree className="size-4 text-accent" /> File naming
</h2>
<p className="text-sm text-muted">How files are named from their tags, in the style of Lidarr. Only the name and folder change.</p>
</div>
<Button variant="primary" size="sm" disabled={!dirty || !!examples.data?.error} loading={save.isPending} onClick={() => save.mutate()}>
Save
</Button>
</div>
{info.data && (
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-muted">Presets:</span>
{info.data.presets.map((p) => (
<button
key={p.name}
type="button"
className={cx(
"rounded-md border px-2 py-0.5 mono cursor-pointer",
sameFormats(p.config, cfg) ? "border-accent bg-accent/15 text-white" : "border-line bg-panel-2 text-gray-300 hover:border-accent",
)}
onClick={() => setCfg({ ...p.config, folders })}
>
{p.name}
</button>
))}
</div>
)}
<FormatFields values={cfg} onChange={(k, v) => setField(null, k, v)} onFocus={(key, el) => (focused.current = { rule: null, key, el })} />
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-medium">Folders with their own naming</h3>
<p className="text-xs text-muted">
For part of a library that is organized differently, such as game soundtracks in{" "}
<span className="mono">Video Games/Album/01 Title</span>. Files in the folder stay in it, and other files are never moved into it.
</p>
</div>
<Button
size="sm"
onClick={() =>
info.data &&
setCfg({
...cfg,
folders: [...folders, { ...info.data.folder_default, folder: folders.length ? "" : info.data.folder_default.folder }],
})
}
>
<FolderPlus className="size-4" /> Add folder
</Button>
</div>
{folders.map((r, i) => (
<div key={i} className="space-y-3 rounded-lg border border-line bg-panel-2/40 p-3">
<div className="flex items-start gap-2">
<div className="flex-1">
<Field label="Folder" hint="Relative to the library, e.g. Video Games or Music/Soundtracks.">
<input
className={inputCls + " mono text-xs"}
value={r.folder}
placeholder="Video Games"
spellCheck={false}
onChange={(e) => setCfg({ ...cfg, folders: folders.map((x, j) => (j === i ? { ...x, folder: e.target.value } : x)) })}
/>
</Field>
</div>
<Button
className="mt-7"
variant="ghost"
size="sm"
title="Remove this folder rule"
onClick={() => setCfg({ ...cfg, folders: folders.filter((_, j) => j !== i) })}
>
<Trash2 className="size-4" />
</Button>
</div>
<FormatFields
values={r}
inside={r.folder || "the folder"}
onChange={(k, v) => setField(i, k, v)}
onFocus={(key, el) => (focused.current = { rule: i, key, el })}
/>
</div>
))}
</div>
<div>
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowTokens(!showTokens)}>
{showTokens ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Tokens (click one to insert it into the field
you last clicked)
</button>
{showTokens && info.data && (
<div className="mt-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{info.data.tokens.map(([tok, ex]) => (
<button
key={tok}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insert(tok)}
className="flex items-center justify-between gap-2 rounded-md border border-line bg-panel-2 px-2 py-1 text-left text-xs hover:border-accent cursor-pointer"
>
<span className="mono text-gray-200">{tok}</span>
<span className="truncate text-muted">{ex}</span>
</button>
))}
<p className="text-[11px] text-muted sm:col-span-2 lg:col-span-3">
Write a token with dots or underscores to use them between words (<code className="mono">{"{Artist.Name}"}</code> → Avenged.Sevenfold),
or in capitals or lower case to change the case (<code className="mono">{"{ARTIST NAME}"}</code>).
</p>
</div>
)}
</div>
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
<div className="mb-1 text-muted">Examples</div>
{examples.data?.error && <div className="text-red-300">{examples.data.error}</div>}
{!examples.data?.error &&
examples.data?.examples.map((ex) => (
<div key={ex.label} className="grid grid-cols-[11rem_1fr] gap-2 py-0.5">
<span className="text-muted">{ex.label}</span>
<span className="mono break-all text-gray-100">{ex.path}</span>
</div>
))}
</div>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Colons" hint="Colons can't be used in names on Windows/SMB shares.">
<select className={inputCls} value={cfg.colon} onChange={(e) => setCfg({ ...cfg, colon: e.target.value as Naming["colon"] })}>
<option value="smart">Smart: "A: B" → "A - B"</option>
<option value="space_dash_space">Space dash space: "A - B"</option>
<option value="space_dash">Space dash: "A -B"</option>
<option value="dash">Dash: "A-B"</option>
<option value="delete">Delete: "A B"</option>
</select>
</Field>
<Field label="Other illegal characters" hint={'/ \\ | * ? " < >'}>
<select className={inputCls} value={cfg.illegal} onChange={(e) => setCfg({ ...cfg, illegal: e.target.value as Naming["illegal"] })}>
<option value="replace">Replace (AC/DC → AC-DC)</option>
<option value="remove">Remove (AC/DC → ACDC)</option>
</select>
</Field>
<Field label="Organize automatically" hint="Renames at the start of every scan.">
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
<option value="off">Off: only with Rename now</option>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
<option value="both">Both libraries</option>
</select>
</Field>
</div>
{auto !== "off" && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Files are renamed during each scan without asking. Every rename is in History and can be undone; files with missing tags or clashing names
are left alone. Dry run still applies.
</Alert>
)}
</Card>
);
}
// ---- rename now ----
function RenameNowCard({ pair }: { pair: Pair }) {
const [scope, setScope] = useState<Scope>("both");
const [open, setOpen] = useState(false);
const running = pair.job?.state === "running";
return (
<Card className="flex flex-wrap items-center justify-between gap-3 p-5">
<div>
<h2 className="font-semibold">Rename now</h2>
<p className="text-sm text-muted">Check the library against the naming below and review every change before anything is renamed.</p>
</div>
<div className="flex items-center gap-2">
<select className={inputCls + " h-9 w-auto"} value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
<option value="both">Both libraries (recommended)</option>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
</select>
<Button variant="primary" onClick={() => setOpen(true)} disabled={running}>
<FolderTree className="size-4" /> Rename now…
</Button>
</div>
{open && <RenameFilesDialog pair={pair} scope={scope} onClose={() => setOpen(false)} />}
</Card>
);
}
type Stage = { kind: "checking"; job?: Job } | { kind: "ready" } | { kind: "error"; message: string };
function RenameFilesDialog({ pair, scope, onClose }: { pair: Pair; scope: Scope; onClose: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const [stage, setStage] = useState<Stage>({ kind: "checking" });
const [data, setData] = useState<{ plan: OrganizePlan; items: OrganizeItem[] } | null>(null);
const [view, setView] = useState<"changes" | "skipped">("changes");
const [sel, setSel] = useState<Set<number>>(new Set());
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
// Start a check and follow its job until it finishes.
const check = async () => {
setStage({ kind: "checking" });
try {
const job = await api.post<Job>(`/pairs/${pair.id}/organize/preview`, { side: scope });
for (;;) {
await new Promise((r) => setTimeout(r, 600));
const j = await api.get<Job | null>(`/pairs/${pair.id}/job`);
if (!j || j.id !== job.id) break;
if (j.state !== "running") {
if (j.state !== "done") throw new Error(j.error || j.state);
break;
}
setStage({ kind: "checking", job: j });
}
const res = await api.get<{ plan: OrganizePlan | null; items: OrganizeItem[] }>(`/pairs/${pair.id}/organize/plan`);
if (!res.plan) throw new Error("The check did not produce a result. Try again.");
setData({ plan: res.plan, items: res.items });
setSel(new Set(res.items.filter((i) => i.move).map((i) => i.id)));
setStage({ kind: "ready" });
} catch (e) {
setStage({ kind: "error", message: (e as Error).message });
}
};
useEffect(() => {
check();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const moves = useMemo(() => (data?.items ?? []).filter((i) => i.move), [data]);
const skipped = useMemo(() => (data?.items ?? []).filter((i) => !i.move), [data]);
// Group the changes by the folder they end up in.
const groups = useMemo(() => {
const m = new Map<string, OrganizeItem[]>();
for (const it of moves) {
const key = `${it.side}\u0000${it.to!.split("/").slice(0, -1).join("/")}`;
m.set(key, [...(m.get(key) ?? []), it]);
}
return [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [moves]);
const both = (data?.plan.sides.length ?? 0) > 1;
const toggle = (ids: number[], on: boolean) =>
setSel((s) => {
const n = new Set(s);
ids.forEach((id) => (on ? n.add(id) : n.delete(id)));
return n;
});
const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel.size === moves.length ? [] : [...sel] }),
onSuccess: () => {
toast(
"ok",
dry ? "Started (dry run): renames are recorded as planned only" : `Renaming ${sel.size} files. The library is rescanned afterwards.`,
);
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
onClose();
},
onError: (e) => toast("err", e.message),
});
return (
<Modal open onClose={onClose} title="Rename files to match the naming" wide>
{stage.kind === "checking" && (
<div className="space-y-2 py-4">
<div className="flex items-center gap-2 text-sm text-gray-300">
<Spinner className="size-4" /> {stage.job?.phase || "Checking files"}…
</div>
{stage.job && stage.job.total > 0 && (
<div className="h-1.5 overflow-hidden rounded-full bg-line">
<div className="h-full bg-accent transition-all" style={{ width: `${Math.round((stage.job.done / stage.job.total) * 100)}%` }} />
</div>
)}
<p className="text-xs text-muted">Reading each file's tags. Nothing is renamed until you confirm.</p>
</div>
)}
{stage.kind === "error" && (
<div className="space-y-3">
<Alert tone="red">{stage.message}</Alert>
<Button onClick={check}>Try again</Button>
</div>
)}
{stage.kind === "ready" && data && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-4 gap-2">
<Stat label="checked" value={data.plan.scanned} />
<Stat label="already named right" value={data.plan.conforming} tone="text-emerald-300" />
<Stat label="to rename" value={data.plan.moves} tone="text-accent" />
<Stat label="skipped" value={skipped.length} tone={skipped.length ? "text-amber-300" : undefined} />
</div>
{moves.length === 0 && skipped.length === 0 && <Alert tone="green">Everything already follows the naming.</Alert>}
{(moves.length > 0 || skipped.length > 0) && (
<div className="flex gap-1 border-b border-line">
{(["changes", "skipped"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={cx(
"-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer",
view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white",
)}
>
{v === "changes" ? `Changes (${moves.length})` : `Skipped (${skipped.length})`}
</button>
))}
</div>
)}
{view === "changes" && moves.length > 0 && (
<div className="overflow-hidden rounded-lg border border-line">
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={sel.size === moves.length}
onChange={(e) =>
toggle(
moves.map((m) => m.id),
e.target.checked,
)
}
/>
{sel.size} of {moves.length} selected, in {groups.length} folder{groups.length !== 1 && "s"}
</label>
<div className="max-h-[50vh] overflow-y-auto">
{groups.map(([key, items]) => {
const [side, folder] = key.split("\u0000");
const ids = items.map((i) => i.id);
return (
<div key={key} className="border-b border-line last:border-0">
<label className="flex items-center gap-2 bg-panel-2/50 px-3 py-1.5 text-xs cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={ids.every((id) => sel.has(id))}
onChange={(e) => toggle(ids, e.target.checked)}
/>
<Folder className="size-3.5 text-accent" />
<span className="mono text-gray-100">{folder}</span>
{both && <Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>}
</label>
{items.map((it) => (
<label
key={it.id}
className={cx("flex items-start gap-2 py-1 pl-9 pr-3 text-xs cursor-pointer", !sel.has(it.id) && "opacity-50")}
>
<input
type="checkbox"
className="mt-0.5 accent-violet-500"
checked={sel.has(it.id)}
onChange={(e) => toggle([it.id], e.target.checked)}
/>
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-start gap-2 mono">
<span className="break-all text-gray-500">{it.from}</span>
<ArrowRight className="mt-0.5 size-3 text-muted" />
<span className="break-all text-gray-100">
{it.to!.split("/").pop()}
{it.sidecars?.length ? <span className="ml-1 font-sans text-[10px] text-muted">+ lyrics</span> : null}
</span>
</div>
</label>
))}
</div>
);
})}
</div>
</div>
)}
{view === "skipped" && (
<div className="space-y-2">
<ul className="text-xs text-muted">
{Object.entries(data.plan.skips).map(([why, n]) => (
<li key={why}>
<b className="tabular-nums text-gray-300">{n}</b> {why}
</li>
))}
</ul>
<div className="max-h-[45vh] overflow-y-auto rounded-lg border border-line">
{skipped.map((it) => (
<div key={it.id} className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs last:border-0">
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
<div className="min-w-0 flex-1">
<div className="mono break-all text-gray-300">{it.from}</div>
<div className="text-amber-300">{it.reason}</div>
</div>
{it.reason?.startsWith("missing") && (
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
<TagsIcon className="size-3.5" /> Fix tags
</Button>
)}
</div>
))}
</div>
<p className="text-[11px] text-muted">After fixing tags, run Rename now again to include those files.</p>
</div>
)}
{moves.length > 0 && (
<ul className="list-disc space-y-0.5 pl-5 text-xs text-muted">
<li>Files move inside their own library; their contents and hardlinks don't change.</li>
<li>Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.</li>
<li>
<b className="text-gray-300">Playlists or apps that point at the old paths will need updating.</b>
</li>
{!both && (
<li>
Only the {data.plan.sides[0]} library is renamed; paths may then differ from the other library (hardlinked songs stay paired).
</li>
)}
<li>Each rename is in History and can be undone. The library is rescanned when it finishes.</li>
</ul>
)}
{dry && moves.length > 0 && <Alert tone="blue">Dry run is on, so the renames are recorded as planned in Activity. No files move.</Alert>}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{stage.kind === "ready" && moves.length === 0 ? "Close" : "Cancel"}
</Button>
{stage.kind === "ready" && moves.length > 0 && (
<Button variant="primary" disabled={sel.size === 0} loading={apply.isPending} onClick={() => apply.mutate()}>
<FolderTree className="size-4" /> Rename {sel.size} file{sel.size !== 1 && "s"}
</Button>
)}
</div>
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
</Modal>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className="rounded-lg border border-line bg-panel-2 px-3 py-2">
<div className={cx("text-xl font-semibold tabular-nums", tone)}>{value}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
}
function sameFormats(a: Naming, b: Naming) {
return (["artist_folder", "album_folder", "track_format", "multi_disc_format", "colon", "illegal"] as const).every((k) => a[k] === b[k]);
}
/** The four path formats, for the library or for one folder rule. */
function FormatFields({
values,
inside,
onChange,
onFocus,
}: {
values: Record<FormatKey, string>;
inside?: string;
onChange: (key: FormatKey, v: string) => void;
onFocus: (key: FormatKey, el: HTMLInputElement) => void;
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
{FORMAT_FIELDS.map((f) => (
<Field
key={f.key}
label={f.label}
hint={inside && f.key === "artist_folder" ? `Inside ${inside}. Leave empty for no artist folder.` : f.hint}
>
<input
className={inputCls + " mono text-xs"}
value={values[f.key]}
placeholder={f.key === "artist_folder" ? "(none)" : undefined}
spellCheck={false}
onFocus={(e) => onFocus(f.key, e.currentTarget)}
onChange={(e) => onChange(f.key, e.target.value)}
/>
</Field>
))}
</div>
);
}
+3 -32
View File
@@ -1,17 +1,12 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type LibraryType, type Pair, type PairInput, type ScanWarning } from "../api";
import { api, type Pair, type PairInput, type ScanWarning } from "../api";
import { WarningList } from "./Warnings";
import { useInfo } from "../App";
import { Alert, Button, Field, Modal, Toggle, cx, inputCls } from "../ui";
import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
export const LIBRARY_TYPES: { id: LibraryType; label: string; layout: string; hint: string }[] = [
{ id: "music", label: "Music", layout: "Artist/Album/01 Title.flac", hint: "Albums by artist." },
{ id: "soundtracks", label: "Soundtracks", layout: "Album/01 Title.flac", hint: "Video game and film soundtracks, named by game or film." },
];
type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] };
export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) {
@@ -30,8 +25,6 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
settle_minutes: 10,
approx_matching: false,
approx_threshold: 85,
auto_organize: "off",
library_type: "music",
};
const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
@@ -59,9 +52,6 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
settle_minutes: f.settle_minutes,
approx_matching: f.approx_matching,
approx_threshold: f.approx_threshold,
// Edited on the Organize tab; sent unchanged so saving here keeps it.
auto_organize: f.auto_organize,
library_type: f.library_type,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
});
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
@@ -78,7 +68,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair"] });
toast("ok", pair ? "Library pair updated" : "Library pair created. Start with the Tune-up, master library first.");
toast("ok", pair ? "Library pair updated" : "Library pair created. Run a scan to find matches.");
onClose();
},
});
@@ -89,25 +79,6 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
<Field label="Name">
<input className={inputCls} value={f.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="Library type" hint="Sets the recommended folder layout; you can change the naming later in Organize.">
<div className="grid gap-2 sm:grid-cols-2">
{LIBRARY_TYPES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => set("library_type", t.id)}
className={cx(
"rounded-lg border p-3 text-left text-sm cursor-pointer",
f.library_type === t.id ? "border-accent bg-accent/10" : "border-line bg-panel-2 hover:border-accent/60",
)}
>
<div className="font-medium">{t.label}</div>
<div className="mono mt-0.5 text-xs text-gray-300">{t.layout}</div>
<div className="mt-1 text-xs text-muted">{t.hint}</div>
</button>
))}
</div>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Master library (keeps the files)"
-11
View File
@@ -10,9 +10,6 @@ import { Review } from "./Review";
import { Build } from "./Build";
import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
import { TagsTab } from "./Tags";
import { OrganizeTab } from "./Organize";
import { TuneUpTab } from "./TuneUp";
export function usePair(id: number) {
return useQuery({
@@ -41,8 +38,6 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);
else toast("err", `${j.kind} ${st}: ${j.error}`);
@@ -125,11 +120,8 @@ function LastJob({ job }: { job: Job }) {
}
const tabs = [
{ id: "tune-up", label: "Tune-up" },
{ id: "review", label: "Review" },
{ id: "build", label: "Build" },
{ id: "tags", label: "Tags" },
{ id: "organize", label: "Organize" },
{ id: "history", label: "History" },
];
@@ -208,11 +200,8 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
))}
</div>
{tab === "tune-up" && <TuneUpTab pair={pair} />}
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "tags" && <TagsTab pair={pair} />}
{tab === "organize" && <OrganizeTab pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
+2 -20
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, Tags as TagsIcon, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo, type Side } from "../api";
import { TagEditor } from "./Tags";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk";
@@ -25,7 +24,6 @@ const kindLabel: Record<Match["kind"], string> = {
fuzzy: "similar title",
elsewhere: "same top folder, other subfolder",
approximate: "approximate match",
inode: "same file, other path",
none: "no match",
};
@@ -377,7 +375,6 @@ const rows: [string, (p: ProbeInfo, size: number) => string][] = [
function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
const toast = useToast();
const [confirm, setConfirm] = useState<null | "downgrade" | "promote">(null);
const [tagSide, setTagSide] = useState<Side | null>(null);
const mp = m.master_probe;
const sp = m.slave_probe;
const v = m.verdict;
@@ -492,13 +489,6 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
<Crown className="size-4" /> Use slave copy as master
</Button>
<span className="mx-1 w-px self-stretch bg-line" aria-hidden />
<Button variant="ghost" onClick={() => setTagSide("slave")}>
<TagsIcon className="size-4" /> Slave tags
</Button>
<Button variant="ghost" onClick={() => setTagSide("master")}>
<TagsIcon className="size-4" /> Master tags
</Button>
</div>
<p className="mt-2 text-xs text-muted">Whatever gets replaced is kept in quarantine and can be restored from History.</p>
@@ -536,14 +526,6 @@ function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) {
</Button>
</div>
</Modal>
{tagSide && (
<TagEditor
pairId={m.pair_id}
side={tagSide}
paths={[tagSide === "slave" ? m.rel_slave : m.rel_master]}
onClose={() => setTagSide(null)}
/>
)}
</div>
);
}
-3
View File
@@ -5,7 +5,6 @@ import { useInfo } from "../App";
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
import { BackupsCard } from "./Backups";
import { ArtSettingsCard } from "./ArtSettings";
export function SettingsPage() {
const info = useInfo();
@@ -80,8 +79,6 @@ export function SettingsPage() {
</p>
</Card>
<ArtSettingsCard />
<BackupsCard />
<Card className="space-y-2 p-5">
-549
View File
@@ -1,549 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, Disc3, FileAudio, Folder, Link2, RotateCcw, Tags as TagsIcon, Wand2, X } from "lucide-react";
import { api, coverURL, type FileEntry, type Job, type Pair, type PreparedArt, type Side, type TagFile } from "../api";
import { AlbumEditor } from "./Album";
import { ArtPicker, ChosenArt, CoverImage } from "./Art";
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, Toggle, cx, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast";
// ---- field layout ----
type FieldDef = { id: string; label: string; span?: 2; perSong?: boolean; multiline?: boolean };
const FIELDS: FieldDef[] = [
{ id: "title", label: "Title", span: 2, perSong: true },
{ id: "artist", label: "Artist" },
{ id: "album_artist", label: "Album artist" },
{ id: "album", label: "Album", span: 2 },
{ id: "track", label: "Track", perSong: true },
{ id: "track_total", label: "of" },
{ id: "disc", label: "Disc" },
{ id: "disc_total", label: "of" },
{ id: "date", label: "Year / date" },
{ id: "genre", label: "Genre" },
{ id: "composer", label: "Composer", span: 2 },
{ id: "comment", label: "Comment", span: 2, multiline: true },
];
// ---- browser tab ----
export function TagsTab({ pair }: { pair: Pair }) {
const info = useInfo();
const [side, setSide] = useState<Side>("slave");
const [path, setPath] = useState("");
const [sel, setSel] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<string[] | null>(null);
const [albumFolder, setAlbumFolder] = useState<string | null>(null);
const [autoMatch, setAutoMatch] = useState(false);
useEffect(() => setSel(new Set()), [side, path]);
const files = useQuery({
queryKey: ["files", pair.id, side, path],
queryFn: () => api.get<FileEntry[]>(`/pairs/${pair.id}/files?side=${side}&path=${encodeURIComponent(path)}`),
});
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">Tag editing needs ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
const entries = files.data ?? [];
const songs = entries.filter((e) => !e.dir && e.editable);
const crumbs = path ? path.split("/") : [];
const allSelected = songs.length > 0 && songs.every((s) => sel.has(s.rel));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex rounded-lg border border-line p-0.5" role="tablist" aria-label="Library">
{(["slave", "master"] as const).map((s) => (
<button
key={s}
role="tab"
aria-selected={side === s}
onClick={() => {
setSide(s);
setPath("");
}}
className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", side === s ? "bg-panel-2 text-white" : "text-muted hover:text-white")}
>
{s}
</button>
))}
</div>
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
{side}
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="size-3.5 text-muted" />
<button className="hover:underline cursor-pointer" onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}>
{c}
</button>
</span>
))}
</nav>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" onClick={() => setAutoMatch(true)} title="Find covers for the albums in this folder">
<Wand2 className="size-3.5" /> Auto-match art…
</Button>
{songs.length > 0 && (
<Button size="sm" onClick={() => setAlbumFolder(path)}>
<Disc3 className="size-3.5" /> Edit album
</Button>
)}
<Button variant="primary" size="sm" disabled={sel.size === 0} onClick={() => setEditing([...sel])}>
<TagsIcon className="size-3.5" /> Edit tags{sel.size > 0 && ` (${sel.size})`}
</Button>
</div>
</div>
<Card className="overflow-hidden">
{files.isLoading && (
<div className="p-8">
<Spinner />
</div>
)}
{files.error && (
<div className="p-4">
<Alert tone="red">{files.error.message}</Alert>
</div>
)}
{files.data?.length === 0 && <Empty icon={<Folder className="size-8" />} title="Empty folder" />}
{songs.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(songs.map((s) => s.rel)))}
/>
Select all {songs.length} songs in this folder
</label>
)}
{entries.map((e) => (
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
{e.dir ? (
<>
<span className="w-4" />
<Folder className="size-4 shrink-0 text-accent" />
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => setPath(e.rel)}>
{e.name}
</button>
<Button size="sm" variant="ghost" onClick={() => setAlbumFolder(e.rel)} title="Edit this folder as an album">
<Disc3 className="size-3.5" /> Edit album
</Button>
</>
) : (
<>
<input
type="checkbox"
className="accent-violet-500"
disabled={!e.editable}
checked={sel.has(e.rel)}
onChange={(ev) =>
setSel((s) => {
const n = new Set(s);
if (ev.target.checked) n.add(e.rel);
else n.delete(e.rel);
return n;
})
}
/>
<FileAudio className="size-4 shrink-0 text-muted" />
<button
className={cx("min-w-0 flex-1 truncate text-left text-sm", e.editable ? "hover:underline cursor-pointer" : "text-muted cursor-not-allowed")}
disabled={!e.editable}
title={e.editable ? "Edit tags" : "Editing tags of this file type is not supported"}
onClick={() => setEditing([e.rel])}
>
{e.name}
</button>
{(e.links ?? 1) > 1 && (
<Badge tone="green" className="shrink-0">
<Link2 className="size-3" /> shared
</Badge>
)}
<span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>
</>
)}
</div>
))}
</Card>
<p className="text-xs text-muted">
Click a song to edit its tags, tick several to edit the tags they share, or use <b>Edit album</b> to edit a whole album (album fields,
every track's number and title, and the cover). <b>shared</b> marks files that are hardlinked to the other library.
</p>
{editing && (
<TagEditor
pairId={pair.id}
side={side}
paths={editing}
onClose={() => setEditing(null)}
onSaved={() => setSel(new Set())}
/>
)}
{albumFolder !== null && <AlbumEditor pairId={pair.id} side={side} folder={albumFolder} onClose={() => setAlbumFolder(null)} />}
{autoMatch && <AutoMatchDialog pairId={pair.id} side={side} folder={path} onClose={() => setAutoMatch(false)} />}
</div>
);
}
// ---- editor ----
type Common = Record<string, { value: string; mixed: boolean; present: boolean }>;
export function TagEditor({
pairId,
side,
paths,
onClose,
onSaved,
}: {
pairId: number;
side: Side;
paths: string[];
onClose: () => void;
onSaved?: () => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const read = useQuery({
queryKey: ["tags", pairId, side, paths.join("\n")],
queryFn: () => api.post<{ files: TagFile[] }>(`/pairs/${pairId}/tags/read`, { side, paths }),
gcTime: 0,
});
const [edits, setEdits] = useState<Record<string, string>>({});
const [separate, setSeparate] = useState(false);
const [showOther, setShowOther] = useState(false);
const [art, setArt] = useState<PreparedArt | null>(null);
const [picking, setPicking] = useState(false);
const editable = useMemo(() => (read.data?.files ?? []).filter((f) => f.editable), [read.data]);
const blocked = (read.data?.files ?? []).filter((f) => !f.editable);
const multi = editable.length > 1;
const shared = editable.filter((f) => f.links > 1).length;
const common: Common = useMemo(() => {
const c: Common = {};
for (const f of FIELDS) {
const vals = editable.map((file) => file.fields[f.id] ?? "");
c[f.id] = { value: vals[0] ?? "", mixed: vals.some((v) => v !== vals[0]), present: vals.some((v) => v !== "") };
}
return c;
}, [editable]);
const changed = Object.keys(edits).filter((id) => common[id]?.mixed || edits[id] !== common[id]?.value);
const set = Object.fromEntries(changed.map((id) => [id, edits[id].trim()]));
const save = useMutation({
mutationFn: () =>
api.post(`/pairs/${pairId}/tags`, {
side,
paths: editable.map((f) => f.rel),
set,
separate,
art: art ? { token: art.token, embed: true, folder_file: false } : undefined,
}),
onSuccess: () => {
toast("ok", dry ? "Started (dry run): the edit is recorded as planned only" : `Saving tags for ${editable.length} song${editable.length > 1 ? "s" : ""}…`);
qc.invalidateQueries({ queryKey: ["pair", pairId] });
qc.invalidateQueries({ queryKey: ["files"] });
onSaved?.();
onClose();
},
onError: (e) => toast("err", e.message),
});
const title = multi ? `Edit tags of ${editable.length} songs` : `Edit tags: ${paths[0].split("/").pop()}`;
return (
<Modal open onClose={onClose} title={title} wide>
{read.isLoading && <Spinner />}
{read.error && <Alert tone="red">{read.error.message}</Alert>}
{read.data && (
<div className="space-y-4 text-sm">
<div className="text-xs text-muted">
<Badge tone={side === "master" ? "violet" : "gray"}>{side}</Badge>{" "}
<span className="mono">{multi ? `${editable.length} files in ${commonFolder(editable.map((f) => f.rel))}` : paths[0]}</span>
</div>
{blocked.length > 0 && (
<Alert tone="amber">
{blocked.length} file{blocked.length > 1 && "s"} can't be edited and will be left alone:
<ul className="mt-1 text-xs">
{blocked.slice(0, 5).map((f) => (
<li key={f.rel}>
<span className="mono">{f.rel.split("/").pop()}</span>: {f.reason}
</li>
))}
</ul>
</Alert>
)}
{editable.length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-3 md:grid-cols-4">
{FIELDS.map((f) => {
const c = common[f.id];
const isChanged = changed.includes(f.id);
const lockedPerSong = multi && f.perSong;
const value = edits[f.id] ?? (c.mixed ? "" : c.value);
const removing = isChanged && edits[f.id] === "" && (c.present || c.mixed);
const Input = f.multiline ? "textarea" : "input";
return (
<label key={f.id} className={cx("block space-y-1", f.span === 2 ? "col-span-2" : "col-span-1")}>
<span className="flex items-center justify-between text-xs font-medium text-gray-300">
{f.label}
{isChanged && (
<button type="button" className="flex items-center gap-0.5 text-[11px] text-muted hover:text-white cursor-pointer" onClick={() => setEdits(({ [f.id]: _, ...rest }) => rest)}>
<RotateCcw className="size-3" /> revert
</button>
)}
</span>
<div className="relative">
<Input
className={cx(inputCls, f.multiline && "h-16 py-2", isChanged && "border-accent ring-1 ring-accent/40", "pr-7")}
value={value}
disabled={lockedPerSong}
placeholder={lockedPerSong ? "different per song" : c.mixed ? "(multiple values: keep)" : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setEdits((s) => ({ ...s, [f.id]: e.target.value }))}
/>
{!lockedPerSong && (value !== "" || c.mixed) && (
<button
type="button"
title="Remove this tag"
className="absolute right-2 top-2.5 text-muted hover:text-white cursor-pointer"
onClick={() => setEdits((s) => ({ ...s, [f.id]: "" }))}
>
<X className="size-3.5" />
</button>
)}
</div>
{removing && <span className="text-[11px] text-amber-300">will be removed</span>}
</label>
);
})}
</div>
)}
{!multi && editable[0] && Object.keys(editable[0].other).length > 0 && (
<div>
<button className="flex items-center gap-1 text-xs text-muted hover:text-white cursor-pointer" onClick={() => setShowOther(!showOther)}>
{showOther ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
{Object.keys(editable[0].other).length} other tags (kept as they are)
</button>
{showOther && (
<table className="mt-2 w-full text-xs">
<tbody>
{Object.entries(editable[0].other).map(([k, v]) => (
<tr key={k} className="border-t border-line/60">
<td className="py-1 pr-3 mono text-muted">{k}</td>
<td className="py-1 break-all">{v}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{editable.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-3">
{art ? (
<ChosenArt art={art} onClear={() => setArt(null)} />
) : (
<>
<CoverImage src={coverURL(pairId, side, editable[0].rel)} className="size-16" />
<div className="text-xs">
<div className="font-medium text-gray-300">Album art{multi && " (first song)"}</div>
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPicking(!picking)}>
{picking ? "Close search" : "Change art…"}
</button>
</div>
</>
)}
</div>
{picking && !art && (
<div className="rounded-lg border border-line bg-panel-2/40 p-3">
<ArtPicker
artist={common.album_artist?.value || common.artist?.value || ""}
album={common.album?.mixed ? "" : common.album?.value ?? ""}
onChoose={(p) => {
setArt(p);
setPicking(false);
}}
/>
</div>
)}
{art && editable.some((f) => /\.(ogg|oga|opus)$/i.test(f.rel)) && (
<p className="text-[11px] text-muted">OGG/Opus files can't hold embedded art; use Edit album to save a folder cover.jpg for them.</p>
)}
</div>
)}
{shared > 0 && (
<Alert tone="blue" icon={<Link2 className="mt-0.5 size-4 shrink-0" />}>
<div>
{shared === editable.length ? (multi ? "These files are" : "This file is") : `${shared} of these files are`} <b>hardlinked</b>:{" "}
the {side === "slave" ? "master" : "slave"} library uses the same file. Editing changes the tags in <b>both</b> libraries, and the
files stay linked.
</div>
<label className="mt-2 flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={separate} onChange={(e) => setSeparate(e.target.checked)} />
<span>
Change only the {side} library's copy instead. This breaks the link: the {side} gets its own file, which uses extra disk space.
</span>
</label>
</Alert>
)}
<p className="text-xs text-muted">
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.
</p>
{dry && <Alert tone="blue">Dry run is on, so the edit is recorded as planned in Activity. No files change.</Alert>}
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
{changed.length > 0 && (
<span className="mr-auto text-xs text-muted">
{changed.length} field{changed.length > 1 && "s"} will change on {editable.length} song{editable.length > 1 && "s"}
</span>
)}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={(changed.length === 0 && !art) || editable.length === 0} loading={save.isPending} onClick={() => save.mutate()}>
Save tags
</Button>
</div>
</Modal>
);
}
function commonFolder(rels: string[]) {
const dirs = rels.map((r) => r.split("/").slice(0, -1));
const first = dirs[0] ?? [];
let n = first.length;
for (const d of dirs) {
let i = 0;
while (i < n && d[i] === first[i]) i++;
n = i;
}
return first.slice(0, n).join("/") || "the library root";
}
// ---- automatic art matching ----
function AutoMatchDialog({ pairId, side, folder, onClose }: { pairId: number; side: Side; folder: string; onClose: () => void }) {
const qc = useQueryClient();
const dry = useInfo().data?.dry_run ?? true;
const [onlyMissing, setOnlyMissing] = useState(true);
const [embed, setEmbed] = useState(true);
const [folderFile, setFolderFile] = useState(true);
const [job, setJob] = useState<Job | null>(null);
const [error, setError] = useState("");
const running = job?.state === "running";
const start = async () => {
setError("");
try {
const j = await api.post<Job>(`/pairs/${pairId}/art/auto`, { side, folder, only_missing: onlyMissing, embed, folder_file: folderFile });
setJob(j);
for (;;) {
await new Promise((r) => setTimeout(r, 800));
const cur = await api.get<Job | null>(`/pairs/${pairId}/job`);
if (!cur || cur.id !== j.id) break;
setJob(cur);
if (cur.state !== "running") break;
}
qc.invalidateQueries({ queryKey: ["files"] });
qc.invalidateQueries({ queryKey: ["pair", pairId] });
} catch (e) {
setError((e as Error).message);
}
};
return (
<Modal open onClose={onClose} title="Auto-match album art" wide>
{!job && (
<div className="space-y-3 text-sm">
<p className="text-gray-300">
Finds covers for every album in <code className="mono">{folder || `the ${side} library`}</code> from iTunes, Deezer and MusicBrainz,
using each album's artist and album tags. A cover is only applied when both clearly match, and only square covers are used.
</p>
<div className="space-y-2">
<Toggle checked={onlyMissing} onChange={setOnlyMissing} label="Only albums that have no art yet" />
<Toggle checked={embed} onChange={setEmbed} label="Embed in songs (MP3, FLAC, M4A)" />
<Toggle checked={folderFile} onChange={setFolderFile} label="Save cover.jpg in each album folder" />
</div>
<p className="text-xs text-muted">
The album names are sent to those services to search. Every change goes through History and can be undone.
</p>
{dry && <Alert tone="blue">Dry run is on, so matches are recorded as planned in Activity. No files change.</Alert>}
{error && <Alert tone="red">{error}</Alert>}
</div>
)}
{job && (
<div className="space-y-3 text-sm">
{running ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-gray-300">
<Spinner className="size-4" /> {job.phase || "Working"}… {job.total > 0 && `${job.done} / ${job.total}`}
</div>
{job.total > 0 && (
<div className="h-1.5 overflow-hidden rounded-full bg-line">
<div className="h-full bg-accent transition-all" style={{ width: `${Math.round((job.done / job.total) * 100)}%` }} />
</div>
)}
</div>
) : (
<Alert tone={job.state === "done" ? "green" : "red"}>
{job.state === "done"
? Object.entries(job.results)
.map(([k, v]) => `${v} ${k}`)
.join(" · ") || "Nothing to do"
: job.error}
</Alert>
)}
{(job.notes?.length ?? 0) > 0 && (
<div>
<div className="mb-1 text-xs font-medium text-gray-300">Matched</div>
<ul className="max-h-40 space-y-0.5 overflow-y-auto text-xs text-emerald-200/90">
{job.notes!.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
)}
{job.skipped.length > 0 && (
<div>
<div className="mb-1 text-xs font-medium text-gray-300">Not matched</div>
<ul className="max-h-40 space-y-0.5 overflow-y-auto text-xs text-amber-200/90">
{job.skipped.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
<p className="mt-1 text-[11px] text-muted">Use Edit album → Change art… to search those by hand.</p>
</div>
)}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{job && !running ? "Close" : "Cancel"}
</Button>
{!job && (
<Button variant="primary" disabled={!embed && !folderFile} onClick={start}>
<Wand2 className="size-4" /> Find covers
</Button>
)}
</div>
</Modal>
);
}
-864
View File
@@ -1,864 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowRight,
Check,
ChevronDown,
ChevronRight,
CircleDashed,
FileAudio,
FolderInput,
FolderTree,
Link2,
Minus,
Pencil,
RotateCcw,
Search,
SkipForward,
Tags as TagsIcon,
} from "lucide-react";
import { api, type AlbumFix, type FixItem, type FixStep, type Fixer, type Pair, type Side, type StepStatus } from "../api";
import { Alert, Badge, Button, Card, Field, Spinner, Toggle, cx, formatAgo, inputCls } from "../ui";
import { useToast } from "../toast";
import { useInfo } from "../App";
import { JobBar, ScanButton } from "./PairPage";
import { WarningList } from "./Warnings";
import { AlbumEditor } from "./Album";
// The tune-up takes each library through the same steps, master first, then
// links the two. Each step is checked, reviewed and applied here.
type Sel = { side: Side; step: FixStep } | "link";
const STEPS: { id: FixStep; title: string; icon: typeof FolderTree; optional?: boolean }[] = [
{ id: "structure", title: "Folder structure", icon: FolderTree },
{ id: "tags", title: "Album tags", icon: TagsIcon, optional: true },
{ id: "folders", title: "Folder names", icon: FolderInput },
{ id: "files", title: "File names", icon: FileAudio },
];
const SIDES: { id: Side; title: string }[] = [
{ id: "master", title: "Master library" },
{ id: "slave", title: "Slave library" },
];
const FIELD_LABEL: Record<string, string> = {
title: "Title",
artist: "Artist",
album: "Album",
album_artist: "Album artist",
track: "Track",
track_total: "Tracks",
disc: "Disc",
disc_total: "Discs",
date: "Date",
genre: "Genre",
};
const finished = (s?: StepStatus) => s?.state === "done" || s?.state === "skipped";
/** layoutOf shows where albums go, e.g. "{Artist Name}/{Album Title}/{track:00} {Track Title}". */
function layoutOf(pair: Pair) {
const n = pair.naming;
return [n.artist_folder, n.album_folder, n.track_format].filter(Boolean).join("/");
}
function firstOpen(pair: Pair): Sel {
for (const s of SIDES) for (const st of STEPS) if (!finished(pair.fixer[s.id][st.id])) return { side: s.id, step: st.id };
return "link";
}
export function TuneUpTab({ pair }: { pair: Pair }) {
const info = useInfo();
const [sel, setSel] = useState<Sel>(() => firstOpen(pair));
const fixer = useQuery({ queryKey: ["fixer", pair.id], queryFn: () => api.get<Fixer>(`/pairs/${pair.id}/fixer`) });
if (info.data && !info.data.tag_editing)
return <Alert tone="amber">The tune-up reads and writes tags with ffmpeg and ffprobe, which were not found. The Docker image includes both.</Alert>;
const isSel = (s: Side, st: FixStep) => sel !== "link" && sel.side === s && sel.step === st;
return (
<div className="grid gap-5 lg:grid-cols-[16rem_1fr]">
<nav className="space-y-4">
<Card className="p-3 text-xs">
<div className="text-muted">{pair.library_type === "soundtracks" ? "Soundtrack library" : "Music library"}</div>
<div className="mono mt-1 text-gray-200">
{layoutOf(pair)
.split("/")
.map((part, i) => (
<span key={i} className="whitespace-nowrap">
{i > 0 && "/"}
{part}
<wbr />
</span>
))}
</div>
<a href={`#/pair/${pair.id}/organize`} className="mt-1 inline-block text-accent hover:underline">
Change the naming
</a>
</Card>
{SIDES.map((s, si) => (
<div key={s.id}>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">
{si + 1}. {s.title}
</div>
<div className="space-y-0.5">
{STEPS.map((st) => (
<StepLink
key={st.id}
status={pair.fixer[s.id][st.id]}
active={isSel(s.id, st.id)}
onClick={() => setSel({ side: s.id, step: st.id })}
>
{st.title}
{st.optional && <span className="ml-1 text-muted">(optional)</span>}
</StepLink>
))}
</div>
</div>
))}
<div>
<div className="mb-1.5 px-1 text-xs font-medium uppercase tracking-wide text-muted">3. Both libraries</div>
<StepLink status={pair.fixer.link} active={sel === "link"} onClick={() => setSel("link")}>
Link the libraries
</StepLink>
<a href={`#/pair/${pair.id}/review`} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-300 hover:bg-panel-2">
<ArrowRight className="size-4 text-muted" /> Review differences
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</a>
</div>
</nav>
<div className="min-w-0">
{sel === "link" ? (
<LinkPanel pair={pair} />
) : (
<StepPanel
key={`${sel.side}-${sel.step}`}
pair={pair}
side={sel.side}
step={sel.step}
fixer={fixer.data}
onNext={() => setSel(firstOpen(pair))}
/>
)}
</div>
</div>
);
}
function StepLink({ status, active, onClick, children }: { status?: StepStatus; active: boolean; onClick: () => void; children: ReactNode }) {
const icon =
status?.state === "done" ? (
<Check className="size-4 text-emerald-400" />
) : status?.state === "skipped" ? (
<Minus className="size-4 text-muted" />
) : (
<CircleDashed className={cx("size-4", active ? "text-accent" : "text-muted")} />
);
return (
<button
onClick={onClick}
className={cx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm cursor-pointer",
active ? "bg-accent/15 text-white" : "text-gray-300 hover:bg-panel-2",
)}
>
{icon}
<span className="min-w-0 flex-1">{children}</span>
</button>
);
}
// ---- one step of one library ----
function stepText(step: FixStep, pair: Pair, side: Side): ReactNode {
const layout = <code className="mono text-gray-200">{layoutOf(pair)}</code>;
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 <span className="mono">FLAC</span> 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 (<code className="mono text-gray-200">{pair.naming.track_format}</code>). Albums with several discs get{" "}
<span className="mono">Disc 01</span>, <span className="mono">Disc 02</span> 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 (
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wide text-muted">{side === "master" ? "Master library" : "Slave library"}</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<meta.icon className="size-5 text-accent" /> {meta.title}
{meta.optional && <Badge>optional</Badge>}
{status?.state === "done" && <Badge tone="green">done {formatAgo(status.at)}</Badge>}
{status?.state === "skipped" && <Badge>skipped</Badge>}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{finished(status) ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => mark.mutate("skipped")} title="Leave this step out">
<SkipForward className="size-3.5" /> Skip
</Button>
{plan && !plan.applied && (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</>
)}
<Button
size="sm"
variant={plan && !plan.applied ? "secondary" : "primary"}
onClick={() => check.mutate()}
disabled={running}
loading={check.isPending}
>
<Search className="size-3.5" /> {plan ? "Check again" : `Check the ${side}`}
</Button>
</div>
</div>
<p className="text-sm text-muted">{stepText(step, pair, side)}</p>
{masterOpen && (
<Alert tone="blue">
The master library isn't finished yet. Fixing the master first is recommended: the slave's album tags and links are matched against it.
</Alert>
)}
{step === "tags" && (
<div className="grid gap-3 rounded-lg border border-line bg-panel-2/40 p-3 md:grid-cols-2">
<Field label="Only this folder" hint="Optional, e.g. Avenged Sevenfold. Empty: the whole library.">
<input className={inputCls + " mono text-xs"} value={folder} placeholder="(whole library)" onChange={(e) => setFolder(e.target.value)} />
</Field>
<div className="pt-6">
<Toggle checked={onlyIncomplete} onChange={setOnlyIncomplete} label="Skip albums whose tags are already complete" />
<p className="mt-1 pl-11.5 text-xs text-muted">Complete: every song has a title, track number, album and artist, and they agree.</p>
</div>
</div>
)}
{ourJob ? (
<JobBar job={pair.job!} />
) : !plan ? (
<div className="rounded-lg border border-dashed border-line p-6 text-center text-sm text-muted">
Check the {side} library to see what needs fixing. Nothing is changed by checking.
</div>
) : plan.applied ? (
<Alert tone="green">
Fixes applied{pair.job?.results?.skipped ? `, ${pair.job.results.skipped} skipped (see above)` : ""}. Every change is in History and can be
undone. {finished(status) ? "This step is done." : "Check again to see what's left, or mark the step done."}
</Alert>
) : step === "tags" ? (
<AlbumResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
) : (
<ItemResults pair={pair} side={side} fixer={fixer!} onApplied={refresh} />
)}
</Card>
);
}
function PlanStats({ fixer }: { fixer: Fixer }) {
const p = fixer.plan!;
const stats: [string, number, string?][] = [
["songs", p.songs],
["albums", p.albums],
["to fix", p.fixable, "text-accent"],
["need attention", p.blocked, p.blocked ? "text-amber-300" : undefined],
];
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map(([label, n, tone]) => (
<div key={label} className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n}</div>
<div className="text-xs text-muted">{label}</div>
</div>
))}
</div>
{p.notices.map((n, i) => (
<Alert key={i} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{n}
</Alert>
))}
</div>
);
}
function useSelection(fixer: Fixer) {
const initial = () => new Set(fixer.items.filter((i) => !i.blocked && i.kind !== "problem" && !i.unsure).map((i) => i.id));
const [sel, setSel] = useState<Set<number>>(initial);
useEffect(() => setSel(initial()), [fixer.plan?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (ids: number[], on: boolean) =>
setSel((s) => {
const n = new Set(s);
ids.forEach((id) => (on ? n.add(id) : n.delete(id)));
return n;
});
return { sel, toggle };
}
function ApplyBar({
pair,
plan,
count,
total,
ids,
onApplied,
}: {
pair: Pair;
plan: number;
count: number;
total: number;
ids: number[];
onApplied: () => void;
}) {
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/fixer/apply`, { plan, ids }),
onSuccess: onApplied,
onError: (e) => toast("err", e.message),
});
return (
<div className="sticky bottom-0 -mx-5 -mb-5 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-line bg-panel/95 px-5 py-3 backdrop-blur">
<Button variant="primary" disabled={count === 0 || pair.job?.state === "running"} loading={apply.isPending} onClick={() => apply.mutate()}>
<Check className="size-4" /> Apply {count} {count === 1 ? "fix" : "fixes"}
</Button>
<span className="text-sm text-muted">
{count} of {total} selected{dry && " · dry run: changes are only recorded"}
</span>
</div>
);
}
// ---- moves and tag fixes ----
const GROUP_PAGE = 60;
function ItemResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) {
const { sel, toggle } = useSelection(fixer);
const [shown, setShown] = useState(GROUP_PAGE);
const [album, setAlbum] = useState<string | null>(null);
const fixable = fixer.items.filter((i) => !i.blocked && i.kind !== "problem");
const attention = fixer.items.filter((i) => i.blocked || i.kind === "problem");
const groups = useMemo(() => {
const m = new Map<string, FixItem[]>();
for (const it of fixable) m.set(it.group, [...(m.get(it.group) ?? []), it]);
return [...m.entries()];
}, [fixer.items]); // eslint-disable-line react-hooks/exhaustive-deps
const plan = fixer.plan!;
return (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{plan.fixable === 0 && plan.blocked === 0 && <Alert tone="green">Nothing to fix here. Mark the step done to continue.</Alert>}
{groups.length > 0 && (
<div className="overflow-hidden rounded-lg border border-line">
<div className="flex items-center gap-2 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted">
<input
type="checkbox"
className="accent-violet-500"
checked={fixable.every((i) => sel.has(i.id))}
onChange={(e) =>
toggle(
fixable.map((i) => i.id),
e.target.checked,
)
}
/>
Select all · grouped by where they end up
</div>
<div className="divide-y divide-line">
{groups.slice(0, shown).map(([g, items]) => (
<FixGroup key={g} group={g} items={items} sel={sel} toggle={toggle} />
))}
</div>
{groups.length > shown && (
<button
className="w-full border-t border-line py-2 text-xs text-accent hover:bg-panel-2 cursor-pointer"
onClick={() => setShown(shown + GROUP_PAGE)}
>
Show more ({groups.length - shown} folders left)
</button>
)}
</div>
)}
{attention.length > 0 && (
<div>
<h3 className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-amber-200">
<AlertTriangle className="size-4" /> Needs attention ({attention.length})
</h3>
<div className="divide-y divide-amber-500/10 overflow-hidden rounded-lg border border-amber-500/25 bg-amber-500/5">
{attention.slice(0, 200).map((it) => (
<div key={it.id} className="flex flex-wrap items-start gap-x-3 gap-y-1 px-3 py-2 text-xs">
<div className="min-w-0 flex-1">
<div className="mono break-all text-gray-100">{it.from}</div>
<div className="text-amber-200/90">
{it.why}
{it.blocked && <span className="text-amber-200/60"> · {it.blocked}</span>}
</div>
</div>
{it.from && (
<Button size="sm" variant="ghost" onClick={() => setAlbum(it.kind === "move-file" ? it.group : it.group || it.from!)}>
<Pencil className="size-3.5" /> Edit album
</Button>
)}
</div>
))}
{attention.length > 200 && <div className="px-3 py-2 text-xs text-muted">and {attention.length - 200} more</div>}
</div>
</div>
)}
{fixable.length > 0 && (
<ApplyBar
pair={pair}
plan={plan.id}
count={fixable.filter((i) => sel.has(i.id)).length}
total={fixable.length}
ids={fixable.filter((i) => sel.has(i.id)).map((i) => i.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function FixGroup({
group,
items,
sel,
toggle,
}: {
group: string;
items: FixItem[];
sel: Set<number>;
toggle: (ids: number[], on: boolean) => void;
}) {
const [open, setOpen] = useState(items.length <= 3);
const ids = items.map((i) => i.id);
const n = ids.filter((id) => sel.has(id)).length;
const single = items.length === 1 ? items[0] : null;
return (
<div>
<div className="flex items-center gap-2 px-3 py-2">
<input type="checkbox" className="accent-violet-500" checked={n === ids.length} onChange={(e) => toggle(ids, e.target.checked)} />
<button className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-sm cursor-pointer" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5 shrink-0 text-muted" /> : <ChevronRight className="size-3.5 shrink-0 text-muted" />}
<span className="mono truncate text-gray-100">{group || "(library root)"}</span>
<span className="shrink-0 text-xs text-muted">
{items.length} {items.length === 1 ? "fix" : "fixes"}
{n !== ids.length && ` · ${n} selected`}
</span>
{items.some((i) => i.unsure) && <Badge tone="amber">check</Badge>}
</button>
</div>
{(open || single) && (
<div className="space-y-1 pb-2 pl-9 pr-3">
{items.map((it) => (
<FixRow key={it.id} it={it} checked={sel.has(it.id)} onChange={(on) => toggle([it.id], on)} />
))}
</div>
)}
</div>
);
}
function FixRow({ it, checked, onChange }: { it: FixItem; checked: boolean; onChange: (on: boolean) => void }) {
return (
<label className="flex items-start gap-2 rounded-md px-2 py-1 text-xs hover:bg-panel-2 cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-violet-500" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<div className="min-w-0 flex-1">
{it.kind === "tags" ? (
<>
<div className="mono break-all text-gray-300">{it.from}</div>
<Changes changes={it.changes ?? {}} />
</>
) : (
<div className="flex flex-wrap items-center gap-x-1.5 mono break-all">
<span className="text-gray-400">{it.from}</span>
<ArrowRight className="size-3 shrink-0 text-muted" />
<span className="text-gray-100">{it.to}</span>
{it.kind === "move-folder" && <Badge>folder</Badge>}
</div>
)}
<div className="text-muted">
{it.why}
{it.unsure && <span className="text-amber-300"> · not selected: check it first</span>}
</div>
</div>
</label>
);
}
function Changes({ changes }: { changes: Record<string, [string, string]> }) {
const keys = Object.keys(changes).sort((a, b) => Object.keys(FIELD_LABEL).indexOf(a) - Object.keys(FIELD_LABEL).indexOf(b));
return (
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{keys.map((k) => (
<span key={k}>
<span className="text-muted">{FIELD_LABEL[k] ?? k}: </span>
{changes[k][0] ? <span className="text-red-300/80 line-through">{changes[k][0]}</span> : <span className="text-muted">(empty)</span>}
<span className="text-muted"> → </span>
<span className="text-emerald-300">{changes[k][1]}</span>
</span>
))}
</div>
);
}
// ---- album tags (online lookup) ----
function AlbumResults({ pair, side, fixer, onApplied }: { pair: Pair; side: Side; fixer: Fixer; onApplied: () => void }) {
const { sel, toggle } = useSelection(fixer);
const [shown, setShown] = useState(40);
const [album, setAlbum] = useState<string | null>(null);
const blocked = new Set(fixer.items.filter((i) => i.blocked).map((i) => i.id));
const ordered = [...fixer.albums].sort((a, b) => Number(blocked.has(a.id)) - Number(blocked.has(b.id)) || a.folder.localeCompare(b.folder));
const usable = fixer.albums.filter((a) => !blocked.has(a.id));
return (
<div className="space-y-4">
<PlanStats fixer={fixer} />
{fixer.albums.length === 0 && <Alert tone="green">Every album checked already matches. Mark the step done to continue.</Alert>}
<div className="space-y-2">
{ordered.slice(0, shown).map((a) => (
<AlbumCard
key={a.id}
pair={pair}
a={a}
blocked={blocked.has(a.id)}
checked={sel.has(a.id)}
onCheck={(on) => toggle([a.id], on)}
onEdit={() => setAlbum(a.folder)}
/>
))}
{ordered.length > shown && (
<Button size="sm" variant="ghost" onClick={() => setShown(shown + 40)}>
Show more ({ordered.length - shown} albums left)
</Button>
)}
</div>
{usable.length > 0 && (
<ApplyBar
pair={pair}
plan={fixer.plan!.id}
count={usable.filter((a) => sel.has(a.id)).length}
total={usable.length}
ids={usable.filter((a) => sel.has(a.id)).map((a) => a.id)}
onApplied={onApplied}
/>
)}
{album !== null && <AlbumEditor pairId={pair.id} side={side} folder={album} onClose={() => setAlbum(null)} />}
</div>
);
}
function Confidence({ c }: { c: number }) {
if (c >= 0.85) return <Badge tone="green">good match</Badge>;
if (c >= 0.6) return <Badge tone="amber">check</Badge>;
return <Badge tone="red">weak match</Badge>;
}
function AlbumCard({
pair,
a,
blocked,
checked,
onCheck,
onEdit,
}: {
pair: Pair;
a: AlbumFix;
blocked: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
}) {
const qc = useQueryClient();
const toast = useToast();
const [open, setOpen] = useState(false);
const choose = useMutation({
mutationFn: (release: string) => api.post<AlbumFix>(`/pairs/${pair.id}/fixer/release`, { album: a.id, release }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["fixer", pair.id] });
if (!res.error) onCheck(true);
},
onError: (e) => toast("err", e.message),
});
const changed = a.files.filter((f) => Object.keys(f.changes).length > 0).length;
const r = a.release;
return (
<div className={cx("rounded-lg border", blocked ? "border-amber-500/25 bg-amber-500/5" : "border-line")}>
<div className="flex flex-wrap items-start gap-3 px-3 py-2.5">
<input
type="checkbox"
className="mt-1 accent-violet-500"
disabled={blocked}
checked={checked && !blocked}
onChange={(e) => onCheck(e.target.checked)}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="mono break-all text-sm text-gray-100">{a.folder}</div>
{r && !blocked ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="text-gray-200">
{r.artist} – {r.title}
</span>
<span className="text-muted">
{[r.date, r.country, r.formats, r.track_count ? `${r.track_count} tracks` : ""].filter(Boolean).join(" · ")}
</span>
{a.source === "master" ? <Badge tone="violet">from master</Badge> : <Badge>MusicBrainz</Badge>}
<Confidence c={a.confidence} />
<span className="text-muted">
{a.matched} of {a.files.length} songs matched · {changed} change
</span>
</div>
) : (
<div className="text-xs text-amber-200">{a.error || "No confident match"}</div>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{a.source === "master" ? (
<Button size="sm" variant="ghost" loading={choose.isPending} onClick={() => choose.mutate("search")}>
<Search className="size-3.5" /> Use MusicBrainz
</Button>
) : (
a.candidates.length > 0 && (
<select
className={inputCls + " h-8 w-56 text-xs"}
value={r?.id ?? ""}
disabled={choose.isPending}
onChange={(e) => e.target.value && choose.mutate(e.target.value)}
title="Pick another release"
>
<option value="">{r ? "Other releases…" : "Pick a release…"}</option>
{a.candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.artist} – {c.title} ({[c.date?.slice(0, 4), c.country, c.formats, `${c.track_count} tr`].filter(Boolean).join(", ")})
</option>
))}
</select>
)
)}
{choose.isPending && <Spinner className="size-4" />}
<Button size="sm" variant="ghost" onClick={onEdit} title="Edit the album's tags by hand">
<Pencil className="size-3.5" /> Edit album
</Button>
{!blocked && (
<Button size="sm" variant="ghost" onClick={() => setOpen(!open)}>
{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />} Changes
</Button>
)}
</div>
</div>
{open && !blocked && (
<div className="border-t border-line px-3 py-2">
{Object.keys(a.album_changes).length > 0 && (
<div className="mb-2 text-xs">
<span className="mr-2 text-muted">Every song:</span>
<Changes changes={a.album_changes} />
</div>
)}
<table className="w-full text-xs">
<thead className="text-left text-muted">
<tr>
<th className="py-1 pr-3 font-normal">File</th>
<th className="py-1 pr-3 font-normal">Matched track</th>
<th className="py-1 font-normal">Changes</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{a.files.map((f) => {
const own = Object.fromEntries(Object.entries(f.changes).filter(([k]) => !(k in a.album_changes)));
return (
<tr key={f.path} className="align-top">
<td className="mono break-all py-1 pr-3 text-gray-300">{f.path.slice(a.folder.length + 1)}</td>
<td className="py-1 pr-3 text-gray-300">{f.track ?? <span className="text-amber-300">not matched</span>}</td>
<td className="py-1">{Object.keys(own).length ? <Changes changes={own} /> : <span className="text-muted">—</span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
// ---- linking ----
function LinkPanel({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [confirm, setConfirm] = useState(false);
const c = pair.summary.counts;
const open = SIDES.flatMap((s) => STEPS.filter((st) => !finished(pair.fixer[s.id][st.id])).map((st) => `${s.title}: ${st.title}`));
const refresh = () => {
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
qc.invalidateQueries({ queryKey: ["pairs"] });
};
const link = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
onSuccess: () => {
setConfirm(false);
refresh();
},
onError: (e) => toast("err", e.message),
});
const mark = useMutation({
mutationFn: (state: StepStatus["state"]) => api.put(`/pairs/${pair.id}/fixer/step`, { side: "", step: "link", state }),
onSuccess: refresh,
onError: (e) => toast("err", e.message),
});
const running = pair.job?.state === "running";
const stat = (label: string, n: number | undefined, tone?: string) => (
<div className="rounded-lg border border-line bg-panel-2/50 px-3 py-2">
<div className={cx("text-lg font-semibold tabular-nums", tone)}>{n ?? 0}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
return (
<Card className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-wide text-muted">Both libraries</div>
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Link2 className="size-5 text-accent" /> Link the libraries
{pair.fixer.link?.state === "done" && <Badge tone="green">done {formatAgo(pair.fixer.link.at)}</Badge>}
</h2>
</div>
{pair.fixer.link?.state === "done" ? (
<Button size="sm" variant="ghost" onClick={() => mark.mutate("")}>
<RotateCcw className="size-3.5" /> Reopen
</Button>
) : (
<Button size="sm" onClick={() => mark.mutate("done")}>
<Check className="size-3.5" /> Mark done
</Button>
)}
</div>
<p className="text-sm text-muted">
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.
</p>
{open.length > 0 && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Not finished yet: {open.join(", ")}. Linking works anyway, but songs in folders that are still named differently won't match.
</Alert>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">1. Scan both libraries</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
<ScanButton pair={pair} />
<span className="text-muted">Last scan {formatAgo(pair.last_scan_at)}</span>
</div>
{running && pair.job && <JobBar job={pair.job} />}
</section>
{pair.last_scan_warnings.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium">2. Folders named differently</h3>
<WarningList warnings={pair.last_scan_warnings} pair={pair} />
</section>
)}
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "3" : "2"}. Link identical songs</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{stat("already linked", c.linked, "text-emerald-300")}
{stat("identical", c.identical, "text-accent")}
{stat("different", (c.different ?? 0) + (c.similar ?? 0), "text-amber-300")}
{stat("only in slave", c.unmatched)}
{stat("errors", c.error, c.error ? "text-red-300" : undefined)}
</div>
{confirm ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-2 text-sm">
Replace {c.identical ?? 0} slave songs with hardlinks to the identical master songs? Each is compared byte by byte first.
<Button size="sm" variant="primary" loading={link.isPending} onClick={() => link.mutate()}>
Link them
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="primary" disabled={!c.identical || running} onClick={() => setConfirm(true)}>
<Link2 className="size-4" /> Link {c.identical ?? 0} identical songs
</Button>
)}
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{pair.last_scan_warnings.length > 0 ? "4" : "3"}. Review the differences</h3>
<p className="text-sm text-muted">Upgrades, other formats and approximate matches are reviewed one album or song at a time.</p>
<a href={`#/pair/${pair.id}/review`}>
<Button>
Open Review <ArrowRight className="size-4" />
{pair.summary.review > 0 && <Badge tone="amber">{pair.summary.review}</Badge>}
</Button>
</a>
</section>
</Card>
);
}