科技 · 實用指南
執行 Restore database 前,先稽核 Calibre 自訂欄位能否保全
不開啟 metadata.db,也不修改唯一的書庫副本,先盤點自訂欄位定義、每本書的 OPF 覆蓋和衝突。

不要在唯一倖存的 Calibre 書庫上直接執行 Restore database。這是重建操作,不是診斷預覽:Calibre 目前文件說明,它會依每本書的 OPF 完全重建資料庫,而且可能遺失已儲存搜尋、使用者分類、外掛面板、單書轉換設定和自訂配方。
更安全的第一步,是縮小問題:目前還有哪些證據?自訂欄位定義可能出現在 metadata_db_prefs_backup.json,也可能出現在各書籍資料夾的 metadata.opf。兩處證據可能不一致、不完整或已過時。唯讀盤點不能保證復原,但能指出何時不該繼續。
任何修復前,先製作已核實的完整副本
確認你看到的是正確的書庫資料夾。路徑錯誤或磁碟無法存取,可能讓健康書庫看起來是空的。若 metadata.db 遺失或損壞,請關閉 Calibre,把完整書庫資料夾複製到另一個本機位置。保留原始位元組,所有實驗都只在獨立副本上進行。
不要刪除 metadata.db,不要執行 restore_database,也不要用正在測試的 Calibre 版本開啟唯一副本。官方 FAQ 建議搬移時複製完整書庫資料夾;calibredb 參考同時警告,restore_database 會重建資料庫,無法保留所有書庫層級設定。
理解三層證據
metadata.db 是即時資料庫,但本稽核刻意不開啟它。metadata_db_prefs_backup.json 可能保存自訂欄位定義;每個書籍資料夾裡的 metadata.opf 也可能保存 Calibre 使用者中繼資料。目前 Calibre 原始碼同時支援 OPF3 的 calibre:user_metadata JSON 對映,以及舊版 OPF2 的 calibre:user_metadata:#lookup 形式。
定義只能說明 #read、#genre 等查找鍵曾經存在;每本書中的 #value# 只能說明某個 OPF 為該鍵記錄了值。兩者都不能證明每本書備份是最新的,空值也可能正常。若不同 OPF 對 datatype、is_multiple、display 或 name 的定義衝突,應停止檢查,而不是讓程式靜默選擇。
執行唯讀盤點
把下方經測試的 Python 3 程式存放在書庫外,命名為 calibre_custom_columns_audit.py。執行 python3 calibre_custom_columns_audit.py "/path/to/Calibre Library" > calibre-column-audit.json,並把報告寫到書庫外。程式會遞迴讀取 metadata.opf 和偏好備份,把 JSON 輸出到標準輸出;不會開啟 metadata.db,也不會在書庫寫檔。
#!/usr/bin/env python3 """Read-only audit of Calibre custom-column recovery evidence. The script never opens metadata.db and never writes inside the library. It prints JSON to stdout; redirect it outside the library if desired. """ from __future__ import annotations import argparse import json import sys from collections import Counter, defaultdict from pathlib import Path from xml.etree import ElementTree as ET def custom_defs_from_json(node): """Find definition-like dictionaries in the prefs backup defensively.""" found = {} def walk(value): if isinstance(value, dict): label = value.get("label") datatype = value.get("datatype") name = value.get("name") if isinstance(label, str) and label and isinstance(datatype, str): key = label if label.startswith("#") else f"#{label}" found.setdefault(key, { "label": label.lstrip("#"), "name": name if isinstance(name, str) else None, "datatype": datatype, "is_multiple": value.get("is_multiple"), }) for child in value.values(): walk(child) elif isinstance(value, list): for child in value: walk(child) walk(node) return found def parse_opf(path: Path): """Return {lookup_key: field_metadata} from OPF2 or OPF3 Calibre tags.""" root = ET.parse(path).getroot() result = {} for elem in root.iter(): if elem.tag.rsplit("}", 1)[-1] != "meta": continue name = elem.attrib.get("name", "") prop = elem.attrib.get("property", "") if name.startswith("calibre:user_metadata:#"): key = name.split("calibre:user_metadata:", 1)[1] raw = elem.attrib.get("content", "") value = json.loads(raw) if isinstance(value, dict): result[key] = value elif prop == "calibre:user_metadata": value = json.loads((elem.text or "").strip()) if isinstance(value, dict): for key, field in value.items(): if isinstance(key, str) and key.startswith("#") and isinstance(field, dict): result[key] = field return result def value_present(field): value = field.get("#value#") if value is None: return False if isinstance(value, (str, list, tuple, dict, set)): return len(value) > 0 return True def signature(field): return json.dumps({ "name": field.get("name"), "label": str(field.get("label", "")).lstrip("#") or None, "datatype": field.get("datatype"), "is_multiple": field.get("is_multiple"), "display": field.get("display"), }, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def audit(library: Path): if not library.is_dir(): raise ValueError("library path is not a directory") prefs_path = library / "metadata_db_prefs_backup.json" prefs_error = None prefs_defs = {} if prefs_path.is_file(): try: prefs_defs = custom_defs_from_json(json.loads(prefs_path.read_text(encoding="utf-8"))) except Exception as exc: # report; do not repair or rewrite prefs_error = f"{type(exc).__name__}: {exc}" opfs = sorted(library.rglob("metadata.opf")) parse_errors = [] per_key_files = Counter() per_key_values = Counter() per_key_signatures = defaultdict(Counter) paths_by_key = defaultdict(list) for path in opfs: try: fields = parse_opf(path) except Exception as exc: parse_errors.append({"path": str(path.relative_to(library)), "error": f"{type(exc).__name__}: {exc}"}) continue for key, field in fields.items(): per_key_files[key] += 1 if value_present(field): per_key_values[key] += 1 per_key_signatures[key][signature(field)] += 1 if len(paths_by_key[key]) < 5: paths_by_key[key].append(str(path.relative_to(library))) all_keys = sorted(set(prefs_defs) | set(per_key_files)) columns = [] for key in all_keys: sigs = per_key_signatures[key] columns.append({ "lookup_key": key, "definition_in_prefs_backup": key in prefs_defs, "prefs_definition": prefs_defs.get(key), "opf_files_with_definition": per_key_files[key], "opf_files_with_nonempty_value": per_key_values[key], "definition_variants_in_opfs": len(sigs), "definition_conflict": len(sigs) > 1, "sample_paths": paths_by_key[key], }) stop_reasons = [] if not opfs: stop_reasons.append("no metadata.opf files found") if prefs_error: stop_reasons.append("preferences backup is unreadable") if parse_errors: stop_reasons.append("one or more metadata.opf files are unreadable") if any(c["definition_conflict"] for c in columns): stop_reasons.append("conflicting custom-column definitions exist across OPFs") if any(not c["definition_in_prefs_backup"] for c in columns): stop_reasons.append("some OPF custom columns are absent from the preferences backup") return { "library": str(library.resolve()), "read_only": True, "metadata_db_present": (library / "metadata.db").is_file(), "prefs_backup_present": prefs_path.is_file(), "prefs_backup_error": prefs_error, "metadata_opf_count": len(opfs), "metadata_opf_parse_errors": parse_errors, "columns": columns, "stop_before_restore": bool(stop_reasons), "stop_reasons": stop_reasons, "notice": "This audit does not prove that a restore will succeed. Test Calibre Restore database only on a verified complete copy.", } def main(): parser = argparse.ArgumentParser(description="Audit Calibre custom-column recovery evidence without modifying the library") parser.add_argument("library", type=Path, help="path to the Calibre library folder") args = parser.parse_args() try: result = audit(args.library) except Exception as exc: print(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False), file=sys.stderr) return 2 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) return 1 if result["stop_before_restore"] else 0 if __name__ == "__main__": raise SystemExit(main())
把報告當成停/走預檢
逐個 #lookup 鍵比較 definition_in_prefs_backup、opf_files_with_definition、opf_files_with_nonempty_value 和 definition_variants_in_opfs。非空值數量少不一定是錯誤,因為許多欄位本來就允許留白。請和明確知道有值的書對照,並在副本上人工檢查 sample_paths 的幾項。
若找不到 metadata.opf、OPF 無法讀取、偏好備份無法解析、某個鍵不在偏好備份中,或 definition_conflict 為 true,請在復原前停止。若報告與你明確知道的書籍值不一致,也應停止。保留 JSON 報告和複製書庫後再求助。
報告乾淨只表示這組有限證據內部一致,不能證明所有書庫層級功能或自訂欄位值都能重建,也不能證明 OPF 備份最新或 Restore database 必定成功。
只在另一份副本上測試復原
若盤點結果合理,再製作第二份已核實副本,並只在那裡執行受支援的 Restore database 流程。讓未碰觸的來源副本保持離線。完成後,比較自訂欄位定義、已知有值與空值的樣本、書籍數、格式與封面;另外核對已儲存搜尋、使用者分類、外掛面板、轉換設定與配方,因為官方命令警告這些內容可能遺失。
若復原後的副本改變了仍需保留的證據,請停止。不要在原件上重複,也不要把「資料庫能開啟」當成自訂中繼資料完整的證明。預檢真正提供的是有邊界的證據紀錄與可逆決策,而不是自動修復。
這項稽核實際測試了什麼
文中程式的精確 SHA-256 為 83b54e367d7aad731b6ba15d3ec2d487e369cda4975611613e015b8d2b00d949。四項合成單元測試通過:一致的 OPF3 與偏好備份、舊版 OPF2、偏好缺失且定義衝突,以及損壞 OPF 回報錯誤且輸入位元組不變。測試檔 SHA-256 為 61a53983de02dcd7aa5c37b4b0ba8fc828bbcfe3367241165c6e4c920f7a349d;Python 位元碼編譯也成功。
這些是合成檔案系統測試,不是在真實損壞書庫上執行,也沒有執行 Restore database。偏好結構可能改變,遞迴搜尋對未來陌生格式仍可能漏檢。依賴結果前請審閱程式與報告,並始終保持原書庫不動。
