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 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, }) # --- 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(""), work_dir: str = Form("/tmp/calibresync"), import_dir: str = Form(""), 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, "work_dir": work_dir, "import_dir": import_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} # --- 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)], }