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

+35 -1
View File
@@ -72,6 +72,7 @@ func (w *Worker) recordPending(job *Job) {
JobID: job.ID,
Status: JobStatusPending,
LibraryID: libID,
JobType: job.Type,
}
}
}
@@ -104,7 +105,11 @@ type JobResult struct {
// LibraryID carries the scan job's library (from Params) so clients
// can label progress rows after a page refresh, when the original
// start-request context is gone.
LibraryID string `json:"library_id,omitempty"`
LibraryID string `json:"library_id,omitempty"`
// JobType distinguishes scan jobs in the active set — the enqueue
// coalescing check (HasActiveScanForLibrary) must only coalesce
// scans, never imports/converts sharing a library id.
JobType JobType `json:"job_type,omitempty"`
Error string `json:"error"`
Result interface{} `json:"result"`
Progress float64 `json:"progress"`
@@ -286,6 +291,7 @@ func (w *Worker) processJob(job *Job) {
JobID: job.ID,
Status: JobStatusRunning,
LibraryID: libID,
JobType: job.Type,
}
// Derive a per-job context so cancelling this job (CancelJob) stops
@@ -486,6 +492,12 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
scanner.SetAdminID(adminUUID)
scanner.SetForce(force)
// Trigger label (plan Part 2 §4): poll / watcher / admin / initial —
// surfaces in the per-scan summary line.
if trigger, ok := job.Params["trigger"].(string); ok {
scanner.SetTrigger(trigger)
}
var libraryUUID pgtype.UUID
if err := libraryUUID.Scan(libraryID); err != nil {
return nil, err
@@ -1073,6 +1085,28 @@ func (w *Worker) GetActiveScanJobs() []JobResult {
return active
}
// HasActiveScanForLibrary reports whether a pending or running SCAN job
// already targets the library (faceted plan Part 2 §5): coalescing
// stops poll ticks from stacking full scans when a scan outlives its
// interval, and enforces the one-scan-per-library invariant the bulk
// scan-state map leans on.
func (w *Worker) HasActiveScanForLibrary(libraryID string) bool {
if w == nil || libraryID == "" {
return false
}
w.mu.RLock()
defer w.mu.RUnlock()
for _, result := range w.results {
if result.JobType != JobTypeScan || result.LibraryID != libraryID {
continue
}
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
return true
}
}
return false
}
// CancelJob cancels a pending or running job by id. Pending jobs are
// marked cancelled so processJob skips them at dequeue (no context
// exists yet); running jobs have their per-job context cancelled — the