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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:37:03 -04:00
co-authored by Claude Opus 5.5
parent 453a977e48
commit edcb3a57ef
25 changed files with 1102 additions and 57 deletions
+231
View File
@@ -0,0 +1,231 @@
package library
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// The library's folders (#6129). They are set in Settings and changed without
// a restart; MINSTREL_LIBRARY_SCAN_PATHS names them until the first save. The
// Scanner holds the live list, and the watcher and the importer read it there.
//
// Changing a folder's path is a move, not a new library: when the files that
// were under /music are now under /data/music, every row under the old path is
// rewritten to the new one, so the tracks keep their ids and their history.
// The scan never sees the change as a mass deletion followed by a mass add.
// LoadRoots is the folders the server starts with: the saved ones, or env
// when nothing has been saved. fromSettings says which.
func LoadRoots(ctx context.Context, pool *pgxpool.Pool, env []string) (roots []string, fromSettings bool, err error) {
row, err := dbq.New(pool).GetLibrarySettings(ctx)
if err != nil {
return env, false, fmt.Errorf("library settings: load: %w", err)
}
if len(row.ScanPaths) == 0 {
return env, false, nil
}
return row.ScanPaths, true, nil
}
// RootChange is one folder of a save. Was is the folder it replaces, whose
// files have moved to Path; "" is a folder added.
type RootChange struct {
Path string
Was string
}
// RerootResult is what a save rewrote.
type RerootResult struct {
Roots []string
Tracks int64
}
// RootsError is a save the folders refuse. Its text says why, in a sentence
// for the operator.
type RootsError struct{ msg string }
func (e *RootsError) Error() string { return e.msg }
func badRoots(format string, args ...any) error {
return &RootsError{msg: fmt.Sprintf(format, args...)}
}
// moveSample is how many present tracks a move checks for at the new path,
// and moveSampleMinFound how many of them must be there.
const (
moveSample = 20
moveSampleMinFound = 0.5
)
// SetRoots validates the folders, rewrites the paths of any that moved, saves
// them and starts reading them. Nothing is written when any check fails.
func (s *Scanner) SetRoots(ctx context.Context, changes []RootChange) (RerootResult, error) {
s.rootsMu.Lock()
defer s.rootsMu.Unlock()
roots, moves, err := planRoots(s.paths, changes)
if err != nil {
return RerootResult{}, err
}
q := dbq.New(s.pool)
// A scan walking the old folders would put their paths back, or mark the
// moved rows missing when it reconciles.
_, err = q.GetInFlightScanRun(ctx)
if err == nil {
return RerootResult{}, badRoots("A library scan is running; save again once it finishes.")
}
if !errors.Is(err, pgx.ErrNoRows) {
return RerootResult{}, fmt.Errorf("in-flight scan check: %w", err)
}
for _, m := range moves {
if err := checkMove(ctx, q, m); err != nil {
return RerootResult{}, err
}
}
var res RerootResult
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
for _, m := range moves {
n, err := tq.RerootTrackPaths(ctx, dbq.RerootTrackPathsParams{NewRoot: m.Path, OldRoot: m.Was})
if err != nil {
if isUniqueViolation(err) {
return badRoots("Some tracks under %s are already in the library at %s, "+
"so its tracks cannot move there.", m.Was, m.Path)
}
return fmt.Errorf("reroot tracks: %w", err)
}
res.Tracks += n
if _, err := tq.RerootAlbumCoverPaths(ctx, dbq.RerootAlbumCoverPathsParams{NewRoot: m.Path, OldRoot: m.Was}); err != nil {
return fmt.Errorf("reroot covers: %w", err)
}
if _, err := tq.RerootGrabDestinations(ctx, dbq.RerootGrabDestinationsParams{NewRoot: m.Path, OldRoot: m.Was}); err != nil {
return fmt.Errorf("reroot downloads: %w", err)
}
if _, err := tq.RerootImportRoot(ctx, dbq.RerootImportRootParams{NewRoot: m.Path, OldRoot: m.Was}); err != nil {
return fmt.Errorf("reroot import folder: %w", err)
}
}
_, err := tq.UpdateLibraryScanPaths(ctx, roots)
return err
})
if err != nil {
return RerootResult{}, err
}
for _, m := range moves {
s.logger.Info("library folder moved", "from", m.Was, "to", m.Path)
}
s.paths = roots
select {
case s.rootsChanged <- struct{}{}:
default:
}
res.Roots = slices.Clone(roots)
return res, nil
}
// RootsChanged is signalled after a save changes the folders. A Scanner built
// without New never signals.
func (s *Scanner) RootsChanged() <-chan struct{} { return s.rootsChanged }
// planRoots checks a save against the current folders and returns the new
// list and the folders that moved.
func planRoots(current []string, changes []RootChange) ([]string, []RootChange, error) {
if len(changes) == 0 {
return nil, nil, badRoots("Name at least one folder.")
}
roots := make([]string, 0, len(changes))
var moves []RootChange
replaced := map[string]bool{}
for _, c := range changes {
p := strings.TrimSpace(c.Path)
if !filepath.IsAbs(p) {
return nil, nil, badRoots("%q is not a full path; it starts with /.", c.Path)
}
p = filepath.Clean(p)
if p == "/" {
return nil, nil, badRoots("The library cannot be the whole filesystem.")
}
if err := checkDir(p); err != nil {
return nil, nil, err
}
for _, r := range roots {
if r == p {
return nil, nil, badRoots("%s is listed twice.", p)
}
if within(p, r) || within(r, p) {
return nil, nil, badRoots("%s and %s overlap; name one of them.", r, p)
}
}
roots = append(roots, p)
if c.Was == "" || c.Was == p {
continue
}
if !slices.Contains(current, c.Was) {
return nil, nil, badRoots("%s is not one of the library's folders.", c.Was)
}
if replaced[c.Was] {
return nil, nil, badRoots("%s can only move to one place.", c.Was)
}
replaced[c.Was] = true
moves = append(moves, RootChange{Path: p, Was: c.Was})
}
return roots, moves, nil
}
// checkDir refuses a folder the server cannot read: a typo, or a mount the
// container does not have.
func checkDir(p string) error {
info, err := os.Stat(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return badRoots("%s does not exist on the server. Is it mounted into the container?", p)
}
return badRoots("%s cannot be read: %v.", p, err)
}
if !info.IsDir() {
return badRoots("%s is a file, not a folder.", p)
}
if _, err := os.ReadDir(p); err != nil {
return badRoots("%s cannot be read: %v.", p, err)
}
return nil
}
// checkMove looks for a sample of the folder's tracks at the new path, so a
// move to the wrong folder is refused before any row is rewritten.
func checkMove(ctx context.Context, q *dbq.Queries, m RootChange) error {
sample, err := q.SampleTrackPathsUnder(ctx, dbq.SampleTrackPathsUnderParams{Root: m.Was, N: moveSample})
if err != nil {
return fmt.Errorf("sample tracks: %w", err)
}
if len(sample) == 0 {
return nil
}
found := 0
for _, p := range sample {
if _, err := os.Stat(m.Path + strings.TrimPrefix(p, m.Was)); err == nil {
found++
}
}
if float64(found) < moveSampleMinFound*float64(len(sample)) {
return badRoots("Only %d of %d tracks checked from %s are at the same place under %s. "+
"Is that the folder they moved to?", found, len(sample), m.Was, m.Path)
}
return nil
}
// within reports whether p is inside dir.
func within(p, dir string) bool {
return strings.HasPrefix(p, dir+"/")
}
+148
View File
@@ -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())
}
}
+3 -2
View File
@@ -176,10 +176,11 @@ func logTrackChanges(ctx context.Context, q trackReconciler, ids []pgtype.UUID)
// obvious case, and it is common enough — a container restart racing its volume
// mount does exactly this.
func (s *Scanner) verifyRootsPresent() error {
if len(s.paths) == 0 {
roots := s.Roots()
if len(roots) == 0 {
return errors.New("no scan roots configured")
}
for _, root := range s.paths {
for _, root := range roots {
info, err := os.Stat(root)
if err != nil {
return fmt.Errorf("scan root %q unavailable: %w", root, err)
+17 -4
View File
@@ -12,6 +12,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dhowden/tag"
@@ -87,7 +88,11 @@ type Stats struct {
type Scanner struct {
pool *pgxpool.Pool
logger *slog.Logger
paths []string
// paths are the library's folders, changed by SetRoots (#6129). Read them
// through Roots.
paths []string
rootsMu sync.RWMutex
rootsChanged chan struct{}
// fingerprint derives a file's acoustic identity (M400). A field so an
// integration test can substitute a deterministic one: CI has no real audio
// to fingerprint, and what the test pins is WHEN the scan fingerprints, not
@@ -99,7 +104,8 @@ type Scanner struct {
}
func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string, settings *FingerprintSettingsService) *Scanner {
return &Scanner{pool: pool, logger: logger, paths: paths, fingerprint: computeFingerprint, settings: settings}
return &Scanner{pool: pool, logger: logger, paths: paths, rootsChanged: make(chan struct{}, 1),
fingerprint: computeFingerprint, settings: settings}
}
// Scan walks every configured root and upserts any audio file whose mtime is
@@ -206,7 +212,7 @@ func (s *Scanner) enumerate(
) ([]string, int) {
paths := make([]string, 0, 8192)
errs := 0
for _, root := range s.paths {
for _, root := range s.Roots() {
// WalkDir's own error return is folded into the per-entry handler below,
// so a bad root is counted rather than aborting the whole scan — one
// unreadable root shouldn't discard the others' results.
@@ -691,4 +697,11 @@ func IsAudioFile(path string) bool {
}
// Roots are the folders the scanner reads: the library.
func (s *Scanner) Roots() []string { return append([]string(nil), s.paths...) }
func (s *Scanner) Roots() []string {
if s == nil {
return nil
}
s.rootsMu.RLock()
defer s.rootsMu.RUnlock()
return append([]string(nil), s.paths...)
}
+31 -9
View File
@@ -67,18 +67,16 @@ type Watcher struct {
scanner *Scanner
enricher *coverart.Enricher
logger *slog.Logger
roots []string
debounce time.Duration
}
func NewWatcher(
scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string,
) *Watcher {
// NewWatcher watches the scanner's folders, and moves to the new ones when
// they change in Settings (#6129).
func NewWatcher(scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger) *Watcher {
return &Watcher{
scanner: scanner,
enricher: enricher,
logger: logger,
roots: roots,
debounce: watcherDebounce,
}
}
@@ -87,8 +85,32 @@ func NewWatcher(
// fsnotify watcher cannot be created at all; per-path watch failures and
// fsnotify errors are logged and tolerated.
func (w *Watcher) Run(ctx context.Context) error {
if len(w.roots) == 0 {
w.logger.Info("library watcher: no scan paths configured, watcher disabled")
for {
if err := w.watch(ctx, w.scanner.Roots()); err != nil {
return err
}
if ctx.Err() != nil {
return nil
}
}
}
// watch watches roots until ctx is cancelled or the folders change.
func (w *Watcher) watch(ctx context.Context, roots []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-w.scanner.RootsChanged():
w.logger.Info("library watcher: folders changed, restarting")
cancel()
case <-ctx.Done():
}
}()
if len(roots) == 0 {
w.logger.Info("library watcher: no scan paths configured, watcher idle")
<-ctx.Done()
return nil
}
fsw, err := fsnotify.NewWatcher()
@@ -98,10 +120,10 @@ func (w *Watcher) Run(ctx context.Context) error {
}
defer func() { _ = fsw.Close() }()
for _, root := range w.roots {
for _, root := range roots {
w.addRecursive(fsw, root)
}
w.logger.Info("library watcher: started", "roots", len(w.roots))
w.logger.Info("library watcher: started", "roots", len(roots))
batches := make(chan []string, batchQueueDepth)
go w.processLoop(ctx, batches)