Commit Graph
2 Commits
Author SHA1 Message Date
john-okeefe 59d5de3607 fix(scanner): eliminate fsnotify watcher leak and harden worker against panics
The bookhoard container crashed with 'panic: Failed to create file
watcher: too many open files' (media_scanner.go) after running for a few
hours, preceded by floods of 'no space left on device' from watcher.Add.

Root cause: every scan job called NewMediaScanner(), which eagerly
created an fsnotify watcher. SetFolders() then walked the entire library
tree and registered one inotify watch per directory (~3,000+ across the
libraries), and ScanFolders() registered them again during its walk. The
worker never called scanner.Close() on these ephemeral per-job scanners,
and the worker loop had no recover(), so:

  1. Leaked watchers accumulated until the kernel inotify watch cap was
     hit (ENOSPC -> 'no space left on device'), then
  2. the process fd limit (ulimit -n 1024) was exhausted, causing
     fsnotify.NewWatcher() to fail with EMFILE, and
  3. NewMediaScanner panicked on that error, taking down the whole
     process (exit code 2). With no restart policy the container stayed
     down.

The scan jobs run frequently (scan_poll_interval), so the leak built up
within hours. Note this was NOT a disk-space issue; df showed plenty free.

Fix:

- media_scanner.go: NewMediaScanner no longer creates a watcher eagerly
  (s.watcher starts nil), which removes the panic site entirely -- there
  is nothing to fail at construction. The watcher is created lazily only
  when needed.

- media_scanner.go: SetFolders gains a [?1049h(B[?7h[?25lEvery 2.0s: boolgaruda-ser8: Fri 31 Jul 2026 10:45:41 AM EDTin 0.002s (127)
sh: line 1: bool: command not found
[?12l[?25h[?1049l
[?1l> parameter. It creates
  and populates a watcher (returning an error instead of panicking) only
  when watch=true; otherwise it skips all watcher.Add calls. ScanFolders
  guards its watcher.Add with a nil check, and the WatchChanges event
  loop exits cleanly when there is no watcher (polling still runs).

- worker.go: the worker() loop now wraps each job in defer/recover() so a
  panicking job is recorded as failed and can never kill the process.

- worker.go: the three ephemeral scan handlers (processScanJob,
  processSetFoldersJob, processDirectoryScanJob) now defer scanner.Close()
  and call SetFolders(..., false), so scan jobs allocate zero watchers and
  zero inotify watches. Any pre-existing leak is also bounded by Close().

- handlers/scanner.go: the long-lived watch-mode scanners (StartScanner
  and StartWatchModeForLibrary) pass watch=true since they actually read
  watcher.Events for live change detection.

- calibre_integration_test.go: updated to the new SetFolders signature
  (watch=false, matching one-off scan usage).

Auto-add is fully preserved: new files are still detected by the periodic
poller (startBackupScan), which is independent of fsnotify and unaffected
by these changes. The watch-mode event loop remains as bonus responsiveness
when inotify is available; through Docker bind mounts where inotify is
unreliable, polling is what catches new books.
2026-07-31 10:45:41 -04:00
john-okeefe a900c78faf Add Calibre metadata.opf sidecar file support to media scanner
Implement sidecar-first metadata extraction approach that prioritizes
Calibre metadata.opf files over embedded metadata when available.

Key Features:
- Sidecar-first approach: Check for metadata.opf before extracting embedded
- Full Dublin Core namespace support: Use complete namespace URLs
- Calibre-specific meta tags: Extract series, series_index from <meta> tags
- Graceful degradation: Fall back to embedded metadata on parse failure
- Identifier extraction: Support ISBN and ASIN from Dublin Core identifiers
- Date parsing: Handle ISO 8601 timestamps and simple date formats

Implementation Details:
- Added extractCalibreSidecar() to check for and parse metadata.opf
- Added parseCalibreMetadataOPF() with full Dublin Core namespace handling
- Modified extractMetadata() to try sidecar first, fallback to embedded
- Added CalibreOPFMetadata struct for intermediate parsing
- Cover image support: findSidecarCover() for sidecar metadata

Tests:
- Unit tests for parseCalibreMetadataOPF() with real Calibre file examples
- Integration tests for Calibre library scanning

This allows users with Calibre-managed libraries to import their curated
metadata (series, tags, custom covers) into Bookhoard.

Fixes: #calibre-opf-support
2026-03-26 14:38:20 -04:00