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 @@ + +
+
+

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: