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 = 24 * 1024
)
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
}
args := []string{
"scan",
"--nobanner", "--subprocess",
"--configfile", conf.Server.ConfigFile,
"--datafolder", conf.Server.DataFolder.String(),
"--cachefolder", conf.Server.CacheFolder.String(),
}
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)
}
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 {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
_ = 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()
}
func targetArguments(ctx context.Context, targets []model.ScanTarget, lengthThreshold int) ([]string, func(), error) {
var args []string
argLength := estimateArgLength(targets)
if argLength > lengthThreshold {
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)
}, nil
}
for _, target := range targets {
args = append(args, "-t", target.String())
}
return args, func() {}, nil
}
func estimateArgLength(targets []model.ScanTarget) int {
length := 0
for _, target := range targets {
length += 3 + len(target.String()) + 1
}
return length
}
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())
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
}
return tmpFile.Name(), nil
}
var _ scanner = (*scannerExternal)(nil)