Files
playlink/internal/engine/organize_test.go
T
cdnutterandClaude Opus 5.5 dbcf562f7e Add organizing files into Artist/Album/01 Title from their tags
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>
2026-09-29 12:23:41 -07:00

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