Files
bvandeusenandClaude Opus 5.5 edcb3a57ef
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
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)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:03 -04:00

232 lines
7.2 KiB
Go

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