diff --git a/main.py b/main.py index aa4775e..27ad9f0 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ import db import sftp as sftp_module import sync import uploader +from uploader import delete_book, fetch_all_books, find_duplicate_groups logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s") log = logging.getLogger(__name__) @@ -185,6 +186,34 @@ async def test_calibre(): return {"ok": ok, "message": message} +# --- Duplicates --- + +@app.get("/duplicates", response_class=HTMLResponse) +async def duplicates_page(request: Request): + cfg = config.load() + error = None + groups: list = [] + total_books = 0 + try: + books = fetch_all_books(cfg.calibre) + total_books = len(books) + groups = find_duplicate_groups(books) + except Exception as e: + error = str(e) + return templates.TemplateResponse(request, "duplicates.html", { + "groups": groups, + "total_books": total_books, + "error": error, + }) + + +@app.post("/api/delete_book/{book_id}") +async def delete_book_api(book_id: int): + cfg = config.load() + ok, message = delete_book(cfg.calibre, book_id) + return {"ok": ok, "message": message} + + # --- Data reset --- @app.post("/settings/reset-sync-data") diff --git a/templates/base.html b/templates/base.html index 95fc200..064dba8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,7 @@ CalibreSync Dashboard Books + Duplicates Settings
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 %} +
+

{{ group[0].title }}

+ + + + + + + + + + + + {% for book in group %} + + + + + + + + {% endfor %} + +
IDTitleAuthorsFormat
{{ book.id }}{{ book.title }}{{ book.authors }}{{ book.format or "—" }} + + +
+
+ {% 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)