import logging from contextlib import asynccontextmanager from pathlib import Path from apscheduler.schedulers.background import BackgroundScheduler from fastapi import BackgroundTasks, FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel import config import db import grimmory as grimmory_module 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="GrimmorySync", lifespan=lifespan) app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") # --- SPA root --- @app.get("/", include_in_schema=False) async def spa_root(): return FileResponse(Path(__file__).parent / "static" / "index.html") # --- Dashboard data --- @app.get("/api/dashboard") async def api_dashboard(): interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0") return { "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)], "books": [dict(b) for b in db.get_recent_books(20)], "sync_running": sync.is_running(), "next_run": next_run_time(), "cache_info": db.get_remote_cache_info(), "interval": interval, } # --- Settings --- @app.get("/api/settings") async def api_get_settings(): s = db.get_all_settings() key_pem = s.get("sftp_key", "") return { "settings": s, "has_key": bool(key_pem.strip()), "key_fingerprint": sftp_module.get_key_fingerprint(key_pem), } class SettingsPayload(BaseModel): sftp_host: str = "" sftp_port: str = "22" sftp_user: str = "" sftp_auth_method: str = "key" sftp_key: str = "" sftp_password: str = "" sftp_remote_path: str = "" grimmory_url: str = "" grimmory_user: str = "" grimmory_password: str = "" grimmory_bookdrop_path: str = "" work_dir: str = "/tmp/grimmorysync" scheduler_interval_minutes: str = "0" sync_batch_size: str = "0" @app.post("/api/settings") async def api_save_settings(payload: SettingsPayload): config.save(payload.model_dump()) _reschedule_auto_sync() return {"ok": True} # --- Sync triggers --- @app.post("/api/sync") async def api_trigger_sync(background_tasks: BackgroundTasks): if sync.is_running(): return {"ok": False, "reason": "already_running"} background_tasks.add_task(sync.run_sync) return {"ok": True} @app.post("/api/sync/test") async def api_trigger_test_sync(background_tasks: BackgroundTasks): if sync.is_running(): return {"ok": False, "reason": "already_running"} background_tasks.add_task(sync.run_sync, 1) return {"ok": True} @app.post("/api/sync/rescan") async def api_trigger_rescan(background_tasks: BackgroundTasks): if sync.is_running(): return {"ok": False, "reason": "already_running"} cfg = config.load() background_tasks.add_task(sftp_module.refresh_remote_zip_cache, cfg.sftp) return {"ok": True} # --- Local folder import --- @app.post("/api/import/local") async def api_local_import(background_tasks: BackgroundTasks): if sync.is_running(): return {"ok": False, "reason": "already_running"} folder = db.get_setting("local_import_path", "") if not folder: return {"ok": False, "reason": "no_path_configured"} background_tasks.add_task(sync.run_local_import, folder) return {"ok": True} # --- 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/grimmory") async def test_grimmory(): cfg = config.load() ok, message = grimmory_module.test_connection(cfg.grimmory.url, cfg.grimmory.user, cfg.grimmory.password) return {"ok": ok, "message": message} # --- Data reset --- @app.post("/api/settings/reset-sync-data") async def api_reset_sync_data(): counts = db.clear_sync_data() log.info("Sync data cleared: %s", counts) return {"ok": True, "counts": counts} # --- 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)], }