switch to grimmory as book web app and a more modern dashboard

This commit is contained in:
2026-05-14 21:15:46 +02:00
parent 27fa22eee1
commit e029da895b
8 changed files with 785 additions and 100 deletions
+17 -3
View File
@@ -14,11 +14,19 @@ class SFTPConfig:
remote_path: str = ""
@dataclass
class GrimmoryConfig:
url: str = ""
user: str = ""
password: str = ""
bookdrop_path: str = ""
@dataclass
class AppConfig:
sftp: SFTPConfig = field(default_factory=SFTPConfig)
grimmory: GrimmoryConfig = field(default_factory=GrimmoryConfig)
work_dir: str = "/tmp/calibresync"
import_dir: str = ""
def load() -> AppConfig:
@@ -33,8 +41,13 @@ def load() -> AppConfig:
password=s.get("sftp_password", ""),
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"),
import_dir=s.get("import_dir", ""),
)
@@ -42,7 +55,8 @@ def save(form: dict) -> None:
keys = [
"sftp_host", "sftp_port", "sftp_user", "sftp_auth_method",
"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",
]
for key in keys:
+63 -7
View File
@@ -64,6 +64,16 @@ def init_db() -> None:
file_size INTEGER 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)
@@ -72,7 +82,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
existing = {row[1] for row in conn.execute("PRAGMA table_info(sync_runs)")}
if "books_imported" not in existing:
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:
try:
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]:
with get_db() as conn:
return conn.execute(
@@ -200,30 +220,66 @@ def get_recent_runs(limit: int = 10) -> list[sqlite3.Row]:
).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:
with get_db() as conn:
total_zips = conn.execute("SELECT COUNT(*) FROM processed_zips").fetchone()[0]
total_imported = conn.execute(
"SELECT COALESCE(SUM(books_imported), 0) FROM sync_runs"
total_books = conn.execute(
"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]
last_run = conn.execute(
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1"
).fetchone()
return {
"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,
}
def clear_sync_data() -> dict:
"""Delete all processed_zips and sync_runs rows. Settings are kept.
Also resets the remote scan timestamp so the next sync does a full rescan."""
"""Delete all processed_books, processed_zips, and sync_runs rows. Settings are kept."""
with get_db() as conn:
books = conn.execute("DELETE FROM processed_books").rowcount
zips = conn.execute("DELETE FROM processed_zips").rowcount
runs = conn.execute("DELETE FROM sync_runs").rowcount
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:
+90
View File
@@ -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)
+69 -68
View File
@@ -3,13 +3,14 @@ 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 import BackgroundTasks, FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import config
import db
import grimmory as grimmory_module
import sftp as sftp_module
import sync
@@ -50,101 +51,94 @@ async def lifespan(app: FastAPI):
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 ---
# --- SPA root ---
@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)]
@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")
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,
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,
"batch_size": batch_size,
"cache_info": cache_info,
})
}
# --- Settings ---
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
@app.get("/api/settings")
async def api_get_settings():
s = db.get_all_settings()
key_pem = s.get("sftp_key", "")
return templates.TemplateResponse(request, "settings.html", {
"s": s,
return {
"settings": 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,
})
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/calibresync"
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 RedirectResponse("/settings?saved=1", status_code=303)
return {"ok": True}
# --- Sync triggers ---
@app.post("/sync")
async def trigger_sync(background_tasks: BackgroundTasks):
@app.post("/api/sync")
async def api_trigger_sync(background_tasks: BackgroundTasks):
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)
return RedirectResponse("/?started=1", status_code=303)
return {"ok": True}
@app.post("/sync/test")
async def trigger_test_sync(background_tasks: BackgroundTasks):
@app.post("/api/sync/test")
async def api_trigger_test_sync(background_tasks: BackgroundTasks):
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)
return RedirectResponse("/?test_started=1", status_code=303)
return {"ok": True}
@app.post("/sync/rescan")
async def trigger_rescan(background_tasks: BackgroundTasks):
@app.post("/api/sync/rescan")
async def api_trigger_rescan(background_tasks: BackgroundTasks):
if sync.is_running():
return RedirectResponse("/?already_running=1", status_code=303)
return {"ok": False, "reason": "already_running"}
cfg = config.load()
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 ---
@@ -156,13 +150,20 @@ async def test_ssh():
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("/settings/reset-sync-data")
async def reset_sync_data():
@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 RedirectResponse("/settings?reset=1", status_code=303)
return {"ok": True, "counts": counts}
# --- JSON status API ---
+1 -2
View File
@@ -1,7 +1,6 @@
fastapi
uvicorn[standard]
jinja2
python-multipart
paramiko
rarfile
apscheduler
requests
+483
View File
@@ -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>
+35 -14
View File
@@ -1,5 +1,4 @@
import logging
import shutil
import threading
import time
from pathlib import Path
@@ -7,6 +6,7 @@ from pathlib import Path
import config
import db
import extractor
import grimmory as grimmory_module
import sftp as sftp_module
log = logging.getLogger(__name__)
@@ -33,20 +33,17 @@ def run_sync(limit: int | None = None) -> None:
_running = True
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:
log.info("Sync started (limit=%s)", limit)
cfg = config.load()
_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.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)
new_zips = sftp_module.list_new_zips(cfg.sftp, max_results=limit)
counters["zips_found"] = len(new_zips)
@@ -71,6 +68,11 @@ def run_sync(limit: int | None = None) -> None:
zip_status = "success"
zip_error = 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:
t0 = time.monotonic()
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))
for book in books:
dest = import_dir / book.name
if dest.exists():
log.info("Skipping '%s' — already exists in import dir", book.name)
else:
shutil.move(str(book), str(dest))
log.info("Moved '%s'%s", book.name, import_dir)
sha256 = grimmory_module.compute_sha256(book)
if db.is_book_processed(sha256):
log.info("Skipping '%s' sha256 already processed", book.name)
counters["books_skipped"] += 1
db.record_book(zip_id, book.name, sha256, status="skipped")
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
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)
except Exception as e:
@@ -127,7 +142,13 @@ def _validate_config(cfg) -> None:
missing.append("SSH private key")
if cfg.sftp.auth_method == "password" and not cfg.sftp.password:
missing.append("SSH password")
if not cfg.import_dir:
missing.append("CWA import folder")
if not cfg.grimmory.url:
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:
raise ValueError(f"Missing configuration: {', '.join(missing)}")
+27 -6
View File
@@ -85,15 +85,35 @@
</section>
<section class="form-section">
<h2>Local</h2>
<h2>Grimmory</h2>
<div class="form-row">
<label for="import_dir">CWA import folder</label>
<input id="import_dir" name="import_dir" type="text" placeholder="/mnt/cwa-import"
value="{{ s.get('import_dir','') }}">
<p class="muted small">Folder watched by Calibre-Web-Automated. Extracted epub/pdf files are moved here flat.</p>
<label for="grimmory_url">URL</label>
<input id="grimmory_url" name="grimmory_url" type="text" placeholder="http://192.168.1.10:6060"
value="{{ s.get('grimmory_url','') }}">
</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">
<label for="work_dir">Temp work directory</label>
<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) {
const result = document.getElementById(`test-${type}-result`);
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Testing…";
result.className = "test-result";
@@ -163,7 +184,7 @@ async function testConn(type, btn) {
result.className = "test-result test-fail";
} finally {
btn.disabled = false;
btn.textContent = "Test SSH connection";
btn.textContent = originalText;
}
}
</script>