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