Watch at Ease看见世界,读懂生活。

科技 · 实用指南

运行 Restore database 前,先审计 Calibre 自定义列能否保全

不打开 metadata.db,也不修改唯一的书库副本,先盘点自定义列定义、每本书的 OPF 覆盖和冲突。

运行 Calibre Restore database 前的原创恢复证据层示意图
原创插画:保留未触碰的书库副本,盘点偏好备份和每个 metadata.opf;证据不可读、缺失或冲突时停止。 Watch at Ease

不要在唯一幸存的 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。偏好结构可能变化,递归搜索对未来陌生格式仍可能漏检。依赖结果前请审阅脚本与报告,并始终保持原书库不动。

解释 Calibre 自定义列审计结果的原创停止与测试矩阵
原创插画:证据不可读、缺失或冲突意味着停止;内部一致也只能在另一份已核实副本上测试。 Watch at Ease