A new Organize tab renames audio files from their tags into a per-pair
naming pattern, by default {artist}/{album}/{track} {title} (album
artist, falling back to artist). Multi-disc albums get a disc prefix
(2-04) or a Disc N folder; names are made safe for Windows/SMB shares.
- Preview first: every rename, files already organized, and files
skipped with the reason (missing tags, with a button into the tag
editor; two files resolving to the same name; an existing file).
- Applying re-checks each file against the preview, moves it within its
library (hardlinks and contents untouched, case-only renames via a
temporary name, completed on startup after a crash), carries .lrc
lyrics and folder cover art along, and removes emptied folders. Each
move is an operation that can be undone; the library is rescanned.
- Optional automatic organizing of the slave, master or both during
scans (off by default). Dry run applies.
- Scans now recognise a slave file that is a hardlink of a master file
at a different path as linked ("same file, other path") instead of
unmatched.
Migration 7 adds the per-pair naming settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
8.3 KiB
Go
197 lines
8.3 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
func TestRenderName(t *testing.T) {
|
|
f := func(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
|
|
}
|
|
cases := []struct {
|
|
name, multi, ext string
|
|
fields map[string]string
|
|
want, missing string
|
|
}{
|
|
{"basic", "prefix", ".mp3", f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
|
|
{"album artist wins", "prefix", ".flac", f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
|
|
{"no track number", "prefix", ".mp3", f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
|
|
{"missing album", "prefix", ".mp3", f("artist", "A", "title", "Song"), "", "album"},
|
|
{"missing title", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "1"), "", "title"},
|
|
{"multi-disc prefix", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
|
|
{"multi-disc folder", "folder", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
|
|
{"single disc of one", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
|
|
{"unsafe characters", "prefix", ".MP3", f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
|
|
{"no hidden names", "prefix", ".mp3", f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
|
|
{"big albums use 3 digits", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.ext, c.fields)
|
|
if got != c.want || missing != c.missing {
|
|
t.Errorf("%s: got %q (missing %q), want %q (missing %q)", c.name, got, missing, c.want, c.missing)
|
|
}
|
|
}
|
|
for _, bad := range []string{"", "{artist}/{album}", "/{title}", "../{title}", "{artist}/{bogus} {title}", "{artist}//{title}"} {
|
|
if ValidateTemplate(bad) == nil {
|
|
t.Errorf("template %q should be rejected", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
func (ev *env) organize(t *testing.T, side string) (*OrganizePlan, []OrganizeItem) {
|
|
t.Helper()
|
|
if _, err := ev.e.StartOrganizePlan(ev.pair.ID, side); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.wait()
|
|
plan, items, _ := ev.e.OrganizePlanFor(ev.pair.ID, "", 0, 1000)
|
|
return plan, items
|
|
}
|
|
|
|
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, items := ev.organize(t, SideSlave)
|
|
if plan.Conforming != 1 || plan.Moves != 2 {
|
|
t.Fatalf("want 1 conforming and 2 moves, got %+v %+v", plan, items)
|
|
}
|
|
if plan.Skips["missing artist or album artist tag"] != 1 || len(plan.Skips) != 2 {
|
|
t.Fatalf("unexpected skips %+v", plan.Skips)
|
|
}
|
|
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := ev.wait()
|
|
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.organize(t, SideSlave)
|
|
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.wait()
|
|
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")
|
|
ev.organize(t, SideSlave)
|
|
if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if j := ev.wait(); 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")
|
|
ev.organize(t, SideSlave)
|
|
mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1", "genre", "changed")
|
|
ev.e.StartOrganizeApply(ev.pair.ID, nil)
|
|
if j := ev.wait(); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 {
|
|
t.Fatalf("a file changed after the preview 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")
|
|
}
|
|
}
|