mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-1 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-2 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-3 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-4 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-5 (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-6 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-7 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-8 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-9 (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-10 (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
* feat(scanner): add Scanner.IgnoreDotFolders to allow scanning dot folders
Adds a new Scanner.IgnoreDotFolders option (default true, preserving current
behavior) that, when disabled, lets the scanner traverse folders whose names
start with a dot, such as albums like ".Hack Sign Original Soundtrack".
Previously every dot-prefixed entry was skipped unconditionally before the
directory check, so such album folders were never indexed. The walk loop now
determines whether an entry is a directory first, then skips dot-prefixed files
always and dot-prefixed folders only when IgnoreDotFolders is enabled. Special
system directories are still ignored in all cases via the ignoredDirs blocklist,
which now also lists .git explicitly (it was previously caught only by the
generic dot-prefix rule). isDirIgnored is reduced to a pure blocklist check and
the name-only predicate is renamed from isEntryIgnored to isDotEntry.
* refactor(scanner): centralize entry ignore policy in isIgnoredEntry
Consolidates the directory-entry ignore decision into a single isIgnoredEntry
helper so the walk loop reads as pure dispatch (recurse into directories,
classify files) instead of interleaving ignore policy with traversal.
The dot-prefix rule and the ignoredDirs blocklist were previously checked in two
separate places inside loadDir's loop. They are now combined behind one helper
that takes the entry name and whether it is a directory. isDirIgnored remains a
standalone blocklist predicate because the file watcher (isIgnoredPath) calls it
directly. Adds focused unit tests for isIgnoredEntry covering both states of
Scanner.IgnoreDotFolders. No behavior change.
* fix(scanner): stop watcher from scanning ignored dot folders
A filesystem change inside a dot-prefixed folder (e.g. ".Hidden Album/track.mp3")
previously triggered a targeted scan of that folder, because isIgnoredPath let
all media files through and only checked the changed path's parent against the
ignore list (which never matched for nested paths due to the trailing separator).
With Scanner.IgnoreDotFolders enabled this caused the folder to be indexed even
though a full scan would skip it.
The watcher now ignores any change located inside an ignored directory via a new
isUnderIgnoredDir helper that reuses the same isIgnoredEntry policy as the scan
walk, and checks the entry itself with isIgnoredEntry instead of the parent dir.
This keeps the watcher and the scanner in agreement for both dot-folders (gated
by the flag) and the ignoredDirs blocklist. Adds direct table tests for
isIgnoredPath covering both states of the option.
* fix(scanner): exclude '.' from isDotEntry and ignore dot media files in watcher
Addresses code review feedback:
- isDotEntry now excludes the literal "." reference, matching its documentation.
Previously isDotEntry(".") returned true, which could mark a path component as
a dot-entry in the watcher.
- isIgnoredPath now ignores dot-prefixed media files (e.g. ".hidden.mp3") so the
watcher matches the scanner, which always skips dot files. Non-media leaves
still fall through to the directory-assumption check, so dot-folders continue
to follow Scanner.IgnoreDotFolders.
Adds unit tests for isDotEntry and watcher coverage for dot-prefixed media files.
* docs(scanner): clarify isDotEntry multi-dot exclusion and add test
Expand the isDotEntry comment to explain why names with two or more
leading dots (".."/"..foo"/"...Album") are not treated as hidden, which
surprised a reviewer testing dot-folder scanning. Add a "..foo" test case
to make the two-leading-dots behavior explicit.
Claude-Session: https://claude.ai/code/session_012STiDTyhZAdH8JNtdNe8L1
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/storage"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/singleton"
|
|
)
|
|
|
|
type Watcher interface {
|
|
Run(ctx context.Context) error
|
|
Watch(ctx context.Context, lib *model.Library) error
|
|
StopWatching(ctx context.Context, libraryID int) error
|
|
}
|
|
|
|
type watcher struct {
|
|
mainCtx context.Context
|
|
ds model.DataStore
|
|
scanner model.Scanner
|
|
triggerWait time.Duration
|
|
watcherNotify chan scanNotification
|
|
libraryWatchers map[int]*libraryWatcherInstance
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
type libraryWatcherInstance struct {
|
|
library *model.Library
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type scanNotification struct {
|
|
Library *model.Library
|
|
FolderPath string
|
|
}
|
|
|
|
// GetWatcher returns the watcher singleton
|
|
func GetWatcher(ds model.DataStore, s model.Scanner) Watcher {
|
|
return singleton.GetInstance(func() *watcher {
|
|
return &watcher{
|
|
ds: ds,
|
|
scanner: s,
|
|
triggerWait: conf.Server.Scanner.WatcherWait,
|
|
watcherNotify: make(chan scanNotification, 500),
|
|
libraryWatchers: make(map[int]*libraryWatcherInstance),
|
|
}
|
|
})
|
|
}
|
|
|
|
func (w *watcher) Run(ctx context.Context) error {
|
|
// Keep the main context to be used in all watchers added later
|
|
w.mainCtx = ctx
|
|
|
|
// Start watchers for all existing libraries
|
|
libs, err := w.ds.Library(ctx).GetAll()
|
|
if err != nil {
|
|
return fmt.Errorf("getting libraries: %w", err)
|
|
}
|
|
|
|
for _, lib := range libs {
|
|
if err := w.Watch(ctx, &lib); err != nil {
|
|
log.Warn(ctx, "Failed to start watcher for existing library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
|
}
|
|
}
|
|
|
|
// Main scan triggering loop
|
|
trigger := time.NewTimer(w.triggerWait)
|
|
trigger.Stop()
|
|
targets := make(map[model.ScanTarget]struct{})
|
|
for {
|
|
select {
|
|
case <-trigger.C:
|
|
log.Info("Watcher: Triggering scan for changed folders", "numTargets", len(targets))
|
|
status, err := w.scanner.Status(ctx)
|
|
if err != nil {
|
|
log.Error(ctx, "Watcher: Error retrieving Scanner status", err)
|
|
break
|
|
}
|
|
if status.Scanning {
|
|
log.Debug(ctx, "Watcher: Already scanning, will retry later", "waitTime", w.triggerWait*3)
|
|
trigger.Reset(w.triggerWait * 3)
|
|
continue
|
|
}
|
|
|
|
// Convert targets map to slice
|
|
targetSlice := make([]model.ScanTarget, 0, len(targets))
|
|
for target := range targets {
|
|
targetSlice = append(targetSlice, target)
|
|
}
|
|
|
|
// Clear targets for next batch
|
|
targets = make(map[model.ScanTarget]struct{})
|
|
|
|
go func() {
|
|
var err error
|
|
if conf.Server.DevSelectiveWatcher {
|
|
_, err = w.scanner.ScanFolders(ctx, false, targetSlice)
|
|
} else {
|
|
_, err = w.scanner.ScanAll(ctx, false)
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Watcher: Error scanning", err)
|
|
} else {
|
|
log.Info(ctx, "Watcher: Scan completed")
|
|
}
|
|
}()
|
|
case <-ctx.Done():
|
|
// Stop all library watchers
|
|
w.mu.Lock()
|
|
for libraryID, instance := range w.libraryWatchers {
|
|
log.Debug(ctx, "Stopping library watcher due to context cancellation", "libraryID", libraryID)
|
|
instance.cancel()
|
|
}
|
|
w.libraryWatchers = make(map[int]*libraryWatcherInstance)
|
|
w.mu.Unlock()
|
|
return nil
|
|
case notification := <-w.watcherNotify:
|
|
// Reset the trigger timer for debounce
|
|
trigger.Reset(w.triggerWait)
|
|
|
|
lib := notification.Library
|
|
folderPath := notification.FolderPath
|
|
|
|
// If already scheduled for scan, skip
|
|
target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}
|
|
if _, exists := targets[target]; exists {
|
|
continue
|
|
}
|
|
targets[target] = struct{}{}
|
|
|
|
log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
|
|
"libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *watcher) Watch(ctx context.Context, lib *model.Library) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
// If Run() hasn't been called yet, mainCtx will be nil - skip watching
|
|
if w.mainCtx == nil {
|
|
log.Debug(ctx, "Watcher not started yet, skipping watch for library", "libraryID", lib.ID, "name", lib.Name)
|
|
return nil
|
|
}
|
|
|
|
// Stop existing watcher if any
|
|
if existingInstance, exists := w.libraryWatchers[lib.ID]; exists {
|
|
log.Debug(ctx, "Stopping existing watcher before starting new one", "libraryID", lib.ID, "name", lib.Name)
|
|
existingInstance.cancel()
|
|
}
|
|
|
|
// Start new watcher
|
|
watcherCtx, cancel := context.WithCancel(w.mainCtx) //nolint:gosec // cancel is stored in instance and called on shutdown
|
|
instance := &libraryWatcherInstance{
|
|
library: lib,
|
|
cancel: cancel,
|
|
}
|
|
|
|
w.libraryWatchers[lib.ID] = instance
|
|
|
|
// Start watching in a goroutine
|
|
go func() {
|
|
defer func() {
|
|
w.mu.Lock()
|
|
if currentInstance, exists := w.libraryWatchers[lib.ID]; exists && currentInstance == instance {
|
|
delete(w.libraryWatchers, lib.ID)
|
|
}
|
|
w.mu.Unlock()
|
|
}()
|
|
|
|
err := w.watchLibrary(watcherCtx, lib)
|
|
if err != nil && watcherCtx.Err() == nil { // Only log error if not due to cancellation
|
|
log.Error(ctx, "Watcher error", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
|
}
|
|
}()
|
|
|
|
log.Info(ctx, "Started watcher for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
|
return nil
|
|
}
|
|
|
|
func (w *watcher) StopWatching(ctx context.Context, libraryID int) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
instance, exists := w.libraryWatchers[libraryID]
|
|
if !exists {
|
|
log.Debug(ctx, "No watcher found to stop", "libraryID", libraryID)
|
|
return nil
|
|
}
|
|
|
|
instance.cancel()
|
|
delete(w.libraryWatchers, libraryID)
|
|
|
|
log.Info(ctx, "Stopped watcher for library", "libraryID", libraryID, "name", instance.library.Name)
|
|
return nil
|
|
}
|
|
|
|
// watchLibrary implements the core watching logic for a single library (extracted from old watchLib function)
|
|
func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
|
|
s, err := storage.For(lib.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("creating storage: %w", err)
|
|
}
|
|
|
|
fsys, err := s.FS()
|
|
if err != nil {
|
|
return fmt.Errorf("getting FS: %w", err)
|
|
}
|
|
|
|
watcher, ok := s.(storage.Watcher)
|
|
if !ok {
|
|
log.Info(ctx, "Watcher not supported for storage type", "libraryID", lib.ID, "path", lib.Path)
|
|
return nil
|
|
}
|
|
|
|
c, err := watcher.Start(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("starting watcher: %w", err)
|
|
}
|
|
|
|
absLibPath, err := filepath.Abs(lib.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("converting to absolute path: %w", err)
|
|
}
|
|
|
|
log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath)
|
|
|
|
return w.processLibraryEvents(ctx, lib, fsys, c, absLibPath)
|
|
}
|
|
|
|
// processLibraryEvents processes filesystem events for a library.
|
|
func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, fsys storage.MusicFS, events <-chan string, absLibPath string) error {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
|
|
return nil
|
|
case path := <-events:
|
|
path, err := filepath.Rel(absLibPath, path)
|
|
if err != nil {
|
|
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
|
|
continue
|
|
}
|
|
|
|
if isIgnoredPath(ctx, fsys, path) {
|
|
log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path)
|
|
continue
|
|
}
|
|
log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath)
|
|
|
|
// Check if the original path (before resolution) matches .ndignore patterns
|
|
// This is crucial for deleted folders - if a deleted folder matches .ndignore,
|
|
// we should ignore it BEFORE resolveFolderPath walks up to the parent
|
|
if w.shouldIgnoreFolderPath(ctx, fsys, path) {
|
|
log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path)
|
|
continue
|
|
}
|
|
|
|
// Find the folder to scan - validate path exists as directory, walk up if needed
|
|
folderPath := resolveFolderPath(fsys, path)
|
|
// Double-check after resolution in case the resolved path is different and also matches patterns
|
|
if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) {
|
|
log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath)
|
|
continue
|
|
}
|
|
|
|
// Notify the main watcher of changes. This will trigger a scan after the debounce period.
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveFolderPath takes a path (which may be a file or directory) and returns
|
|
// the folder path to scan. If the path is a file, it walks up to find the parent
|
|
// directory. Returns empty string if the path should scan the library root.
|
|
func resolveFolderPath(fsys fs.FS, path string) string {
|
|
// Handle root paths immediately
|
|
if path == "." || path == "" {
|
|
return ""
|
|
}
|
|
|
|
folderPath := path
|
|
for {
|
|
info, err := fs.Stat(fsys, folderPath)
|
|
if err == nil && info.IsDir() {
|
|
// Found a valid directory
|
|
return folderPath
|
|
}
|
|
if folderPath == "." || folderPath == "" {
|
|
// Reached root, scan entire library
|
|
return ""
|
|
}
|
|
// Walk up the tree
|
|
dir, _ := filepath.Split(folderPath)
|
|
if dir == "" || dir == "." {
|
|
return ""
|
|
}
|
|
// Remove trailing slash
|
|
folderPath = filepath.Clean(dir)
|
|
}
|
|
}
|
|
|
|
// shouldIgnoreFolderPath checks if the given folderPath should be ignored based on .ndignore patterns
|
|
// in the library. It pushes all parent folders onto the IgnoreChecker stack before checking.
|
|
func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.MusicFS, folderPath string) bool {
|
|
checker := newIgnoreChecker(fsys)
|
|
err := checker.PushAllParents(ctx, folderPath)
|
|
if err != nil {
|
|
log.Warn(ctx, "Watcher: Error pushing ignore patterns for folder", "path", folderPath, err)
|
|
}
|
|
return checker.ShouldIgnore(ctx, folderPath)
|
|
}
|
|
|
|
func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
|
|
_, name := filepath.Split(path)
|
|
// A change anywhere inside an ignored directory (a dot-folder when
|
|
// Scanner.IgnoreDotFolders is enabled, or a special system folder) must not
|
|
// trigger a scan, even for media files: the scan would skip it anyway.
|
|
if isUnderIgnoredDir(path) {
|
|
return true
|
|
}
|
|
switch {
|
|
case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path):
|
|
// A media file is normally not ignored, but a dot-prefixed one (e.g.
|
|
// ".hidden.mp3") is always skipped by the scanner, so don't scan for it.
|
|
return isDotEntry(name)
|
|
case name == ".DS_Store":
|
|
return true
|
|
}
|
|
// As it can be a deletion and not a change, we cannot reliably know if the
|
|
// path is a file or directory. But at this point, we can assume it's a
|
|
// directory. If it's a file, it would be ignored anyway.
|
|
return isIgnoredEntry(name, true)
|
|
}
|
|
|
|
// isUnderIgnoredDir returns true if any parent directory component of the given
|
|
// path is an ignored directory, reusing the same policy as the scanner walk.
|
|
func isUnderIgnoredDir(path string) bool {
|
|
dir, _ := filepath.Split(path)
|
|
for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") {
|
|
if part != "" && isIgnoredEntry(part, true) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|