feat(scanner): ScanFolders honors its context; cancelling never archives

ScanFolders never checked its context, so a cancelled job's walk ran
to completion no matter what the worker's cancel did.

- ctx checks at every stage boundary (file-counting pass, per folder,
  before and during the archive pass) and inside the walk callback
  (fs.SkipAll): the file currently being processed finishes, then the
  scan aborts with context.Canceled, which processJob classifies as a
  cancelled job rather than a failed one
- the archive/purge pass is skipped entirely when the scan was
  cancelled. It only visits what the walk visited, so after a partial
  walk the unvisited (but on-disk) files would be treated as missing,
  archived, and eventually purged. Cancelling is a UI convenience and
  must never destroy library data.
This commit is contained in:
John O'Keefe committed 2026-10-18 18:02:19 -04:00
1 parent b2220756e2
commit 30ecdd4bf3
1 file changed
+32
+32
View File
@@ -419,6 +419,14 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder string) {
}
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// Cancellation is checked at every stage boundary and inside the
// walk callback: the in-flight file finishes, then the scan aborts
// and reports context.Canceled (processJob classifies that as a
// cancelled job, not a failed one).
if ctx.Err() != nil {
return ctx.Err()
}
if len(s.folders) == 0 {
return fmt.Errorf("no folders set")
}
@@ -428,6 +436,9 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
s.errors = 0
for _, folder := range s.folders {
if ctx.Err() != nil {
return ctx.Err()
}
if err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() && s.isScannableFile(path) {
s.totalFiles++
@@ -445,6 +456,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
mediaFiles := 0
for _, folder := range s.folders {
if ctx.Err() != nil {
return ctx.Err()
}
fmt.Printf("Scanning folder: %s\n", folder)
if _, err := os.Stat(folder); os.IsNotExist(err) {
@@ -454,6 +469,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
}
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return fs.SkipAll
}
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
s.errors++
@@ -502,6 +521,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
processedFiles, mediaFiles, s.newItems, s.errors)
// A cancelled scan must not run the archive lifecycle pass below:
// it never visited every folder, so unvisited files would be
// treated as missing and archived (and eventually purged). Abort
// instead — cancelling never destroys library data.
if ctx.Err() != nil {
return ctx.Err()
}
// Archive lifecycle pass. Items whose files vanished from disk are
// archived after two consecutive missing scans (reading history kept,
// item hidden), and purged for good once archived older than the
@@ -509,6 +536,11 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// Every branch logs - the previous hard-delete cleanup failed silently
// and left orphaned rows undetected.
for _, folder := range s.folders {
if ctx.Err() != nil {
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
return ctx.Err()
}
lib, err := s.db.GetLibraryByFolder(ctx, folder)
if err != nil {
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)