Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
.git/
|
||||
.gitignore
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
+23
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)],
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
jinja2
|
||||
python-multipart
|
||||
paramiko
|
||||
rarfile
|
||||
requests
|
||||
apscheduler
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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)}")
|
||||
@@ -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>
|
||||
@@ -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 }}">« Prev</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ pages }}</span>
|
||||
{% if page < pages %}
|
||||
<a href="/books?page={{ page + 1 }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="muted">No books recorded yet.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 %} — 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 %}
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user