commit d384f1f896da3bd2b0a3bd8bbe80730ba379a667 Author: grymphen Date: Sat May 9 18:41:03 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..78a0b55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +.env +data/ +*.db +.git/ +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..43ec988 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c73c47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.pyo +.env +data/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e2c7737 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/config.py b/config.py new file mode 100644 index 0000000..9dace51 --- /dev/null +++ b/config.py @@ -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()) diff --git a/db.py b/db.py new file mode 100644 index 0000000..f65a4de --- /dev/null +++ b/db.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b79528 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/extractor.py b/extractor.py new file mode 100644 index 0000000..cdce47c --- /dev/null +++ b/extractor.py @@ -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) diff --git a/main.py b/main.py new file mode 100644 index 0000000..02f3191 --- /dev/null +++ b/main.py @@ -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)], + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..305da9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +jinja2 +python-multipart +paramiko +rarfile +requests +apscheduler diff --git a/sftp.py b/sftp.py new file mode 100644 index 0000000..8038fb0 --- /dev/null +++ b/sftp.py @@ -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 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..296a736 --- /dev/null +++ b/static/style.css @@ -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; } diff --git a/sync.py b/sync.py new file mode 100644 index 0000000..254caa9 --- /dev/null +++ b/sync.py @@ -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)}") diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..95fc200 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ + + + + + + {% block title %}CalibreSync{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/templates/books.html b/templates/books.html new file mode 100644 index 0000000..1546e49 --- /dev/null +++ b/templates/books.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Books — CalibreSync{% endblock %} + +{% block content %} + + +{% if books %} + + + + + + + + + + + {% for b in books %} + + + + + + + {% endfor %} + +
FilenameStatusSource zipUploaded
{{ b.filename }}{{ b.status }}{{ b.zip_source or "—" }}{{ b.uploaded_at[:19].replace("T"," ") if b.uploaded_at else "—" }}
+ +{% if pages > 1 %} + +{% endif %} + +{% else %} +

No books recorded yet.

+{% endif %} +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..49c07cb --- /dev/null +++ b/templates/index.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% block title %}Dashboard — CalibreSync{% endblock %} + +{% block content %} + + +{% if request.query_params.get("started") %} +
Sync started in background.
+{% endif %} +{% if request.query_params.get("already_running") %} +
A sync is already running.
+{% endif %} + +
+
+
{{ stats.total_zips }}
+
Zip archives processed
+
+
+
{{ stats.uploaded }}
+
Books uploaded
+
+
+
{{ stats.skipped }}
+
Duplicates skipped
+
+
+
{{ stats.total_books }}
+
Total book records
+
+
+ +

Recent sync runs

+{% if runs %} + + + + + + + + + + + + + + {% for r in runs %} + + + + + + + + + + {% endfor %} + +
StartedFinishedStatusNew zipsUploadedSkippedErrors
{{ r.started_at[:19].replace("T"," ") }}{{ r.finished_at[:19].replace("T"," ") if r.finished_at else "—" }}{{ r.status }}{{ r.zips_new }}{{ r.books_uploaded }}{{ r.books_skipped }}{{ r.books_errored }}
+{% else %} +

No sync runs yet. Click "Run Sync" to start.

+{% endif %} + +

Recent zip archives

+{% if zips %} + + + + + + + + + + + + {% for z in zips %} + + + + + + + + {% endfor %} + +
Remote pathSizeProcessedStatusError
{{ z.remote_path }}{{ (z.file_size / 1048576) | round(1) }} MB{{ z.processed_at[:19].replace("T"," ") if z.processed_at else "—" }}{{ z.status }}{{ z.error_msg or "" }}
+{% else %} +

No zip archives processed yet.

+{% endif %} + +{% if sync_running %} + +{% endif %} +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..5a5b38f --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} +{% block title %}Settings — CalibreSync{% endblock %} + +{% block content %} +

Settings

+ +{% if request.query_params.get("saved") %} +
Settings saved.
+{% endif %} + +
+ +
+

Remote host (SFTP)

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + + {% if has_key %} +

A key is already configured. Paste a new one above to replace it, or leave blank to keep the existing key.

+ {% endif %} +
+ + +
+ +
+

Calibre-Web

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

Local

+ +
+ + +

Temporary storage for downloaded zips and extracted files. Cleaned up after each run.

+
+
+ +
+

Automatic sync schedule

+ +
+ + +

Set to 0 to disable automatic sync. Changes take effect immediately on save. Examples: 60 = hourly, 1440 = daily.

+
+
+ +
+ +
+
+ + +{% endblock %} diff --git a/uploader.py b/uploader.py new file mode 100644 index 0000000..6d50ac6 --- /dev/null +++ b/uploader.py @@ -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()