feat(scanner): quiet scans, enqueue coalescing, watcher probe, sibling cache

Part 2 of the faceted plan, Phase 5 (steps 5-6): the scan that used to
print ~4 lines per unchanged file now prints ONE summary line, and a
poll tick landing mid-scan no longer stacks a second full scan.

- Quiet scans (§4): the walk loop is silent — inserts, changes, and
  errors log inside processMediaFile; the duplicate
  'Successfully processed' branch (both branches printed it) and the
  per-file 'processing / already exists / same size, skipping' chatter
  are gone. Summary carries the trigger and a changed counter:
  'Scan of <folders> (trigger: poll): N files, N changed, N new, Xs,
  N errors'. Triggers threaded through every enqueue site: poll
  (startBackupScan), watcher (dirty-dirs flush), admin (scan API),
  initial (startup scan).
- Enqueue coalescing (§5): Worker.HasActiveScanForLibrary (JobResult
  gains additive job_type so only SCAN jobs coalesce — an import
  sharing the library id never blocks one). enqueueLibraryScan skips
  silently; the admin scan API answers 409 with the active job id.
  Enforces the one-scan-per-library invariant the bulk scan-state map
  leans on.
- Watcher capability probe (§4): ONE Add on a root folder; failure
  disables the watcher with a single startup line (replaces the
  per-directory warning spam on inotify-broken deployments). The
  watcher stays an optional latency reducer over scheduled scans.
- Per-directory sibling cache (§6): hasSiblingBookFileCached caches
  the ReadDir per directory for one scan — loose-image manga re-stats
  thousands of images sharing a handful of directories.

Tests: TestHasActiveScanForLibrary (unit — running/pending coalesce,
terminal jobs don't, non-scan jobs don't, empty library never, nil
worker safe). Full scanner suite green in the container.
This commit is contained in:
John O'Keefe committed 2026-10-19 20:35:07 -04:00
1 parent 6750e9e0dc
commit 5245f292ca
5 files changed
+225 -49

No files matched your search

+43
View File
@@ -58,3 +58,46 @@ func TestGetActiveScanJobsEmpty(t *testing.T) {
t.Fatalf("expected no active jobs, got %d", len(jobs))
}
}
// HasActiveScanForLibrary is the enqueue-coalescing predicate (plan
// Part 2 §5): a pending/running SCAN for the same library makes the
// next poll tick / watcher event a no-op. Only scans coalesce — an
// import or convert sharing the library id must not block one.
func TestHasActiveScanForLibrary(t *testing.T) {
w := NewWorker(1, nil)
seed := map[string]*JobResult{
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeScan},
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2", JobType: JobTypeScan},
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3", JobType: JobTypeScan},
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed, LibraryID: "lib-4", JobType: JobTypeScan},
"import-lib1": {JobID: "import-lib1", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeImport},
"no-library": {JobID: "no-library", Status: JobStatusRunning, JobType: JobTypeScan},
}
w.mu.Lock()
w.results = seed
w.mu.Unlock()
cases := []struct {
library string
want bool
}{
{"lib-1", true}, // running scan
{"lib-2", true}, // pending scan
{"lib-3", false}, // completed = terminal
{"lib-4", false}, // failed = terminal
{"lib-9", false}, // unknown library
{"", false}, // jobs without a library never coalesce
}
for _, c := range cases {
if got := w.HasActiveScanForLibrary(c.library); got != c.want {
t.Errorf("HasActiveScanForLibrary(%q) = %v, want %v", c.library, got, c.want)
}
}
// A nil worker (tests, early startup) must not panic.
var nilWorker *Worker
if nilWorker.HasActiveScanForLibrary("lib-1") {
t.Error("nil worker must report no active scans")
}
}