feat: detect books that may already be in the library
Match on a shared identifier, or on a normalised title credited to a shared author, and report candidates rather than refusing anything — a metadata match is a guess, and a second edition is not a mistake. Adds a library-wide review pass, dismissals, and renames the fingerprint pre-flight to duplicate-files.
This commit is contained in:
+92
-2
@@ -149,8 +149,98 @@ Three things to preserve when touching this code:
|
||||
submitted twice in one request are caught. Those duplicates report `book_id: None` —
|
||||
there is no row to point at yet.
|
||||
|
||||
`POST /books/duplicates` answers the same question from fingerprints alone, for clients
|
||||
that want to ask before uploading anything.
|
||||
`POST /books/duplicate-files` answers the same question from fingerprints alone, for
|
||||
clients that want to ask before uploading anything.
|
||||
|
||||
### Book-level detection — a different question
|
||||
|
||||
The file check answers "are these the same bytes?". `find_duplicate_books` answers "is
|
||||
this the same book?", which a re-scan, a re-zipped EPUB or another edition cannot be
|
||||
asked with a hash. Two signals, either sufficient: a **shared identifier**, or a
|
||||
**matching normalized title with at least one shared author**.
|
||||
|
||||
It **never blocks**. A metadata match is a guess — a work shares title and author with
|
||||
its own translation, its own second edition and its own audiobook — so the book is
|
||||
created and the candidates are reported alongside it in
|
||||
`ImportResult.possible_duplicates`. File-level dedupe keeps its refuse/skip behaviour;
|
||||
that one is near-certain and this one is not. Do not "improve" this into a refusal.
|
||||
|
||||
Two rules narrow it, both applied in Python over the small candidate set:
|
||||
|
||||
- **A shared author is required for a title match.** Without it every book the
|
||||
extractors gave up on and titled `Unknown` is a duplicate of every other one. A book
|
||||
with no authors can therefore only match on an identifier.
|
||||
- **The same series at a different `series_position` disqualifies a match.** A trilogy
|
||||
shares an author and often most of its title; the position is the library saying
|
||||
outright that these are two books.
|
||||
|
||||
A book is compared under **several title keys, not one** (`_title_keys`). Ebook files
|
||||
are overwhelmingly named `Title - Author.epub`, and wherever nothing inside the file
|
||||
overrode that name the author ended up in the title column — so one copy is stored as
|
||||
`Building Microservices` and another as `Building Microservices Sam Newman`. Both
|
||||
directions are generated, the author stripped off and the author added on, which is why
|
||||
the query is `normalized_title.in_(keys)` rather than `==`. This is still exact matching
|
||||
on an indexed column: no similarity score, nothing to tune. It does not weaken the
|
||||
shared-author requirement, which is a separate condition.
|
||||
|
||||
`find_duplicate_book_groups` is the library-wide pass behind
|
||||
`GET /books/duplicate-books`, since the import-time check says nothing about a
|
||||
collection someone already has. It buckets books by every key they carry and merges the
|
||||
buckets with union-find, so A~B by ISBN and B~C by title land in one group. Pairs in
|
||||
`duplicate_dismissals` are never merged — a reader disagreeing with one pairing must not
|
||||
silently break a group that stands on other evidence.
|
||||
|
||||
### Author names have one stored form
|
||||
|
||||
`Author.name` is always the canonical form, produced by `format_author_name`. Extractors
|
||||
hand over whatever the file said — `Newman, Sam;` from a `DC:creator` list, `Sam Newman`
|
||||
from a PDF, `Sam Newman.epub` from a filename — and storing those verbatim is how one
|
||||
person becomes four rows in the sidebar, four entries in the author filter, and four
|
||||
books that never look like each other.
|
||||
|
||||
This is **display** canonicalization, distinct from `normalize_author`, which throws
|
||||
away case, accents and spacing to build a comparison key nobody sees. Tidying only
|
||||
removes what an extractor added: a trailing separator, a file extension, and the
|
||||
`Surname, Given` ordering. It never touches case or accents — `Michał Płachta` and
|
||||
`Steve McConnell` are the author's own spelling, not something to correct.
|
||||
|
||||
Three places have to agree, and `Author` keeps them together: the `@validates("name")`
|
||||
hook, `unique_hash`, and `unique_filter`. `as_unique_async` looks a row up with the
|
||||
filter and then constructs with the validator, so if the lookup used the raw name and
|
||||
the insert used the tidy one, every variant spelling would miss the existing row and
|
||||
then collide with it on the unique index.
|
||||
|
||||
A form the rule does not recognise is **left exactly as it was found** — `Dave Thomas,
|
||||
Andy Hunt` is two people in one string, and flipping it would invent a third. Leaving a
|
||||
mess visible beats rewriting it wrongly.
|
||||
|
||||
### The normalized columns are written by validators
|
||||
|
||||
`Book.normalized_title`, `Author.normalized_name` and `Identifier.normalized_value` are
|
||||
derived from `services/matching.py` and kept current by **SQLAlchemy `@validates` hooks
|
||||
on the models**, not by any service. Assigning them directly is always wrong.
|
||||
|
||||
This is deliberate and it is invisible at the call sites: `BookService` writes titles
|
||||
through at least three paths (`to_model_on_create`, `to_model_on_update`, and the
|
||||
`setattr` loop in `_populate_with_unique_relationships`), and a validator is the only
|
||||
thing a fourth cannot bypass. The columns carry plain, **non-unique** btree indexes —
|
||||
two spellings collapsing onto one value is the entire point.
|
||||
|
||||
`services/matching.py` is imported *inside* those validators rather than at module
|
||||
scope: reaching it initialises the `chitai.services` package, which imports the
|
||||
services, which import the models. Keep the local import.
|
||||
|
||||
The validators only fire on write, so a migration that adds one of these columns must
|
||||
backfill existing rows through the same helpers — see the `data_upgrades()` hook in
|
||||
`2026-08-15_add_book_matching_keys_and_duplicate__4358e7d4743a.py`.
|
||||
|
||||
**Changing anything in `services/matching.py` needs a revision that recomputes them.**
|
||||
The keys are derived and already written, so a normalization change silently invalidates
|
||||
every stored row: a book written under the old rules just stops matching one written
|
||||
under the new rules, with nothing to show that anything is wrong. Copy
|
||||
`2026-08-15_recompute_book_matching_keys_ed41acf21270.py`, which exists because
|
||||
`normalize_title` learned to strip compact edition markers (`2E`, `5e`). It is
|
||||
idempotent and safe to re-run.
|
||||
|
||||
## KOReader hashing
|
||||
|
||||
|
||||
Reference in New Issue
Block a user