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.
This commit is contained in:
Deluan Quintão 2026-08-12 14:55:04 -04:00 committed by GitHub
parent 752b38609c
commit 5a4a3099f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 67 additions and 2 deletions

View File

@ -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 {

View File

@ -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 = "."
}

View File

@ -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]