Initial commit

This commit is contained in:
2026-05-09 18:41:03 +02:00
commit d384f1f896
18 changed files with 1339 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
.env
data/
*.db
.git/
.gitignore
+20
View File
@@ -0,0 +1,20 @@
# CalibreSync — example environment file
# All settings are stored in the SQLite database and configurable via the
# dashboard at /settings. This file is only used as a reference.
# SFTP / remote host
SFTP_HOST=192.168.1.10
SFTP_PORT=22
SFTP_USER=myuser
SFTP_REMOTE_PATH=/mnt/media/zips
# Auth: "key" (paste PEM in dashboard) or "password"
SFTP_AUTH_METHOD=key
# Calibre-Web
CALIBRE_URL=http://localhost:8083
CALIBRE_USER=admin
CALIBRE_PASS=admin123
# Local temp directory for downloads and extraction
LOCAL_WORK_DIR=/tmp/calibresync
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.pyo
.env
data/
*.db
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim-bookworm
# unrar-free covers RAR4; for RAR5 archives replace with the non-free unrar:
# RUN echo "deb http://deb.debian.org/debian bookworm non-free" >> /etc/apt/sources.list \
# && apt-get update && apt-get install -y --no-install-recommends unrar
RUN apt-get update \
&& apt-get install -y --no-install-recommends unrar-free \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# SQLite database and any persistent state live here; mount a volume in production
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+65
View File
@@ -0,0 +1,65 @@
from dataclasses import dataclass, field
import db
@dataclass
class SFTPConfig:
host: str = ""
port: int = 22
user: str = ""
auth_method: str = "key" # "key" or "password"
key: str = ""
password: str = ""
remote_path: str = ""
@dataclass
class CalibreConfig:
url: str = ""
user: str = ""
password: str = ""
@dataclass
class AppConfig:
sftp: SFTPConfig = field(default_factory=SFTPConfig)
calibre: CalibreConfig = field(default_factory=CalibreConfig)
local_work_dir: str = "/tmp/calibresync"
def load() -> AppConfig:
s = db.get_all_settings()
return AppConfig(
sftp=SFTPConfig(
host=s.get("sftp_host", ""),
port=int(s.get("sftp_port", "22")),
user=s.get("sftp_user", ""),
auth_method=s.get("sftp_auth_method", "key"),
key=s.get("sftp_key", ""),
password=s.get("sftp_password", ""),
remote_path=s.get("sftp_remote_path", ""),
),
calibre=CalibreConfig(
url=s.get("calibre_url", "").rstrip("/"),
user=s.get("calibre_user", ""),
password=s.get("calibre_pass", ""),
),
local_work_dir=s.get("local_work_dir", "/tmp/calibresync"),
)
def save(form: dict) -> None:
keys = [
"sftp_host", "sftp_port", "sftp_user", "sftp_auth_method",
"sftp_password", "sftp_remote_path",
"calibre_url", "calibre_user", "calibre_pass",
"local_work_dir", "scheduler_interval_minutes",
]
for key in keys:
if key in form and form[key] is not None:
db.set_setting(key, str(form[key]))
# Only overwrite the SSH key if a non-empty value was submitted
if form.get("sftp_key", "").strip():
db.set_setting("sftp_key", form["sftp_key"].strip())
+208
View File
@@ -0,0 +1,208 @@
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
_data_dir = Path(os.environ.get("DATA_DIR", Path(__file__).parent))
_data_dir.mkdir(parents=True, exist_ok=True)
DB_PATH = _data_dir / "calibresync.db"
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def get_db():
conn = _connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db() -> None:
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS processed_zips (
id INTEGER PRIMARY KEY,
remote_path TEXT UNIQUE NOT NULL,
file_size INTEGER,
processed_at TEXT,
status TEXT,
error_msg TEXT
);
CREATE TABLE IF NOT EXISTS uploaded_books (
id INTEGER PRIMARY KEY,
filename TEXT NOT NULL,
file_hash TEXT UNIQUE NOT NULL,
zip_source TEXT,
uploaded_at TEXT,
status TEXT
);
CREATE TABLE IF NOT EXISTS sync_runs (
id INTEGER PRIMARY KEY,
started_at TEXT NOT NULL,
finished_at TEXT,
zips_found INTEGER DEFAULT 0,
zips_new INTEGER DEFAULT 0,
books_uploaded INTEGER DEFAULT 0,
books_skipped INTEGER DEFAULT 0,
books_errored INTEGER DEFAULT 0,
status TEXT DEFAULT 'running',
error_msg TEXT
);
""")
# --- Settings ---
def get_setting(key: str, default: str | None = None) -> str | None:
with get_db() as conn:
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(key: str, value: str) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def get_all_settings() -> dict[str, str]:
with get_db() as conn:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
return {row["key"]: row["value"] for row in rows}
# --- Processed zips ---
def is_zip_processed(remote_path: str) -> bool:
with get_db() as conn:
row = conn.execute(
"SELECT id FROM processed_zips WHERE remote_path = ?", (remote_path,)
).fetchone()
return row is not None
def mark_zip_processed(remote_path: str, file_size: int, status: str, error_msg: str | None = None) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO processed_zips (remote_path, file_size, processed_at, status, error_msg)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(remote_path) DO UPDATE SET
processed_at = excluded.processed_at,
status = excluded.status,
error_msg = excluded.error_msg""",
(remote_path, file_size, _now(), status, error_msg),
)
def get_recent_zips(limit: int = 50) -> list[sqlite3.Row]:
with get_db() as conn:
return conn.execute(
"SELECT * FROM processed_zips ORDER BY processed_at DESC LIMIT ?", (limit,)
).fetchall()
# --- Uploaded books ---
def is_book_uploaded(file_hash: str) -> bool:
with get_db() as conn:
row = conn.execute(
"SELECT id FROM uploaded_books WHERE file_hash = ?", (file_hash,)
).fetchone()
return row is not None
def record_book(filename: str, file_hash: str, zip_source: str, status: str) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO uploaded_books (filename, file_hash, zip_source, uploaded_at, status)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(file_hash) DO UPDATE SET status = excluded.status""",
(filename, file_hash, zip_source, _now(), status),
)
def get_books(limit: int = 200, offset: int = 0) -> list[sqlite3.Row]:
with get_db() as conn:
return conn.execute(
"SELECT * FROM uploaded_books ORDER BY uploaded_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
def get_books_count() -> int:
with get_db() as conn:
return conn.execute("SELECT COUNT(*) FROM uploaded_books").fetchone()[0]
# --- Sync runs ---
def start_sync_run() -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO sync_runs (started_at) VALUES (?)", (_now(),)
)
return cur.lastrowid
def finish_sync_run(run_id: int, **kwargs) -> None:
fields = ", ".join(f"{k} = ?" for k in kwargs)
values = list(kwargs.values()) + [_now(), run_id]
with get_db() as conn:
conn.execute(
f"UPDATE sync_runs SET {fields}, finished_at = ? WHERE id = ?", values
)
def get_recent_runs(limit: int = 10) -> list[sqlite3.Row]:
with get_db() as conn:
return conn.execute(
"SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT ?", (limit,)
).fetchall()
def get_stats() -> dict:
with get_db() as conn:
total_books = conn.execute("SELECT COUNT(*) FROM uploaded_books").fetchone()[0]
uploaded = conn.execute(
"SELECT COUNT(*) FROM uploaded_books WHERE status = 'uploaded'"
).fetchone()[0]
skipped = conn.execute(
"SELECT COUNT(*) FROM uploaded_books WHERE status = 'skipped_duplicate'"
).fetchone()[0]
total_zips = conn.execute("SELECT COUNT(*) FROM processed_zips").fetchone()[0]
last_run = conn.execute(
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1"
).fetchone()
return {
"total_books": total_books,
"uploaded": uploaded,
"skipped": skipped,
"total_zips": total_zips,
"last_run": dict(last_run) if last_run else None,
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
+13
View File
@@ -0,0 +1,13 @@
services:
calibresync:
build: .
ports:
- "8000:8000"
volumes:
# Persists the SQLite database and settings across container restarts
- ./data:/app/data
# Optional: mount your SSH private key read-only instead of pasting it in the UI
# - ~/.ssh/id_rsa:/run/secrets/ssh_key:ro
restart: unless-stopped
environment:
DATA_DIR: /app/data
+57
View File
@@ -0,0 +1,57 @@
import logging
import shutil
import zipfile
from pathlib import Path
import rarfile
log = logging.getLogger(__name__)
BOOK_EXTENSIONS = {".epub", ".pdf"}
def extract(zip_path: Path, work_dir: Path) -> list[Path]:
"""Unzip, then unrar, then return all epub/pdf paths found."""
extract_root = work_dir / zip_path.stem
extract_root.mkdir(parents=True, exist_ok=True)
try:
_unzip(zip_path, extract_root)
rar_files = list(extract_root.rglob("*.rar")) + list(extract_root.rglob("*.RAR"))
for rar in rar_files:
_unrar(rar, rar.parent)
books = [
p for p in extract_root.rglob("*")
if p.suffix.lower() in BOOK_EXTENSIONS and p.is_file()
]
log.info("Extracted %d book(s) from %s", len(books), zip_path.name)
return books
except Exception as e:
log.error("Failed to extract %s: %s", zip_path, e)
shutil.rmtree(extract_root, ignore_errors=True)
raise
def cleanup(path: Path) -> None:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.is_file():
path.unlink(missing_ok=True)
def _unzip(zip_path: Path, dest: Path) -> None:
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(dest)
log.debug("Unzipped %s%s", zip_path.name, dest)
def _unrar(rar_path: Path, dest: Path) -> None:
try:
with rarfile.RarFile(rar_path, "r") as rf:
rf.extractall(dest)
log.debug("Unrared %s%s", rar_path.name, dest)
except rarfile.NeedFirstVolume:
log.debug("Skipping non-first volume: %s", rar_path.name)
except Exception as e:
log.warning("Failed to unrar %s: %s", rar_path.name, e)
+157
View File
@@ -0,0 +1,157 @@
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 sync
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s%(message)s")
log = logging.getLogger(__name__)
_scheduler = BackgroundScheduler(timezone="UTC")
def _reschedule_auto_sync() -> None:
_scheduler.remove_all_jobs()
try:
interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0")
except ValueError:
interval = 0
if interval > 0:
_scheduler.add_job(sync.run_sync, "interval", minutes=interval, id="auto_sync")
log.info("Auto-sync scheduled every %d minute(s)", interval)
else:
log.info("Auto-sync disabled")
def next_run_time() -> str | None:
job = _scheduler.get_job("auto_sync")
if job and job.next_run_time:
return job.next_run_time.strftime("%Y-%m-%d %H:%M UTC")
return None
@asynccontextmanager
async def lifespan(app: FastAPI):
db.init_db()
_scheduler.start()
_reschedule_auto_sync()
yield
_scheduler.shutdown(wait=False)
app = FastAPI(title="CalibreSync", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
# --- Dashboard ---
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
stats = db.get_stats()
runs = [dict(r) for r in db.get_recent_runs(10)]
zips = [dict(z) for z in db.get_recent_zips(20)]
interval = int(db.get_setting("scheduler_interval_minutes", "0") or "0")
return templates.TemplateResponse(
"index.html",
{
"request": request,
"stats": stats,
"runs": runs,
"zips": zips,
"sync_running": sync.is_running(),
"next_run": next_run_time(),
"interval": interval,
},
)
# --- 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(
"books.html",
{"request": request, "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()
has_key = bool(s.get("sftp_key", "").strip())
return templates.TemplateResponse(
"settings.html",
{"request": request, "s": s, "has_key": has_key},
)
@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"),
):
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,
})
_reschedule_auto_sync()
return RedirectResponse("/settings?saved=1", status_code=303)
# --- Sync trigger ---
@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)
# --- 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)],
}
+8
View File
@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
jinja2
python-multipart
paramiko
rarfile
requests
apscheduler
+75
View File
@@ -0,0 +1,75 @@
import io
import logging
import posixpath
from dataclasses import dataclass
from pathlib import Path
import paramiko
import db
from config import SFTPConfig
log = logging.getLogger(__name__)
@dataclass
class RemoteZip:
remote_path: str
file_size: int
def _make_transport(cfg: SFTPConfig) -> paramiko.Transport:
transport = paramiko.Transport((cfg.host, cfg.port))
if cfg.auth_method == "key" and cfg.key:
key = paramiko.RSAKey.from_private_key(io.StringIO(cfg.key))
transport.connect(username=cfg.user, pkey=key)
else:
transport.connect(username=cfg.user, password=cfg.password)
return transport
def list_new_zips(cfg: SFTPConfig) -> list[RemoteZip]:
transport = _make_transport(cfg)
sftp = paramiko.SFTPClient.from_transport(transport)
try:
all_zips = _walk_zips(sftp, cfg.remote_path)
new_zips = [z for z in all_zips if not db.is_zip_processed(z.remote_path)]
log.info("Remote: %d zip(s) total, %d new", len(all_zips), len(new_zips))
return new_zips
finally:
sftp.close()
transport.close()
def download(cfg: SFTPConfig, remote_zip: RemoteZip, dest_dir: str) -> Path:
dest = Path(dest_dir)
dest.mkdir(parents=True, exist_ok=True)
local_path = dest / Path(remote_zip.remote_path).name
transport = _make_transport(cfg)
sftp = paramiko.SFTPClient.from_transport(transport)
try:
log.info("Downloading %s%s", remote_zip.remote_path, local_path)
sftp.get(remote_zip.remote_path, str(local_path))
finally:
sftp.close()
transport.close()
return local_path
def _walk_zips(sftp: paramiko.SFTPClient, remote_dir: str) -> list[RemoteZip]:
results: list[RemoteZip] = []
try:
entries = sftp.listdir_attr(remote_dir)
except IOError as e:
log.warning("Cannot list %s: %s", remote_dir, e)
return results
for entry in entries:
full_path = posixpath.join(remote_dir, entry.filename)
import stat
if stat.S_ISDIR(entry.st_mode):
results.extend(_walk_zips(sftp, full_path))
elif entry.filename.lower().endswith(".zip"):
results.append(RemoteZip(remote_path=full_path, file_size=entry.st_size or 0))
return results
+209
View File
@@ -0,0 +1,209 @@
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e2e8f0;
--muted: #64748b;
--accent: #6366f1;
--accent-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--running: #3b82f6;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Nav */
nav {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 2rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
nav a { color: var(--muted); text-decoration: none; font-size: 0.9rem; }
nav a:hover { color: var(--text); }
nav .brand { font-weight: 700; font-size: 1rem; color: var(--accent); margin-right: auto; }
/* Main */
main {
flex: 1;
max-width: 1100px;
width: 100%;
margin: 0 auto;
padding: 2rem;
}
footer {
text-align: center;
padding: 1rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
footer a { color: var(--muted); }
/* Headings */
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; }
h2 { font-size: 1.1rem; font-weight: 600; margin: 2rem 0 1rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.75rem; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-header h1 { margin-bottom: 0; }
/* Alerts */
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.alert-success { background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); color: var(--success); }
.alert-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); }
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem 1.5rem;
}
.stat-value { font-size: 2rem; font-weight: 700; color: var(--accent); }
.stat-label { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
}
th {
text-align: left;
padding: 0.6rem 1rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.02);
}
td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.02); }
/* Badges */
.badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-success, .badge-uploaded { background: rgba(34,197,94,0.15); color: var(--success); }
.badge-error { background: rgba(239,68,68,0.15); color: var(--error); }
.badge-partial, .badge-skipped_duplicate { background: rgba(245,158,11,0.15); color: var(--warning); }
.badge-running { background: rgba(59,130,246,0.15); color: var(--running); }
/* Buttons */
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
text-decoration: none;
transition: background 0.15s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
/* Forms */
.form-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.form-section h2 { margin-top: 0; }
.form-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.form-row:last-child { margin-bottom: 0; }
label { font-size: 0.85rem; color: var(--muted); font-weight: 500; }
input[type=text], input[type=url], input[type=password], input[type=number], textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
font-family: inherit;
width: 100%;
transition: border-color 0.15s;
}
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
textarea { font-family: monospace; resize: vertical; }
.radio-group { display: flex; gap: 1.5rem; }
.radio-group label { display: flex; align-items: center; gap: 0.4rem; color: var(--text); cursor: pointer; }
.form-actions { display: flex; justify-content: flex-end; }
/* Pagination */
.pagination { display: flex; align-items: center; gap: 1rem; justify-content: center; padding: 1rem 0; }
.pagination a { color: var(--accent); text-decoration: none; }
.pagination span { color: var(--muted); font-size: 0.85rem; }
/* Header actions */
.header-actions { display: flex; align-items: center; gap: 1rem; }
.schedule-badge {
font-size: 0.8rem;
color: var(--running);
background: rgba(59,130,246,0.12);
border: 1px solid rgba(59,130,246,0.25);
border-radius: 999px;
padding: 0.3rem 0.8rem;
}
/* Utilities */
.muted { color: var(--muted); }
.small { font-size: 0.8rem; }
.mono { font-family: monospace; font-size: 0.82rem; }
p.muted { padding: 1.5rem 0; }
+103
View File
@@ -0,0 +1,103 @@
import logging
import threading
from pathlib import Path
import config
import db
import extractor
import sftp as sftp_module
from uploader import CalibreClient
log = logging.getLogger(__name__)
_lock = threading.Lock()
_running = False
def is_running() -> bool:
return _running
def run_sync() -> None:
global _running
if not _lock.acquire(blocking=False):
log.warning("Sync already running, skipping")
return
_running = True
run_id = db.start_sync_run()
counters = dict(zips_found=0, zips_new=0, books_uploaded=0, books_skipped=0, books_errored=0)
try:
cfg = config.load()
_validate_config(cfg)
work_dir = Path(cfg.local_work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
log.info("Listing remote zips at %s@%s:%s", cfg.sftp.user, cfg.sftp.host, cfg.sftp.remote_path)
new_zips = sftp_module.list_new_zips(cfg.sftp)
counters["zips_found"] = len(new_zips)
counters["zips_new"] = len(new_zips)
client = CalibreClient(cfg.calibre)
for remote_zip in new_zips:
zip_status = "success"
zip_error = None
local_zip = None
try:
local_zip = sftp_module.download(cfg.sftp, remote_zip, str(work_dir / "downloads"))
books = extractor.extract(local_zip, work_dir / "extracted")
for book in books:
status = client.upload(book, zip_source=remote_zip.remote_path)
if status == "uploaded":
counters["books_uploaded"] += 1
elif status == "skipped_duplicate":
counters["books_skipped"] += 1
else:
counters["books_errored"] += 1
extractor.cleanup(work_dir / "extracted" / local_zip.stem)
except Exception as e:
log.error("Error processing %s: %s", remote_zip.remote_path, e)
zip_status = "error"
zip_error = str(e)
finally:
if local_zip and local_zip.exists():
extractor.cleanup(local_zip)
db.mark_zip_processed(remote_zip.remote_path, remote_zip.file_size, zip_status, zip_error)
db.finish_sync_run(run_id, status="success", **counters)
log.info(
"Sync done. Zips: %d, Uploaded: %d, Skipped: %d, Errors: %d",
counters["zips_new"], counters["books_uploaded"],
counters["books_skipped"], counters["books_errored"],
)
except Exception as e:
log.exception("Sync run failed: %s", e)
db.finish_sync_run(run_id, status="error", error_msg=str(e), **counters)
finally:
_running = False
_lock.release()
def _validate_config(cfg) -> None:
missing = []
if not cfg.sftp.host:
missing.append("SFTP host")
if not cfg.sftp.user:
missing.append("SFTP user")
if not cfg.sftp.remote_path:
missing.append("SFTP remote path")
if cfg.sftp.auth_method == "key" and not cfg.sftp.key:
missing.append("SSH private key")
if cfg.sftp.auth_method == "password" and not cfg.sftp.password:
missing.append("SSH password")
if not cfg.calibre.url:
missing.append("Calibre-Web URL")
if not cfg.calibre.user:
missing.append("Calibre-Web username")
if missing:
raise ValueError(f"Missing configuration: {', '.join(missing)}")
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CalibreSync{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<a href="/" class="brand">CalibreSync</a>
<a href="/">Dashboard</a>
<a href="/books">Books</a>
<a href="/settings">Settings</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<footer>CalibreSync — <a href="/docs" target="_blank">API docs</a></footer>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Books — CalibreSync{% endblock %}
{% block content %}
<div class="page-header">
<h1>Books <span class="muted">({{ total }})</span></h1>
</div>
{% if books %}
<table>
<thead>
<tr>
<th>Filename</th>
<th>Status</th>
<th>Source zip</th>
<th>Uploaded</th>
</tr>
</thead>
<tbody>
{% for b in books %}
<tr>
<td>{{ b.filename }}</td>
<td><span class="badge badge-{{ b.status }}">{{ b.status }}</span></td>
<td class="mono small muted">{{ b.zip_source or "—" }}</td>
<td>{{ b.uploaded_at[:19].replace("T"," ") if b.uploaded_at else "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="/books?page={{ page - 1 }}">&laquo; Prev</a>
{% endif %}
<span>Page {{ page }} of {{ pages }}</span>
{% if page < pages %}
<a href="/books?page={{ page + 1 }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="muted">No books recorded yet.</p>
{% endif %}
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Dashboard — CalibreSync{% endblock %}
{% block content %}
<div class="page-header">
<h1>Dashboard</h1>
<div class="header-actions">
{% if interval > 0 %}
<span class="schedule-badge">
Auto every {{ interval }}m
{% if next_run %} &mdash; next: {{ next_run }}{% endif %}
</span>
{% endif %}
<form method="post" action="/sync" style="display:inline">
{% if sync_running %}
<button class="btn btn-disabled" disabled>Sync running…</button>
{% else %}
<button class="btn btn-primary">Run Sync Now</button>
{% endif %}
</form>
</div>
</div>
{% if request.query_params.get("started") %}
<div class="alert alert-success">Sync started in background.</div>
{% endif %}
{% if request.query_params.get("already_running") %}
<div class="alert alert-warning">A sync is already running.</div>
{% endif %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_zips }}</div>
<div class="stat-label">Zip archives processed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.uploaded }}</div>
<div class="stat-label">Books uploaded</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.skipped }}</div>
<div class="stat-label">Duplicates skipped</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_books }}</div>
<div class="stat-label">Total book records</div>
</div>
</div>
<h2>Recent sync runs</h2>
{% if runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>New zips</th>
<th>Uploaded</th>
<th>Skipped</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.started_at[:19].replace("T"," ") }}</td>
<td>{{ r.finished_at[:19].replace("T"," ") if r.finished_at else "—" }}</td>
<td><span class="badge badge-{{ r.status }}">{{ r.status }}</span></td>
<td>{{ r.zips_new }}</td>
<td>{{ r.books_uploaded }}</td>
<td>{{ r.books_skipped }}</td>
<td>{{ r.books_errored }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No sync runs yet. Click "Run Sync" to start.</p>
{% endif %}
<h2>Recent zip archives</h2>
{% if zips %}
<table>
<thead>
<tr>
<th>Remote path</th>
<th>Size</th>
<th>Processed</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for z in zips %}
<tr>
<td class="mono">{{ z.remote_path }}</td>
<td>{{ (z.file_size / 1048576) | round(1) }} MB</td>
<td>{{ z.processed_at[:19].replace("T"," ") if z.processed_at else "—" }}</td>
<td><span class="badge badge-{{ z.status }}">{{ z.status }}</span></td>
<td class="muted small">{{ z.error_msg or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No zip archives processed yet.</p>
{% endif %}
{% if sync_running %}
<script>
setTimeout(() => location.reload(), 5000);
</script>
{% endif %}
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}Settings — CalibreSync{% endblock %}
{% block content %}
<h1>Settings</h1>
{% if request.query_params.get("saved") %}
<div class="alert alert-success">Settings saved.</div>
{% endif %}
<form method="post" action="/settings">
<section class="form-section">
<h2>Remote host (SFTP)</h2>
<div class="form-row">
<label for="sftp_host">Host</label>
<input id="sftp_host" name="sftp_host" type="text" placeholder="192.168.1.10"
value="{{ s.get('sftp_host','') }}">
</div>
<div class="form-row">
<label for="sftp_port">Port</label>
<input id="sftp_port" name="sftp_port" type="number" value="{{ s.get('sftp_port','22') }}" style="width:6rem">
</div>
<div class="form-row">
<label for="sftp_user">Username</label>
<input id="sftp_user" name="sftp_user" type="text" value="{{ s.get('sftp_user','') }}">
</div>
<div class="form-row">
<label for="sftp_remote_path">Remote zip directory</label>
<input id="sftp_remote_path" name="sftp_remote_path" type="text" placeholder="/mnt/media/zips"
value="{{ s.get('sftp_remote_path','') }}">
</div>
<div class="form-row">
<label>Authentication</label>
<div class="radio-group">
<label>
<input type="radio" name="sftp_auth_method" value="key"
{% if s.get('sftp_auth_method','key') == 'key' %}checked{% endif %}
onchange="toggleAuth(this.value)">
SSH key
</label>
<label>
<input type="radio" name="sftp_auth_method" value="password"
{% if s.get('sftp_auth_method') == 'password' %}checked{% endif %}
onchange="toggleAuth(this.value)">
Password
</label>
</div>
</div>
<div class="form-row" id="row-key">
<label for="sftp_key">Private key (PEM)</label>
<textarea id="sftp_key" name="sftp_key" rows="8" placeholder="-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----"></textarea>
{% if has_key %}
<p class="muted small">A key is already configured. Paste a new one above to replace it, or leave blank to keep the existing key.</p>
{% endif %}
</div>
<div class="form-row" id="row-password" style="display:none">
<label for="sftp_password">Password</label>
<input id="sftp_password" name="sftp_password" type="password"
value="{{ s.get('sftp_password','') }}">
</div>
</section>
<section class="form-section">
<h2>Calibre-Web</h2>
<div class="form-row">
<label for="calibre_url">URL</label>
<input id="calibre_url" name="calibre_url" type="url" placeholder="http://localhost:8083"
value="{{ s.get('calibre_url','') }}">
</div>
<div class="form-row">
<label for="calibre_user">Username</label>
<input id="calibre_user" name="calibre_user" type="text" value="{{ s.get('calibre_user','') }}">
</div>
<div class="form-row">
<label for="calibre_pass">Password</label>
<input id="calibre_pass" name="calibre_pass" type="password"
value="{{ s.get('calibre_pass','') }}">
</div>
</section>
<section class="form-section">
<h2>Local</h2>
<div class="form-row">
<label for="local_work_dir">Work directory</label>
<input id="local_work_dir" name="local_work_dir" type="text" placeholder="/tmp/calibresync"
value="{{ s.get('local_work_dir','/tmp/calibresync') }}">
<p class="muted small">Temporary storage for downloaded zips and extracted files. Cleaned up after each run.</p>
</div>
</section>
<section class="form-section">
<h2>Automatic sync schedule</h2>
<div class="form-row">
<label for="scheduler_interval_minutes">Run every (minutes)</label>
<input id="scheduler_interval_minutes" name="scheduler_interval_minutes" type="number"
min="0" step="1" style="width:8rem"
value="{{ s.get('scheduler_interval_minutes','0') }}"
placeholder="0">
<p class="muted small">Set to 0 to disable automatic sync. Changes take effect immediately on save. Examples: 60 = hourly, 1440 = daily.</p>
</div>
</section>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save settings</button>
</div>
</form>
<script>
function toggleAuth(method) {
document.getElementById("row-key").style.display = method === "key" ? "" : "none";
document.getElementById("row-password").style.display = method === "password" ? "" : "none";
}
// Init on load
toggleAuth(document.querySelector('[name=sftp_auth_method]:checked')?.value || "key");
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
import hashlib
import logging
import mimetypes
from pathlib import Path
import requests
import db
from config import CalibreConfig
log = logging.getLogger(__name__)
MIME_TYPES = {
".epub": "application/epub+zip",
".pdf": "application/pdf",
}
class CalibreClient:
def __init__(self, cfg: CalibreConfig):
self._cfg = cfg
self._session = requests.Session()
self._authenticated = False
def _ensure_auth(self) -> None:
if self._authenticated:
return
resp = self._session.post(
f"{self._cfg.url}/login",
data={"username": self._cfg.user, "password": self._cfg.password},
allow_redirects=True,
timeout=30,
)
resp.raise_for_status()
# Calibre-Web redirects to / on success; a 200 on /login means bad creds
if resp.url.endswith("/login"):
raise RuntimeError("Calibre-Web authentication failed — check credentials")
self._authenticated = True
log.info("Authenticated to Calibre-Web at %s", self._cfg.url)
def upload(self, book_path: Path, zip_source: str) -> str:
"""Upload a book file. Returns status: 'uploaded' | 'skipped_duplicate' | 'error'."""
file_hash = _sha256(book_path)
if db.is_book_uploaded(file_hash):
log.info("Skipping duplicate: %s (hash %s)", book_path.name, file_hash[:8])
db.record_book(book_path.name, file_hash, zip_source, "skipped_duplicate")
return "skipped_duplicate"
try:
self._ensure_auth()
mime = MIME_TYPES.get(book_path.suffix.lower(), "application/octet-stream")
with book_path.open("rb") as fh:
resp = self._session.post(
f"{self._cfg.url}/upload",
files={"btn-upload": (book_path.name, fh, mime)},
timeout=120,
)
resp.raise_for_status()
log.info("Uploaded: %s", book_path.name)
db.record_book(book_path.name, file_hash, zip_source, "uploaded")
return "uploaded"
except Exception as e:
log.error("Upload failed for %s: %s", book_path.name, e)
db.record_book(book_path.name, file_hash, zip_source, "error")
return "error"
def _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()