テクノロジー · 実用ガイド

Restore database の前に Calibre のカスタム列を読み取り専用で監査する

唯一のライブラリを変更せず、カスタム列の定義、各冊の 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 リファレンスは再構築でライブラリレベル設定をすべて保持できないと警告しています。

3つの証拠層を理解する

metadata.db は稼働中のデータベースですが、この監査は意図的に開きません。metadata_db_prefs_backup.json にはカスタム列定義が、各書籍フォルダーの metadata.opf には 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 の報告と入力バイト不変の4件の合成テストが通過しました。テストファイルの SHA-256 は 61a53983de02dcd7aa5c37b4b0ba8fc828bbcfe3367241165c6e4c920f7a349d で、Python のバイトコードコンパイルも成功しました。

これは合成ファイルシステム試験であり、実際に壊れたライブラリや Restore database の試験ではありません。設定バックアップの構造は変わり得るため、未知の将来形式を見落とす可能性があります。利用前にスクリプトと報告を確認し、元ライブラリを動かさないでください。

Calibre カスタム列監査を解釈するオリジナルの停止/試験表
オリジナルの概念イラスト:読めない、欠ける、競合する証拠なら停止し、整合しても別の検証済みコピーだけで試します。 Watch at Ease