diff --git a/config.py b/config.py
index faf7cfc..7dce05d 100644
--- a/config.py
+++ b/config.py
@@ -26,7 +26,7 @@ class GrimmoryConfig:
class AppConfig:
sftp: SFTPConfig = field(default_factory=SFTPConfig)
grimmory: GrimmoryConfig = field(default_factory=GrimmoryConfig)
- work_dir: str = "/tmp/calibresync"
+ work_dir: str = "/tmp/grimmorysync"
def load() -> AppConfig:
@@ -47,7 +47,7 @@ def load() -> AppConfig:
password=s.get("grimmory_password", ""),
bookdrop_path=s.get("grimmory_bookdrop_path", ""),
),
- work_dir=s.get("work_dir", "/tmp/calibresync"),
+ work_dir=s.get("work_dir", "/tmp/grimmorysync"),
)
@@ -56,7 +56,7 @@ def save(form: dict) -> None:
"sftp_host", "sftp_port", "sftp_user", "sftp_auth_method",
"sftp_remote_path",
"grimmory_url", "grimmory_user", "grimmory_bookdrop_path",
- "work_dir",
+ "work_dir", "local_import_path",
"scheduler_interval_minutes", "sync_batch_size",
]
for key in keys:
diff --git a/docker-compose.yml b/docker-compose.yml
index e09f12a..72c476f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,5 @@
services:
- calibresync:
+ grimmorysync:
build: .
ports:
- "8000:8000"
diff --git a/main.py b/main.py
index 7b83f64..c4f5964 100644
--- a/main.py
+++ b/main.py
@@ -49,7 +49,7 @@ async def lifespan(app: FastAPI):
_scheduler.shutdown(wait=False)
-app = FastAPI(title="CalibreSync", lifespan=lifespan)
+app = FastAPI(title="GrimmorySync", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
@@ -102,7 +102,7 @@ class SettingsPayload(BaseModel):
grimmory_user: str = ""
grimmory_password: str = ""
grimmory_bookdrop_path: str = ""
- work_dir: str = "/tmp/calibresync"
+ work_dir: str = "/tmp/grimmorysync"
scheduler_interval_minutes: str = "0"
sync_batch_size: str = "0"
@@ -141,6 +141,19 @@ async def api_trigger_rescan(background_tasks: BackgroundTasks):
return {"ok": True}
+# --- Local folder import ---
+
+@app.post("/api/import/local")
+async def api_local_import(background_tasks: BackgroundTasks):
+ if sync.is_running():
+ return {"ok": False, "reason": "already_running"}
+ folder = db.get_setting("local_import_path", "")
+ if not folder:
+ return {"ok": False, "reason": "no_path_configured"}
+ background_tasks.add_task(sync.run_local_import, folder)
+ return {"ok": True}
+
+
# --- Connection tests ---
@app.get("/api/test/ssh")
diff --git a/static/index.html b/static/index.html
index 1826149..5e55ddb 100644
--- a/static/index.html
+++ b/static/index.html
@@ -3,7 +3,7 @@
- CalibreSync
+ GrimmorySync
@@ -23,7 +23,7 @@
- CalibreSync
+ GrimmorySync
@@ -56,6 +56,8 @@
@click="triggerSync('/api/sync/rescan')">Rescan remote
+
@@ -342,6 +344,21 @@
+
+
+
+
Local import
+
+
+
+
+
+
+
@@ -422,7 +439,10 @@ function app() {
this._prevRunning = true
this.showToast('Started', 'success')
} else {
- this.showToast(r.reason === 'already_running' ? 'Sync already running' : 'Failed to start', 'error')
+ this.showToast(
+ r.reason === 'already_running' ? 'Sync already running' :
+ r.reason === 'no_path_configured' ? 'Set a local import path in Settings first' :
+ 'Failed to start', 'error')
}
} catch (_) {
this.showToast('Request failed', 'error')
diff --git a/sync.py b/sync.py
index c1dd682..eaec933 100644
--- a/sync.py
+++ b/sync.py
@@ -14,6 +14,8 @@ log = logging.getLogger(__name__)
_lock = threading.Lock()
_running = False
+_BOOK_EXTENSIONS = {".epub", ".pdf", ".mobi", ".cbz"}
+
def is_running() -> bool:
return _running
@@ -130,6 +132,69 @@ def run_sync(limit: int | None = None) -> None:
_lock.release()
+def run_local_import(folder_path: str) -> None:
+ """Process a local folder of book files using the same duplicate-check + bookdrop flow."""
+ global _running
+ if not _lock.acquire(blocking=False):
+ log.warning("Sync already running, skipping local import")
+ return
+
+ _running = True
+ run_id = db.start_sync_run()
+ counters = dict(zips_found=0, zips_new=0, books_imported=0, books_skipped=0, books_errored=0)
+
+ try:
+ cfg = config.load()
+ if not cfg.grimmory.bookdrop_path:
+ raise ValueError("Grimmory bookdrop path not configured")
+
+ folder = Path(folder_path)
+ if not folder.is_dir():
+ raise ValueError(f"Not a directory: {folder_path}")
+
+ books = [p for p in folder.rglob("*") if p.is_file() and p.suffix.lower() in _BOOK_EXTENSIONS]
+ log.info("Local import: found %d book file(s) in %s", len(books), folder_path)
+ counters["zips_found"] = len(books)
+ counters["zips_new"] = len(books)
+
+ for book in books:
+ try:
+ sha256 = grimmory_module.compute_sha256(book)
+ if db.is_book_processed(sha256):
+ log.info("Skipping '%s' — sha256 already processed", book.name)
+ counters["books_skipped"] += 1
+ db.record_book(None, book.name, sha256, status="skipped")
+ continue
+ result = grimmory_module.place_book(
+ book,
+ cfg.grimmory.bookdrop_path,
+ cfg.grimmory.url,
+ cfg.grimmory.user,
+ cfg.grimmory.password,
+ sha256=sha256,
+ )
+ if result.status == "success":
+ counters["books_imported"] += 1
+ elif result.status == "skipped":
+ counters["books_skipped"] += 1
+ db.record_book(None, book.name, result.sha256,
+ status=result.status, error_msg=result.error_msg)
+ except Exception as e:
+ log.error("Error importing '%s': %s", book.name, e)
+ counters["books_errored"] += 1
+ db.record_book(None, book.name, "", status="error", error_msg=str(e))
+
+ db.finish_sync_run(run_id, status="success", **counters)
+ log.info("Local import done. Imported: %d, Skipped: %d, Errors: %d",
+ counters["books_imported"], counters["books_skipped"], counters["books_errored"])
+ except Exception as e:
+ log.exception("Local import 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: