236 lines
7.0 KiB
Python
236 lines
7.0 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}
|
|
|
|
|
|
# --- 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)],
|
|
}
|