From 5a4a3099f1a65e31c9c1201674067987ae9fe990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 12 Aug 2026 14:55:04 -0400 Subject: [PATCH] fix(cli): accept absolute paths in selective scan --target (#5947) * fix(scanner): accept absolute paths in selective scan --target The scanner's fs.FS only accepts paths relative to the library root, so an absolute --target path (e.g. 2:/jukebox/collection) failed with an opaque "invalid argument" error. Rebase absolute targets onto the library root before scanning; relative paths are unchanged. Fixes #5943 * refactor(scanner): simplify libraryRelativePath with IsLocal and slice.ToMap * fix(scanner): make libraryRelativePath cross-platform Windows CI failed: the tests hardcoded Unix-style absolute paths, which are not absolute on Windows, and filepath.Rel yields backslash-separated paths that the io/fs-based scanner FS rejects. Build the test paths with filepath.Abs so they are absolute on every OS, and normalize the rebased result with filepath.ToSlash. * fix(scanner): resolve relative library root before rebasing target filepath.Rel cannot rebase an absolute target onto a relative library root (e.g. the default MusicFolder=./music), so an absolute --target was left unchanged and rejected by the rooted io/fs. Make the library root absolute first; it resolves against the same cwd as the scanner's fs. --- model/scanner.go | 2 +- scanner/scanner.go | 28 ++++++++++++++++++++++- scanner/scanner_internal_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/model/scanner.go b/model/scanner.go index 54f81037c..36c9007fb 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -12,7 +12,7 @@ import ( // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { LibraryID int - FolderPath string // Relative path within the library, or "" for entire library + FolderPath string // Path within the library (relative or absolute), or "" for entire library } func (st ScanTarget) String() string { diff --git a/scanner/scanner.go b/scanner/scanner.go index 29a1f9b4e..d73007bdd 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "maps" + "path/filepath" "slices" "sync/atomic" "time" @@ -51,6 +52,27 @@ func (s *scanState) sendError(err error) { s.sendProgress(&ProgressInfo{Error: err.Error()}) } +// libraryRelativePath rebases an absolute scan target path onto the library root, since the +// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside +// the library root, are returned unchanged. +func libraryRelativePath(libPath, folderPath string) string { + if !filepath.IsAbs(folderPath) { + return folderPath + } + // The library root may be relative (e.g. the default "./music"); it must be made absolute + // to match against an absolute target, and it resolves against the same cwd as the scanner's fs. + absLib, err := filepath.Abs(libPath) + if err != nil { + return folderPath + } + rel, err := filepath.Rel(absLib, folderPath) + if err != nil || !filepath.IsLocal(rel) { + return folderPath + } + // The scanner's fs.FS is an io/fs, which always uses forward slashes. + return filepath.ToSlash(rel) +} + func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { startTime := time.Now() @@ -77,8 +99,12 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Selective scan: filter libraries and build targets map state.targets = make(map[int][]string) + libPaths := slice.ToMap(allLibs, func(lib model.Library) (int, string) { + return lib.ID, lib.Path + }) + for _, target := range targets { - folderPath := target.FolderPath + folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath) if folderPath == "" { folderPath = "." } diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index e8abb7c7d..0778bd6ec 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -4,6 +4,8 @@ package scanner import ( "context" "errors" + "os" + "path/filepath" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -11,6 +13,43 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("libraryRelativePath", func() { + // Paths are built with filepath so the "absolute" cases stay absolute on every OS + // (a Unix-style "/foo" is not absolute on Windows). + libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) + outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) + + It("returns a relative path unchanged", func() { + Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) + }) + + It("rebases an absolute target when the library root is relative", func() { + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) + }) + + It("rebases an absolute path that equals the library root to '.'", func() { + Expect(libraryRelativePath(libRoot, libRoot)).To(Equal(".")) + }) + + It("rebases an absolute path under the library root", func() { + Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("handles a trailing slash on the library path", func() { + Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("leaves an absolute path outside the library root unchanged", func() { + Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside)) + }) + + It("returns an empty path unchanged", func() { + Expect(libraryRelativePath(libRoot, "")).To(Equal("")) + }) +}) + type mockPhase struct { num int produceFunc func() ppl.Producer[int]