Dev build 2026-06-01 20:30
This commit is contained in:
parent
566c52cf34
commit
6df47a8161
@ -1,10 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@ -17,7 +18,12 @@ from fastapi.responses import HTMLResponse
|
|||||||
from shared_templates import templates
|
from shared_templates import templates
|
||||||
|
|
||||||
from db import get_db_conn
|
from db import get_db_conn
|
||||||
from routers.common import scan_media, upsert_book
|
from routers.common import (
|
||||||
|
scan_media,
|
||||||
|
upsert_book,
|
||||||
|
upsert_chapter,
|
||||||
|
upsert_cover_cache,
|
||||||
|
)
|
||||||
from security import decrypt_value, encrypt_value, is_encrypted_value
|
from security import decrypt_value, encrypt_value, is_encrypted_value
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -533,6 +539,169 @@ def _snapshot_name() -> str:
|
|||||||
return f"snapshot-{stamp}.json"
|
return f"snapshot-{stamp}.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Database-stored books (storage_type='db') ────────────────────────────────
|
||||||
|
# These books have no file on disk: their content lives in PostgreSQL
|
||||||
|
# (library row + book_chapters + book_tags + library_cover_cache). Inline images
|
||||||
|
# referenced from chapters live on disk under library/images/ and are backed up
|
||||||
|
# as ordinary files. To make db-books restorable per-book (just like file books),
|
||||||
|
# each one is serialized to JSON and stored in the same content-addressed object
|
||||||
|
# store, then referenced from the snapshot with a "storage": "db" marker.
|
||||||
|
|
||||||
|
_DB_LIBRARY_COLS = [
|
||||||
|
"filename",
|
||||||
|
"media_type",
|
||||||
|
"storage_type",
|
||||||
|
"title",
|
||||||
|
"author",
|
||||||
|
"publisher",
|
||||||
|
"series",
|
||||||
|
"series_index",
|
||||||
|
"series_suffix",
|
||||||
|
"series_volume",
|
||||||
|
"publication_status",
|
||||||
|
"has_cover",
|
||||||
|
"description",
|
||||||
|
"source_url",
|
||||||
|
"publish_date",
|
||||||
|
"archived",
|
||||||
|
"want_to_read",
|
||||||
|
"needs_review",
|
||||||
|
"rating",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _db_book_filenames(cur) -> list[str]:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT filename FROM library WHERE storage_type = 'db' OR filename LIKE 'db/%' ORDER BY filename"
|
||||||
|
)
|
||||||
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_db_book(cur, filename: str) -> dict | None:
|
||||||
|
cols = _DB_LIBRARY_COLS
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT {', '.join(cols)} FROM library WHERE filename = %s LIMIT 1",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
lib: dict = {}
|
||||||
|
for col, val in zip(cols, row):
|
||||||
|
if isinstance(val, (datetime, date)):
|
||||||
|
val = val.isoformat()
|
||||||
|
lib[col] = val
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT chapter_index, title, content FROM book_chapters WHERE filename = %s ORDER BY chapter_index",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
chapters = [
|
||||||
|
{"chapter_index": r[0], "title": r[1] or "", "content": r[2] or ""}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag, tag_type",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
tags = [{"tag": r[0], "tag_type": r[1]} for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT mime_type, thumb_webp FROM library_cover_cache WHERE filename = %s LIMIT 1",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
cover_row = cur.fetchone()
|
||||||
|
cover = None
|
||||||
|
if cover_row and cover_row[1] is not None:
|
||||||
|
cover = {
|
||||||
|
"mime_type": cover_row[0] or "image/webp",
|
||||||
|
"thumb_webp_b64": base64.b64encode(bytes(cover_row[1])).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"novela_db_book": 1,
|
||||||
|
"filename": filename,
|
||||||
|
"library": lib,
|
||||||
|
"chapters": chapters,
|
||||||
|
"tags": tags,
|
||||||
|
"cover": cover,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_db_book(filename: str, payload: dict) -> None:
|
||||||
|
"""Re-create a db-stored book from a serialized snapshot object."""
|
||||||
|
lib = dict(payload.get("library") or {})
|
||||||
|
lib["filename"] = filename
|
||||||
|
lib.setdefault("storage_type", "db")
|
||||||
|
lib.setdefault("media_type", "epub")
|
||||||
|
|
||||||
|
cols = [c for c in _DB_LIBRARY_COLS if c in lib]
|
||||||
|
if "filename" not in cols:
|
||||||
|
cols.insert(0, "filename")
|
||||||
|
|
||||||
|
chapters = payload.get("chapters") or []
|
||||||
|
tags = payload.get("tags") or []
|
||||||
|
cover = payload.get("cover")
|
||||||
|
|
||||||
|
col_list = ", ".join(cols)
|
||||||
|
placeholders = ", ".join(["%s"] * len(cols))
|
||||||
|
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != "filename")
|
||||||
|
values = [lib.get(c) for c in cols]
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO library ({col_list})
|
||||||
|
VALUES ({placeholders})
|
||||||
|
ON CONFLICT (filename) DO UPDATE SET
|
||||||
|
{updates},
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute("DELETE FROM book_chapters WHERE filename = %s", (filename,))
|
||||||
|
|
||||||
|
for ch in chapters:
|
||||||
|
try:
|
||||||
|
idx = int(ch.get("chapter_index"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
upsert_chapter(conn, filename, idx, ch.get("title", ""), ch.get("content", ""))
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM book_tags WHERE filename = %s", (filename,))
|
||||||
|
rows = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for t in tags:
|
||||||
|
tag = (t.get("tag") or "").strip()
|
||||||
|
ttype = (t.get("tag_type") or "").strip()
|
||||||
|
if not tag or not ttype:
|
||||||
|
continue
|
||||||
|
key = (tag.casefold(), ttype)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append((filename, tag, ttype))
|
||||||
|
if rows:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO book_tags (filename, tag, tag_type) VALUES (%s, %s, %s) "
|
||||||
|
"ON CONFLICT (filename, tag, tag_type) DO NOTHING",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cover and cover.get("thumb_webp_b64"):
|
||||||
|
try:
|
||||||
|
thumb = base64.b64decode(cover["thumb_webp_b64"])
|
||||||
|
upsert_cover_cache(conn, filename, cover.get("mime_type", "image/webp"), thumb)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _object_path(objects_root: str, sha256: str) -> str:
|
def _object_path(objects_root: str, sha256: str) -> str:
|
||||||
return _dropbox_join(objects_root, sha256[:2], sha256)
|
return _dropbox_join(objects_root, sha256[:2], sha256)
|
||||||
|
|
||||||
@ -574,6 +743,61 @@ def _run_pg_dump() -> tuple[bytes, str]:
|
|||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _psql_base_args() -> list[str]:
|
||||||
|
return [
|
||||||
|
"-h",
|
||||||
|
os.environ.get("POSTGRES_HOST", "postgres"),
|
||||||
|
"-p",
|
||||||
|
str(os.environ.get("POSTGRES_PORT", "5432")),
|
||||||
|
"-U",
|
||||||
|
os.environ.get("POSTGRES_USER", "novela"),
|
||||||
|
"-d",
|
||||||
|
os.environ.get("POSTGRES_DB", "novela"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pg_restore(dump_bytes: bytes) -> None:
|
||||||
|
"""Restore a full PostgreSQL dump.
|
||||||
|
|
||||||
|
Resets the public schema first so any plain pg_dump (with or without
|
||||||
|
--clean) restores cleanly into an empty schema. This is destructive: it
|
||||||
|
drops and recreates the entire public schema before applying the dump.
|
||||||
|
"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
base = _psql_base_args()
|
||||||
|
|
||||||
|
reset = subprocess.run(
|
||||||
|
["psql", *base, "-v", "ON_ERROR_STOP=1", "-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if reset.returncode != 0:
|
||||||
|
raise RuntimeError(f"schema reset failed: {(reset.stderr or '').strip()[:500] or 'unknown error'}")
|
||||||
|
|
||||||
|
with NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
tmp_path.write_bytes(dump_bytes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["psql", *base, "-v", "ON_ERROR_STOP=1", "-f", str(tmp_path)],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"psql restore failed: {(proc.stderr or '').strip()[:500] or 'unknown error'}")
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_pg_dump_paths(client: dropbox.Dropbox, postgres_root: str) -> list[str]:
|
||||||
|
files = _dropbox_list_files_recursive(client, postgres_root)
|
||||||
|
return sorted([p for p in files if p.endswith(".sql")], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def _has_running_backup() -> bool:
|
def _has_running_backup() -> bool:
|
||||||
with get_db_conn() as conn:
|
with get_db_conn() as conn:
|
||||||
with conn:
|
with conn:
|
||||||
@ -765,6 +989,27 @@ def _run_backup_internal(*, dry_run: bool, progress_key: int | None = None) -> t
|
|||||||
uploaded_size += int(state["size"])
|
uploaded_size += int(state["size"])
|
||||||
uploaded_count += 1
|
uploaded_count += 1
|
||||||
|
|
||||||
|
# Database-stored books: serialize each into the content-addressed object
|
||||||
|
# store and reference it from the snapshot so it can be restored per-book.
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
db_filenames = _db_book_filenames(cur)
|
||||||
|
for fn in db_filenames:
|
||||||
|
payload = _serialize_db_book(cur, fn)
|
||||||
|
if payload is None:
|
||||||
|
continue
|
||||||
|
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
sha256 = hashlib.sha256(data).hexdigest()
|
||||||
|
snapshot_files[fn] = {"size": len(data), "sha256": sha256, "storage": "db"}
|
||||||
|
object_target = _object_path(objects_root, sha256)
|
||||||
|
if client is not None:
|
||||||
|
if not _dropbox_exists(client, object_target):
|
||||||
|
uploaded_size += _dropbox_upload_bytes(client, object_target, data)
|
||||||
|
uploaded_count += 1
|
||||||
|
else:
|
||||||
|
uploaded_size += len(data)
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
_prog(total_files, total_files, "snapshot")
|
_prog(total_files, total_files, "snapshot")
|
||||||
|
|
||||||
snapshot = {
|
snapshot = {
|
||||||
@ -927,6 +1172,7 @@ async def backup_dropbox_credentials_delete():
|
|||||||
async def backup_health():
|
async def backup_health():
|
||||||
token_present = bool(_load_dropbox_token())
|
token_present = bool(_load_dropbox_token())
|
||||||
pg_dump_path = shutil.which("pg_dump")
|
pg_dump_path = shutil.which("pg_dump")
|
||||||
|
psql_path = shutil.which("psql")
|
||||||
|
|
||||||
dropbox_ok = False
|
dropbox_ok = False
|
||||||
dropbox_error = None
|
dropbox_error = None
|
||||||
@ -951,6 +1197,8 @@ async def backup_health():
|
|||||||
"schedule_interval_hours": schedule_interval_hours,
|
"schedule_interval_hours": schedule_interval_hours,
|
||||||
"pg_dump_available": bool(pg_dump_path),
|
"pg_dump_available": bool(pg_dump_path),
|
||||||
"pg_dump_path": pg_dump_path,
|
"pg_dump_path": pg_dump_path,
|
||||||
|
"psql_available": bool(psql_path),
|
||||||
|
"psql_path": psql_path,
|
||||||
"library_exists": LIBRARY_DIR.exists(),
|
"library_exists": LIBRARY_DIR.exists(),
|
||||||
"library_path": str(LIBRARY_DIR.resolve()),
|
"library_path": str(LIBRARY_DIR.resolve()),
|
||||||
}
|
}
|
||||||
@ -1250,6 +1498,13 @@ def _parse_snapshot_date(name: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_storage(rel: str, info: dict) -> str:
|
||||||
|
storage = str(info.get("storage") or "").strip().lower()
|
||||||
|
if storage:
|
||||||
|
return storage
|
||||||
|
return "db" if rel.startswith("db/") else "file"
|
||||||
|
|
||||||
|
|
||||||
def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str, info: dict) -> None:
|
def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str, info: dict) -> None:
|
||||||
sha256 = str(info.get("sha256") or "")
|
sha256 = str(info.get("sha256") or "")
|
||||||
if not sha256:
|
if not sha256:
|
||||||
@ -1257,6 +1512,17 @@ def _download_and_restore(client: dropbox.Dropbox, objects_root: str, rel: str,
|
|||||||
obj_path = _object_path(objects_root, sha256)
|
obj_path = _object_path(objects_root, sha256)
|
||||||
_meta, res = client.files_download(obj_path)
|
_meta, res = client.files_download(obj_path)
|
||||||
data = res.content
|
data = res.content
|
||||||
|
|
||||||
|
if _entry_storage(rel, info) == "db":
|
||||||
|
try:
|
||||||
|
payload = json.loads(data.decode("utf-8", errors="replace"))
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid db-book snapshot object: {e}")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("db-book snapshot object is not an object")
|
||||||
|
_restore_db_book(rel, payload)
|
||||||
|
return
|
||||||
|
|
||||||
dest = LIBRARY_DIR / rel
|
dest = LIBRARY_DIR / rel
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest.write_bytes(data)
|
dest.write_bytes(data)
|
||||||
@ -1306,16 +1572,37 @@ async def snapshot_files(snapshot_name: str):
|
|||||||
return {"ok": False, "error": str(e), "files": []}
|
return {"ok": False, "error": str(e), "files": []}
|
||||||
|
|
||||||
files_data = snap.get("files", {})
|
files_data = snap.get("files", {})
|
||||||
result = [
|
|
||||||
{
|
# db-books "exist" when their row is present in the library table, not on disk.
|
||||||
"path": rel,
|
db_rels = [
|
||||||
"size": info.get("size", 0),
|
rel
|
||||||
"sha256": info.get("sha256", ""),
|
for rel, info in files_data.items()
|
||||||
"exists_locally": (LIBRARY_DIR / rel).exists(),
|
if isinstance(info, dict) and _entry_storage(rel, info) == "db"
|
||||||
}
|
|
||||||
for rel, info in sorted(files_data.items())
|
|
||||||
if isinstance(info, dict)
|
|
||||||
]
|
]
|
||||||
|
existing_db: set[str] = set()
|
||||||
|
if db_rels:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT filename FROM library WHERE filename = ANY(%s)", (db_rels,)
|
||||||
|
)
|
||||||
|
existing_db = {r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for rel, info in sorted(files_data.items()):
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
storage = _entry_storage(rel, info)
|
||||||
|
exists = rel in existing_db if storage == "db" else (LIBRARY_DIR / rel).exists()
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"path": rel,
|
||||||
|
"size": info.get("size", 0),
|
||||||
|
"sha256": info.get("sha256", ""),
|
||||||
|
"storage": storage,
|
||||||
|
"exists_locally": exists,
|
||||||
|
}
|
||||||
|
)
|
||||||
return {"ok": True, "snapshot": snapshot_name, "files": result}
|
return {"ok": True, "snapshot": snapshot_name, "files": result}
|
||||||
|
|
||||||
|
|
||||||
@ -1365,3 +1652,69 @@ async def restore_files(request: Request):
|
|||||||
|
|
||||||
ok_count = sum(1 for r in results if r["ok"])
|
ok_count = sum(1 for r in results if r["ok"])
|
||||||
return {"ok": True, "restored": ok_count, "total": len(results), "results": results}
|
return {"ok": True, "restored": ok_count, "total": len(results), "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/backup/postgres/dumps")
|
||||||
|
async def list_pg_dumps():
|
||||||
|
try:
|
||||||
|
client = await asyncio.to_thread(_dbx)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "dumps": []}
|
||||||
|
|
||||||
|
dropbox_root = _load_dropbox_root()
|
||||||
|
postgres_root = _dropbox_join(dropbox_root, "postgres")
|
||||||
|
|
||||||
|
try:
|
||||||
|
paths = await asyncio.to_thread(_list_pg_dump_paths, client, postgres_root)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "dumps": []}
|
||||||
|
|
||||||
|
dumps = [{"name": Path(p).name} for p in paths]
|
||||||
|
return {"ok": True, "dumps": dumps}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/backup/postgres/restore")
|
||||||
|
async def restore_pg_dump(request: Request):
|
||||||
|
"""Restore the entire PostgreSQL database from a Dropbox pg_dump.
|
||||||
|
|
||||||
|
DESTRUCTIVE: drops and recreates the public schema before applying the
|
||||||
|
dump. This recovers everything, including database-stored books, but
|
||||||
|
overwrites the current database.
|
||||||
|
"""
|
||||||
|
body = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
name = Path((body.get("name") or "").strip()).name
|
||||||
|
if not name or not name.endswith(".sql"):
|
||||||
|
return {"ok": False, "error": "A valid .sql dump name is required"}
|
||||||
|
|
||||||
|
if not shutil.which("psql"):
|
||||||
|
return {"ok": False, "error": "psql is not available in this container"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await asyncio.to_thread(_dbx)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
dropbox_root = _load_dropbox_root()
|
||||||
|
postgres_root = _dropbox_join(dropbox_root, "postgres")
|
||||||
|
dump_path = _dropbox_join(postgres_root, name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
def _download() -> bytes:
|
||||||
|
_meta, res = client.files_download(dump_path)
|
||||||
|
return res.content
|
||||||
|
|
||||||
|
data = await asyncio.to_thread(_download)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": f"Failed to download dump: {e}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_run_pg_restore, data)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
return {"ok": True, "restored": name, "size_bytes": len(data)}
|
||||||
|
|||||||
@ -242,7 +242,10 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">Restore</div>
|
<div class="card-head">Restore</div>
|
||||||
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
|
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
|
||||||
Browse a snapshot and restore individual books from Dropbox back to disk.
|
Browse a snapshot and restore individual books from Dropbox. File books are
|
||||||
|
written back to disk; database books (format <strong>DB</strong>) are re-inserted
|
||||||
|
into the library. Database books only appear in snapshots created after this
|
||||||
|
feature was added — to recover older database books, use Full Database Restore below.
|
||||||
</p>
|
</p>
|
||||||
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
|
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
|
||||||
<select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()">
|
<select class="field-input" id="snapshot-select" style="flex:1;min-width:220px;margin:0;" onchange="onSnapshotChange()">
|
||||||
@ -275,6 +278,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="status-line" id="restore-status"></div>
|
<div class="status-line" id="restore-status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">Full Database Restore</div>
|
||||||
|
<p class="muted" style="margin-top:0;margin-bottom:0.6rem;">
|
||||||
|
Restore the entire PostgreSQL database from a Dropbox <code>pg_dump</code>. This
|
||||||
|
recovers <strong>everything</strong> — including all database-stored books, reading
|
||||||
|
progress, tags and settings — from any backup.
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;color:var(--err);">
|
||||||
|
⚠ Destructive: the current database is dropped and replaced entirely. There is no undo.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.7rem;">
|
||||||
|
<select class="field-input" id="pgdump-select" style="flex:1;min-width:220px;margin:0;">
|
||||||
|
<option value="">— select database dump —</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" onclick="loadPgDumps()">Refresh</button>
|
||||||
|
<button class="btn primary" id="btn-pg-restore" onclick="restorePgDump()" disabled>Restore database</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-line" id="pgdump-status"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/books.js"></script>
|
<script src="/static/books.js"></script>
|
||||||
@ -302,6 +325,7 @@
|
|||||||
rowHtml('Snapshots keep', d.retention_count ?? 14),
|
rowHtml('Snapshots keep', d.retention_count ?? 14),
|
||||||
rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'),
|
rowHtml('Schedule', d.schedule_enabled ? `enabled (${d.schedule_interval_hours || 24}h)` : 'disabled'),
|
||||||
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
|
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
|
||||||
|
rowHtml('psql', d.psql_available ? (d.psql_path || 'available') : 'missing'),
|
||||||
rowHtml('Library exists', fmtStatus(d.library_exists)),
|
rowHtml('Library exists', fmtStatus(d.library_exists)),
|
||||||
rowHtml('Library path', d.library_path || '-'),
|
rowHtml('Library path', d.library_path || '-'),
|
||||||
].join('');
|
].join('');
|
||||||
@ -545,7 +569,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots()]);
|
await Promise.all([loadDropboxSettings(), loadHealth(), loadStatus(), loadHistory(), loadSnapshots(), loadPgDumps()]);
|
||||||
pollRunProgress();
|
pollRunProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -648,13 +672,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = filtered.map(f => {
|
body.innerHTML = filtered.map(f => {
|
||||||
const ext = f.path.split('.').pop().toUpperCase();
|
const isDb = f.storage === 'db';
|
||||||
|
const ext = isDb ? 'DB' : f.path.split('.').pop().toUpperCase();
|
||||||
const parts = f.path.split('/');
|
const parts = f.path.split('/');
|
||||||
const name = parts[parts.length - 1];
|
const name = parts[parts.length - 1];
|
||||||
const dir = parts.slice(0, -1).join('/');
|
const dir = parts.slice(0, -1).join('/');
|
||||||
const onDisk = f.exists_locally
|
const onDisk = isDb
|
||||||
? '<span class="ok" title="File already on disk">✓ exists</span>'
|
? (f.exists_locally
|
||||||
: '<span class="warn">missing</span>';
|
? '<span class="ok" title="Book already present in the library">✓ in library</span>'
|
||||||
|
: '<span class="warn">not in library</span>')
|
||||||
|
: (f.exists_locally
|
||||||
|
? '<span class="ok" title="File already on disk">✓ exists</span>'
|
||||||
|
: '<span class="warn">missing</span>');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td>
|
<td><input type="checkbox" class="restore-chk" data-path="${esc(f.path)}" onchange="updateRestoreBtn()"/></td>
|
||||||
<td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td>
|
<td><span style="font-family:var(--mono);font-size:0.68rem;color:var(--text-dim)">${esc(ext)}</span></td>
|
||||||
@ -722,6 +751,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full database restore ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadPgDumps() {
|
||||||
|
const sel = document.getElementById('pgdump-select');
|
||||||
|
const btn = document.getElementById('btn-pg-restore');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/backup/postgres/dumps');
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok || !d.dumps.length) {
|
||||||
|
sel.innerHTML = '<option value="">— no database dumps available —</option>';
|
||||||
|
btn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">— select database dump —</option>' +
|
||||||
|
d.dumps.map(x => `<option value="${esc(x.name)}"${x.name === current ? ' selected' : ''}>${esc(x.name)}</option>`).join('');
|
||||||
|
btn.disabled = !sel.value;
|
||||||
|
} catch (_) {
|
||||||
|
sel.innerHTML = '<option value="">— Dropbox not configured —</option>';
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pgdump-select').addEventListener('change', (e) => {
|
||||||
|
document.getElementById('btn-pg-restore').disabled = !e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function restorePgDump() {
|
||||||
|
const name = document.getElementById('pgdump-select').value;
|
||||||
|
const out = document.getElementById('pgdump-status');
|
||||||
|
if (!name) return;
|
||||||
|
if (!confirm(`Restore the ENTIRE database from "${name}"?\n\nThis drops and replaces the current database. All current data will be lost. This cannot be undone.`)) return;
|
||||||
|
if (!confirm('Are you absolutely sure? This is your last chance to cancel.')) return;
|
||||||
|
const btn = document.getElementById('btn-pg-restore');
|
||||||
|
btn.disabled = true;
|
||||||
|
out.className = 'status-line warn';
|
||||||
|
out.textContent = `Restoring database from ${name}… (do not navigate away)`;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/backup/postgres/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) throw new Error(d.error || 'failed');
|
||||||
|
out.className = 'status-line ok';
|
||||||
|
out.textContent = `Database restored from ${d.restored} (${fmtBytes(d.size_bytes)}). Reload the app to see the restored library.`;
|
||||||
|
} catch (e) {
|
||||||
|
out.className = 'status-line err';
|
||||||
|
out.textContent = `Database restore failed: ${e}`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from changelog import CHANGELOG
|
from changelog import CHANGELOG
|
||||||
|
|
||||||
BUILD = 0
|
BUILD = 1
|
||||||
|
|
||||||
|
|
||||||
def _release_version() -> str:
|
def _release_version() -> str:
|
||||||
|
|||||||
@ -1,5 +1,18 @@
|
|||||||
# Develop Changelog
|
# Develop Changelog
|
||||||
|
|
||||||
|
## 2026-06-01 — Backup/Restore: database-stored books are now restorable
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Database-stored books (`storage_type='db'`, synthetic `db/...` filenames) could not be restored through the Backup → Restore option. The restore UI only listed and restored files from the on-disk library object store, but db-books have no file on disk — their content lives entirely in PostgreSQL (`book_chapters` + `library` row + `book_tags` + `library_cover_cache`). They were captured only in the full `pg_dump`, which the UI offered no way to restore. As a result db-books never appeared in the restore list and were effectively unrecoverable per-book.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Per-book db restore.** The backup writer now serializes each database book to JSON (library row, all chapters, tags, and the cached cover) and stores it in the same content-addressed Dropbox object store used for file books, referenced from the snapshot with a `"storage": "db"` marker. Such books now appear in the Restore table (format **DB**) and can be restored individually; restore re-inserts the library row, chapters, tags and cover into PostgreSQL. Inline chapter images are unaffected — they already live on disk under `library/images/` and are backed up as ordinary files.
|
||||||
|
- `routers/backup.py`: new `_db_book_filenames`, `_serialize_db_book`, `_restore_db_book`, `_entry_storage` helpers; db-books serialized during `_run_backup_internal`; `_download_and_restore` branches on storage type; `/api/backup/snapshots/{name}/files` now reports `storage` and computes `exists_locally` for db-books from the `library` table.
|
||||||
|
- Note: db-books only appear in snapshots created *after* this change. Older backups still contain them only in the `pg_dump` — recover those via Full Database Restore below.
|
||||||
|
- **Full Database Restore.** New Backup-page card and endpoints to restore the entire PostgreSQL database from any Dropbox `pg_dump`. This recovers everything (all db-books, reading progress, tags, settings) from existing backups too. It is destructive: the public schema is dropped and recreated before the dump is applied, so any plain dump (with or without `--clean`) restores cleanly. Guarded behind a double confirmation in the UI.
|
||||||
|
- `routers/backup.py`: `_psql_base_args`, `_run_pg_restore`, `_list_pg_dump_paths`; endpoints `GET /api/backup/postgres/dumps` and `POST /api/backup/postgres/restore`; health endpoint now also reports `psql_available`/`psql_path`.
|
||||||
|
- `templates/backup.html`: db-books shown with **DB** format and "in library / not in library" status in the restore table; new "Full Database Restore" card with dump selector, double-confirm, and `psql` health row.
|
||||||
|
|
||||||
## 2026-06-01 — Reader: Sepia reading theme
|
## 2026-06-01 — Reader: Sepia reading theme
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user