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:
2026-08-15 21:55:13 -04:00
parent 6d1890ce04
commit 5047277845
8 changed files with 1220 additions and 16 deletions
+112 -1
View File
@@ -570,7 +570,7 @@ class TestDuplicateHandling:
stored = book["files"][0]
response = await authenticated_client.post(
"/books/duplicates?library_id=1",
"/books/duplicate-files?library_id=1",
json=[
{
"hash": stored["hash"],
@@ -590,6 +590,117 @@ class TestDuplicateHandling:
assert matches[0]["book_id"] == book["id"]
@pytest.mark.asyncio
class TestDuplicateBooks:
"""A second copy of a book is imported and reported, never refused."""
epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
def upload(self, name: str, pad: bool = False) -> list[tuple[str, tuple]]:
"""
The fixture, optionally padded so it is a different file and the same book.
Padding the archive changes its size and its sampled hash without disturbing
the metadata, which is exactly the case the file-level check cannot see.
"""
data = self.epub_path.read_bytes() + (b"\0" * 64 if pad else b"")
return [("files", (name, data, "application/epub+zip"))]
async def test_a_second_edition_is_created_and_reported(
self, authenticated_client: AsyncClient
) -> None:
first = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
)
original = first.json()["created"][0]
second = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
)
assert second.status_code == 201
body = second.json()
# Created, not skipped: a metadata match is a guess, and refusing a legitimate
# second edition costs more than a note does.
assert len(body["created"]) == 1
assert body["skipped"] == []
assert len(body["possible_duplicates"]) == 1
possible = body["possible_duplicates"][0]
assert possible["book_id"] == body["created"][0]["id"]
candidate = possible["candidates"][0]
assert candidate["book_id"] == original["id"]
assert candidate["title"] == original["title"]
assert candidate["authors"] == ["Franz Kafka"]
assert "title-author" in candidate["matched_on"]
async def test_the_review_screen_groups_them(
self, authenticated_client: AsyncClient
) -> None:
first = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
)
second = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
)
book_ids = sorted(
[first.json()["created"][0]["id"], second.json()["created"][0]["id"]]
)
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert response.status_code == 200
groups = response.json()
assert len(groups) == 1
assert [book["book_id"] for book in groups[0]["books"]] == book_ids
async def test_a_dismissed_group_stays_dismissed(
self, authenticated_client: AsyncClient
) -> None:
first = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
)
second = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
)
pair = {
"book_a_id": first.json()["created"][0]["id"],
"book_b_id": second.json()["created"][0]["id"],
}
dismissed = await authenticated_client.post(
"/books/duplicate-books/dismissals", json=pair
)
assert dismissed.status_code == 204
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert response.json() == []
restored = await authenticated_client.delete(
"/books/duplicate-books/dismissals"
f"?book_a_id={pair['book_b_id']}&book_b_id={pair['book_a_id']}"
)
assert restored.status_code == 204
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert len(response.json()) == 1
async def test_dismissing_an_unknown_book_is_refused(
self, authenticated_client: AsyncClient
) -> None:
response = await authenticated_client.post(
"/books/duplicate-books/dismissals",
json={"book_a_id": 1, "book_b_id": 9999},
)
assert response.status_code == 400
# NOTE: the multi-book ZIP download is covered at the service level, in
# tests/unit/test_services/test_book_service.py. Driving `/books/download` through
# AsyncTestClient hangs in fixture teardown: it is the only `Stream` endpoint in the