import logging from contextlib import asynccontextmanager from pathlib import Path from apscheduler.schedulers.background import BackgroundScheduler from fastapi import BackgroundTasks, FastAPI, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import config import db import sftp as sftp_module import sync import uploader from uploader import CalibreClient, 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__) _scheduler = BackgroundScheduler(timezone="UTC") def _reschedule_auto_sync() -> None: _scheduler.remove_all_jobs() try: interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0") except ValueError: interval = 0 if interval > 0: _scheduler.add_job(sync.run_sync, "interval", minutes=interval, id="auto_sync") log.info("Auto-sync scheduled every %d minute(s)", interval) else: log.info("Auto-sync disabled") def next_run_time() -> str | None: job = _scheduler.get_job("auto_sync") if job and job.next_run_time: return job.next_run_time.strftime("%Y-%m-%d %H:%M UTC") return None @asynccontextmanager async def lifespan(app: FastAPI): db.init_db() _scheduler.start() _reschedule_auto_sync() yield _scheduler.shutdown(wait=False) app = FastAPI(title="CalibreSync", lifespan=lifespan) app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") templates = Jinja2Templates(directory=Path(__file__).parent / "templates") # --- Dashboard --- @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): stats = db.get_stats() runs = [dict(r) for r in db.get_recent_runs(10)] zips = [dict(z) for z in db.get_recent_zips(20)] interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0") batch_size = int(db.get_setting("sync_batch_size", "0") or "0") cache_info = db.get_remote_cache_info() return templates.TemplateResponse(request, "index.html", { "stats": stats, "runs": runs, "zips": zips, "sync_running": sync.is_running(), "next_run": next_run_time(), "interval": interval, "batch_size": batch_size, "cache_info": cache_info, }) # --- Books --- @app.get("/books", response_class=HTMLResponse) async def books_page(request: Request, page: int = 1): per_page = 50 offset = (page - 1) * per_page books = [dict(b) for b in db.get_books(limit=per_page, offset=offset)] total = db.get_books_count() pages = max(1, (total + per_page - 1) // per_page) return templates.TemplateResponse(request, "books.html", { "books": books, "page": page, "pages": pages, "total": total, }) # --- Settings --- @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): s = db.get_all_settings() key_pem = s.get("sftp_key", "") return templates.TemplateResponse(request, "settings.html", { "s": s, "has_key": bool(key_pem.strip()), "key_fingerprint": sftp_module.get_key_fingerprint(key_pem), }) @app.post("/settings") async def save_settings( request: Request, sftp_host: str = Form(""), sftp_port: str = Form("22"), sftp_user: str = Form(""), sftp_auth_method: str = Form("key"), sftp_key: str = Form(""), sftp_password: str = Form(""), sftp_remote_path: str = Form(""), calibre_url: str = Form(""), calibre_user: str = Form(""), calibre_pass: str = Form(""), local_work_dir: str = Form("/tmp/calibresync"), scheduler_interval_minutes: str = Form("0"), sync_batch_size: str = Form("0"), ): config.save({ "sftp_host": sftp_host, "sftp_port": sftp_port, "sftp_user": sftp_user, "sftp_auth_method": sftp_auth_method, "sftp_key": sftp_key, "sftp_password": sftp_password, "sftp_remote_path": sftp_remote_path, "calibre_url": calibre_url, "calibre_user": calibre_user, "calibre_pass": calibre_pass, "local_work_dir": local_work_dir, "scheduler_interval_minutes": scheduler_interval_minutes, "sync_batch_size": sync_batch_size, }) _reschedule_auto_sync() return RedirectResponse("/settings?saved=1", status_code=303) # --- Sync triggers --- @app.post("/sync") async def trigger_sync(background_tasks: BackgroundTasks): if sync.is_running(): return RedirectResponse("/?already_running=1", status_code=303) background_tasks.add_task(sync.run_sync) return RedirectResponse("/?started=1", status_code=303) @app.post("/sync/test") async def trigger_test_sync(background_tasks: BackgroundTasks): if sync.is_running(): return RedirectResponse("/?already_running=1", status_code=303) background_tasks.add_task(sync.run_sync, 1) return RedirectResponse("/?test_started=1", status_code=303) @app.post("/sync/rescan") async def trigger_rescan(background_tasks: BackgroundTasks): if sync.is_running(): return RedirectResponse("/?already_running=1", status_code=303) cfg = config.load() background_tasks.add_task(sftp_module.refresh_remote_zip_cache, cfg.sftp) return RedirectResponse("/?rescan_started=1", status_code=303) # --- Connection tests --- @app.get("/api/test/ssh") async def test_ssh(): cfg = config.load() ok, message = sftp_module.test_connection(cfg.sftp) return {"ok": ok, "message": message} @app.get("/api/test/calibre") async def test_calibre(): cfg = config.load() ok, message = uploader.test_connection(cfg.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} _dedup_state: dict = {"running": False, "deleted": 0, "failed": 0, "total": 0, "done": False, "error": None} def _run_dedup(): global _dedup_state try: cfg = config.load() log.info("Dedup: fetching all books ...") client = CalibreClient(cfg.calibre) client._ensure_auth() books = fetch_all_books(cfg.calibre) groups = find_duplicate_groups(books) to_delete = [b for group in groups for b in sorted(group, key=lambda x: x.get("id", 0))[1:]] _dedup_state.update({"total": len(to_delete), "deleted": 0, "failed": 0}) log.info("Dedup: %d duplicate(s) to delete across %d group(s)", len(to_delete), len(groups)) for book in to_delete: ok, msg = delete_book(cfg.calibre, book["id"], client) if ok: _dedup_state["deleted"] += 1 else: _dedup_state["failed"] += 1 log.warning("Dedup: failed to delete book %d: %s", book["id"], msg) if _dedup_state["deleted"] % 10 == 0: log.info("Dedup progress: %d / %d deleted", _dedup_state["deleted"], _dedup_state["total"]) log.info("Dedup done: %d deleted, %d failed", _dedup_state["deleted"], _dedup_state["failed"]) except Exception as e: log.error("Dedup error: %s", e) _dedup_state["error"] = str(e) finally: _dedup_state["running"] = False _dedup_state["done"] = True @app.post("/api/delete_duplicates") async def delete_duplicates_api(background_tasks: BackgroundTasks): if _dedup_state["running"]: return {"ok": False, "message": "Already running"} _dedup_state.update({"running": True, "deleted": 0, "failed": 0, "total": 0, "done": False, "error": None}) background_tasks.add_task(_run_dedup) return {"ok": True, "message": "Started"} @app.get("/api/delete_duplicates/status") async def delete_duplicates_status(): return _dedup_state @app.get("/api/debug/calibre_books") async def debug_calibre_books(): """Show raw Calibre-Web listbooks response shape so we can identify field names.""" cfg = config.load() from uploader import CalibreClient client = CalibreClient(cfg.calibre) client._ensure_auth() resp = client._session.get( f"{cfg.calibre.url}/ajax/listbooks", params={"draw": 1, "start": 0, "length": 5, "sort": "title", "order": "asc"}, timeout=30, ) data = resp.json() non_list = {k: v for k, v in data.items() if not isinstance(v, list)} list_keys = {k: len(v) for k, v in data.items() if isinstance(v, list)} return { "http_status": resp.status_code, "top_level_keys": list(data.keys()), "non_list_fields": non_list, "list_fields_lengths": list_keys, } # --- Data reset --- @app.post("/settings/reset-sync-data") async def reset_sync_data(): counts = db.clear_sync_data() log.info("Sync data cleared: %s", counts) return RedirectResponse("/settings?reset=1", status_code=303) # --- JSON status API --- @app.get("/api/status") async def api_status(): return { "sync_running": sync.is_running(), "next_run": next_run_time(), "stats": db.get_stats(), "last_run": [dict(r) for r in db.get_recent_runs(1)], }