What happens
- User runs Calibre import (Settings → Import → Calibre, against `metadata.db`). Authors and books appear in the Library.
- User sets `BINDERY_LIBRARY_DIR` and runs Settings → General → Scan Library. Bindery reports finding all the user's .epub files (e.g. "3700 found").
- All 3700 epubs reconcile to zero books. Library page still shows "no file on disk" for every book.
- If the user then clicks Refresh Metadata on an author and re-scans, the books for that author reconcile correctly. Repeating this per-author works.
Reported by @Jashun44 in #support (2026-05-29) with a 500-author Calibre library; reproduced previously in user reports for Calibre migrations.
Root cause
`internal/importer/scanner.go:2132` only considers books with `Status == BookStatusWanted` as reconciliation candidates. Calibre-imported books are set to `BookStatusImported` at `internal/calibre/importer.go:575` (and at line 604 in the existing-book apply path), so the scan candidate set is empty for every Calibre-sourced row.
When the user refreshes an author from upstream metadata, the refresh pulls in the rest of the author's catalogue as `Status=Wanted` rows, AND in some cases touches the existing rows. The scan then matches against the newly-Wanted rows, which is why the user sees per-author reconciliation only after a per-author refresh.
The Calibre-import-then-scan path is the canonical "migrating from another tool" flow for new users. Right now it requires a per-author manual refresh step that most users will not discover on their own.
Why scanning skips Imported books
The current logic is "Imported means Bindery already knows where the file is, so it doesn't need to reconcile." But:
- `internal/calibre/importer.go:602-605` only sets `FilePath` when `cb.Formats[0].AbsolutePath != ""`. When Calibre's library path differs from the mount Bindery sees (a common case in Docker setups), this branch does not fire and `FilePath` stays empty while `Status` is still Imported. The book is then in a "imported but no file" limbo that the scan refuses to touch.
- Even when Calibre's path is correct, an Imported book whose file is moved or renamed never reconciles to its new location because the scan filter excludes Imported entirely.
Fix shape
Make the scan reconciliation candidate set include any book where the recorded `FilePath` is missing or does not exist on disk, in addition to the existing Wanted-status filter. Concretely:
```go
if b.Status != models.BookStatusWanted &&
!(b.Status == models.BookStatusImported && fileMissing(b.FilePath)) {
continue
}
```
This handles both the Calibre-imported-with-no-file case AND the "user moved their files and ran a re-scan" case, which is also broken today.
`fileMissing` is `b.FilePath == "" || os.Stat(b.FilePath) returns ENOENT`. The `os.Stat` is cheap (one syscall per book) but should be batched against the trackedPaths cache the scanner already builds.
Test plan
- New unit test: Imported book with empty FilePath gets reconciled when matching epub is found in scan path. Mirror the existing TestScanLibrary_ReconcilesMatchingBook (`scanner_extra_test.go:80`).
- New unit test: Imported book whose stored FilePath no longer exists on disk gets reconciled to the file's new location.
- Regression test: Imported book whose FilePath is valid (file exists at the stored path) is NOT re-reconciled (no churn).
- Integration test against a Calibre fixture DB: full import → scan reconciles every imported book to its on-disk file in one pass.
Related
What happens
Reported by @Jashun44 in #support (2026-05-29) with a 500-author Calibre library; reproduced previously in user reports for Calibre migrations.
Root cause
`internal/importer/scanner.go:2132` only considers books with `Status == BookStatusWanted` as reconciliation candidates. Calibre-imported books are set to `BookStatusImported` at `internal/calibre/importer.go:575` (and at line 604 in the existing-book apply path), so the scan candidate set is empty for every Calibre-sourced row.
When the user refreshes an author from upstream metadata, the refresh pulls in the rest of the author's catalogue as `Status=Wanted` rows, AND in some cases touches the existing rows. The scan then matches against the newly-Wanted rows, which is why the user sees per-author reconciliation only after a per-author refresh.
The Calibre-import-then-scan path is the canonical "migrating from another tool" flow for new users. Right now it requires a per-author manual refresh step that most users will not discover on their own.
Why scanning skips Imported books
The current logic is "Imported means Bindery already knows where the file is, so it doesn't need to reconcile." But:
Fix shape
Make the scan reconciliation candidate set include any book where the recorded `FilePath` is missing or does not exist on disk, in addition to the existing Wanted-status filter. Concretely:
```go
if b.Status != models.BookStatusWanted &&
!(b.Status == models.BookStatusImported && fileMissing(b.FilePath)) {
continue
}
```
This handles both the Calibre-imported-with-no-file case AND the "user moved their files and ran a re-scan" case, which is also broken today.
`fileMissing` is `b.FilePath == "" || os.Stat(b.FilePath) returns ENOENT`. The `os.Stat` is cheap (one syscall per book) but should be batched against the trackedPaths cache the scanner already builds.
Test plan
Related