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>
232 lines
7.2 KiB
Go
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+"/")
|
|
}
|