Skip to content

fix(importer): bind an existing file to the best title match, not the first (#2941) - #3033

Merged
vavallee merged 4 commits into
mainfrom
fix/findexisting-best-match
Oct 6, 2026
Merged

vavallee merged 4 commits into
mainfrom
fix/findexisting-best-match

Conversation

@vavallee

@vavallee vavallee commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Refs #2941; complements #2978.

LibrarySnapshot.FindExisting returned the first file that cleared titleWordsMatch, not the best one. On the add author path that meant "Harry Potter" took an untracked "Harry Potter en het vervloekte kind.epub", skipped its search, and the exact book was then refused the file as already owned. #2978 fixes the same first match problem in the library scan's reconcileByTitle; this applies the same file ranking to the add paths (add author, add book, series fill, recommendations). No file in #2978's diff is changed except one godoc line in scanner.go that #2978 does not touch.

Implementation notes

Direction Rule
Files for the book (bestExistingFile) Score every matching file by Jaro-Winkler on normalised titles. The best answers when no file of a different title competes, when it is an exact normalised match strictly ahead, or when it leads by 0.05 (same margin as #2978). Otherwise no match, so the book stays Wanted and searchable. A title group answers with its best scoring member ("Dune" gets Dune.epub, not Dune 2.epub beside it).
Books for the files (dropRivalFiles) Before ranking, a file is dropped when its normalised title is exactly another non excluded book's of the author and not the wanted one, so the next best file can answer ("Dune" still gets its own file beside "Dune Messiah.epub").

Why the second direction: when the library holds only the Dutch epub, ranking files cannot stop "Harry Potter" from taking it, because it is the only candidate. That is the exact layout in the report. Why exact only: an earlier scored version of this PR withdrew real matches during review ("Project Hail Mary A Novel.epub" vs a "Proyecto Hail Mary" row, "Mistborn.epub" vs the rest of the series, an excluded duplicate blocking the canonical book). All of those are now tests.

Wiring: FindExistingAmong(ctx, title, author, mediaType, rivals) on LibrarySnapshot, reached through a new optional rivalAwareFinder capability; LibraryFinder is unchanged. FetchAuthorBooks passes the catalogue it already holds (allBooks plus createdBooks) through handleNewWantedBookAmong. The single book callers read ListByAuthor once per new book. The rival aware lookup always runs, because dropping a rival's file can also settle a near tie. Excluded books and other users' books are not rivals.

Kept as before:

Follow-ups (not in this PR)

  • Known residual: with "Stormlight Archive The Way of Kings.epub" and "The Way of Kings Prime.epub" on disk but "The Way of Kings Prime" NOT in the catalogue, "The Way of Kings" binds the Prime file, because nothing marks it as another book and it outscores the real one.

Checklist

  • Tests added or updated
  • Doc-update gate cleared (godoc; Scanner.FindExisting godoc corrected)
  • Changelog fragment changelog.d/findexisting-best-match.md

Test plan

Fail before, against main for the first commit and against the previous PR head for the review fixes:

  • TestFetchAuthorBooks_FileGoesToExactTitleNotShorterBook, TestLibrarySnapshot_ExactTitleWinsRegardlessOfWalkOrder, TestLibrarySnapshot_AmbiguousTitlesBindNothing, TestLibrarySnapshot_RivalBookKeepsItsExactFile failed on main.
  • TestLibrarySnapshot_PartialRivalDoesNotWithdrawFile (4 cases), TestLibrarySnapshot_GroupAnswersWithItsBestMember, TestHandleNewWantedBook_ExcludedRowDoesNotBlockBind failed on 500c4f6.
  • TestLibrarySnapshot_RivalFileStepsAsideForNextBest (Way of Kings, Dune) and TestHandleNewWantedBook_RivalFileStepsAside failed on 2bc2c0d.

After (on the branch with origin/main merged in):

  • gofmt -l: clean
  • go vet ./internal/importer/ ./internal/api/
  • golangci-lint v2.11.4 on ./internal/importer/... ./internal/api/...: 0 issues
  • go test -timeout 40m ./internal/importer/... ./internal/api/...
  • Earlier full go test ./cmd/... ./internal/... on the first commit: green except a load dependent timing budget in internal/duplicates (no importer dependency), which passed when rerun. Not rerun after the review fixes.

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

… first (#2941)

FindExisting answered with the first library file whose title cleared
titleWordsMatch, so "Harry Potter" took an untracked "Harry Potter en
het vervloekte kind.epub" on the add author path, skipped its search,
and left the exact book unbound once the file was owned.

FindExisting now scores every matching file by Jaro-Winkler on the
normalised titles, using the rule the library scan adopts in #2978: the
best file answers when it is the only title, when it is an exact
normalised match strictly ahead, or when it leads by 0.05. A closer
pair answers nothing so the book stays Wanted. Files that differ only
in digits (tracks, parts, formats) or share a numbered book folder are
one book. Supplement ranking and the volume veto apply as before.

FindExistingAmong adds the other direction: the chosen file is held to
the same rule against the author's other catalogue titles, so a file
titled exactly as another book is not taken by a shorter one. The add
paths pass the author's books as rivals through handleNewWantedBook.

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>
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.61404% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/api/authors.go 81.48% 3 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

vavallee and others added 3 commits October 6, 2026 15:04
…-match

Signed-off-by: vavallee <vavallee@protonmail.com>
#2941)

Review of the first pass found the scored rival rule withdrew real
matches. Any catalogue row sharing two words with the file and scoring
within 0.05 of the wanted title blocked the bind, so "Project Hail Mary
A Novel.epub" was refused to "Project Hail Mary" over a "Proyecto Hail
Mary" row, "Mistborn.epub" to "Mistborn: The Final Empire" over the
rest of the series, and the owned book was downloaded again. A rival
now withdraws a file only when its normalised title is exactly the
file's, which still keeps the Harry Potter case from the report fixed.
Excluded rows are no longer rivals.

A title group now answers with its best scoring member, so "Dune" gets
"Dune.epub" rather than "Dune 2.epub" walked before it.

The author sync passes the catalogue it already holds instead of
listing the author again for every bound book. Other callers read the
listing once per new book, only when a file was found.

Also corrects the Scanner.FindExisting godoc and widens the changelog
fragment to every add path.

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>
…2941)

A file titled exactly as another of the author's books was withdrawn
after ranking, so when it outscored the wanted book's own file nothing
answered: "The Way of Kings" found nothing beside "The Way of Kings
Prime.epub", and "Dune" nothing beside "Dune Messiah.epub", where main
bound the right file. Such files now leave the candidate set before
bestExistingFile ranks, and the next best file can answer.

Because dropping a file can also settle a near tie, the add path now
always asks the rival aware lookup instead of only after a plain
lookup found something.

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
vavallee marked this pull request as ready for review October 6, 2026 18:20
@vavallee
vavallee enabled auto-merge (squash) October 6, 2026 18:20

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff + surrounding code. No blockers.

Correctness

dropRivalFiles runs before bestExistingFile, which is the right sequencing: rivals are pruned before ranking, so a rival's file can reveal the wanted file rather than just withdrawing the answer after the fact. The margin/exact-lead logic in clearLead matches #2978's scanner behaviour, keeping the two paths consistent.

fileBelongsToRival correctly rejects partial matches (only exact normalised title drops a file) and skips numbered-folder entries, which addresses the "Project Hail Mary A Novel.epub" and Mistborn series regressions documented in the PR description. All these cases are tested.

One double-normalisation call worth knowing about

findExistingForNewBook (authors.go:2958) calls snapshotFinder(finder), but on the add-author path the caller at line 2129 already snapshotted h.finder. When finder arrives as a *LibrarySnapshot, snapshotFinder returns it unchanged (no librarySnapshotter impl), so there is no bug — just a harmless extra interface check per call. Not a blocker, but worth knowing if you ever add SnapshotFinder() to LibrarySnapshot.

Two siblings paths fetch differently

  • Pre-built path (runCatalogueSync): allBooks comes from ListByAuthorIncludingExcluded; excluded rows enter authorCatalogue but are filtered out in the rival loop (s.Excluded check at authors.go:2981). ✓
  • Lazy path (findExistingForNewBook when siblings == nil): calls ListByAuthor, which already has excluded = 0. ✓

Both paths agree on the final rivals list. No bug, just worth documenting if this function is touched again.

Tenancy

Cross-user rivals are excluded from steering each other (authors.go:2985–2988). NULL-owner (global) books act as universal rivals for all users, which matches the existing tenancy model. ListByAuthor fetches unscoped rows but the filter handles the cross-user case before titles are passed to FindExistingAmong. No cross-user visibility issue.

License

JaroWinkler is internal/textutil, not a new external module. No new deps in go.mod.

Tests

12 new test functions covering walk-order independence, the ambiguous/no-answer case, partial rivals, numbered-folder carve-out, duplicate catalogue rows, and the end-to-end add-author path. Coverage matches the documented failure scenarios.

— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.

@vavallee
vavallee merged commit 1d37f7b into main Oct 6, 2026
40 of 42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant