diff --git a/cmd/scan.go b/cmd/scan.go index 2c43baf62..057d4902f 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/utils/pl" @@ -72,7 +73,7 @@ func runScanner(ctx context.Context) { pls := core.NewPlaylists(ds) // Parse targets if provided - var scanTargets []scanner.ScanTarget + var scanTargets []model.ScanTarget if targets != "" { var err error scanTargets, err = parseTargets(targets) @@ -96,7 +97,7 @@ func runScanner(ctx context.Context) { } // parseTargets parses the comma-separated targets string into ScanTarget structs -func parseTargets(targetsStr string) ([]scanner.ScanTarget, error) { +func parseTargets(targetsStr string) ([]model.ScanTarget, error) { targets := strings.Split(targetsStr, ",") return scanner.ParseTargets(targets) } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index fecd79c4b..7abc8ef88 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -12,9 +12,9 @@ var _ = Describe("parseTargets", func() { targets, err := parseTargets("1:Music/Rock,2:Jazz,3:Classical/Beethoven") Expect(err).ToNot(HaveOccurred()) Expect(targets).To(HaveLen(3)) - Expect(targets[0]).To(Equal(scanner.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"})) - Expect(targets[1]).To(Equal(scanner.ScanTarget{LibraryID: 2, FolderPath: "Jazz"})) - Expect(targets[2]).To(Equal(scanner.ScanTarget{LibraryID: 3, FolderPath: "Classical/Beethoven"})) + Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"})) + Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Jazz"})) + Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical/Beethoven"})) }) It("returns error for empty string", func() { diff --git a/model/folder.go b/model/folder.go index 7a769735e..c59e9d465 100644 --- a/model/folder.go +++ b/model/folder.go @@ -90,3 +90,14 @@ type FolderRepository interface { MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) } + +// ScanTarget represents a specific folder within a library to be scanned. +// 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 +} + +func (st ScanTarget) String() string { + return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath) +} diff --git a/scanner/controller.go b/scanner/controller.go index 70e2d6a69..caff41b0a 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -28,21 +28,10 @@ var ( ErrAlreadyScanning = errors.New("already scanning") ) -// ScanTarget represents a specific folder within a library to be scanned. -// 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 -} - -func (st ScanTarget) String() string { - return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath) -} - // ParseTargets parses scan targets strings into ScanTarget structs. // Example: []string{"1:Music/Rock", "2:Classical"} -func ParseTargets(libFolders []string) ([]ScanTarget, error) { - targets := make([]ScanTarget, 0, len(libFolders)) +func ParseTargets(libFolders []string) ([]model.ScanTarget, error) { + targets := make([]model.ScanTarget, 0, len(libFolders)) for _, part := range libFolders { part = strings.TrimSpace(part) @@ -67,7 +56,7 @@ func ParseTargets(libFolders []string) ([]ScanTarget, error) { return nil, fmt.Errorf("invalid library ID %q", libIDStr) } - targets = append(targets, ScanTarget{ + targets = append(targets, model.ScanTarget{ LibraryID: libID, FolderPath: folderPath, }) @@ -85,7 +74,7 @@ type Scanner interface { ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) // ScanFolders scans specific library/folder pairs, recursing into subdirectories. // If targets is nil, it scans all libraries. This is a blocking operation. - ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error) + ScanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget) (warnings []string, err error) Status(context.Context) (*StatusInfo, error) } @@ -125,7 +114,7 @@ func (s *controller) getScanner() scanner { // CallScan starts an in-process scan of specific library/folder pairs. // If targets is empty, it scans all libraries. // This is meant to be called from the command line (see cmd/scan.go). -func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []ScanTarget) (<-chan *ProgressInfo, error) { +func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) { release, err := lockScan(ctx) if err != nil { return nil, err @@ -161,7 +150,7 @@ type ProgressInfo struct { // This allows for swapping between in-process and external scanners. type scanner interface { // scanFolders performs the actual scanning of folders. If targets is nil, it scans all libraries. - scanFolders(ctx context.Context, fullScan bool, targets []ScanTarget, progress chan<- *ProgressInfo) + scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) } type controller struct { @@ -272,7 +261,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin return s.ScanFolders(requestCtx, fullScan, nil) } -func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []ScanTarget) ([]string, error) { +func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { release, err := lockScan(requestCtx) if err != nil { return nil, err diff --git a/scanner/external.go b/scanner/external.go index 155fdf58b..b6d7639be 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -24,11 +25,11 @@ import ( // process will forward them to the caller. type scannerExternal struct{} -func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []ScanTarget, progress chan<- *ProgressInfo) { +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 []ScanTarget, progress chan<- *ProgressInfo) { +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)} @@ -46,7 +47,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []Sca // Add targets if provided if len(targets) > 0 { - targetsStr := strings.Join(slice.Map(targets, func(t ScanTarget) string { return t.String() }), ",") + targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",") args = append(args, "--targets", targetsStr) log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr) } else { diff --git a/scanner/scanner.go b/scanner/scanner.go index f1b42a911..96fdb1604 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -47,7 +47,7 @@ func (s *scanState) sendError(err error) { s.sendProgress(&ProgressInfo{Error: err.Error()}) } -func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []ScanTarget, progress chan<- *ProgressInfo) { +func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { startTime := time.Now() state := scanState{ diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index ecad6f501..e1b2e6f32 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -736,7 +736,7 @@ var _ = Describe("Scanner", Ordered, func() { // (lib is already created with the path "fake:///music") // Scan only the "rock" and "jazz" folders (including their subdirectories) - targets := []scanner.ScanTarget{ + targets := []model.ScanTarget{ {LibraryID: lib.ID, FolderPath: "rock"}, {LibraryID: lib.ID, FolderPath: "jazz"}, } diff --git a/scanner/selective_scan_test.go b/scanner/selective_scan_test.go index a706af159..4bac1c2e1 100644 --- a/scanner/selective_scan_test.go +++ b/scanner/selective_scan_test.go @@ -146,7 +146,7 @@ var _ = Describe("Selective Scan - Deleted Child Folders", Ordered, func() { // Run selective scan on the parent folder (Artist) // This simulates what the watcher does when a child folder is deleted - _, err := s.ScanFolders(ctx, false, []scanner.ScanTarget{ + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{ {LibraryID: lib.ID, FolderPath: "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -208,7 +208,7 @@ var _ = Describe("Selective Scan - Deleted Child Folders", Ordered, func() { }) // Run selective scan on parent - _, err = s.ScanFolders(ctx, false, []scanner.ScanTarget{ + _, err = s.ScanFolders(ctx, false, []model.ScanTarget{ {LibraryID: lib.ID, FolderPath: "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/scanner/watcher.go b/scanner/watcher.go index dd0d1cca2..911ea310b 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -73,7 +73,7 @@ func (w *watcher) Run(ctx context.Context) error { // Main scan triggering loop trigger := time.NewTimer(w.triggerWait) trigger.Stop() - targets := make(map[ScanTarget]struct{}) + targets := make(map[model.ScanTarget]struct{}) for { select { case <-trigger.C: @@ -90,13 +90,13 @@ func (w *watcher) Run(ctx context.Context) error { } // Convert targets map to slice - targetSlice := make([]ScanTarget, 0, len(targets)) + targetSlice := make([]model.ScanTarget, 0, len(targets)) for target := range targets { targetSlice = append(targetSlice, target) } // Clear targets for next batch - targets = make(map[ScanTarget]struct{}) + targets = make(map[model.ScanTarget]struct{}) go func() { _, err := w.scanner.ScanFolders(ctx, false, targetSlice) @@ -121,7 +121,7 @@ func (w *watcher) Run(ctx context.Context) error { folderPath := notification.FolderPath // If already scheduled for scan, skip - target := ScanTarget{LibraryID: lib.ID, FolderPath: folderPath} + target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath} if _, exists := targets[target]; exists { continue } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index be813a10b..c10ffe00e 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -351,7 +351,7 @@ type ScanAllCall struct { type ScanFoldersCall struct { FullScan bool - Targets []ScanTarget + Targets []model.ScanTarget } func NewMockScanner() *mockScanner { @@ -370,12 +370,12 @@ func (m *mockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error return nil, nil } -func (m *mockScanner) ScanFolders(_ context.Context, fullScan bool, targets []ScanTarget) ([]string, error) { +func (m *mockScanner) ScanFolders(_ context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { m.mu.Lock() defer m.mu.Unlock() // Make a copy of targets to avoid race conditions - targetsCopy := make([]ScanTarget, len(targets)) + targetsCopy := make([]model.ScanTarget, len(targets)) copy(targetsCopy, targets) m.scanFoldersCalls = append(m.scanFoldersCalls, ScanFoldersCall{ diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index abd388428..5f180dc4c 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -47,7 +48,7 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { fullScan := p.BoolOr("fullScan", false) // Parse optional path parameters for selective scanning - var targets []scanner.ScanTarget + var targets []model.ScanTarget if pathParams, err := p.Strings("path"); err == nil && len(pathParams) > 0 { targets, err = scanner.ParseTargets(pathParams) if err != nil { diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index 1e242597d..4eb1703e8 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -292,7 +292,7 @@ type mockScanner struct { // ScanFolders tracking scanFoldersCalled bool scanFoldersFullScan bool - scanFoldersTargets []scanner.ScanTarget + scanFoldersTargets []model.ScanTarget scanFoldersError error scanFoldersWarnings []string @@ -310,14 +310,14 @@ func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) ([]string, err return m.scanAllWarnings, m.scanAllError } -func (m *mockScanner) ScanFolders(ctx context.Context, fullScan bool, targets []scanner.ScanTarget) ([]string, error) { +func (m *mockScanner) ScanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { m.mu.Lock() defer m.mu.Unlock() m.scanFoldersCalled = true m.scanFoldersFullScan = fullScan // Make a copy of targets to avoid race conditions - m.scanFoldersTargets = make([]scanner.ScanTarget, len(targets)) + m.scanFoldersTargets = make([]model.ScanTarget, len(targets)) copy(m.scanFoldersTargets, targets) return m.scanFoldersWarnings, m.scanFoldersError } @@ -357,11 +357,11 @@ func (m *mockScanner) getScanFoldersFullScan() bool { return m.scanFoldersFullScan } -func (m *mockScanner) getScanFoldersTargets() []scanner.ScanTarget { +func (m *mockScanner) getScanFoldersTargets() []model.ScanTarget { m.mu.Lock() defer m.mu.Unlock() // Return a copy to avoid race conditions - targets := make([]scanner.ScanTarget, len(m.scanFoldersTargets)) + targets := make([]model.ScanTarget, len(m.scanFoldersTargets)) copy(targets, m.scanFoldersTargets) return targets }