switch to grimmory as book web app and a more modern dashboard
This commit is contained in:
@@ -14,11 +14,19 @@ class SFTPConfig:
|
|||||||
remote_path: str = ""
|
remote_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GrimmoryConfig:
|
||||||
|
url: str = ""
|
||||||
|
user: str = ""
|
||||||
|
password: str = ""
|
||||||
|
bookdrop_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
sftp: SFTPConfig = field(default_factory=SFTPConfig)
|
sftp: SFTPConfig = field(default_factory=SFTPConfig)
|
||||||
|
grimmory: GrimmoryConfig = field(default_factory=GrimmoryConfig)
|
||||||
work_dir: str = "/tmp/calibresync"
|
work_dir: str = "/tmp/calibresync"
|
||||||
import_dir: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def load() -> AppConfig:
|
def load() -> AppConfig:
|
||||||
@@ -33,8 +41,13 @@ def load() -> AppConfig:
|
|||||||
password=s.get("sftp_password", ""),
|
password=s.get("sftp_password", ""),
|
||||||
remote_path=s.get("sftp_remote_path", ""),
|
remote_path=s.get("sftp_remote_path", ""),
|
||||||
),
|
),
|
||||||
|
grimmory=GrimmoryConfig(
|
||||||
|
url=s.get("grimmory_url", ""),
|
||||||
|
user=s.get("grimmory_user", ""),
|
||||||
|
password=s.get("grimmory_password", ""),
|
||||||
|
bookdrop_path=s.get("grimmory_bookdrop_path", ""),
|
||||||
|
),
|
||||||
work_dir=s.get("work_dir", "/tmp/calibresync"),
|
work_dir=s.get("work_dir", "/tmp/calibresync"),
|
||||||
import_dir=s.get("import_dir", ""),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +55,8 @@ def save(form: dict) -> None:
|
|||||||
keys = [
|
keys = [
|
||||||
"sftp_host", "sftp_port", "sftp_user", "sftp_auth_method",
|
"sftp_host", "sftp_port", "sftp_user", "sftp_auth_method",
|
||||||
"sftp_password", "sftp_remote_path",
|
"sftp_password", "sftp_remote_path",
|
||||||
"work_dir", "import_dir",
|
"grimmory_url", "grimmory_user", "grimmory_password", "grimmory_bookdrop_path",
|
||||||
|
"work_dir",
|
||||||
"scheduler_interval_minutes", "sync_batch_size",
|
"scheduler_interval_minutes", "sync_batch_size",
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ def init_db() -> None:
|
|||||||
file_size INTEGER NOT NULL,
|
file_size INTEGER NOT NULL,
|
||||||
cached_at TEXT NOT NULL
|
cached_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS processed_books (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
zip_id INTEGER REFERENCES processed_zips(id),
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
sha256 TEXT,
|
||||||
|
placed_at TEXT,
|
||||||
|
status TEXT DEFAULT 'success',
|
||||||
|
error_msg TEXT
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
_migrate(conn)
|
_migrate(conn)
|
||||||
|
|
||||||
@@ -72,7 +82,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
existing = {row[1] for row in conn.execute("PRAGMA table_info(sync_runs)")}
|
existing = {row[1] for row in conn.execute("PRAGMA table_info(sync_runs)")}
|
||||||
if "books_imported" not in existing:
|
if "books_imported" not in existing:
|
||||||
conn.execute("ALTER TABLE sync_runs ADD COLUMN books_imported INTEGER DEFAULT 0")
|
conn.execute("ALTER TABLE sync_runs ADD COLUMN books_imported INTEGER DEFAULT 0")
|
||||||
for old_col in ("books_uploaded", "books_skipped"):
|
if "books_skipped" not in existing:
|
||||||
|
conn.execute("ALTER TABLE sync_runs ADD COLUMN books_skipped INTEGER DEFAULT 0")
|
||||||
|
for old_col in ("books_uploaded",):
|
||||||
if old_col in existing:
|
if old_col in existing:
|
||||||
try:
|
try:
|
||||||
conn.execute(f"ALTER TABLE sync_runs DROP COLUMN {old_col}")
|
conn.execute(f"ALTER TABLE sync_runs DROP COLUMN {old_col}")
|
||||||
@@ -167,6 +179,14 @@ def mark_zip_processed(remote_path: str, file_size: int, status: str, error_msg:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_zip_id_by_path(remote_path: str) -> int | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM processed_zips WHERE remote_path = ?", (remote_path,)
|
||||||
|
).fetchone()
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_recent_zips(limit: int = 50) -> list[sqlite3.Row]:
|
def get_recent_zips(limit: int = 50) -> list[sqlite3.Row]:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
@@ -200,30 +220,66 @@ def get_recent_runs(limit: int = 10) -> list[sqlite3.Row]:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def is_book_processed(sha256: str) -> bool:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM processed_books WHERE sha256 = ? AND status = 'success'", (sha256,)
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def record_book(zip_id: int | None, filename: str, sha256: str,
|
||||||
|
status: str = "success", error_msg: str | None = None) -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO processed_books (zip_id, filename, sha256, placed_at, status, error_msg) VALUES (?,?,?,?,?,?)",
|
||||||
|
(zip_id, filename, sha256, _now(), status, error_msg),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_books(limit: int = 50) -> list[sqlite3.Row]:
|
||||||
|
with get_db() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"""SELECT pb.*, pz.remote_path as zip_remote_path
|
||||||
|
FROM processed_books pb
|
||||||
|
LEFT JOIN processed_zips pz ON pz.id = pb.zip_id
|
||||||
|
ORDER BY pb.placed_at DESC LIMIT ?""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
def get_stats() -> dict:
|
def get_stats() -> dict:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
total_zips = conn.execute("SELECT COUNT(*) FROM processed_zips").fetchone()[0]
|
total_zips = conn.execute("SELECT COUNT(*) FROM processed_zips").fetchone()[0]
|
||||||
total_imported = conn.execute(
|
total_books = conn.execute(
|
||||||
"SELECT COALESCE(SUM(books_imported), 0) FROM sync_runs"
|
"SELECT COUNT(*) FROM processed_books WHERE status = 'success'"
|
||||||
|
).fetchone()[0]
|
||||||
|
total_skipped = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM processed_books WHERE status = 'skipped'"
|
||||||
|
).fetchone()[0]
|
||||||
|
total_errored = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM processed_books WHERE status = 'error'"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
last_run = conn.execute(
|
last_run = conn.execute(
|
||||||
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1"
|
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return {
|
return {
|
||||||
"total_zips": total_zips,
|
"total_zips": total_zips,
|
||||||
"total_imported": total_imported,
|
"total_books": total_books,
|
||||||
|
"total_skipped": total_skipped,
|
||||||
|
"total_errored": total_errored,
|
||||||
"last_run": dict(last_run) if last_run else None,
|
"last_run": dict(last_run) if last_run else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def clear_sync_data() -> dict:
|
def clear_sync_data() -> dict:
|
||||||
"""Delete all processed_zips and sync_runs rows. Settings are kept.
|
"""Delete all processed_books, processed_zips, and sync_runs rows. Settings are kept."""
|
||||||
Also resets the remote scan timestamp so the next sync does a full rescan."""
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
books = conn.execute("DELETE FROM processed_books").rowcount
|
||||||
zips = conn.execute("DELETE FROM processed_zips").rowcount
|
zips = conn.execute("DELETE FROM processed_zips").rowcount
|
||||||
runs = conn.execute("DELETE FROM sync_runs").rowcount
|
runs = conn.execute("DELETE FROM sync_runs").rowcount
|
||||||
conn.execute("DELETE FROM settings WHERE key = 'remote_cache_last_scan'")
|
conn.execute("DELETE FROM settings WHERE key = 'remote_cache_last_scan'")
|
||||||
return {"zips": zips, "runs": runs}
|
return {"zips": zips, "runs": runs, "books": books}
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_sha256(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_in_grimmory(filename: str, url: str, user: str, password: str) -> bool:
|
||||||
|
"""Search Grimmory Komga-compatible API by filename stem. Non-fatal on error."""
|
||||||
|
stem = Path(filename).stem
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
url.rstrip("/") + "/api/v1/books",
|
||||||
|
params={"search": stem},
|
||||||
|
auth=(user, password),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json().get("totalElements", 0) > 0
|
||||||
|
log.warning("Grimmory search returned HTTP %s for '%s'", r.status_code, stem)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Grimmory duplicate check failed for '%s': %s", filename, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class PlacementResult:
|
||||||
|
__slots__ = ("status", "sha256", "error_msg")
|
||||||
|
|
||||||
|
def __init__(self, status: str, sha256: str = "", error_msg: str = ""):
|
||||||
|
self.status = status
|
||||||
|
self.sha256 = sha256
|
||||||
|
self.error_msg = error_msg
|
||||||
|
|
||||||
|
|
||||||
|
def place_book(
|
||||||
|
book_path: Path,
|
||||||
|
bookdrop_path: str,
|
||||||
|
url: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
sha256: str | None = None,
|
||||||
|
) -> PlacementResult:
|
||||||
|
if sha256 is None:
|
||||||
|
sha256 = compute_sha256(book_path)
|
||||||
|
|
||||||
|
if _is_in_grimmory(book_path.name, url, user, password):
|
||||||
|
log.info("Skipping '%s' — already in Grimmory", book_path.name)
|
||||||
|
return PlacementResult("skipped", sha256)
|
||||||
|
|
||||||
|
dest_dir = Path(bookdrop_path)
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / book_path.name
|
||||||
|
counter = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = dest_dir / f"{book_path.stem}_{counter}{book_path.suffix}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
shutil.copy2(book_path, dest)
|
||||||
|
log.info("Placed '%s' → %s", book_path.name, dest)
|
||||||
|
return PlacementResult("success", sha256)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(url: str, user: str, password: str) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
url.rstrip("/") + "/api/v1/books",
|
||||||
|
params={"size": 1},
|
||||||
|
auth=(user, password),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return True, "Connected to Grimmory successfully"
|
||||||
|
if r.status_code == 401:
|
||||||
|
return False, "Authentication failed — check username and password"
|
||||||
|
return False, f"HTTP {r.status_code}"
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False, "Could not connect — check the URL"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
@@ -3,13 +3,14 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from fastapi import BackgroundTasks, FastAPI, Form, Request
|
from fastapi import BackgroundTasks, FastAPI
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import db
|
import db
|
||||||
|
import grimmory as grimmory_module
|
||||||
import sftp as sftp_module
|
import sftp as sftp_module
|
||||||
import sync
|
import sync
|
||||||
|
|
||||||
@@ -50,101 +51,94 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="CalibreSync", lifespan=lifespan)
|
app = FastAPI(title="CalibreSync", lifespan=lifespan)
|
||||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||||
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Dashboard ---
|
# --- SPA root ---
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", include_in_schema=False)
|
||||||
async def dashboard(request: Request):
|
async def spa_root():
|
||||||
stats = db.get_stats()
|
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
||||||
runs = [dict(r) for r in db.get_recent_runs(10)]
|
|
||||||
zips = [dict(z) for z in db.get_recent_zips(20)]
|
|
||||||
|
# --- Dashboard data ---
|
||||||
|
|
||||||
|
@app.get("/api/dashboard")
|
||||||
|
async def api_dashboard():
|
||||||
interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0")
|
interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0")
|
||||||
batch_size = int(db.get_setting("sync_batch_size", "0") or "0")
|
return {
|
||||||
cache_info = db.get_remote_cache_info()
|
"stats": db.get_stats(),
|
||||||
return templates.TemplateResponse(request, "index.html", {
|
"runs": [dict(r) for r in db.get_recent_runs(10)],
|
||||||
"stats": stats,
|
"zips": [dict(z) for z in db.get_recent_zips(20)],
|
||||||
"runs": runs,
|
"books": [dict(b) for b in db.get_recent_books(20)],
|
||||||
"zips": zips,
|
|
||||||
"sync_running": sync.is_running(),
|
"sync_running": sync.is_running(),
|
||||||
"next_run": next_run_time(),
|
"next_run": next_run_time(),
|
||||||
|
"cache_info": db.get_remote_cache_info(),
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
"batch_size": batch_size,
|
}
|
||||||
"cache_info": cache_info,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# --- Settings ---
|
# --- Settings ---
|
||||||
|
|
||||||
@app.get("/settings", response_class=HTMLResponse)
|
@app.get("/api/settings")
|
||||||
async def settings_page(request: Request):
|
async def api_get_settings():
|
||||||
s = db.get_all_settings()
|
s = db.get_all_settings()
|
||||||
key_pem = s.get("sftp_key", "")
|
key_pem = s.get("sftp_key", "")
|
||||||
return templates.TemplateResponse(request, "settings.html", {
|
return {
|
||||||
"s": s,
|
"settings": s,
|
||||||
"has_key": bool(key_pem.strip()),
|
"has_key": bool(key_pem.strip()),
|
||||||
"key_fingerprint": sftp_module.get_key_fingerprint(key_pem),
|
"key_fingerprint": sftp_module.get_key_fingerprint(key_pem),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/settings")
|
class SettingsPayload(BaseModel):
|
||||||
async def save_settings(
|
sftp_host: str = ""
|
||||||
request: Request,
|
sftp_port: str = "22"
|
||||||
sftp_host: str = Form(""),
|
sftp_user: str = ""
|
||||||
sftp_port: str = Form("22"),
|
sftp_auth_method: str = "key"
|
||||||
sftp_user: str = Form(""),
|
sftp_key: str = ""
|
||||||
sftp_auth_method: str = Form("key"),
|
sftp_password: str = ""
|
||||||
sftp_key: str = Form(""),
|
sftp_remote_path: str = ""
|
||||||
sftp_password: str = Form(""),
|
grimmory_url: str = ""
|
||||||
sftp_remote_path: str = Form(""),
|
grimmory_user: str = ""
|
||||||
work_dir: str = Form("/tmp/calibresync"),
|
grimmory_password: str = ""
|
||||||
import_dir: str = Form(""),
|
grimmory_bookdrop_path: str = ""
|
||||||
scheduler_interval_minutes: str = Form("0"),
|
work_dir: str = "/tmp/calibresync"
|
||||||
sync_batch_size: str = Form("0"),
|
scheduler_interval_minutes: str = "0"
|
||||||
):
|
sync_batch_size: str = "0"
|
||||||
config.save({
|
|
||||||
"sftp_host": sftp_host,
|
|
||||||
"sftp_port": sftp_port,
|
@app.post("/api/settings")
|
||||||
"sftp_user": sftp_user,
|
async def api_save_settings(payload: SettingsPayload):
|
||||||
"sftp_auth_method": sftp_auth_method,
|
config.save(payload.model_dump())
|
||||||
"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()
|
_reschedule_auto_sync()
|
||||||
return RedirectResponse("/settings?saved=1", status_code=303)
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# --- Sync triggers ---
|
# --- Sync triggers ---
|
||||||
|
|
||||||
@app.post("/sync")
|
@app.post("/api/sync")
|
||||||
async def trigger_sync(background_tasks: BackgroundTasks):
|
async def api_trigger_sync(background_tasks: BackgroundTasks):
|
||||||
if sync.is_running():
|
if sync.is_running():
|
||||||
return RedirectResponse("/?already_running=1", status_code=303)
|
return {"ok": False, "reason": "already_running"}
|
||||||
background_tasks.add_task(sync.run_sync)
|
background_tasks.add_task(sync.run_sync)
|
||||||
return RedirectResponse("/?started=1", status_code=303)
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/sync/test")
|
@app.post("/api/sync/test")
|
||||||
async def trigger_test_sync(background_tasks: BackgroundTasks):
|
async def api_trigger_test_sync(background_tasks: BackgroundTasks):
|
||||||
if sync.is_running():
|
if sync.is_running():
|
||||||
return RedirectResponse("/?already_running=1", status_code=303)
|
return {"ok": False, "reason": "already_running"}
|
||||||
background_tasks.add_task(sync.run_sync, 1)
|
background_tasks.add_task(sync.run_sync, 1)
|
||||||
return RedirectResponse("/?test_started=1", status_code=303)
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/sync/rescan")
|
@app.post("/api/sync/rescan")
|
||||||
async def trigger_rescan(background_tasks: BackgroundTasks):
|
async def api_trigger_rescan(background_tasks: BackgroundTasks):
|
||||||
if sync.is_running():
|
if sync.is_running():
|
||||||
return RedirectResponse("/?already_running=1", status_code=303)
|
return {"ok": False, "reason": "already_running"}
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
background_tasks.add_task(sftp_module.refresh_remote_zip_cache, cfg.sftp)
|
background_tasks.add_task(sftp_module.refresh_remote_zip_cache, cfg.sftp)
|
||||||
return RedirectResponse("/?rescan_started=1", status_code=303)
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# --- Connection tests ---
|
# --- Connection tests ---
|
||||||
@@ -156,13 +150,20 @@ async def test_ssh():
|
|||||||
return {"ok": ok, "message": message}
|
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 ---
|
# --- Data reset ---
|
||||||
|
|
||||||
@app.post("/settings/reset-sync-data")
|
@app.post("/api/settings/reset-sync-data")
|
||||||
async def reset_sync_data():
|
async def api_reset_sync_data():
|
||||||
counts = db.clear_sync_data()
|
counts = db.clear_sync_data()
|
||||||
log.info("Sync data cleared: %s", counts)
|
log.info("Sync data cleared: %s", counts)
|
||||||
return RedirectResponse("/settings?reset=1", status_code=303)
|
return {"ok": True, "counts": counts}
|
||||||
|
|
||||||
|
|
||||||
# --- JSON status API ---
|
# --- JSON status API ---
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
jinja2
|
|
||||||
python-multipart
|
|
||||||
paramiko
|
paramiko
|
||||||
rarfile
|
rarfile
|
||||||
apscheduler
|
apscheduler
|
||||||
|
requests
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CalibreSync</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" />
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-theme="dim" x-data="app()" x-init="init()" class="min-h-screen bg-base-100">
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div class="toast toast-top toast-end z-50" x-cloak x-show="toast.show" x-transition>
|
||||||
|
<div :class="toast.type === 'success' ? 'alert alert-success' : 'alert alert-error'" class="text-sm">
|
||||||
|
<span x-text="toast.msg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navbar -->
|
||||||
|
<div class="navbar bg-base-200 shadow-md sticky top-0 z-40">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<span class="text-xl font-bold px-4 tracking-tight">CalibreSync</span>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-center">
|
||||||
|
<div role="tablist" class="tabs tabs-boxed">
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'dashboard' }" @click="tab = 'dashboard'">Dashboard</a>
|
||||||
|
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'settings' }" @click="tab = 'settings'">Settings</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end pr-4">
|
||||||
|
<a href="/docs" target="_blank" class="link link-hover text-xs opacity-50">API docs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-6 max-w-5xl">
|
||||||
|
|
||||||
|
<!-- DASHBOARD TAB -->
|
||||||
|
<div x-show="tab === 'dashboard'" x-cloak>
|
||||||
|
<template x-if="dashboard">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||||
|
<h1 class="text-2xl font-bold flex-1">Dashboard</h1>
|
||||||
|
<template x-if="dashboard.interval > 0">
|
||||||
|
<span class="badge badge-outline badge-sm" x-text="'Auto every ' + dashboard.interval + ' min'"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="dashboard.next_run">
|
||||||
|
<span class="badge badge-ghost badge-sm" x-text="'Next: ' + dashboard.next_run"></span>
|
||||||
|
</template>
|
||||||
|
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
||||||
|
@click="triggerSync('/api/sync/rescan')">Rescan remote</button>
|
||||||
|
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
||||||
|
@click="triggerSync('/api/sync/test')">Test (1 zip)</button>
|
||||||
|
<button class="btn btn-sm btn-primary" :disabled="dashboard.sync_running"
|
||||||
|
@click="triggerSync('/api/sync')">
|
||||||
|
<span x-show="dashboard.sync_running" class="loading loading-spinner loading-xs"></span>
|
||||||
|
<span x-text="dashboard.sync_running ? 'Running…' : 'Run Sync Now'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache status -->
|
||||||
|
<div class="text-sm opacity-50 mb-5" x-show="dashboard.cache_info">
|
||||||
|
<template x-if="dashboard.cache_info && dashboard.cache_info.count > 0">
|
||||||
|
<span>Remote cache: <strong x-text="dashboard.cache_info.count"></strong> zip(s) — last scanned <span x-text="formatDate(dashboard.cache_info.last_scan)"></span> UTC</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="dashboard.cache_info && !dashboard.cache_info.count">
|
||||||
|
<span class="text-warning">Remote cache empty — first sync will run a full scan.</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats cards -->
|
||||||
|
<div class="stats stats-horizontal shadow mb-6 w-full flex-wrap">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Archives processed</div>
|
||||||
|
<div class="stat-value text-base-content" x-text="dashboard.stats.total_zips ?? 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Books imported</div>
|
||||||
|
<div class="stat-value text-success" x-text="dashboard.stats.total_books ?? 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Skipped (dupes)</div>
|
||||||
|
<div class="stat-value text-info" x-text="dashboard.stats.total_skipped ?? 0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Errors</div>
|
||||||
|
<div class="stat-value text-error" x-text="dashboard.stats.total_errored ?? 0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent sync runs -->
|
||||||
|
<h2 class="text-lg font-semibold mb-2">Recent sync runs</h2>
|
||||||
|
<div class="overflow-x-auto mb-6 rounded-lg border border-base-300">
|
||||||
|
<table class="table table-zebra table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Started</th><th>Finished</th><th>Status</th>
|
||||||
|
<th class="text-right">New zips</th><th class="text-right">Imported</th>
|
||||||
|
<th class="text-right">Skipped</th><th class="text-right">Errors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="r in dashboard.runs" :key="r.id">
|
||||||
|
<tr>
|
||||||
|
<td class="text-xs font-mono" x-text="formatDate(r.started_at)"></td>
|
||||||
|
<td class="text-xs font-mono" x-text="formatDate(r.finished_at)"></td>
|
||||||
|
<td>
|
||||||
|
<span :class="{
|
||||||
|
'badge badge-success badge-sm': r.status === 'success',
|
||||||
|
'badge badge-error badge-sm': r.status === 'error',
|
||||||
|
'badge badge-warning badge-sm': r.status === 'running'
|
||||||
|
}" x-text="r.status"></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right" x-text="r.zips_new ?? 0"></td>
|
||||||
|
<td class="text-right" x-text="r.books_imported ?? 0"></td>
|
||||||
|
<td class="text-right" x-text="r.books_skipped ?? 0"></td>
|
||||||
|
<td class="text-right" x-text="r.books_errored ?? 0"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="!dashboard.runs || !dashboard.runs.length">
|
||||||
|
<tr><td colspan="7" class="text-center opacity-40 py-4">No sync runs yet</td></tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent books -->
|
||||||
|
<h2 class="text-lg font-semibold mb-2">Recent books</h2>
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-base-300">
|
||||||
|
<table class="table table-zebra table-sm w-full">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Filename</th><th>Status</th><th>Placed at</th><th>Source zip</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="b in dashboard.books" :key="b.id">
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono text-xs max-w-xs truncate" x-text="b.filename"></td>
|
||||||
|
<td>
|
||||||
|
<span :class="{
|
||||||
|
'badge badge-success badge-sm': b.status === 'success',
|
||||||
|
'badge badge-info badge-sm': b.status === 'skipped',
|
||||||
|
'badge badge-error badge-sm': b.status === 'error'
|
||||||
|
}" x-text="b.status"></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs font-mono" x-text="formatDate(b.placed_at)"></td>
|
||||||
|
<td class="font-mono text-xs opacity-50 max-w-xs truncate" x-text="b.zip_remote_path ?? '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="!dashboard.books || !dashboard.books.length">
|
||||||
|
<tr><td colspan="4" class="text-center opacity-40 py-4">No books processed yet</td></tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!dashboard">
|
||||||
|
<div class="flex justify-center items-center py-24">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SETTINGS TAB -->
|
||||||
|
<div x-show="tab === 'settings'" x-cloak>
|
||||||
|
<template x-if="settings">
|
||||||
|
<form @submit.prevent="saveSettings($el)">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<!-- SFTP -->
|
||||||
|
<div class="card bg-base-200 shadow mb-4">
|
||||||
|
<div class="card-body gap-3">
|
||||||
|
<h2 class="card-title text-base">Remote host (SFTP)</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div class="sm:col-span-2 form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Host</span></label>
|
||||||
|
<input name="sftp_host" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.sftp_host ?? ''" placeholder="sftp.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Port</span></label>
|
||||||
|
<input name="sftp_port" type="number" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.sftp_port ?? '22'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Username</span></label>
|
||||||
|
<input name="sftp_user" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.sftp_user ?? ''" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Remote path</span></label>
|
||||||
|
<input name="sftp_remote_path" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.sftp_remote_path ?? ''" placeholder="/books" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth method -->
|
||||||
|
<div x-data="{ authMethod: settings.settings.sftp_auth_method || 'key' }">
|
||||||
|
<input type="hidden" name="sftp_auth_method" :value="authMethod" />
|
||||||
|
<div class="flex gap-4 mb-2 text-sm">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" class="radio radio-sm" value="key"
|
||||||
|
x-model="authMethod" /> SSH key
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" class="radio radio-sm" value="password"
|
||||||
|
x-model="authMethod" /> Password
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div x-show="authMethod === 'key'">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-xs">Private key (PEM)</span>
|
||||||
|
<span class="label-text-alt text-xs opacity-50" x-show="settings.has_key"
|
||||||
|
x-text="'Current: ' + (settings.key_fingerprint || 'loaded')"></span>
|
||||||
|
</label>
|
||||||
|
<textarea name="sftp_key" rows="5"
|
||||||
|
class="textarea textarea-bordered w-full font-mono text-xs"
|
||||||
|
placeholder="Paste new key to replace. Leave empty to keep current."></textarea>
|
||||||
|
</div>
|
||||||
|
<div x-show="authMethod === 'password'">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">SSH password</span></label>
|
||||||
|
<input name="sftp_password" type="password" class="input input-bordered input-sm w-full"
|
||||||
|
placeholder="Leave empty to keep current" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mt-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline"
|
||||||
|
@click="testConn('ssh')" :disabled="sshTestLoading">
|
||||||
|
<span x-show="sshTestLoading" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Test SSH connection
|
||||||
|
</button>
|
||||||
|
<template x-if="sshTestResult">
|
||||||
|
<span :class="sshTestResult.ok ? 'text-success text-sm' : 'text-error text-sm'"
|
||||||
|
x-text="sshTestResult.message"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grimmory -->
|
||||||
|
<div class="card bg-base-200 shadow mb-4">
|
||||||
|
<div class="card-body gap-3">
|
||||||
|
<h2 class="card-title text-base">Grimmory</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">URL</span></label>
|
||||||
|
<input name="grimmory_url" type="url" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.grimmory_url ?? ''" placeholder="http://grimmory:6060" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Username</span></label>
|
||||||
|
<input name="grimmory_user" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.grimmory_user ?? ''" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Password</span></label>
|
||||||
|
<input name="grimmory_password" type="password" class="input input-bordered input-sm"
|
||||||
|
placeholder="Leave empty to keep current" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-xs">Bookdrop path</span>
|
||||||
|
<span class="label-text-alt text-xs opacity-50">local mount of Grimmory's /bookdrop volume</span>
|
||||||
|
</label>
|
||||||
|
<input name="grimmory_bookdrop_path" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.grimmory_bookdrop_path ?? ''" placeholder="/bookdrop" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-1">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline"
|
||||||
|
@click="testConn('grimmory')" :disabled="grimmoryTestLoading">
|
||||||
|
<span x-show="grimmoryTestLoading" class="loading loading-spinner loading-xs"></span>
|
||||||
|
Test Grimmory connection
|
||||||
|
</button>
|
||||||
|
<template x-if="grimmoryTestResult">
|
||||||
|
<span :class="grimmoryTestResult.ok ? 'text-success text-sm' : 'text-error text-sm'"
|
||||||
|
x-text="grimmoryTestResult.message"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Local / Schedule -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="card bg-base-200 shadow">
|
||||||
|
<div class="card-body gap-3">
|
||||||
|
<h2 class="card-title text-base">Local</h2>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1"><span class="label-text text-xs">Work directory</span></label>
|
||||||
|
<input name="work_dir" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.work_dir ?? '/tmp/calibresync'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-200 shadow">
|
||||||
|
<div class="card-body gap-3">
|
||||||
|
<h2 class="card-title text-base">Schedule</h2>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-xs">Interval (minutes)</span>
|
||||||
|
<span class="label-text-alt text-xs opacity-50">0 = disabled</span>
|
||||||
|
</label>
|
||||||
|
<input name="scheduler_interval_minutes" type="number" min="0"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.scheduler_interval_minutes ?? '0'" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-xs">Batch size</span>
|
||||||
|
<span class="label-text-alt text-xs opacity-50">0 = all</span>
|
||||||
|
</label>
|
||||||
|
<input name="sync_batch_size" type="number" min="0"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.sync_batch_size ?? '0'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 mb-8">
|
||||||
|
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger zone -->
|
||||||
|
<div class="card border border-error">
|
||||||
|
<div class="card-body gap-2">
|
||||||
|
<h2 class="card-title text-error text-base">Danger zone</h2>
|
||||||
|
<p class="text-sm opacity-60">Deletes all records of processed zips, books, and sync run history. Settings are kept.</p>
|
||||||
|
<button type="button" class="btn btn-error btn-sm w-fit mt-1"
|
||||||
|
@click="resetSyncData()">Delete all sync data</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template x-if="!settings">
|
||||||
|
<div class="flex justify-center items-center py-24">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function app() {
|
||||||
|
return {
|
||||||
|
tab: 'dashboard',
|
||||||
|
dashboard: null,
|
||||||
|
settings: null,
|
||||||
|
toast: { show: false, msg: '', type: 'success' },
|
||||||
|
sshTestResult: null,
|
||||||
|
sshTestLoading: false,
|
||||||
|
grimmoryTestResult: null,
|
||||||
|
grimmoryTestLoading: false,
|
||||||
|
_pollInterval: null,
|
||||||
|
_prevRunning: false,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await Promise.all([this.loadDashboard(), this.loadSettings()])
|
||||||
|
this._prevRunning = this.dashboard?.sync_running ?? false
|
||||||
|
this._pollInterval = setInterval(() => this.poll(), 3000)
|
||||||
|
},
|
||||||
|
|
||||||
|
async poll() {
|
||||||
|
try {
|
||||||
|
const s = await fetch('/api/status').then(r => r.json())
|
||||||
|
if (this.dashboard) {
|
||||||
|
this.dashboard.sync_running = s.sync_running
|
||||||
|
this.dashboard.stats = s.stats
|
||||||
|
this.dashboard.next_run = s.next_run
|
||||||
|
if (this._prevRunning && !s.sync_running) {
|
||||||
|
await this.loadDashboard()
|
||||||
|
}
|
||||||
|
this._prevRunning = s.sync_running
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadDashboard() {
|
||||||
|
try {
|
||||||
|
this.dashboard = await fetch('/api/dashboard').then(r => r.json())
|
||||||
|
} catch (e) {
|
||||||
|
this.showToast('Failed to load dashboard', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
try {
|
||||||
|
this.settings = await fetch('/api/settings').then(r => r.json())
|
||||||
|
} catch (e) {
|
||||||
|
this.showToast('Failed to load settings', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async triggerSync(endpoint) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(endpoint, { method: 'POST' }).then(r => r.json())
|
||||||
|
if (r.ok) {
|
||||||
|
if (this.dashboard) this.dashboard.sync_running = true
|
||||||
|
this._prevRunning = true
|
||||||
|
this.showToast('Started', 'success')
|
||||||
|
} else {
|
||||||
|
this.showToast(r.reason === 'already_running' ? 'Sync already running' : 'Failed to start', 'error')
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
this.showToast('Request failed', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSettings(form) {
|
||||||
|
const data = {}
|
||||||
|
const fd = new FormData(form)
|
||||||
|
for (const [k, v] of fd.entries()) data[k] = v
|
||||||
|
// Ensure auth-method-hidden fields aren't duplicated by radio inputs
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then(r => r.json())
|
||||||
|
if (r.ok) {
|
||||||
|
this.showToast('Settings saved', 'success')
|
||||||
|
await Promise.all([this.loadSettings(), this.loadDashboard()])
|
||||||
|
} else {
|
||||||
|
this.showToast('Save failed', 'error')
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
this.showToast('Request failed', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async testConn(type) {
|
||||||
|
this[type + 'TestLoading'] = true
|
||||||
|
this[type + 'TestResult'] = null
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/test/' + type).then(r => r.json())
|
||||||
|
this[type + 'TestResult'] = r
|
||||||
|
} catch (e) {
|
||||||
|
this[type + 'TestResult'] = { ok: false, message: 'Request failed' }
|
||||||
|
} finally {
|
||||||
|
this[type + 'TestLoading'] = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetSyncData() {
|
||||||
|
if (!confirm('Delete all sync history? This cannot be undone.')) return
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/settings/reset-sync-data', { method: 'POST' }).then(r => r.json())
|
||||||
|
if (r.ok) {
|
||||||
|
this.showToast('Sync data cleared', 'success')
|
||||||
|
await this.loadDashboard()
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
this.showToast('Request failed', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showToast(msg, type) {
|
||||||
|
this.toast = { show: true, msg, type }
|
||||||
|
setTimeout(() => { this.toast.show = false }, 3500)
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(iso) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return iso.substring(0, 19).replace('T', ' ')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,6 +6,7 @@ from pathlib import Path
|
|||||||
import config
|
import config
|
||||||
import db
|
import db
|
||||||
import extractor
|
import extractor
|
||||||
|
import grimmory as grimmory_module
|
||||||
import sftp as sftp_module
|
import sftp as sftp_module
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -33,20 +33,17 @@ def run_sync(limit: int | None = None) -> None:
|
|||||||
|
|
||||||
_running = True
|
_running = True
|
||||||
run_id = db.start_sync_run()
|
run_id = db.start_sync_run()
|
||||||
counters = dict(zips_found=0, zips_new=0, books_imported=0, books_errored=0)
|
counters = dict(zips_found=0, zips_new=0, books_imported=0, books_skipped=0, books_errored=0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log.info("Sync started (limit=%s)", limit)
|
log.info("Sync started (limit=%s)", limit)
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
_validate_config(cfg)
|
_validate_config(cfg)
|
||||||
log.info("Config OK — work dir: %s, import dir: %s", cfg.work_dir, cfg.import_dir)
|
log.info("Config OK — work dir: %s, bookdrop: %s", cfg.work_dir, cfg.grimmory.bookdrop_path)
|
||||||
|
|
||||||
work_dir = Path(cfg.work_dir)
|
work_dir = Path(cfg.work_dir)
|
||||||
work_dir.mkdir(parents=True, exist_ok=True)
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
import_dir = Path(cfg.import_dir)
|
|
||||||
import_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
log.info("Connecting to SFTP %s@%s:%s ...", cfg.sftp.user, cfg.sftp.host, cfg.sftp.port)
|
log.info("Connecting to SFTP %s@%s:%s ...", cfg.sftp.user, cfg.sftp.host, cfg.sftp.port)
|
||||||
new_zips = sftp_module.list_new_zips(cfg.sftp, max_results=limit)
|
new_zips = sftp_module.list_new_zips(cfg.sftp, max_results=limit)
|
||||||
counters["zips_found"] = len(new_zips)
|
counters["zips_found"] = len(new_zips)
|
||||||
@@ -71,6 +68,11 @@ def run_sync(limit: int | None = None) -> None:
|
|||||||
zip_status = "success"
|
zip_status = "success"
|
||||||
zip_error = None
|
zip_error = None
|
||||||
local_zip = None
|
local_zip = None
|
||||||
|
|
||||||
|
# Insert zip row early so we have a zip_id for per-book records
|
||||||
|
db.mark_zip_processed(remote_zip.remote_path, remote_zip.file_size, "running")
|
||||||
|
zip_id = db.get_zip_id_by_path(remote_zip.remote_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
local_zip = sftp_module.download(cfg.sftp, remote_zip, str(work_dir / "downloads"))
|
local_zip = sftp_module.download(cfg.sftp, remote_zip, str(work_dir / "downloads"))
|
||||||
@@ -81,13 +83,26 @@ def run_sync(limit: int | None = None) -> None:
|
|||||||
log.info("Extract done in %.1fs — %d book(s)", time.monotonic() - t1, len(books))
|
log.info("Extract done in %.1fs — %d book(s)", time.monotonic() - t1, len(books))
|
||||||
|
|
||||||
for book in books:
|
for book in books:
|
||||||
dest = import_dir / book.name
|
sha256 = grimmory_module.compute_sha256(book)
|
||||||
if dest.exists():
|
if db.is_book_processed(sha256):
|
||||||
log.info("Skipping '%s' — already exists in import dir", book.name)
|
log.info("Skipping '%s' — sha256 already processed", book.name)
|
||||||
else:
|
counters["books_skipped"] += 1
|
||||||
shutil.move(str(book), str(dest))
|
db.record_book(zip_id, book.name, sha256, status="skipped")
|
||||||
log.info("Moved '%s' → %s", book.name, import_dir)
|
continue
|
||||||
|
result = grimmory_module.place_book(
|
||||||
|
book,
|
||||||
|
cfg.grimmory.bookdrop_path,
|
||||||
|
cfg.grimmory.url,
|
||||||
|
cfg.grimmory.user,
|
||||||
|
cfg.grimmory.password,
|
||||||
|
sha256=sha256,
|
||||||
|
)
|
||||||
|
if result.status == "success":
|
||||||
counters["books_imported"] += 1
|
counters["books_imported"] += 1
|
||||||
|
elif result.status == "skipped":
|
||||||
|
counters["books_skipped"] += 1
|
||||||
|
db.record_book(zip_id, book.name, result.sha256,
|
||||||
|
status=result.status, error_msg=result.error_msg)
|
||||||
|
|
||||||
extractor.cleanup(work_dir / "extracted" / local_zip.stem)
|
extractor.cleanup(work_dir / "extracted" / local_zip.stem)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -127,7 +142,13 @@ def _validate_config(cfg) -> None:
|
|||||||
missing.append("SSH private key")
|
missing.append("SSH private key")
|
||||||
if cfg.sftp.auth_method == "password" and not cfg.sftp.password:
|
if cfg.sftp.auth_method == "password" and not cfg.sftp.password:
|
||||||
missing.append("SSH password")
|
missing.append("SSH password")
|
||||||
if not cfg.import_dir:
|
if not cfg.grimmory.url:
|
||||||
missing.append("CWA import folder")
|
missing.append("Grimmory URL")
|
||||||
|
if not cfg.grimmory.user:
|
||||||
|
missing.append("Grimmory username")
|
||||||
|
if not cfg.grimmory.password:
|
||||||
|
missing.append("Grimmory password")
|
||||||
|
if not cfg.grimmory.bookdrop_path:
|
||||||
|
missing.append("Grimmory bookdrop path")
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"Missing configuration: {', '.join(missing)}")
|
raise ValueError(f"Missing configuration: {', '.join(missing)}")
|
||||||
|
|||||||
+27
-6
@@ -85,15 +85,35 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h2>Local</h2>
|
<h2>Grimmory</h2>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="import_dir">CWA import folder</label>
|
<label for="grimmory_url">URL</label>
|
||||||
<input id="import_dir" name="import_dir" type="text" placeholder="/mnt/cwa-import"
|
<input id="grimmory_url" name="grimmory_url" type="text" placeholder="http://192.168.1.10:6060"
|
||||||
value="{{ s.get('import_dir','') }}">
|
value="{{ s.get('grimmory_url','') }}">
|
||||||
<p class="muted small">Folder watched by Calibre-Web-Automated. Extracted epub/pdf files are moved here flat.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="grimmory_user">Username</label>
|
||||||
|
<input id="grimmory_user" name="grimmory_user" type="text"
|
||||||
|
value="{{ s.get('grimmory_user','') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="grimmory_password">Password</label>
|
||||||
|
<input id="grimmory_password" name="grimmory_password" type="password"
|
||||||
|
value="{{ s.get('grimmory_password','') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="testConn('grimmory', this)">Test Grimmory connection</button>
|
||||||
|
<p id="test-grimmory-result" class="test-result"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<h2>Local</h2>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="work_dir">Temp work directory</label>
|
<label for="work_dir">Temp work directory</label>
|
||||||
<input id="work_dir" name="work_dir" type="text" placeholder="/tmp/calibresync"
|
<input id="work_dir" name="work_dir" type="text" placeholder="/tmp/calibresync"
|
||||||
@@ -149,6 +169,7 @@ toggleAuth(document.querySelector('[name=sftp_auth_method]:checked')?.value || "
|
|||||||
|
|
||||||
async function testConn(type, btn) {
|
async function testConn(type, btn) {
|
||||||
const result = document.getElementById(`test-${type}-result`);
|
const result = document.getElementById(`test-${type}-result`);
|
||||||
|
const originalText = btn.textContent;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Testing…";
|
btn.textContent = "Testing…";
|
||||||
result.className = "test-result";
|
result.className = "test-result";
|
||||||
@@ -163,7 +184,7 @@ async function testConn(type, btn) {
|
|||||||
result.className = "test-result test-fail";
|
result.className = "test-result test-fail";
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Test SSH connection";
|
btn.textContent = originalText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user