feat: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func mkdir(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(parts...)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestPlanRoots(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
music := mkdir(t, base, "music")
|
||||
data := mkdir(t, base, "data", "music")
|
||||
more := mkdir(t, base, "more")
|
||||
file := filepath.Join(base, "a.mp3")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := []string{music, more}
|
||||
|
||||
roots, moves, err := planRoots(current, []RootChange{{Path: data + "/", Was: music}, {Path: more, Was: more}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(roots, []string{data, more}) {
|
||||
t.Errorf("roots %v", roots)
|
||||
}
|
||||
if len(moves) != 1 || moves[0] != (RootChange{Path: data, Was: music}) {
|
||||
t.Errorf("moves %v; want only %s → %s", moves, music, data)
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
changes []RootChange
|
||||
mentions string
|
||||
}{
|
||||
"none": {nil, "At least one"},
|
||||
"relative": {[]RootChange{{Path: "music"}}, "full path"},
|
||||
"root": {[]RootChange{{Path: "/"}}, "whole filesystem"},
|
||||
"absent": {[]RootChange{{Path: filepath.Join(base, "nope")}}, "does not exist"},
|
||||
"a file": {[]RootChange{{Path: file}}, "not a folder"},
|
||||
"twice": {[]RootChange{{Path: music}, {Path: music}}, "twice"},
|
||||
"nested": {[]RootChange{{Path: base}, {Path: music}}, "overlap"},
|
||||
"unknown was": {[]RootChange{{Path: data, Was: "/elsewhere"}}, "not one of"},
|
||||
"split a move": {[]RootChange{{Path: data, Was: music}, {Path: more, Was: music}}, "one place"},
|
||||
} {
|
||||
_, _, err := planRoots(current, tc.changes)
|
||||
var re *RootsError
|
||||
if !errors.As(err, &re) || !strings.Contains(err.Error(), tc.mentions) {
|
||||
t.Errorf("%s: err %v; want a RootsError mentioning %q", name, err, tc.mentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearSavedRoots puts the folders back to env afterwards. It also finishes any
|
||||
// scan an earlier test left running: dbtest.ResetDB keeps scan_runs, and a
|
||||
// save refuses while a scan is in flight.
|
||||
func clearSavedRoots(t *testing.T, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE scan_runs SET finished_at = now() WHERE finished_at IS NULL`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `UPDATE library_settings SET scan_paths = NULL`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetRoots_TracksMoveWithTheirFolder_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
clearSavedRoots(t, pool)
|
||||
ctx := context.Background()
|
||||
base := t.TempDir()
|
||||
old := filepath.Join(base, "music") // gone: the mount moved
|
||||
moved := mkdir(t, base, "data", "music", "Artist")
|
||||
if err := os.WriteFile(filepath.Join(moved, "a.mp3"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newRoot := filepath.Dir(moved)
|
||||
track, _, _ := seedTrack(t, pool, filepath.Join(old, "Artist", "a.mp3"))
|
||||
|
||||
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), []string{old}, nil)
|
||||
res, err := s.SetRoots(ctx, []RootChange{{Path: newRoot, Was: old}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Tracks != 1 {
|
||||
t.Errorf("moved %d tracks; want 1", res.Tracks)
|
||||
}
|
||||
got, err := dbq.New(pool).GetTrackByPath(ctx, filepath.Join(newRoot, "Artist", "a.mp3"))
|
||||
if err != nil {
|
||||
t.Fatalf("the track is not at its new path: %v", err)
|
||||
}
|
||||
if got.ID != track.ID {
|
||||
t.Error("the move made a new track instead of keeping the old one")
|
||||
}
|
||||
if !slices.Equal(s.Roots(), []string{newRoot}) {
|
||||
t.Errorf("roots %v", s.Roots())
|
||||
}
|
||||
select {
|
||||
case <-s.RootsChanged():
|
||||
default:
|
||||
t.Error("the watcher was not told the folders changed")
|
||||
}
|
||||
roots, saved, err := LoadRoots(ctx, pool, []string{old})
|
||||
if err != nil || !saved || !slices.Equal(roots, []string{newRoot}) {
|
||||
t.Errorf("after a restart: %v saved=%v err=%v; want the saved folder over env", roots, saved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRoots_RefusesAMoveToAFolderWithoutTheFiles_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
clearSavedRoots(t, pool)
|
||||
ctx := context.Background()
|
||||
base := t.TempDir()
|
||||
old := filepath.Join(base, "music")
|
||||
wrong := mkdir(t, base, "elsewhere")
|
||||
seedTrack(t, pool, filepath.Join(old, "a.mp3"))
|
||||
|
||||
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), []string{old}, nil)
|
||||
var re *RootsError
|
||||
if _, err := s.SetRoots(ctx, []RootChange{{Path: wrong, Was: old}}); !errors.As(err, &re) {
|
||||
t.Fatalf("err %v; want a RootsError", err)
|
||||
}
|
||||
if _, err := dbq.New(pool).GetTrackByPath(ctx, filepath.Join(old, "a.mp3")); err != nil {
|
||||
t.Errorf("a refused move rewrote the track: %v", err)
|
||||
}
|
||||
if !slices.Equal(s.Roots(), []string{old}) {
|
||||
t.Errorf("a refused move changed the folders to %v", s.Roots())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user