Watch at Ease세상을 읽고, 일상의 답을 찾다.

기술 · 실용 가이드

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 문서는 재구성 과정에서 모든 라이브러리 수준 설정을 보존할 수 없다고 경고합니다.

세 가지 증거 층을 이해한다

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 보고와 입력 바이트 불변의 네 가지 합성 단위 테스트가 통과했습니다. 테스트 파일 SHA-256은 61a53983de02dcd7aa5c37b4b0ba8fc828bbcfe3367241165c6e4c920f7a349d이며 Python 바이트코드 컴파일도 성공했습니다.

이는 합성 파일 시스템 테스트이며 실제로 손상된 라이브러리나 Restore database를 시험한 것이 아닙니다. 설정 백업 구조는 달라질 수 있어 향후 낯선 형식을 놓칠 수 있습니다. 결과를 사용하기 전에 스크립트와 보고서를 검토하고 원본 라이브러리는 그대로 보존하세요.

Calibre 사용자 지정 열 점검 결과를 해석하는 독창적 중지/시험 개념 일러스트
독창적 개념 일러스트: 읽을 수 없거나 누락되거나 충돌하는 증거는 중지 신호이며, 내부적으로 일치해도 다른 검증된 복사본에서만 시험합니다. Watch at Ease