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 digits int fields map[string]string want, missing string }{ {"basic", "prefix", ".mp3", 0, f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""}, {"album artist wins", "prefix", ".flac", 0, 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", 0, f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""}, {"missing album", "prefix", ".mp3", 0, f("artist", "A", "title", "Song"), "", "album"}, {"missing title", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "1"), "", "title"}, {"multi-disc prefix", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/02-04 S.mp3", ""}, {"multi-disc folder", "folder", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 02/04 S.mp3", ""}, {"single disc of one", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""}, {"unsafe characters", "prefix", ".MP3", 0, 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", 0, f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""}, {"unpadded disc prefix", "prefix", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""}, {"unpadded disc folder", "folder", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""}, {"big albums use 3 digits", "prefix", ".mp3", 0, 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.digits, 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)) } } // 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") } }