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

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

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

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

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

No files matched your search

-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)
}
}