diff --git a/.gitignore b/.gitignore index 36b13f1..8b87ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +backup/ + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/core/backup.py b/core/backup.py new file mode 100644 index 0000000..d078788 --- /dev/null +++ b/core/backup.py @@ -0,0 +1,15 @@ +# core/backup.py + +import os +import shutil +from core.paths import get_project_root + +def move_to_backup(path): + """Mueve un archivo al directorio centralizado /backup.""" + root = get_project_root() + backup_dir = os.path.join(root, "backup") + os.makedirs(backup_dir, exist_ok=True) + + target = os.path.join(backup_dir, os.path.basename(path)) + shutil.move(path, target) + return target diff --git a/core/paths.py b/core/paths.py new file mode 100644 index 0000000..ae2a3d8 --- /dev/null +++ b/core/paths.py @@ -0,0 +1,7 @@ +# core/paths.py + +import os + +def get_project_root(): + """Devuelve la ruta donde está main.py.""" + return os.path.dirname(os.path.abspath(__file__ + "/..")) diff --git a/main.py b/main.py index 31dbd90..ba8cf97 100644 --- a/main.py +++ b/main.py @@ -9,54 +9,15 @@ from processors.standardizer import standardize_comic def parse_args(): - parser = argparse.ArgumentParser( - description="Gestor de colección de cómics CBR/CBZ" - ) + parser = argparse.ArgumentParser(description="Gestor de cómics CBR/CBZ") - parser.add_argument( - "--ruta", - type=str, - required=True, - help="Ruta base donde buscar los cómics" - ) - - parser.add_argument( - "--listar", - action="store_true", - help="Listar todos los archivos CBR/CBZ encontrados" - ) - - parser.add_argument( - "--validar", - action="store_true", - help="Validar los archivos encontrados" - ) - - parser.add_argument( - "--limpiar", - action="store_true", - help="Eliminar archivos basura y reconstruir CBZ limpios" - ) - - parser.add_argument( - "--convertir", - action="store_true", - help="Convertir los archivos al formato indicado con --formato" - ) - - parser.add_argument( - "--formato", - type=str, - choices=["cbz", "cbr"], - default="cbz", - help="Formato final deseado (por defecto: cbz)" - ) - - parser.add_argument( - "--estandarizar", - action="store_true", - help="Pipeline completo: limpiar, convertir y normalizar" - ) + parser.add_argument("--ruta", required=True) + parser.add_argument("--listar", action="store_true") + parser.add_argument("--validar", action="store_true") + parser.add_argument("--limpiar", action="store_true") + parser.add_argument("--convertir", action="store_true") + parser.add_argument("--estandarizar", action="store_true") + parser.add_argument("--formato", choices=["cbz", "cbr"], default="cbz") return parser.parse_args() @@ -64,74 +25,38 @@ def parse_args(): def main(): args = parse_args() - print(f"Buscando archivos CBR/CBZ en: {args.ruta}\n") comic_files = find_comic_files(args.ruta) - if not comic_files: - print("No se encontraron archivos .cbr o .cbz") - return - - # ------------------------------------------------------------ - # LISTAR - # ------------------------------------------------------------ if args.listar: - print("Archivos encontrados:") for f in comic_files: - print(f" - {f}") - print() + print(f) - # ------------------------------------------------------------ - # VALIDAR - # ------------------------------------------------------------ if args.validar: - print("Validando archivos...\n") for f in comic_files: - result = validate_comic(f) - print(result) - - if result.errors: - print(" Errores:") - for e in result.errors: - print(f" - {e}") - - if result.warnings: - print(" Avisos:") - for w in result.warnings: - print(f" - {w}") - + print(f"Validando: {f}") + print(validate_comic(f)) print() - # ------------------------------------------------------------ - # LIMPIAR - # ------------------------------------------------------------ if args.limpiar: - print("Limpiando archivos...\n") for f in comic_files: - result = clean_comic(f) - print(result) + print(f"Limpieza: {f}") + print(clean_comic(f)) print() - # ------------------------------------------------------------ - # CONVERTIR - # ------------------------------------------------------------ if args.convertir: - print(f"Convirtiendo archivos al formato {args.formato}...\n") for f in comic_files: + print(f"Convirtiendo: {f}") info = convert_comic(f, args.formato) if info["needs_conversion"]: - print(f"Convertido: {f} → {info['target_path']}") + print(f" → Convertido a {args.formato.upper()}: {info['target_path']}") else: - print(f"Sin cambios: {f}") - print() + print(f" → Sin cambios (ya era {args.formato.upper()})") + print() - # ------------------------------------------------------------ - # ESTANDARIZAR - # ------------------------------------------------------------ if args.estandarizar: - print("Estandarizando archivos...\n") for f in comic_files: - result = standardize_comic(f, args.formato) - print(result) + print(f"Estandarizando: {f}") + print(standardize_comic(f, args.formato)) print() diff --git a/processors/cleaner.py b/processors/cleaner.py index bb4d01e..f8721b5 100644 --- a/processors/cleaner.py +++ b/processors/cleaner.py @@ -7,7 +7,7 @@ import tempfile import shutil from core.constants import TRASH_FILES -from processors.validator import try_open_rar, try_open_zip +from processors.validator import validate_comic class CleanResult: @@ -16,116 +16,48 @@ class CleanResult: self.cleaned_path = None self.removed_files = [] self.repacked = False - self.converted_to_cbz = False - - def pretty_removed_files(self): - if not self.removed_files: - return " No se eliminaron archivos\n" - msg = f" Archivos eliminados ({len(self.removed_files)}):\n" - for f in self.removed_files: - msg += f" - {f}\n" - return msg def __str__(self): msg = f"Limpieza de: {self.original_path}\n" - msg += self.pretty_removed_files() - if self.repacked: - msg += " Archivo reconstruido\n" - if self.converted_to_cbz: - msg += " Convertido a CBZ\n" - msg += f" Resultado final: {self.cleaned_path}" + msg += f" Archivos eliminados: {len(self.removed_files)}\n" + msg += f" Resultado final: {self.cleaned_path}\n" return msg -# ------------------------------------------------------------ -# 1) Limpieza de una carpeta ya extraída -# ------------------------------------------------------------ +def clean_comic(path): + validation = validate_comic(path) -def clean_folder(folder_path): - """ - Elimina archivos basura dentro de una carpeta ya extraída. - Devuelve una lista con las rutas relativas de los archivos eliminados. - """ - removed = [] + if validation.errors: + raise Exception(f"Archivo corrupto: {path}") - for root, _, files in os.walk(folder_path): - for f in files: - if f.lower() in TRASH_FILES: - full = os.path.join(root, f) - rel = os.path.relpath(full, folder_path) - os.remove(full) - removed.append(rel) + real = validation.real_format - return removed + # Abrir según formato real + archive = zipfile.ZipFile(path, "r") if real == "zip" else rarfile.RarFile(path, "r") - -# ------------------------------------------------------------ -# 2) Limpieza de un archivo completo (modo actual) -# ------------------------------------------------------------ - -def clean_comic(path, output_path=None): - """ - Limpia un archivo CBR/CBZ: - - elimina basura - - convierte CBR → CBZ - - reconstruye solo si es necesario - """ - result = CleanResult(path) - ext = os.path.splitext(path)[1].lower() - - # 1) Abrir archivo - if ext == ".cbr": - archive = try_open_rar(path) - if archive: - real_format = "rar" - else: - archive = try_open_zip(path) - if archive: - real_format = "zip" - else: - raise Exception(f"No se puede abrir {path}") - - elif ext == ".cbz": - archive = try_open_zip(path) - if archive: - real_format = "zip" - else: - raise Exception(f"No se puede abrir {path}") - - # 2) Extraer a carpeta temporal temp_dir = tempfile.mkdtemp() archive.extractall(temp_dir) archive.close() - # 3) Limpiar carpeta - removed = clean_folder(temp_dir) - result.removed_files = removed + removed = [] + for root, _, files in os.walk(temp_dir): + for f in files: + if f.lower() in TRASH_FILES: + full = os.path.join(root, f) + os.remove(full) + removed.append(os.path.relpath(full, temp_dir)) - # 4) Determinar si hay que reconstruir - changes_needed = False - - if removed: - changes_needed = True - - if ext == ".cbr": - changes_needed = True - result.converted_to_cbz = True - - if not changes_needed: - result.cleaned_path = path + # Si no se eliminó nada → no reconstruir + if not removed: shutil.rmtree(temp_dir) + result = CleanResult(path) + result.cleaned_path = path return result - # 5) Ruta final - if output_path: - final_path = output_path - else: - base, _ = os.path.splitext(path) - final_path = base + ".cbz" + # Reconstruir como CBZ + base, _ = os.path.splitext(path) + final_path = base + ".cbz" - result.cleaned_path = final_path - - # 6) Reempaquetar como CBZ with zipfile.ZipFile(final_path, "w", zipfile.ZIP_DEFLATED) as new_zip: for root, _, files in os.walk(temp_dir): for f in files: @@ -133,7 +65,14 @@ def clean_comic(path, output_path=None): rel = os.path.relpath(full, temp_dir) new_zip.write(full, rel) - result.repacked = True - shutil.rmtree(temp_dir) + + # Mover original a backup + from core.backup import move_to_backup + move_to_backup(path) + + result = CleanResult(path) + result.cleaned_path = final_path + result.removed_files = removed + result.repacked = True return result diff --git a/processors/converter.py b/processors/converter.py index 3398d6d..79473f1 100644 --- a/processors/converter.py +++ b/processors/converter.py @@ -6,53 +6,33 @@ import rarfile import tempfile import shutil - -def decide_target_format(original_path, desired_format="cbz"): - """ - Decide si el archivo debe convertirse y cuál será su ruta final. - No realiza la conversión. - """ - base, _ = os.path.splitext(original_path) - target_path = f"{base}.{desired_format.lower()}" - - needs_conversion = not original_path.lower().endswith(desired_format.lower()) - - return { - "needs_conversion": needs_conversion, - "target_format": desired_format.lower(), - "target_path": target_path - } +from processors.validator import validate_comic def convert_comic(path, desired_format="cbz"): - """ - Convierte un archivo CBR/CBZ al formato deseado. - NO limpia basura. - NO renombra páginas. - NO reordena nada. - """ - info = decide_target_format(path, desired_format) + validation = validate_comic(path) - if not info["needs_conversion"]: - return info # Nada que hacer + if validation.errors: + raise Exception(f"Archivo corrupto: {path}") - ext = os.path.splitext(path)[1].lower() + real = validation.real_format - # 1) Abrir archivo original - if ext == ".cbr": - archive = rarfile.RarFile(path, "r") - elif ext == ".cbz": - archive = zipfile.ZipFile(path, "r") - else: - raise Exception("Formato no soportado") + # Si ya está en el formato deseado → nada que hacer + if desired_format == "cbz" and real == "zip": + return {"needs_conversion": False, "target_path": path} + + if desired_format == "cbr" and real == "rar": + return {"needs_conversion": False, "target_path": path} + + # Abrir según formato real + archive = zipfile.ZipFile(path, "r") if real == "zip" else rarfile.RarFile(path, "r") - # 2) Extraer a carpeta temporal temp_dir = tempfile.mkdtemp() archive.extractall(temp_dir) archive.close() - # 3) Reempaquetar en el formato deseado - target_path = info["target_path"] + base, _ = os.path.splitext(path) + target_path = f"{base}.{desired_format}" if desired_format == "cbz": with zipfile.ZipFile(target_path, "w", zipfile.ZIP_DEFLATED) as new_zip: @@ -63,10 +43,12 @@ def convert_comic(path, desired_format="cbz"): new_zip.write(full, rel) elif desired_format == "cbr": - # rarfile no puede crear RAR → hay que usar "rar" externo - raise NotImplementedError("Crear CBR requiere la herramienta 'rar' instalada") + raise NotImplementedError("Crear CBR requiere 'rar' instalado") - # 4) Limpiar temporal shutil.rmtree(temp_dir) - return info + # Mover original a backup + from core.backup import move_to_backup + move_to_backup(path) + + return {"needs_conversion": True, "target_path": target_path} diff --git a/processors/standardizer.py b/processors/standardizer.py index 6083012..e3cb3e7 100644 --- a/processors/standardizer.py +++ b/processors/standardizer.py @@ -1,55 +1,44 @@ # processors/standardizer.py +from processors.validator import validate_comic from processors.cleaner import clean_comic from processors.converter import convert_comic + class StandardizeResult: - def __init__(self, original_path): - self.original_path = original_path + def __init__(self, path): + self.path = path self.cleaned = None self.converted = None self.final_path = None def __str__(self): - msg = f"Estandarización de: {self.original_path}\n" + msg = f"Estandarización de: {self.path}\n" if self.cleaned: - msg += f" Limpieza: OK ({len(self.cleaned.removed_files)} archivos eliminados)\n" - else: - msg += " Limpieza: no realizada\n" - + msg += f" Limpieza: OK ({len(self.cleaned.removed_files)} eliminados)\n" if self.converted: if self.converted["needs_conversion"]: msg += f" Conversión: OK → {self.converted['target_path']}\n" else: msg += " Conversión: no necesaria\n" - msg += f" Resultado final: {self.final_path}\n" return msg def standardize_comic(path, desired_format="cbz"): - """ - Pipeline básico: - 1. Limpiar - 2. Convertir - """ + validation = validate_comic(path) + + if validation.errors: + raise Exception(f"Archivo corrupto: {path}") + result = StandardizeResult(path) - # 1) Limpiar clean_result = clean_comic(path) result.cleaned = clean_result - # El archivo resultante tras limpiar - cleaned_path = clean_result.cleaned_path - - # 2) Convertir - convert_result = convert_comic(cleaned_path, desired_format) + convert_result = convert_comic(clean_result.cleaned_path, desired_format) result.converted = convert_result - # Ruta final - if convert_result["needs_conversion"]: - result.final_path = convert_result["target_path"] - else: - result.final_path = cleaned_path + result.final_path = convert_result["target_path"] if convert_result["needs_conversion"] else clean_result.cleaned_path return result diff --git a/processors/validator.py b/processors/validator.py index 93c4ff6..c27c76c 100644 --- a/processors/validator.py +++ b/processors/validator.py @@ -3,99 +3,64 @@ import os import zipfile import rarfile -from core.constants import IMAGE_EXTENSIONS, TRASH_FILES -IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} -TRASH_FILES = {"thumbs.db", ".ds_store"} - class ValidationResult: def __init__(self, path): self.path = path - self.is_valid = True + self.real_format = None # "zip", "rar", None + self.extension = None # ".cbz" o ".cbr" self.errors = [] self.warnings = [] - self.images = [] - self.real_format = None # "rar", "zip", None - - def add_error(self, msg): - self.is_valid = False - self.errors.append(msg) - - def add_warning(self, msg): - self.warnings.append(msg) def __str__(self): - status = "OK" if self.is_valid else "ERROR" - return f"[{status}] {self.path}" + msg = f"Validación de: {self.path}\n" + msg += f" Formato real: {self.real_format}\n" + msg += f" Extensión: {self.extension}\n" + if self.errors: + msg += " Errores:\n" + for e in self.errors: + msg += f" - {e}\n" + if self.warnings: + msg += " Avisos:\n" + for w in self.warnings: + msg += f" - {w}\n" + return msg -def try_open_rar(path): +def detect_real_format(path): + """Devuelve 'zip', 'rar' o None.""" try: - archive = rarfile.RarFile(path, "r") - archive.namelist() # fuerza lectura - return archive + zipfile.ZipFile(path).close() + return "zip" except: - return None + pass - -def try_open_zip(path): try: - archive = zipfile.ZipFile(path, "r") - archive.namelist() - return archive + rarfile.RarFile(path).close() + return "rar" except: - return None + pass + + return None def validate_comic(path): result = ValidationResult(path) ext = os.path.splitext(path)[1].lower() + result.extension = ext - archive = None + real = detect_real_format(path) + result.real_format = real - # 1) Intentar abrir como RAR - if ext == ".cbr": - archive = try_open_rar(path) - if archive: - result.real_format = "rar" - else: - # 2) Intentar abrir como ZIP (CBZ renombrado) - archive = try_open_zip(path) - if archive: - result.real_format = "zip" - result.add_warning("El archivo tiene extensión .cbr pero es un ZIP (CBZ renombrado)") - else: - result.add_error("No se pudo abrir como RAR ni como ZIP") - return result - - # 3) Si es CBZ, abrir como ZIP - elif ext == ".cbz": - archive = try_open_zip(path) - if archive: - result.real_format = "zip" - else: - result.add_error("No se pudo abrir el archivo ZIP") - return result - - else: - result.add_error("Extensión no reconocida") + if real is None: + result.errors.append("Archivo corrupto o ilegible") return result - # 4) Validar contenido - file_list = archive.namelist() + if ext == ".cbz" and real == "rar": + result.warnings.append("Extensión incorrecta: debería ser .cbr") - for f in file_list: - name = f.lower() - _, fext = os.path.splitext(name) - - if fext in IMAGE_EXTENSIONS: - result.images.append(f) - - if os.path.basename(name) in TRASH_FILES: - result.add_warning(f"Archivo basura encontrado: {f}") - - if not result.images: - result.add_error("No contiene imágenes válidas") + if ext == ".cbr" and real == "zip": + result.warnings.append("Extensión incorrecta: debería ser .cbz") return result