Skip to content

feat(author): add safe catalogue reconciliation (#2208) - #2247

Merged
vavallee merged 2 commits into
vavallee:mainfrom
kevinatlee:codex/author-catalogue-reconciliation
Sep 5, 2026
Merged

vavallee merged 2 commits into
vavallee:mainfrom
kevinatlee:codex/author-catalogue-reconciliation

Conversation

@kevinatlee

@kevinatlee kevinatlee commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add an explicit More → Reconcile catalogue… preview/apply flow for one author; normal metadata refresh remains non-destructive
  • compare local rows with a fresh effective catalogue (primary provider plus configured supplements) and the current metadata profile, reporting candidates, protection counts, indeterminate rows, and per-book reasons
  • let users deselect individual preview rows and submit only the chosen IDs; every candidate starts selected
  • treat partial primary or supplemental results conservatively: absence is never deletion evidence after a provider failure, and unresolved language/edition evidence is kept
  • recompute the preview on apply, intersect it with the requested IDs, and repeat every safeguard in one guarded database delete
  • preserve imported/owned books and any row with filePath, ebookFilePath, audiobookFilePath, or a book_files record; no filesystem deletion is involved
  • document the UI/API behavior and add a changelog fragment

The branch is rebased onto current main, including the merged Hardcover language fix from #2246. Reconciliation remains a separate explicit operation and does not make refresh destructive. Refs #2208; the separate series-fill behavior described there is intentionally outside this PR.

Suggested review order

  1. internal/db/books.go and internal/api/catalogue_reconciliation.go — deletion boundary and apply-time safeguards
  2. internal/metadata/aggregator_author_works.go and OpenLibrary completeness reporting — conservative catalogue evidence
  3. internal/api/catalogue_reconciliation_test.go and internal/db/books_reconciliation_test.go — destructive/error-path coverage
  4. web/src/components/CatalogueReconciliationModal.tsx — explicit preview and per-row selection
  5. route wiring, API docs, user guide, and changelog fragment

Checklist

  • Commits signed off with git commit -s — see Sign your work
  • Tests added or updated
  • docs/DEPLOYMENT.md updated if env vars, config, or upgrade path changed (not applicable: no deployment/config change)
  • Added a changelog fragment under changelog.d/ (not an edit to CHANGELOG.md) — see changelog.d/README.md
  • Wiki pages updated if user-facing behaviour changed

Test plan

  • go test -timeout=30m ./cmd/... ./internal/...
  • reconciliation API race tests, -count=10
  • guarded DB deletion race tests, -count=10
  • focused coverage: 100% statements for every function in catalogue_reconciliation.go and for DeleteMetadataOnlyWantedByIDs
  • go vet ./...
  • go build ./...
  • golangci-lint run ./... (0 issues)
  • pinned govulncheck ./... (no called vulnerabilities)
  • frontend typecheck, lint, production build, and full test suite (67 files / 657 tests)
  • real-binary HTTP smoke suite (go test -count=1 -timeout=60s ./tests/smoke/...)

The full backend run used TMPDIR=/private/tmp on macOS so the existing rTorrent deletion safety test sees a direct path instead of the /var → /private/var system symlink.

@github-actions github-actions Bot added the bindery-notified Discord notification already sent for this PR label Aug 27, 2026
@kevinatlee
kevinatlee marked this pull request as ready for review August 27, 2026 00:21
@codecov

codecov Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.10188% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/metadata/aggregator_author_works.go 64.86% 8 Missing and 5 partials ⚠️
internal/db/book_identifiers.go 70.00% 3 Missing and 3 partials ⚠️
internal/metadata/openlibrary/client.go 91.17% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@kevinatlee
kevinatlee force-pushed the codex/author-catalogue-reconciliation branch 4 times, most recently from 54d7662 to f3fa718 Compare August 27, 2026 19:35
@vavallee

Copy link
Copy Markdown
Owner

Read the whole thing and ran it locally. This is careful work, and the deletion boundary in particular is built the way I'd want it built: ownership checked before anything else, apply recomputes the preview and intersects rather than trusting the client's ids, and every predicate repeated inside the SQL so an import that lands between preview and apply wins the race. The route sits next to DELETE /author/{id}, which is the right precedent for gating. Thanks for the suggested review order too, it made this much faster to go through.

Two things I want changed, then one limitation I think needs saying out loud rather than fixing.

1. An empty ForeignID is treated as a real identifier

buildCatalogueReconciliation keys both maps on the provider work's id:

acceptedIDs[work.ForeignID] = struct{}{}   // line 270
rejectedIDs[work.ForeignID] = reason       // line 278

and then looks a local book up by its own ForeignID plus its book_identifiers rows (lines 332 to 339). Nothing filters an empty string on either side, so "" becomes a shared key. You already guard for this exact case in the editions fan-out at line 242, so I think it's just an oversight rather than a decision.

I wrote a throwaway test against your branch to be sure. A provider work with no id, rejected on language, plus a local Wanted book with no id:

candidates=[{BookID:1 Title:A Locally Created Book Reason:language_not_allowed}]

The local book is offered for deletion under a reason it never earned. The mirror case also reproduces: an accepted empty-id work grants an unrelated empty-id local book a spurious Kept.

Reachability is genuinely narrow, and I want to be fair about that: books.foreign_id is TEXT NOT NULL UNIQUE, so at most one such row can exist in a database, and I could not find a creation path that writes one. So this is latent rather than something users will hit next week. I still want it closed, because it is a one line guard on a code path whose failure mode is deleting the wrong row, and because "no current path creates one" is a property of today's code rather than a property this function enforces.

2. ListBookIdentifiers runs once per local book

Line 332 issues a query per row inside the loop. On a 500 book author that's 500 round trips, and with SetMaxOpenConns(1) (#2147) they serialize behind every other reader. A single fetch keyed by book id before the loop would be a small change and keeps preview latency flat.

3. Provider call volume, and a limitation worth documenting

I measured this rather than guessing. A 300 work author with minPages set costs 300 GetEditions calls per preview at concurrency 4. Apply re-fetches the catalogue but reuses cached editions, so it is 2 catalogue fetches and 300 edition calls total rather than 600, which is better than I expected.

I'm not asking you to change it: it's the same RunBounded(..., authorAutoSearchConcurrency, ...) shape already used at internal/api/authors.go:330, so it is consistent with the codebase. Worth knowing that it lands on the providers we just had to add throttling to in #2277.

The one I do want said explicitly: searchAuthorWorks requests limit=200, and GetAuthorWorksSnapshot returns complete only when both halves are complete. So for any OpenLibrary author with more than 200 works, ProviderComplete is false permanently, the warning banner always shows, and reconciliation can never remove a not_in_current_catalogue row. That is the safe direction and I would not want it inverted. But it means the feature's headline capability quietly does not apply to exactly the prolific authors whose catalogues are worst, and a user staring at the banner has no way to know it will never clear. A sentence in the user guide saying so would be enough.

Not your problem

The red licenses check is infrastructure. go install github.com/google/go-licenses@v1.6.0 failed on sum.golang.org returning an HTTP/2 INTERNAL_ERROR while verifying a genproto go.mod, so the drift comparison never ran. Your branch touches no go.mod, go.sum, package.json or THIRD_PARTY_LICENSES.md. I'll re-run it.

Fix 1 and 2 and I'm happy to merge this.

@vavallee vavallee added the needs-triage New issue, not yet reviewed label Aug 31, 2026
Signed-off-by: kevinatlee <kevin.atlee@live.ca>
Ignore blank provider and local identifiers so unrelated books cannot match through an empty key. Batch-load identifier rows by author to avoid serialized per-book queries, and document OpenLibrary's 200-work partial-catalogue limitation.

Signed-off-by: kevinatlee <kevin.atlee@live.ca>
@kevinatlee
kevinatlee force-pushed the codex/author-catalogue-reconciliation branch from f3fa718 to b0d43b5 Compare August 31, 2026 20:35
@kevinatlee

Copy link
Copy Markdown
Contributor Author

Addressed in b0d43b5 after rebasing onto current main:

  • Blank or whitespace-only provider and local IDs are no longer indexed or matched during reconciliation. Added focused regressions for both the rejected-empty-ID false candidate and accepted-empty-ID false kept-match cases.
  • Reconciliation now fetches all identifiers for the author in one query and indexes them by book ID before the local-book loop. Added a DB test covering grouping and author scoping; the existing cross-provider alias behavior remains covered.
  • The user guide now states that OpenLibrary searchAuthorWorks uses limit=200, so authors over 200 works remain partial, the warning may remain, and not_in_current_catalogue rows stay protected.

The branch is rebased onto current upstream main. All CI checks are green, including lint, Go/frontend validation, smoke, licenses, patch coverage, and all six race shards.

@vavallee vavallee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three land. Checked locally rather than taking CI's word for it.

Empty and whitespace ids are now out of acceptedIDs, rejectedIDs and indeterminateIDs, and out of the per book ids slice. I reverted just those four guards on a scratch copy and both new tests fail there, so they are real regressions rather than tests that would have passed before the fix.

ListBookIdentifiersByAuthor is scope equivalent to the loop it replaces: ListByAuthorIncludingExcluded selects on WHERE author_id = ?, and the new query joins books b ON b.id = bi.book_id WHERE b.author_id = ?. Same book set, same columns, same per book ordering, one query instead of one per book. Moving the error context from the book id to the author id is the right call given there is no longer a per book call to attribute it to.

The 200 work ceiling being written down is what I wanted. That is the honest description of the behaviour until the OpenLibrary side paginates.

Branch builds and vets clean here, and go test ./internal/api/... ./internal/db/... is green.

Approving. The regression tests that actually fail without the fix are the part most PRs skip, so thanks for doing it properly.

@vavallee
vavallee merged commit 7917b7e into vavallee:main Sep 5, 2026
41 checks passed
vavallee added a commit that referenced this pull request Sep 5, 2026
…2352)

Foreign-ID prefix routing is written out nine times. Seven agree on six
branches; providerNameForForeignID and metadataProviderFromForeignID had
drifted to four and were missing "calibre:" and "abs:", so an importer's
synthetic ID fell through to the openlibrary default.

For the metadata one that mattered: providerForForeignID routes on the name and
falls back to the primary for "openlibrary", so calibre:author:N was handed to
the OpenLibrary client, which 404s on it. It now returns nil, which every one of
the six call sites already handles. Two paths improve as a result:
canonicalPrimaryBookAuthorWorks stops treating a Calibre author as an
OpenLibrary one, and GetPrimaryAuthorWorksSnapshot returns "metadata provider
is not available for this author" instead of a snapshot built from a 404, which
matters because that snapshot feeds the catalogue reconciliation delete
decision (#2247).

For the api one: a Calibre or Audiobookshelf sourced book was stamped
"openlibrary" in books.metadata_provider, disagreeing with the value
migration 078 backfills for the same id.

No hand-written prefix guard is removed. All three in internal/api/authors.go
(:1820, :2428, :3579) do more than route: the first drives relinkCalibreAuthor
and the single-work early return, the other two rewrite a Calibre stub's
foreign id during dedup. The one in internal/scheduler/scheduler.go:1172 does
become redundant, since the updated == nil check ten lines below now catches
the same case, but it is left in place to keep this off another branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUzQ8XBez4cD68eS2FWsXP
Signed-off-by: vavallee <vavallee@protonmail.com>
vavallee added a commit that referenced this pull request Sep 5, 2026
Eight entries from the code audit, plus the catalogue reconciliation
fragment that has been sitting in changelog.d since #2247 landed after
v1.33.3 was tagged.

Three entries carry a behaviour change worth reading before upgrading:
restore now applies at the next start rather than immediately, the Google
Books key field loads empty because the key is write-only now, and books
that want both formats will start searching for the format they had
silently stopped looking for.

changelog.d is cleared, per its README.


Claude-Session: https://claude.ai/code/session_01SUzQ8XBez4cD68eS2FWsXP

Signed-off-by: vavallee <vavallee@protonmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
vavallee added a commit that referenced this pull request Sep 5, 2026
…2352) (#2392)

Foreign-ID prefix routing is written out nine times. Seven agree on six
branches; providerNameForForeignID and metadataProviderFromForeignID had
drifted to four and were missing "calibre:" and "abs:", so an importer's
synthetic ID fell through to the openlibrary default.

For the metadata one that mattered: providerForForeignID routes on the name and
falls back to the primary for "openlibrary", so calibre:author:N was handed to
the OpenLibrary client, which 404s on it. It now returns nil, which every one of
the six call sites already handles. Two paths improve as a result:
canonicalPrimaryBookAuthorWorks stops treating a Calibre author as an
OpenLibrary one, and GetPrimaryAuthorWorksSnapshot returns "metadata provider
is not available for this author" instead of a snapshot built from a 404, which
matters because that snapshot feeds the catalogue reconciliation delete
decision (#2247).

For the api one: a Calibre or Audiobookshelf sourced book was stamped
"openlibrary" in books.metadata_provider, disagreeing with the value
migration 078 backfills for the same id.

No hand-written prefix guard is removed. All three in internal/api/authors.go
(:1820, :2428, :3579) do more than route: the first drives relinkCalibreAuthor
and the single-work early return, the other two rewrite a Calibre stub's
foreign id during dedup. The one in internal/scheduler/scheduler.go:1172 does
become redundant, since the updated == nil check ten lines below now catches
the same case, but it is left in place to keep this off another branch.


Claude-Session: https://claude.ai/code/session_01SUzQ8XBez4cD68eS2FWsXP

Signed-off-by: vavallee <vavallee@protonmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
vavallee added a commit that referenced this pull request Oct 8, 2026
…and profile filters (#3048, #2208)

Series page marks split edition parts, leaves them out of the missing
count, offers a one click unmonitor and reports what a fill skipped.
Drops the profile level retroactive endpoints from the first commit:
per author Reconcile catalogue (#2247) already applies every filter to
stored rows. Adds the db test for the covered split part query, docs and
changelog fragments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
vavallee added a commit that referenced this pull request Oct 8, 2026
…ta profile filters to series fill (#3048, #2208) (#3109)

* fix(series): skip split edition parts and apply profile filters in series fill (#3048, #2208)

Fill and the wanted sweep skip a stored book that is a split edition part
of a whole already in the library, using the same two signal rule as #2915.
A new series endpoint unmonitors those parts. Series fill now screens the
books it creates and re queues through the author's metadata profile, and
two admin endpoints preview and unmonitor wanted books a profile filter
turned on later would have kept out. Nothing is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

* fix(series): series page cleanup, docs and fragments for split parts and profile filters (#3048, #2208)

Series page marks split edition parts, leaves them out of the missing
count, offers a one click unmonitor and reports what a fill skipped.
Drops the profile level retroactive endpoints from the first commit:
per author Reconcile catalogue (#2247) already applies every filter to
stored rows. Adds the db test for the covered split part query, docs and
changelog fragments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

* fix(series): let unknown languages through a series fill and stop counting hand monitored books as skipped (#2208)

Review follow up. Hardcover series entries carry no language, so a
profile with unknown languages set to fail rejected every book of an add
all whenever OpenLibrary was primary. A blank language now passes during
a fill, and a failed language evidence lookup passes too, the same
unknown passes rule MinPages and SkipMissingISBN use. Only a definitive
"not allowed" rejects.

The re-queue loop now screens only unmonitored stored rows. A monitored
book is already being sought, often because it was added by hand, so it
is queued as before and no longer reported as skipped on every fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>

---------

Signed-off-by: vavallee <vavallee@protonmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bindery-notified Discord notification already sent for this PR needs-triage New issue, not yet reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants