Watch at EaseHistorias. Respuestas. Perspectiva.

Tecnología · GUÍA PRÁCTICA

Audita las columnas personalizadas de Calibre antes de Restaurar la base de datos

Inventaría definiciones, cobertura OPF por libro y conflictos sin abrir metadata.db ni modificar la única copia de la biblioteca.

Ilustración conceptual original de las capas de evidencia antes de restaurar una base de Calibre
Ilustración conceptual original: conserva una copia intacta, inventaría las preferencias y cada metadata.opf, y detente si faltan pruebas, son ilegibles o entran en conflicto. Watch at Ease

No empieces ejecutando Restaurar la base de datos sobre la única biblioteca de Calibre que queda. Es una operación de reconstrucción, no una vista previa de diagnóstico: la documentación actual de Calibre dice que regenera por completo la base a partir de los OPF de cada libro y puede perder búsquedas guardadas, categorías de usuario, paneles de complementos, ajustes de conversión por libro y recetas personalizadas.

La primera pregunta segura es más limitada: ¿qué pruebas siguen existiendo? Las definiciones de columnas personalizadas pueden estar en metadata_db_prefs_backup.json y también en los registros de metadatos de Calibre dentro de cada metadata.opf. Esos archivos pueden discrepar, estar incompletos o desactualizados. Un inventario de solo lectura no garantiza la recuperación, pero puede indicar cuándo no debes continuar.

Haz una copia completa y verificada antes de reparar

Confirma que miras la carpeta de biblioteca correcta. Una ruta equivocada o una unidad inaccesible puede hacer que una biblioteca sana parezca vacía. Si metadata.db falta o está dañado, cierra Calibre y copia toda la carpeta de la biblioteca a otra ubicación local. Conserva los bytes originales y realiza todos los experimentos únicamente en una copia separada.

No borres metadata.db, no ejecutes restore_database ni abras la única copia con una versión de Calibre que solo estés probando. Las preguntas frecuentes oficiales recomiendan copiar la carpeta completa al trasladar bibliotecas; la referencia de calibredb advierte que la regeneración no conserva todos los ajustes de biblioteca.

Comprende las tres capas de evidencia

metadata.db es la base activa, pero esta auditoría no la abre. metadata_db_prefs_backup.json puede contener definiciones de columnas; cada carpeta de libro puede contener metadata.opf con metadatos de usuario de Calibre. El código actual admite tanto el mapa JSON calibre:user_metadata de OPF3 como la forma antigua calibre:user_metadata:#lookup de OPF2.

Una definición solo muestra que existió una clave como #read o #genre. Un #value# por libro solo muestra que ese OPF registró un valor. Ninguno demuestra que todas las copias estén al día, y un valor vacío puede ser correcto. Si datatype, is_multiple, display o name difieren entre OPF, detente: la auditoría no debe elegir silenciosamente una variante.

Ejecuta el inventario de solo lectura

Guarda fuera de la biblioteca el siguiente script probado de Python 3 como calibre_custom_columns_audit.py. Ejecuta python3 calibre_custom_columns_audit.py "/ruta/a/Biblioteca de Calibre" > calibre-column-audit.json y escribe el informe fuera de la biblioteca. El script recorre metadata.opf y la copia de preferencias, imprime JSON en la salida estándar, nunca abre metadata.db y no escribe en la biblioteca.

#!/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())

Lee el informe como límite entre parar y probar

Para cada clave #lookup compara definition_in_prefs_backup, opf_files_with_definition, opf_files_with_nonempty_value y definition_variants_in_opfs. Pocos valores no implican por sí solos un error: muchas columnas están vacías intencionadamente. Compara con libros cuyo valor conoces e inspecciona manualmente algunas sample_paths en la copia.

Detente antes de restaurar si faltan metadata.opf, no pueden leerse, la copia de preferencias no se analiza, falta una clave en las preferencias o definition_conflict es true. Detente también si el informe contradice valores que conoces. Conserva el JSON y la copia antes de pedir ayuda.

Un informe limpio solo significa que este conjunto limitado de pruebas es coherente internamente. No demuestra que puedan reconstruirse todas las funciones o valores, que los OPF estén actualizados ni que Restaurar la base de datos vaya a funcionar.

Prueba la restauración solo en otra copia

Si el inventario parece plausible, crea una segunda copia verificada y ejecuta allí el procedimiento admitido de Restaurar la base de datos. Mantén sin conexión la copia fuente intacta. Después compara definiciones, muestras conocidas con valor y vacías, recuento de libros, formatos y portadas. Verifica aparte búsquedas guardadas, categorías, paneles, ajustes de conversión y recetas, porque el comando oficial advierte que pueden perderse.

Si la copia restaurada altera pruebas que aún necesitas, detente. No repitas el proceso sobre el original ni tomes una base que abre como prueba de que los metadatos personalizados están completos. El resultado útil es un registro acotado y una decisión reversible, no una reparación automática.

Qué se probó realmente

El SHA-256 exacto del script es 83b54e367d7aad731b6ba15d3ec2d487e369cda4975611613e015b8d2b00d949. Pasaron cuatro pruebas unitarias sintéticas: OPF3 coherente con preferencias, OPF2 antiguo, definiciones en conflicto y ausentes de preferencias, y OPF malformado informado sin cambiar los bytes de entrada. El SHA-256 del archivo de pruebas es 61a53983de02dcd7aa5c37b4b0ba8fc828bbcfe3367241165c6e4c920f7a349d; también pasó la compilación de bytecode de Python.

Son pruebas sintéticas del sistema de archivos, no una ejecución sobre una biblioteca real dañada ni una prueba de Restore database. La estructura de preferencias puede cambiar y la búsqueda recursiva podría omitir una forma futura desconocida. Revisa el script y el informe antes de confiar en ellos y conserva intacta la biblioteca original.

Ilustración conceptual original para interpretar la auditoría de columnas de Calibre
Ilustración conceptual original: evidencia ilegible, ausente o conflictiva significa parar; la coherencia solo permite probar en otra copia verificada. Watch at Ease