diff --git a/templates/duplicates.html b/templates/duplicates.html
new file mode 100644
index 0000000..542ac3c
--- /dev/null
+++ b/templates/duplicates.html
@@ -0,0 +1,81 @@
+{% extends "base.html" %}
+{% block title %}Duplicates — CalibreSync{% endblock %}
+
+{% block content %}
+
+
+{% if error %}
+ Could not fetch books from Calibre-Web: {{ error }}
+{% else %}
+
+ Scanned {{ total_books }} book(s) —
+ {% if groups %}
+ found {{ groups|length }} duplicate group(s).
+ Books are grouped by normalised title. Keep the one you want and delete the rest.
+ {% else %}
+ no duplicates found.
+ {% endif %}
+
+
+ {% for group in groups %}
+
+ {% endfor %}
+{% endif %}
+
+
+{% endblock %}
diff --git a/uploader.py b/uploader.py
index ea66a6d..dcb3559 100644
--- a/uploader.py
+++ b/uploader.py
@@ -157,6 +157,58 @@ class CalibreClient:
return "error"
+def fetch_all_books(cfg: CalibreConfig) -> list[dict]:
+ """Fetch every book from Calibre-Web via /ajax/listbooks. Returns raw row dicts."""
+ client = CalibreClient(cfg)
+ client._ensure_auth()
+ all_books: list[dict] = []
+ page_size = 100
+ start = 0
+ while True:
+ resp = client._session.get(
+ f"{cfg.url}/ajax/listbooks",
+ params={"start": start, "length": page_size, "sort": "title", "order": "asc"},
+ timeout=30,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ rows = data.get("rows", [])
+ all_books.extend(rows)
+ if start + page_size >= data.get("total_count", 0):
+ break
+ start += page_size
+ return all_books
+
+
+def delete_book(cfg: CalibreConfig, book_id: int) -> tuple[bool, str]:
+ """Delete a book from Calibre-Web by ID."""
+ client = CalibreClient(cfg)
+ client._ensure_auth()
+ resp = client._session.post(
+ f"{cfg.url}/delete/{book_id}",
+ data={"csrf_token": client._upload_csrf} if client._upload_csrf else {},
+ timeout=30,
+ )
+ if resp.ok:
+ return True, "Deleted"
+ return False, f"HTTP {resp.status_code}"
+
+
+def find_duplicate_groups(books: list[dict]) -> list[list[dict]]:
+ """Group books by normalised title; return only groups with 2+ entries."""
+ from collections import defaultdict
+ groups: dict[str, list[dict]] = defaultdict(list)
+ for book in books:
+ words = _normalize_words(book.get("title", ""))
+ key = " ".join(sorted(words))
+ if key:
+ groups[key].append(book)
+ return sorted(
+ [g for g in groups.values() if len(g) > 1],
+ key=lambda g: g[0].get("title", "").lower(),
+ )
+
+
def test_connection(cfg: CalibreConfig) -> tuple[bool, str]:
try:
client = CalibreClient(cfg)