Files
calibresync/main.py
T
2026-05-10 23:02:59 +02:00

304 lines
9.6 KiB
Python

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 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 ...")
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"])
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)],
}