name change and local sync added
This commit is contained in:
@@ -26,7 +26,7 @@ class GrimmoryConfig:
|
|||||||
class AppConfig:
|
class AppConfig:
|
||||||
sftp: SFTPConfig = field(default_factory=SFTPConfig)
|
sftp: SFTPConfig = field(default_factory=SFTPConfig)
|
||||||
grimmory: GrimmoryConfig = field(default_factory=GrimmoryConfig)
|
grimmory: GrimmoryConfig = field(default_factory=GrimmoryConfig)
|
||||||
work_dir: str = "/tmp/calibresync"
|
work_dir: str = "/tmp/grimmorysync"
|
||||||
|
|
||||||
|
|
||||||
def load() -> AppConfig:
|
def load() -> AppConfig:
|
||||||
@@ -47,7 +47,7 @@ def load() -> AppConfig:
|
|||||||
password=s.get("grimmory_password", ""),
|
password=s.get("grimmory_password", ""),
|
||||||
bookdrop_path=s.get("grimmory_bookdrop_path", ""),
|
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_host", "sftp_port", "sftp_user", "sftp_auth_method",
|
||||||
"sftp_remote_path",
|
"sftp_remote_path",
|
||||||
"grimmory_url", "grimmory_user", "grimmory_bookdrop_path",
|
"grimmory_url", "grimmory_user", "grimmory_bookdrop_path",
|
||||||
"work_dir",
|
"work_dir", "local_import_path",
|
||||||
"scheduler_interval_minutes", "sync_batch_size",
|
"scheduler_interval_minutes", "sync_batch_size",
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
calibresync:
|
grimmorysync:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async def lifespan(app: FastAPI):
|
|||||||
_scheduler.shutdown(wait=False)
|
_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")
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class SettingsPayload(BaseModel):
|
|||||||
grimmory_user: str = ""
|
grimmory_user: str = ""
|
||||||
grimmory_password: str = ""
|
grimmory_password: str = ""
|
||||||
grimmory_bookdrop_path: str = ""
|
grimmory_bookdrop_path: str = ""
|
||||||
work_dir: str = "/tmp/calibresync"
|
work_dir: str = "/tmp/grimmorysync"
|
||||||
scheduler_interval_minutes: str = "0"
|
scheduler_interval_minutes: str = "0"
|
||||||
sync_batch_size: str = "0"
|
sync_batch_size: str = "0"
|
||||||
|
|
||||||
@@ -141,6 +141,19 @@ async def api_trigger_rescan(background_tasks: BackgroundTasks):
|
|||||||
return {"ok": True}
|
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 ---
|
# --- Connection tests ---
|
||||||
|
|
||||||
@app.get("/api/test/ssh")
|
@app.get("/api/test/ssh")
|
||||||
|
|||||||
+24
-4
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CalibreSync</title>
|
<title>GrimmorySync</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" />
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<div class="navbar bg-base-200 shadow-md sticky top-0 z-40">
|
<div class="navbar bg-base-200 shadow-md sticky top-0 z-40">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<span class="text-xl font-bold px-4 tracking-tight">CalibreSync</span>
|
<span class="text-xl font-bold px-4 tracking-tight">GrimmorySync</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-center">
|
<div class="navbar-center">
|
||||||
<div role="tablist" class="tabs tabs-boxed">
|
<div role="tablist" class="tabs tabs-boxed">
|
||||||
@@ -56,6 +56,8 @@
|
|||||||
@click="triggerSync('/api/sync/rescan')">Rescan remote</button>
|
@click="triggerSync('/api/sync/rescan')">Rescan remote</button>
|
||||||
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
||||||
@click="triggerSync('/api/sync/test')">Test (1 zip)</button>
|
@click="triggerSync('/api/sync/test')">Test (1 zip)</button>
|
||||||
|
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
||||||
|
@click="triggerSync('/api/import/local')">Import local folder</button>
|
||||||
<button class="btn btn-sm btn-primary" :disabled="dashboard.sync_running"
|
<button class="btn btn-sm btn-primary" :disabled="dashboard.sync_running"
|
||||||
@click="triggerSync('/api/sync')">
|
@click="triggerSync('/api/sync')">
|
||||||
<span x-show="dashboard.sync_running" class="loading loading-spinner loading-xs"></span>
|
<span x-show="dashboard.sync_running" class="loading loading-spinner loading-xs"></span>
|
||||||
@@ -309,7 +311,7 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label py-1"><span class="label-text text-xs">Work directory</span></label>
|
<label class="label py-1"><span class="label-text text-xs">Work directory</span></label>
|
||||||
<input name="work_dir" type="text" class="input input-bordered input-sm"
|
<input name="work_dir" type="text" class="input input-bordered input-sm"
|
||||||
:value="settings.settings.work_dir ?? '/tmp/calibresync'" />
|
:value="settings.settings.work_dir ?? '/tmp/grimmorysync'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,6 +344,21 @@
|
|||||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local import -->
|
||||||
|
<div class="card bg-base-200 shadow mb-4">
|
||||||
|
<div class="card-body gap-3">
|
||||||
|
<h2 class="card-title text-base">Local import</h2>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text text-xs">Folder path</span>
|
||||||
|
<span class="label-text-alt text-xs opacity-50">directory of epub/pdf files to import</span>
|
||||||
|
</label>
|
||||||
|
<input name="local_import_path" type="text" class="input input-bordered input-sm"
|
||||||
|
:value="settings.settings.local_import_path ?? ''" placeholder="/mnt/books" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Danger zone -->
|
||||||
<div class="card border border-error">
|
<div class="card border border-error">
|
||||||
<div class="card-body gap-2">
|
<div class="card-body gap-2">
|
||||||
@@ -422,7 +439,10 @@ function app() {
|
|||||||
this._prevRunning = true
|
this._prevRunning = true
|
||||||
this.showToast('Started', 'success')
|
this.showToast('Started', 'success')
|
||||||
} else {
|
} 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 (_) {
|
} catch (_) {
|
||||||
this.showToast('Request failed', 'error')
|
this.showToast('Request failed', 'error')
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ log = logging.getLogger(__name__)
|
|||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_running = False
|
_running = False
|
||||||
|
|
||||||
|
_BOOK_EXTENSIONS = {".epub", ".pdf", ".mobi", ".cbz"}
|
||||||
|
|
||||||
|
|
||||||
def is_running() -> bool:
|
def is_running() -> bool:
|
||||||
return _running
|
return _running
|
||||||
@@ -130,6 +132,69 @@ def run_sync(limit: int | None = None) -> None:
|
|||||||
_lock.release()
|
_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:
|
def _validate_config(cfg) -> None:
|
||||||
missing = []
|
missing = []
|
||||||
if not cfg.sftp.host:
|
if not cfg.sftp.host:
|
||||||
|
|||||||
Reference in New Issue
Block a user