Repository navigation
fix(importer): bind an existing file to the best title match, not the first (#2941) - #3033
Conversation
… 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 Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…-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>
There was a problem hiding this comment.
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):allBookscomes fromListByAuthorIncludingExcluded; excluded rows enterauthorCataloguebut are filtered out in the rival loop (s.Excludedcheck atauthors.go:2981). ✓ - Lazy path (
findExistingForNewBookwhensiblings == nil): callsListByAuthor, which already hasexcluded = 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.
Summary
Refs #2941; complements #2978.
LibrarySnapshot.FindExistingreturned the first file that clearedtitleWordsMatch, 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'sreconcileByTitle; 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 inscanner.gothat #2978 does not touch.Implementation notes
bestExistingFile)Dune.epub, notDune 2.epubbeside it).dropRivalFiles)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)onLibrarySnapshot, reached through a new optionalrivalAwareFindercapability;LibraryFinderis unchanged.FetchAuthorBookspasses the catalogue it already holds (allBookspluscreatedBooks) throughhandleNewWantedBookAmong. The single book callers readListByAuthoronce 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:
LibrarySnapshot.FindExisting, so a new book can be bound to a.cue.txtin another book's folder #2240) and the volume veto (FindExisting binds a newly added series volume to volume 1's file when filenames omit the volume number #2810, Library scan reconciles an untracked series volume onto a different wanted volume (title tier has no volume check) #2860).Follow-ups (not in this PR)
Checklist
Scanner.FindExistinggodoc corrected)changelog.d/findexisting-best-match.mdTest 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_RivalBookKeepsItsExactFilefailed on main.TestLibrarySnapshot_PartialRivalDoesNotWithdrawFile(4 cases),TestLibrarySnapshot_GroupAnswersWithItsBestMember,TestHandleNewWantedBook_ExcludedRowDoesNotBlockBindfailed on 500c4f6.TestLibrarySnapshot_RivalFileStepsAsideForNextBest(Way of Kings, Dune) andTestHandleNewWantedBook_RivalFileStepsAsidefailed on 2bc2c0d.After (on the branch with origin/main merged in):
gofmt -l: cleango vet ./internal/importer/ ./internal/api/golangci-lintv2.11.4 on./internal/importer/... ./internal/api/...: 0 issuesgo test -timeout 40m ./internal/importer/... ./internal/api/...go test ./cmd/... ./internal/...on the first commit: green except a load dependent timing budget ininternal/duplicates(no importer dependency), which passed when rerun. Not rerun after the review fixes.🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9