feat: check for duplicate files when importing books

Incoming files are matched against what is already stored, keyed on the
KOReader hash and the file size. Bulk uploads skip and report them, deliberate
creates are refused with a 409, the consume directory parks them aside, and
allow_duplicates overrides all three.

Also: books whose metadata generates a path another book already owns are moved
aside, so a forced copy cannot overwrite the original's files.
This commit is contained in:
2026-08-13 17:23:48 -04:00
parent 968166c1fd
commit d78b21c27f
14 changed files with 1422 additions and 67 deletions
+169 -3
View File
@@ -366,7 +366,8 @@ async def test_create_multiple_books_from_directory(
assert response.status_code == 201
data = response.json()
assert len(data.get("items") or data.get("data")) >= 1
assert len(data["created"]) == 2
assert data["skipped"] == []
async def test_create_books_from_parent_directory_keeps_embedded_title(
@@ -396,7 +397,7 @@ async def test_create_books_from_parent_directory_keeps_embedded_title(
assert response.status_code == 201
books = response.json()["items"]
books = response.json()["created"]
assert len(books) == 1
assert books[0]["title"] == "Metamorphosis"
@@ -419,11 +420,176 @@ async def test_create_books_groups_formats_within_one_folder(
assert response.status_code == 201
books = response.json()["items"]
books = response.json()["created"]
assert len(books) == 1
assert len(books[0]["files"]) == 2
class TestDuplicateHandling:
"""A file the library already holds must not be stored a second time."""
epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
def upload(self, name: str | None = None) -> list[tuple[str, tuple]]:
return [
(
"files",
(
name or self.epub_path.name,
self.epub_path.read_bytes(),
"application/epub+zip",
),
)
]
async def test_bulk_upload_reports_skipped_files(
self, authenticated_client: AsyncClient
) -> None:
"""Re-dropping a folder must import what is new and name what was not."""
first = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
assert first.status_code == 201
created = first.json()["created"][0]
second = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
assert second.status_code == 201
result = second.json()
assert result["created"] == []
assert len(result["skipped"]) == 1
skipped = result["skipped"][0]
assert skipped["filename"] == self.epub_path.name
assert skipped["book_id"] == created["id"]
assert skipped["book_title"] == created["title"]
async def test_bulk_upload_can_be_forced(
self, authenticated_client: AsyncClient
) -> None:
await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
response = await authenticated_client.post(
"/books/fromFiles?library_id=1&allow_duplicates=true", files=self.upload()
)
assert response.status_code == 201
assert len(response.json()["created"]) == 1
assert response.json()["skipped"] == []
async def test_single_book_create_conflicts(
self, authenticated_client: AsyncClient
) -> None:
"""Naming files deliberately earns a refusal rather than a silent drop."""
await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
response = await authenticated_client.post(
"/books?library_id=1", files=self.upload(), data={"library_id": 1}
)
assert response.status_code == 409
assert response.json()["extra"][0]["filename"] == self.epub_path.name
forced = await authenticated_client.post(
"/books?library_id=1&allow_duplicates=true",
files=self.upload(),
data={"library_id": 1},
)
assert forced.status_code == 201
async def test_adding_another_books_file_conflicts(
self, authenticated_client: AsyncClient
) -> None:
created = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
book_id = created.json()["created"][0]["id"]
other = await authenticated_client.post(
"/books?library_id=1",
files=[
(
"files",
(
"war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
"application/epub+zip",
),
)
],
data={"library_id": 1},
)
other_id = other.json()["id"]
response = await authenticated_client.post(
f"/books/{book_id}/files",
files=[
(
"files",
(
"war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
"application/epub+zip",
),
)
],
)
assert response.status_code == 409
assert response.json()["extra"][0]["book_id"] == other_id
async def test_resending_a_books_own_file_changes_nothing(
self, authenticated_client: AsyncClient
) -> None:
created = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
book_id = created.json()["created"][0]["id"]
response = await authenticated_client.post(
f"/books/{book_id}/files", files=self.upload()
)
assert response.status_code == 201
assert len(response.json()["files"]) == 1
async def test_duplicates_can_be_checked_before_uploading(
self, authenticated_client: AsyncClient
) -> None:
"""The pre-flight check answers from hashes alone, with no file sent."""
created = await authenticated_client.post(
"/books/fromFiles?library_id=1", files=self.upload()
)
book = created.json()["created"][0]
stored = book["files"][0]
response = await authenticated_client.post(
"/books/duplicates?library_id=1",
json=[
{
"hash": stored["hash"],
"size": stored["size"],
"filename": "local-copy.epub",
},
{"hash": stored["hash"], "size": stored["size"] + 1},
{"hash": "0" * 32, "size": 1234},
],
)
assert response.status_code == 200
matches = response.json()
assert len(matches) == 1
assert matches[0]["filename"] == "local-copy.epub"
assert matches[0]["book_id"] == book["id"]
# 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