mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
170 lines
5.2 KiB
Go
170 lines
5.2 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/gob"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
)
|
|
|
|
const (
|
|
// argLengthThreshold is the threshold for switching from command-line args to file-based target passing.
|
|
// Set conservatively at 24KB to support Windows (~32KB limit) with margin for env vars.
|
|
argLengthThreshold = 24 * 1024
|
|
)
|
|
|
|
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
|
|
// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The
|
|
// external process will be spawned with the same executable as the current process, and will run
|
|
// the "scan" command with the "--subprocess" flag.
|
|
//
|
|
// The external process will send progress updates to the main process through its STDOUT, and the main
|
|
// process will forward them to the caller.
|
|
type scannerExternal struct{}
|
|
|
|
func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
|
|
s.scan(ctx, fullScan, targets, progress)
|
|
}
|
|
|
|
func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
progress <- &ProgressInfo{Error: fmt.Sprintf("failed to get executable path: %s", err)}
|
|
return
|
|
}
|
|
|
|
// Build command arguments
|
|
args := []string{
|
|
"scan",
|
|
"--nobanner", "--subprocess",
|
|
"--configfile", conf.Server.ConfigFile,
|
|
"--datafolder", conf.Server.DataFolder.String(),
|
|
"--cachefolder", conf.Server.CacheFolder.String(),
|
|
}
|
|
|
|
// Add targets if provided
|
|
if len(targets) > 0 {
|
|
targetArgs, cleanup, err := targetArguments(ctx, targets, argLengthThreshold)
|
|
if err != nil {
|
|
progress <- &ProgressInfo{Error: err.Error()}
|
|
return
|
|
}
|
|
defer cleanup()
|
|
log.Debug(ctx, "Spawning external scanner process with target file", "fullScan", fullScan, "path", exe, "numTargets", len(targets))
|
|
args = append(args, targetArgs...)
|
|
} else {
|
|
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
|
|
}
|
|
|
|
// Add full scan flag if needed
|
|
if fullScan {
|
|
args = append(args, "--full")
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, exe, args...)
|
|
|
|
in, out := io.Pipe()
|
|
defer in.Close()
|
|
defer out.Close()
|
|
cmd.Stdout = out
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
progress <- &ProgressInfo{Error: fmt.Sprintf("failed to start scanner process: %s", err)}
|
|
return
|
|
}
|
|
go s.wait(cmd, out)
|
|
|
|
decoder := gob.NewDecoder(in)
|
|
for {
|
|
var p ProgressInfo
|
|
if err := decoder.Decode(&p); err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
progress <- &ProgressInfo{Error: fmt.Sprintf("failed to read status from scanner: %s", err)}
|
|
}
|
|
break
|
|
}
|
|
progress <- &p
|
|
}
|
|
}
|
|
|
|
func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) {
|
|
if err := cmd.Wait(); err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
_ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr))
|
|
} else {
|
|
_ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err))
|
|
}
|
|
return
|
|
}
|
|
_ = out.Close()
|
|
}
|
|
|
|
// targetArguments builds command-line arguments for the given scan targets.
|
|
// If the estimated argument length exceeds a threshold, it writes the targets to a temp file
|
|
// and returns the --target-file argument instead.
|
|
// Returns the arguments, a cleanup function to remove any temp file created, and an error if any.
|
|
func targetArguments(ctx context.Context, targets []model.ScanTarget, lengthThreshold int) ([]string, func(), error) {
|
|
var args []string
|
|
|
|
// Estimate argument length to decide whether to use file-based approach
|
|
argLength := estimateArgLength(targets)
|
|
|
|
if argLength > lengthThreshold {
|
|
// Write targets to temp file and pass via --target-file
|
|
targetFile, err := writeTargetsToFile(targets)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to write targets to file: %w", err)
|
|
}
|
|
args = append(args, "--target-file", targetFile)
|
|
return args, func() {
|
|
os.Remove(targetFile) // Clean up temp file
|
|
}, nil
|
|
}
|
|
|
|
// Use command-line arguments for small target lists
|
|
for _, target := range targets {
|
|
args = append(args, "-t", target.String())
|
|
}
|
|
return args, func() {}, nil
|
|
}
|
|
|
|
// estimateArgLength estimates the total length of command-line arguments for the given targets.
|
|
func estimateArgLength(targets []model.ScanTarget) int {
|
|
length := 0
|
|
for _, target := range targets {
|
|
// Each target adds: "-t " + target string + space
|
|
length += 3 + len(target.String()) + 1
|
|
}
|
|
return length
|
|
}
|
|
|
|
// writeTargetsToFile writes the targets to a temporary file, one per line.
|
|
// Returns the path to the temp file, which the caller should clean up.
|
|
func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
|
|
tmpFile, err := os.CreateTemp("", "navidrome-scan-targets-*.txt")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
for _, target := range targets {
|
|
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
|
|
os.Remove(tmpFile.Name()) //nolint:gosec
|
|
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
|
}
|
|
}
|
|
|
|
return tmpFile.Name(), nil
|
|
}
|
|
|
|
var _ scanner = (*scannerExternal)(nil)
|