# -*- coding: utf-8 -*- """ Organizador Labstream — inventario, mapa, orden y optimización del material. Solo librería estándar de Python (3.10+). Es un programa de Windows: abre su propia ventana (ventana.py) y la interfaz habla con este módulo por un servidor que solo escucha en este equipo. OrganizadorLabstream.exe (el paquete: lo arma empaquetar.py) py -3.12 organizador.py (desde el código) Nunca borra nada: lo que sobra va a _Para borrar dentro de la misma raíz y cada movimiento queda en datos/bitacora.jsonl para poder deshacerlo. """ import os import sys import re import json import zlib import struct import ctypes import time import queue import shutil import socket import stat as stat_mod import hashlib import sqlite3 import threading import subprocess import tempfile import datetime import webbrowser from collections import Counter, defaultdict from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs import reglas as R import braw import asistente import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] VERSION = "3.8.0" BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es # el intérprete de Python tal como lo firma la Python Software Foundation, con otro nombre (arranque.py # cuenta cómo). Hasta la 2.x era un .exe de PyInstaller, sin firma, y Smart App Control lo bloqueaba. CONGELADO = bool(getattr(sys, "frozen", False)) or os.path.normcase(os.path.dirname(os.path.abspath(sys.executable))) == os.path.normcase(BASE) SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras DATOS = os.path.join(BASE, "datos") os.makedirs(DATOS, exist_ok=True) DB_PATH = os.path.join(DATOS, "inventario.sqlite") CONFIG_PATH = os.path.join(DATOS, "config.json") DICC_PATH = os.path.join(DATOS, "diccionario.json") BITACORA_PATH = os.path.join(DATOS, "bitacora.jsonl") PLANTILLAS_PATH = os.path.join(DATOS, "plantillas.json") PUERTO = int(os.environ.get("ORGANIZADOR_PUERTO", "8765")) LOCK = threading.RLock() # ========================================================================== # Configuración y diccionario (lo que la app aprende) # ========================================================================== CONFIG_DEFECTO = { # ninguna carpeta puesta de fábrica: la primera vez el programa abre en blanco y las carpetas # entran cuando alguien las suelta. Lo que sí viene de serie es el diccionario de clientes y # discos del estudio, que es conocimiento, no estado. "raices": [], "clientes": { "David Reyes": {"tipo": "contenido", "alias": ["DR. DAVID REYES", "David Reyes", "Davids", "Dr David Reyes"]}, "Danney Gómez": {"tipo": "contenido", "alias": ["Danney", "DRA. DANNEY GOMEZ", "Dra_Danney", "Dra Danney", "Skincenter"]}, "Camilo Ortega": {"tipo": "pauta", "alias": ["Camilo Ortega", "Camilo", "Camilo Ortega Anuncios", "Camilo Anuncio", "VSL Camilo Ortega", "Apoyos Camilo", "Anuncios Jonathan"]}, "Andrea Galeano": {"tipo": "contenido", "alias": ["DRA. ANDREA GALEANO", "Galeano", "Portadas Galeano"]}, "Carolina Solórzano": {"tipo": "contenido", "alias": ["DRA. CAROLINA SOLORZANO", "Solorzano"]}, "Diana Méndez": {"tipo": "contenido", "alias": ["DRA. DIANA MENDEZ", "Diana Mendez", "DRA. DIANA MENDEZ (1)"]}, "Luisa Báez": {"tipo": "contenido", "alias": ["DRA. LUISA BAEZ", "Luisa Baez"]}, "Malpraxis": {"tipo": "pauta", "alias": ["Malpraxis", "Malpraxiss", "Malpraxis 2026", "App Malpraxis - Hotjamin"]}, "Brigard & Urrutia": {"tipo": "proyecto", "alias": ["Brigard & Urrutia", "Brigard Urrutia", "Brigard", "Brigard 2025", "Brigard 2026"]}, "PepsiCo": {"tipo": "proyecto", "alias": ["PepsiCo", "pepsico"]}, "Kingspan / Synthesia": {"tipo": "proyecto", "alias": ["Kingspam", "Kingspan", "Sinthesia", "Syntesia", "SYNTHESIA - KINGSPAN"]}, "Filarmónica de Bogotá": {"tipo": "proyecto", "alias": ["FILARMONICA DE BOGOTA", "Filarmónica de Bogotá", "Filarmonica"]}, "Fundación Vascular": {"tipo": "proyecto", "alias": ["CLINICA FUNDACIÓN VASCULAR", "Fundación Vascular"]}, "Legal Core": {"tipo": "proyecto", "alias": ["LEGAL CORE", "Legal Core"]}, "Vía Sumapaz": {"tipo": "proyecto", "alias": ["Vía SUMAPAZ", "VÍA SUMAPAZ", "Sumapaz", "Páramo Sumapaz lucy"]}, "Latinología": {"tipo": "proyecto", "alias": ["Latinología"]}, "CICR": {"tipo": "proyecto", "alias": ["CICR"]}, "Andrés Arango": {"tipo": "proyecto", "alias": ["Andres Arango"]}, "Labstream (interno)": {"tipo": "interno", "alias": ["LABSTREAM", "LABSTREAM ACADEMY", "Labstream Medicina", "producción", "Recursos audiovisuales", "Recursos Audiovisuales", "Sonidos"]}, }, "discos": { "Z:": {"nombre": "labstream · NAS Labstream", "nivel": "trabajo"}, "W:": {"nombre": "Dra_Danney · Cloud Store Mini (10G)", "nivel": "caliente"}, "N:": {"nombre": "Camilo Ortega · NAS Labstream", "nivel": "trabajo"}, "K:": {"nombre": "Entregas_LAB · LabTem (lo maneja Labstream OS)", "nivel": "entregas"}, "Q:": {"nombre": "Exportados · NAS Labstream (lo maneja Labstream OS)", "nivel": "entregas"}, "T:": {"nombre": "temple · LabTem", "nivel": "frio"}, "M:": {"nombre": "Malpraxis · NAS Labstream", "nivel": "trabajo"}, "X:": {"nombre": "Datos · NAS Labstream", "nivel": "frio"}, "Y:": {"nombre": "Nubes · NAS Labstream", "nivel": "frio"}, "V:": {"nombre": "Medicina1 · NAS Labstream", "nivel": "trabajo"}, "U:": {"nombre": "Reels · LabTem", "nivel": "trabajo"}, "L:": {"nombre": "Apoyos Camilo · LabTem", "nivel": "trabajo"}, "R:": {"nombre": "Recursos Audiovisuales · LabTem", "nivel": "recursos"}, "S:": {"nombre": "Sonidos · LabTem", "nivel": "recursos"}, "O:": {"nombre": "Operaciones_LAB · NAS Labstream", "nivel": "sistema"}, }, "optimizar": { "meses_minimos": 3, "min_mb": 300, "perfil": "equilibrado", "formato": "h265_10bit", "chroma": "420", "color": "rec709", "salida": "junto", # junto = al lado del original con sufijo _10bit ; espejo = _Archivo/ "sufijo": "_10bit", "ffmpeg": "ffmpeg", "ffprobe": "ffprobe", "python_resolve": "", # vacío = el mismo intérprete "braw": "", # carpeta del decodificador de Blackmagic RAW; vacío = se busca sola }, # las subcarpetas con las que nace una pieza nueva (las que más se repiten en las piezas del estudio) "pieza": {"subcarpetas": ["Guion", "Videos", "Imagenes", "Musica", "Elements", "IA", "Portada", "Exportado"]}, # el orden automático viene apagado: lo enciende una persona, y entonces Windows lanza la pasada a esa hora. # «limite» es cuántos archivos puede mover de una carpeta sin que nadie lo mire. "automatico": {"activo": False, "hora": "02:00", "limite": 3000}, } PERFILES = { "ahorro": {"bpp": 0.055, "cq": 27, "nombre": "Ahorro (pesa menos)"}, "equilibrado": {"bpp": 0.085, "cq": 24, "nombre": "Equilibrado (recomendado)"}, "maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"}, } # Formatos de salida del convertidor. gpu/cpu = codificador de ffmpeg que lo hace; se usa la GPU si está # disponible y si no la CPU. bpp_mult escala el bitrate objetivo del perfil (H.265 10 bits = 1.0). # ProRes no va por bitrate: mbps_1080p30 es su peso aproximado a 1080p 30 fps para estimar. FORMATOS = { "h265_10bit": {"nombre": "MP4 H.265 10 bits · archivo (recomendado)", "ext": ".mp4", "codec": "hevc", "bits": 10, "sufijo": "_10bit", "gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 1.0, "que": "unas 10 veces más ligero que el original, sin perder calidad visible; el que usa el respaldo"}, "h265_ligero": {"nombre": "MP4 H.265 8 bits · ligero para compartir", "ext": ".mp4", "codec": "hevc", "bits": 8, "sufijo": "_ligero", "gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "cq_mas": 5, "que": "para enviar por WhatsApp o Drive; pierde latitud de color"}, "h264": {"nombre": "MP4 H.264 8 bits · compatible con todo", "ext": ".mp4", "codec": "h264", "bits": 8, "sufijo": "_h264", "gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "cq_mas": -2, "que": "se abre en cualquier equipo o televisor; pesa más que H.265"}, "prores_proxy": {"nombre": "MOV ProRes Proxy · edición ligera", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_proxy", "cpu": "prores_ks", "prores": 0, "mbps_1080p30": 45, "que": "para editar suave en Resolve o Premiere; luego se reenlaza al original"}, "prores_lt": {"nombre": "MOV ProRes 422 LT · edición", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_LT", "cpu": "prores_ks", "prores": 1, "mbps_1080p30": 102, "que": "calidad de edición; pesa bastante"}, "prores_hq": {"nombre": "MOV ProRes 422 HQ · máster", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_HQ", "cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"}, } _MOTORES = {"ts": 0, "datos": None} _BRAW = {"ts": 0, "datos": None} def _lector_braw(*args, espera=120): """Lanza braw.py —proceso aparte: ahí se carga el decodificador de Blackmagic, no aquí— y devuelve lo que dijo, una línea JSON por evento.""" carpeta = CONFIG["optimizar"].get("braw") or "" r = subprocess.run([sys.executable, os.path.join(BASE, "braw.py"), *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=espera, creationflags=SIN_VENTANA, env=dict(os.environ, ORGANILAB_BRAW=carpeta) if carpeta else None) out = [] for linea in (r.stdout or "").splitlines(): try: out.append(json.loads(linea)) except ValueError: pass return out def decodificador_braw(): """¿Se pueden leer los BRAW sin Resolve? Lo dice braw.py después de cargar el decodificador de Blackmagic y pedirle las interfaces que se usan. La respuesta se recuerda diez minutos.""" if _BRAW["datos"] and time.time() - _BRAW["ts"] < 600: return _BRAW["datos"] carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "") datos = {"ok": False, "carpeta": "", "motivo": "no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito)"} if carpeta: try: dicho = _lector_braw("sonda", carpeta, espera=60) datos = dicho[-1] if dicho else {"ok": False, "carpeta": carpeta, "motivo": "el decodificador de Blackmagic RAW no respondió"} except Exception as ex: # noqa datos = {"ok": False, "carpeta": carpeta, "motivo": f"el decodificador de Blackmagic RAW no respondió: {ex}"} _BRAW.update(ts=time.time(), datos=datos) return datos def medir_braw(ruta): """Lo que el decodificador dice de un BRAW: su tamaño de verdad (ffprobe lo lee con relleno y sin girar: 4128×2176 para un clip que es 2160×4096 en vertical), sus cuadros, su código de tiempo y su curva.""" try: dicho = _lector_braw("info", ruta) except Exception as ex: # noqa return {"ok": False, "error": f"el lector de BRAW no respondió: {ex}"} return dicho[-1] if dicho else {"ok": False, "error": "el lector de BRAW no dijo nada"} def _curva(m): return (m.get("gamma") or "BRAW").replace("Blackmagic Design ", "") def motores(): """Qué codificadores hay: GPU NVIDIA (NVENC) y CPU. Se prueba de verdad (un clip de 3 cuadros), no solo por la lista de ffmpeg, porque la lista incluye nvenc aunque no haya tarjeta NVIDIA.""" if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600: return _MOTORES["datos"] ff = ruta_herramienta("ffmpeg") out = {"gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False}, "cpu": {"libx265": False, "libx264": False, "prores_ks": False}, "formatos": {}, "chroma422": False, "ffmpeg": bool(ff)} disponibles = set() if ff: try: r = subprocess.run([ff, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA) for linea in r.stdout.splitlines(): partes = linea.split() if len(partes) >= 2 and partes[0][:1] in ("V", "A") and len(partes[0]) == 6: disponibles.add(partes[1]) except Exception: # noqa pass for k in ("libx265", "libx264", "prores_ks"): out["cpu"][k] = k in disponibles for k, enc in (("hevc", "hevc_nvenc"), ("h264", "h264_nvenc")): if enc in disponibles: try: r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30", "-frames:v", "3", "-c:v", enc, "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA) out["gpu"][k] = r.returncode == 0 except Exception: # noqa pass if out["gpu"]["hevc"]: try: r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30", "-frames:v", "3", "-c:v", "hevc_nvenc", "-pix_fmt", "p210le", "-profile:v", "rext", "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA) out["gpu"]["chroma422"] = r.returncode == 0 except Exception: # noqa out["gpu"]["chroma422"] = False out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265")) if out["gpu"]["hevc"]: try: # ¿la tarjeta y su controlador admiten el ajuste «uhq»? (ver _args_video) r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30", "-frames:v", "3", "-c:v", "hevc_nvenc", "-pix_fmt", "p010le", "-tune", "uhq", "-profile:v", "main10", "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA) out["gpu"]["uhq"] = r.returncode == 0 except Exception: # noqa out["gpu"]["uhq"] = False try: r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA) out["gpu"]["nombre"] = (r.stdout or "").strip().splitlines()[0] if r.returncode == 0 and r.stdout.strip() else "" except Exception: # noqa pass for k, f in FORMATOS.items(): m = None if f.get("gpu") and out["gpu"].get(f["codec"]): m = "gpu" elif f.get("cpu") and out["cpu"].get(f["cpu"]): m = "cpu" out["formatos"][k] = m # None = no se puede con este ffmpeg out["braw"] = decodificador_braw() _MOTORES.update(ts=time.time(), datos=out) return out def cargar_json(ruta, defecto): try: with open(ruta, "r", encoding="utf-8") as f: return json.load(f) except OSError: return json.loads(json.dumps(defecto)) except ValueError as ex: # el archivo existe pero no se puede leer (una edición a mano con una coma de más, un corte a # medias). Antes se sustituía por los valores de fábrica y se guardaba encima: las carpetas # registradas y los clientes desaparecían sin decir nada. Ahora se aparta con copia. copia = f"{ruta}.roto-{time.strftime('%Y%m%d-%H%M%S')}" try: shutil.copy2(ruta, copia) except OSError: copia = "(no se pudo copiar)" print(f"AVISO: {os.path.basename(ruta)} no se pudo leer ({ex}); se apartó en {copia} y se parte de cero.") return json.loads(json.dumps(defecto)) def guardar_json(ruta, datos): tmp = ruta + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(datos, f, ensure_ascii=False, indent=2) os.replace(tmp, ruta) CONFIG = cargar_json(CONFIG_PATH, CONFIG_DEFECTO) for k, v in CONFIG_DEFECTO.items(): # completar claves nuevas sin pisar lo del usuario CONFIG.setdefault(k, v) if isinstance(v, dict): for kk, vv in v.items(): CONFIG[k].setdefault(kk, vv) guardar_json(CONFIG_PATH, CONFIG) def _sufijos_conversion(): """El sufijo del convertidor se puede cambiar en Ajustes: las reglas tienen que conocerlo para saber que «C0001_ligero2.mp4» sigue siendo el clip C0001 y no un exportado.""" propio = (CONFIG["optimizar"].get("sufijo") or "").strip().lower() if len(propio) > 2 and propio not in R.SUFIJOS_CONVERSION: R.SUFIJOS_CONVERSION = R.SUFIJOS_CONVERSION + (propio,) _sufijos_conversion() def ruta_herramienta(h): """ffmpeg/ffprobe: primero el empaquetado en ffmpeg\\ junto al programa, luego lo que diga la config, luego el PATH. No se guarda en la config para que la carpeta se pueda mover de sitio.""" local = os.path.join(BASE, "ffmpeg", h + ".exe") if os.path.exists(local): return local return CONFIG["optimizar"].get(h) or h def herramientas(): """qué hay disponible: ffmpeg/ffprobe, el decodificador de Blackmagic RAW y Resolve""" out = {} for h in ("ffmpeg", "ffprobe"): ruta = ruta_herramienta(h) ok = os.path.exists(ruta) or shutil.which(ruta) is not None out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())} out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"} # solo se mira si el archivo está: cargarlo de verdad lo hace decodificador_braw(), en un proceso aparte carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "") out["braw"] = {"ok": bool(carpeta), "ruta": carpeta} out["congelado"] = CONGELADO return out DICC = cargar_json(DICC_PATH, {"carpetas": R.DICCIONARIO_SEMILLA, "reglas": [], "aprendido": []}) DICC.setdefault("carpetas", dict(R.DICCIONARIO_SEMILLA)) DICC.setdefault("reglas", []) DICC.setdefault("aprendido", []) for k, v in R.DICCIONARIO_SEMILLA.items(): # palabras nuevas de la semilla, sin pisar lo aprendido DICC["carpetas"].setdefault(k, v) # Hasta la 3.1.1 «Por nombrar» preguntaba también por las carpetas de la propia app («basura», de # _Para borrar\basura) y proponía «grabación» si dentro había clips. Lo que se aprendió así se olvida: # nunca fue una carpeta del cliente. for k in [k for k in DICC["carpetas"] if k in R.NOMBRES_DE_LA_APP and k not in R.DICCIONARIO_SEMILLA]: del DICC["carpetas"][k] guardar_json(DICC_PATH, DICC) _act = CONFIG.get("actualizaciones") if isinstance(_act, dict) and _act.get("auto") is True and not _act.get("elegido"): _act["auto"] = False guardar_json(CONFIG_PATH, CONFIG) PLANTILLAS = cargar_json(PLANTILLAS_PATH, R.PLANTILLAS) for t, d in R.PLANTILLAS.items(): PLANTILLAS.setdefault(t, d) for b, p in d.items(): PLANTILLAS[t].setdefault(b, p) # la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan # sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja # mientras toda la interfaz hablaba de la nueva. Se migra una sola vez. if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"): PLANTILLAS[t][b] = p # los proyectos pasaron a separarse por programa: «01_Proyectos/{nombre}» -> «01_Proyectos/{app}/{nombre}» if b == "PROYECTOS" and PLANTILLAS[t][b] == p.replace("{app}/", ""): PLANTILLAS[t][b] = p # y las grabaciones, por día de rodaje cuando el mes tiene más de uno if b in ("GRABACION", "CELULAR") and PLANTILLAS[t][b] == p.replace("{dia}/", ""): PLANTILLAS[t][b] = p guardar_json(PLANTILLAS_PATH, PLANTILLAS) def raiz_cfg(ruta): ruta = os.path.normpath(ruta) for r in CONFIG["raices"]: if os.path.normpath(r["ruta"]).lower() == ruta.lower(): return r return None def dentro_de(raiz, ruta): """True si `ruta` está dentro de `raiz` (o es la propia raíz). Evita mover cosas fuera de la carpeta del cliente.""" try: a = os.path.normcase(os.path.normpath(raiz)) b = os.path.normcase(os.path.normpath(ruta)) except (TypeError, ValueError): return False return b == a or b.startswith(a.rstrip(os.sep) + os.sep) # ========================================================================== # Base de datos # ========================================================================== def db(): con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=60) con.row_factory = sqlite3.Row return con def init_db(): con = db() con.executescript(""" PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS archivos ( ruta TEXT PRIMARY KEY, raiz TEXT, dir TEXT, nombre TEXT, ext TEXT, bytes INTEGER, mtime REAL, rol TEXT, fuente TEXT, contexto TEXT, anio_ruta INTEGER, mes_ruta INTEGER, fecha TEXT, fecha_origen TEXT, es_basura INTEGER, es_proxy INTEGER, codec TEXT, perfil TEXT, pix_fmt TEXT, ancho INTEGER, alto INTEGER, fps REAL, dur REAL, kbps INTEGER, alfa INTEGER, acodec TEXT, analizado INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS ix_arch_dir ON archivos(dir); CREATE INDEX IF NOT EXISTS ix_arch_raiz ON archivos(raiz); CREATE INDEX IF NOT EXISTS ix_arch_nb ON archivos(nombre, bytes); CREATE TABLE IF NOT EXISTS carpetas ( ruta TEXT PRIMARY KEY, raiz TEXT, nombre TEXT, n INTEGER, bytes INTEGER, anio_moda INTEGER, mes_moda INTEGER, frac_moda REAL, dias_rango INTEGER, es_proxy INTEGER, es_basura INTEGER ); CREATE INDEX IF NOT EXISTS ix_carp_raiz ON carpetas(raiz); CREATE TABLE IF NOT EXISTS escaneos (raiz TEXT PRIMARY KEY, ts REAL, n INTEGER, bytes INTEGER, seg REAL); CREATE TABLE IF NOT EXISTS trabajos ( id INTEGER PRIMARY KEY AUTOINCREMENT, ruta TEXT, salida TEXT, motor TEXT, perfil TEXT, chroma TEXT, color TEXT, kbps INTEGER, estado TEXT, pct REAL DEFAULT 0, log TEXT DEFAULT '', bytes_in INTEGER, bytes_out INTEGER, creado REAL, fin REAL, raiz TEXT ); """) con.commit() for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'", "ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1", # con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS) "ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0", # «primero este»: la cola va por orden descendente y luego por id "ALTER TABLE trabajos ADD COLUMN orden INTEGER DEFAULT 0", # la nota de calidad (VMAF) medida en «comparar»: media y cuadro peor "ALTER TABLE trabajos ADD COLUMN nota REAL", "ALTER TABLE trabajos ADD COLUMN nota_min REAL"): try: con.execute(alter) con.commit() except sqlite3.OperationalError: pass con.close() init_db() def con_sep(ruta): r"""la ruta con un separador al final, sin duplicarlo (N: ya lo trae)""" ruta = os.path.normpath(ruta) return ruta if ruta.endswith(os.sep) else ruta + os.sep def esc_like(s): return s.replace("!", "!!").replace("%", "!%").replace("_", "!_") def archivos_bajo(con, carpeta): """filas de archivos dentro de una carpeta (recursivo)""" c = os.path.normpath(carpeta) return con.execute( "SELECT * FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'", (c, esc_like(con_sep(c)) + "%")).fetchall() def archivos_en(con, carpeta): return con.execute("SELECT * FROM archivos WHERE dir = ?", (os.path.normpath(carpeta),)).fetchall() # ========================================================================== # Escaneo (inventario) # ========================================================================== PROGRESO = {} # raiz -> dict def contexto_dir(nombre, ctx_padre, nivel_padre): """cubeta de contexto que hereda un archivo por la carpeta donde está""" if R.detectar_anio(nombre): return "ANIO", "anio" if nivel_padre == "anio" and R.detectar_mes(nombre)[0]: return "MES", "mes" if nivel_padre == "mes" and R.detectar_pieza(nombre): return "PIEZA", "pieza" cub, _ = R.cubeta_carpeta(nombre, DICC["carpetas"]) if cub in (None, "CONTENEDOR"): return ctx_padre, nivel_padre if cub == "CONTENEDOR" else nivel_padre if cub == "PIEZA": return "PIEZA", "pieza" return cub, nivel_padre def _es_enlace(entrada): """True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta: para una unión devuelve False, y aun así al entrar se recorre otro árbol.""" try: if entrada.is_symlink(): return True return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT) except (OSError, AttributeError): return False def _id_carpeta(ruta): """Identificador real de una carpeta en el disco, para no recorrer dos veces la misma.""" st = os.stat(ruta) return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta)) def escanear(ruta): raiz = os.path.normpath(ruta) prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""} filas = [] fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre] props = ("n", "bytes") def agrega_dir(d, size): info = dirs.get(d) if info is None: info = dirs[d] = [0, 0, Counter(), 0, 0, os.path.basename(d)] info[0] += 1 info[1] += size def subir(d): """`d` y todas las carpetas de encima, hasta la raíz.""" while True: yield d if os.path.normpath(d).lower() == raiz.lower(): return padre = os.path.dirname(d) if padre == d: return d = padre def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None): # `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows # (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando # vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos). if vistos is None: vistos = set() try: clave = _id_carpeta(path) if clave in vistos: fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra") return vistos = vistos | {clave} except OSError: pass try: it = os.scandir(path) except OSError as ex: fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})") return prog["actual"] = path with it: for e in it: try: nombre = e.name if e.is_dir(follow_symlinks=False): if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP): continue # lo de la app (sus iconos de color) no es material del cliente # dentro de la M4ROOT de una tarjeta Sony, SUB son los proxies que graba la cámara y # THMBNL y GENERAL sus miniaturas e índices: no son material sony = os.path.basename(path).upper() == "M4ROOT" p = en_proxy or R.es_proxy_dir(nombre) or (sony and nombre.upper() == "SUB") b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM \ or (sony and nombre.upper() in ("THMBNL", "GENERAL")) # una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro: # medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de # material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye # por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de # Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo. s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) c2, n2 = contexto_dir(nombre, ctx, nivel) a2, m2 = anio, mes if c2 == "ANIO": a2, m2 = R.detectar_anio(nombre), None elif c2 == "MES": m2 = R.detectar_mes(nombre)[0] dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre]) dirs[e.path][3] = 1 if p else 0 dirs[e.path][4] = 1 if b else 0 if _es_enlace(e): fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra") continue walk(e.path, p, b, s, c2, n2, a2, m2, vistos) else: if nombre.lower() == "desktop.ini" and _ini_nuestro(e.path): continue # el color de la carpeta lo puso la app: ni se indexa ni se aparta st = e.stat(follow_symlinks=False) ext = os.path.splitext(nombre)[1].lower() firma = R.firma_archivo(nombre) rol = R.rol_archivo(nombre, ext, firma) basura = 1 if (en_basura or rol == "basura") else 0 proxy = 1 if (en_proxy or rol == "proxy") else 0 stock = 1 if (en_stock or rol == "stock") else 0 fecha, origen = R.fecha_de_nombre(nombre, R.fecha_real(st)) filas.append((e.path, raiz, path, nombre, ext, st.st_size, st.st_mtime, rol, firma or "", ctx or "", anio, mes, fecha, origen, basura, proxy, stock)) prog["n"] += 1 prog["bytes"] += st.st_size for d in subir(path): agrega_dir(d, st.st_size) except OSError as ex: fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})") continue try: walk(raiz, False, False, False, "", "raiz", None, None) prog["fallos"] = len(fallos) _fechas_de_rodaje(filas) _heredar_fechas(filas) # de qué mes es cada carpeta: se cuenta al final, con las fechas ya corregidas, y sin la basura # ni los proxies (un .DS_Store de otro año no dice nada del material) for f in filas: if f[14] or f[15]: continue am = (int(f[12][:4]), int(f[12][5:7])) for d in subir(f[2]): dirs[d][2][am] += 1 con = db() # Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de # red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de # ahí el plan, el respaldo y el convertidor trabajaban sobre datos parciales sin que nadie lo supiera. anterior = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() if fallos and anterior and anterior["n"] and prog["n"] < anterior["n"] * 0.9: con.close() prog["estado"] = "error" prog["error"] = (f"el escaneo no pudo leer {len(fallos)} sitios y solo encontró {prog['n']:,} archivos " f"frente a {anterior['n']:,} del anterior: se conserva el inventario que ya había. " f"Comprueba que la unidad esté conectada. Primero que falló: {fallos[0]}") prog["fin"] = time.time() return with con: # conservar el análisis de ffprobe de los archivos que no cambiaron previo = {r["ruta"]: r for r in con.execute( "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, " "pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,)) con.executemany( "INSERT OR REPLACE INTO archivos (ruta, raiz, dir, nombre, ext, bytes, mtime, rol, fuente, contexto, " "anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", filas) for f in filas: pv = previo.get(f[0]) if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2: con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", (pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0])) _aplicar_relojes(con, raiz) # las fechas corregidas a mano siguen corregidas cfilas = [] for d, info in dirs.items(): n, b, fechas, p, bs, nombre = info if fechas: (ay, am), cnt = fechas.most_common(1)[0] frac = cnt / sum(fechas.values()) claves = sorted(fechas) d0 = datetime.date(claves[0][0], claves[0][1], 1) d1 = datetime.date(claves[-1][0], claves[-1][1], 1) rango = (d1 - d0).days else: ay, am, frac, rango = None, None, 0, 0 cfilas.append((d, raiz, nombre, n, b, ay, am, frac, rango, p, bs)) con.executemany("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", cfilas) con.execute("INSERT OR REPLACE INTO escaneos (raiz, ts, n, bytes, seg, reglas) VALUES (?,?,?,?,?,?)", (raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"], R.VERSION_REGLAS)) con.close() prog["estado"] = "listo" if fallos: prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})" olvidar_plan(raiz, recalcular=False) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco except Exception as ex: # noqa prog["estado"] = "error" prog["error"] = repr(ex) prog["fin"] = time.time() def _heredar_fechas(filas): """Un render o una conversión se crean mucho después del rodaje, así que su fecha no dice nada del material: si el original está en la misma carpeta, el derivado toma SU fecha. Así «C0001_10bit.mp4» se archiva en el mes en que se grabó «C0001.MP4», no en el mes en que se convirtió, y el mes de una carpeta se decide por el material de verdad.""" I_RUTA, I_DIR, I_NOMBRE, I_FECHA, I_ORIGEN = 0, 2, 3, 12, 13 por_dir = defaultdict(dict) en_todo = defaultdict(list) for f in filas: tallo = os.path.splitext(f[I_NOMBRE])[0].lower() por_dir[f[I_DIR].lower()][tallo] = f en_todo[tallo].append(f) heredadas = 0 for i, f in enumerate(filas): orig = None # primero el original del todo («C0001» para «C0001_10bit_10bit.mp4») y, si no está, el de un # paso atrás: una conversión de una conversión no debe quedarse con la fecha de la primera for base in dict.fromkeys(b for b in (R.raiz_derivado(f[I_NOMBRE]), R.base_derivado(f[I_NOMBRE])) if b): orig = por_dir[f[I_DIR].lower()].get(base.lower()) if orig is None and len(base) >= 6: # el render puede estar en otra carpeta («_Exportados», «_Archivo»): vale si el nombre es # bastante propio y no hay dos candidatos que se llamen igual en toda la carpeta del cliente cands = en_todo.get(base.lower()) or [] orig = cands[0] if len(cands) == 1 else None if orig is not None: break if orig is None or orig is f or orig[I_ORIGEN] == "derivado": continue if orig[I_FECHA] >= f[I_FECHA]: continue # el «original» no es más viejo: no hereda nada nueva = list(f) nueva[I_FECHA] = orig[I_FECHA] nueva[I_ORIGEN] = "derivado" # la fecha viene del archivo del que salió filas[i] = tuple(nueva) heredadas += 1 return heredadas def _fechas_de_rodaje(filas): """Los clips de Blackmagic se llaman por la tarjeta, el mes y el día («A014_1209…»), sin año. El año sale de la fecha del archivo, y esa cambia cuando una conversión o una copia lo reescriben. Todos los clips de la misma tarjeta y el mismo día son del mismo rodaje, así que comparten año: si uno conserva su fecha de cámara, ese año es seguro para todos; si ninguno, vale el más antiguo, porque un clip no puede ser posterior a ninguna de sus copias. Medido el 4-oct-2026 sobre el material real: un mismo rodaje del 2 de marzo estaba partido entre 2025 y 2026 solo porque la mitad de los clips se había vuelto a guardar en julio.""" I_NOMBRE, I_FECHA, I_ORIGEN = 3, 12, 13 grupos = defaultdict(list) for i, f in enumerate(filas): if f[I_ORIGEN] in ("nombre", "nombre+mtime"): clave = R.clave_rodaje(f[I_NOMBRE]) if clave: grupos[clave].append(i) cambiadas = 0 for idx in grupos.values(): seguras = {filas[i][I_FECHA] for i in idx if filas[i][I_ORIGEN] == "nombre"} if len(seguras) > 1: continue # la misma tarjeta y día en dos años distintos: no se decide fecha = next(iter(seguras)) if seguras else min(filas[i][I_FECHA] for i in idx) origen = "nombre" if seguras else "nombre+mtime" for i in idx: f = filas[i] if f[I_FECHA] == fecha and f[I_ORIGEN] == origen: continue nueva = list(f) nueva[I_FECHA], nueva[I_ORIGEN] = fecha, origen filas[i] = tuple(nueva) cambiadas += 1 # Un rodaje que pasa de medianoche es un solo rodaje. El nombre trae también la hora («A006_05312107», # «A006_06010000»): lo grabado de madrugada cuenta como del día en que se empezó, si ese día se # estaba grabando por la noche. Medido el 4-oct-2026: un rodaje del 31 de mayo a las 21:07 al 1 de # junio a las 04:00 salía partido en dos meses, 63 clips en mayo y 103 en junio. de_noche = set() for idx in grupos.values(): for i in idx: try: if int(filas[i][I_NOMBRE][9:11]) >= 18: de_noche.add(filas[i][I_FECHA]) except ValueError: pass for idx in grupos.values(): for i in idx: f = filas[i] try: if int(f[I_NOMBRE][9:11]) >= 6: continue vispera = (datetime.date.fromisoformat(f[I_FECHA]) - datetime.timedelta(days=1)).isoformat() except ValueError: continue if vispera in de_noche: nueva = list(f) nueva[I_FECHA] = vispera filas[i] = tuple(nueva) cambiadas += 1 return cambiadas def lanzar_escaneo(ruta): raiz = os.path.normpath(ruta) p = PROGRESO.get(raiz) if p and p["estado"] == "corriendo": return False threading.Thread(target=_escanear_y_leer, args=(raiz,), daemon=True).start() return True def _escanear_y_leer(raiz): """El escaneo y, si está el escaneo profundo (por defecto sí), después la lectura de cada clip: códec, duración y resolución, en segundo plano y solo de lo que no se había leído. El escaneo solo mira nombres, tamaños y fechas; sin leer los clips no se sabe si dos que se llaman igual son el mismo con otra calidad (2.387 «por comparar» en Vía Sumapaz) ni cuáles conviene convertir.""" escanear(raiz) ajuste = CONFIG.get("escaneo") or {} if (PROGRESO.get(raiz) or {}).get("estado") != "listo" or not ajuste.get("profundo", True): return a = ANALISIS.get(raiz) if a and a.get("estado") == "corriendo": return analizar(raiz, int(ajuste.get("min_mb", 5))) # ---- el reloj de una cámara # Una cámara con el reloj sin poner fecha sus clips en 2008 o 2009 (medido en Vía Sumapaz: 250 clips de la # Blackmagic «del 28 de julio de 2008»). La app lo detecta; aquí se corrige: se dice la fecha real del primer # clip de esa tanda y los demás se corren lo mismo. La corrección se guarda por archivo (nombre y peso), así # sobrevive a volver a escanear y a que el plan mueva los clips de carpeta. _ROLES_RELOJ = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara") _ANIO_RELOJ = "2014" def _tabla_relojes(con): con.execute("CREATE TABLE IF NOT EXISTS relojes (raiz TEXT, nombre TEXT, bytes INTEGER, dias INTEGER, grupo TEXT, " "dir TEXT, fuente TEXT, ts REAL)") def _aplicar_relojes(con, raiz): """Vuelve a aplicar las correcciones de reloj de esta carpeta (después de escanear, la fecha vuelve a ser la de la cámara).""" _tabla_relojes(con) con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), (SELECT r.dias || ' days' FROM relojes r " "WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes LIMIT 1)), " "fecha_origen = 'corregida' WHERE raiz = ? AND fecha_origen != 'corregida' AND EXISTS (SELECT 1 FROM " "relojes r WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes)", (raiz,)) def _nombres_fuente(): out = {} for k, _, d in R.FIRMAS: out.setdefault(k, d) return out def relojes(raiz): """Las tandas de clips con fecha imposible, con una pista de su fecha real; y las ya corregidas.""" raiz = _clave(raiz) con = db() _tabla_relojes(con) roles = ",".join("?" * len(_ROLES_RELOJ)) grupos = [dict(x) for x in con.execute( f"SELECT dir, fuente, COUNT(*) n, SUM(bytes) bytes, MIN(substr(fecha, 1, 10)) desde, MAX(substr(fecha, 1, 10)) hasta, " f"MIN(nombre) ejemplo FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({roles}) " f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ? GROUP BY dir, fuente " f"ORDER BY SUM(bytes) DESC", (raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()] nf = _nombres_fuente() for g in grupos: g["rel"] = os.path.relpath(g["dir"], raiz) g["camara"] = nf.get(g["fuente"], g["fuente"] or "") # la pista: la fecha de otros clips de esa misma carpeta que sí la traen bien, o el año que diga la ruta bien = [x[0] for x in con.execute( f"SELECT substr(fecha, 1, 10) FROM archivos WHERE raiz = ? AND dir = ? AND es_basura = 0 AND rol IN ({roles}, 'celular_video') " f"AND (fecha_origen IN ('nombre', 'corregida') OR substr(fecha, 1, 4) >= ?) ORDER BY fecha", (raiz, g["dir"]) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()] sug, por_que = "", "" if bien: sug = bien[len(bien) // 2] por_que = (f"es la fecha de otro clip de esa misma carpeta que sí la trae bien" if len(bien) == 1 else f"es la fecha de otros {len(bien):,} clips de esa misma carpeta que sí la traen bien".replace(",", ".")) else: anios = [a for a in (R.detectar_anio(s) for s in g["rel"].split(os.sep)) if a] if anios: sug = f"{anios[-1]}-{g['desde'][5:]}" por_que = f"la carpeta dice {anios[-1]}; el mes y el día son los de la cámara" g.update(sugerencia=sug, por_que=por_que) hechos = [dict(x) for x in con.execute( "SELECT grupo, dir, fuente, COUNT(*) n, MAX(dias) dias, MAX(ts) ts FROM relojes WHERE raiz = ? GROUP BY grupo ORDER BY ts DESC", (raiz,)).fetchall()] for h in hechos: h["rel"] = os.path.relpath(h["dir"], raiz) if h["dir"] else "" h["camara"] = nf.get(h["fuente"], h["fuente"] or "") con.close() return {"grupos": grupos, "corregidos": hechos} def corregir_reloj(raiz, dir_, fuente, fecha_real): raiz = _clave(raiz) try: real = datetime.date.fromisoformat((fecha_real or "")[:10]) except ValueError: return {"error": "escribe la fecha real del primer clip"} if real > datetime.date.today(): return {"error": "esa fecha todavía no ha llegado"} con = db() _tabla_relojes(con) cond = (f"raiz = ? AND dir = ? AND fuente = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) " f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?") args = (raiz, os.path.normpath(dir_ or ""), fuente or "") + _ROLES_RELOJ + (_ANIO_RELOJ,) filas = con.execute(f"SELECT nombre, bytes, substr(fecha, 1, 10) f FROM archivos WHERE {cond}", args).fetchall() if not filas: con.close() return {"error": "ahí ya no queda nada que corregir"} desde = min(x["f"] for x in filas) dias = (real - datetime.date.fromisoformat(desde)).days grupo = f"{time.time():.3f}" with con: con.executemany("INSERT INTO relojes VALUES (?,?,?,?,?,?,?,?)", [(raiz, x["nombre"], x["bytes"], dias, grupo, os.path.normpath(dir_), fuente or "", time.time()) for x in filas]) _aplicar_relojes(con, raiz) con.close() bitacora({"op": "reloj", "raiz": raiz, "dir": dir_, "fuente": fuente, "dias": dias, "n": len(filas)}) olvidar_plan(raiz) return {"ok": True, "n": len(filas), "dias": dias, "desde": desde, "a": real.isoformat()} def deshacer_reloj(raiz, grupo): raiz = _clave(raiz) con = db() _tabla_relojes(con) filas = con.execute("SELECT nombre, bytes, dias FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo)).fetchall() if not filas: con.close() return {"error": "esa corrección ya no está"} with con: for x in filas: con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), ?), fecha_origen = 'mtime' WHERE raiz = ? AND " "nombre = ? AND bytes = ? AND fecha_origen = 'corregida'", (f"{-x['dias']} days", raiz, x["nombre"], x["bytes"])) con.execute("DELETE FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo)) con.close() olvidar_plan(raiz) return {"ok": True, "n": len(filas)} # ---- buscar en todos los clientes _SIN_TILDES = str.maketrans("áéíóúüñàèìòùÁÉÍÓÚÜÑÀÈÌÒÙ", "aeiouunaeiouAEIOUUNAEIOU") def _llano(t): return (t or "").translate(_SIN_TILDES).lower() def buscar(texto, limite=200): """Un archivo o una carpeta por su nombre, en todas las carpetas de clientes escaneadas. Antes solo había «¿Dónde va a parar este archivo?», que dice dónde iría, no dónde está.""" pals = [p for p in _llano(texto).replace("\\", " ").replace("/", " ").split() if p] if not pals or max(len(p) for p in pals) < 2: return {"error": "escribe al menos dos letras"} clientes = {_clave(r["ruta"]).lower(): r["cliente"] for r in CONFIG["raices"]} con = db() # un primer filtro en la base (sin tildes no se puede: SQLite solo pasa a minúsculas lo que no lleva tilde) ancla = max(pals, key=len) filas = con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos " "WHERE lower(ruta) LIKE ? ESCAPE '!' OR ruta LIKE ? ESCAPE '!'", ("%" + esc_like(ancla) + "%", "%" + esc_like(ancla.upper()) + "%")).fetchall() \ if ancla.isascii() else con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos").fetchall() con.close() hay = [] for f in filas: r = _llano(f["ruta"]) if all(p in r for p in pals): en_nombre = all(p in _llano(f["nombre"]) for p in pals) hay.append((0 if en_nombre else 1, -(f["bytes"] or 0), f)) hay.sort(key=lambda x: (x[0], x[1])) out = [] for _, _, f in hay[:limite]: out.append({"ruta": f["ruta"], "dir": f["dir"], "nombre": f["nombre"], "bytes": f["bytes"], "fecha": (f["fecha"] or "")[:10], "rol": R.ROL_QUE.get(f["rol"], f["rol"]), "raiz": f["raiz"], "cliente": clientes.get((f["raiz"] or "").lower(), f["raiz"]), "rel": os.path.relpath(f["dir"], f["raiz"]) if f["raiz"] else f["dir"], "apartado": bool(f["es_basura"] or f["es_proxy"])}) return {"total": len(hay), "resultados": out, "carpetas": len(clientes)} # ---- el mismo cliente en otros discos _DISCOS_REFRESCO = {"activo": False} def _refrescar_discos(): try: discos() finally: _DISCOS_REFRESCO["activo"] = False def otros_sitios(raiz): """Dónde más está el cliente de esta carpeta, según los discos conectados. No espera: usa lo último que se miró y, si es viejo, lo vuelve a mirar en segundo plano (mirar todos los discos de red tarda).""" r = raiz_cfg(raiz) if not r: return [] if time.time() - (_DISCOS_CACHE.get("ts") or 0) > 120 and not _DISCOS_REFRESCO["activo"]: _DISCOS_REFRESCO["activo"] = True threading.Thread(target=_refrescar_discos, daemon=True).start() d = _DISCOS_CACHE.get("datos") if not d: return [] registradas = [_clave(x["ruta"]).lower() for x in CONFIG["raices"]] out = [] for ruta in d["clientes"].get(r["cliente"], []): rl = os.path.normpath(ruta).lower() if any(rl == x or rl.startswith(x + os.sep) or x.startswith(rl + os.sep) for x in registradas): continue disco = next((x for x in d["discos"] if x["letra"].lower() == ruta[:2].lower()), {}) out.append({"ruta": ruta, "unc": disco.get("unc", ""), "disco": disco.get("nombre", "")}) return out # ========================================================================== # Utilidades de fechas / nombres para el plan # ========================================================================== def fecha_carpeta(con, ruta): """(anio, mes, confianza, motivo) para una carpeta según sus archivos""" fila = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (os.path.normpath(ruta),)).fetchone() if not fila or not fila["anio_moda"]: return None, None, "baja", "sin archivos con fecha" frac = fila["frac_moda"] or 0 if frac >= 0.8: return fila["anio_moda"], fila["mes_moda"], "media", f"fecha de sus archivos ({int(frac * 100)}% del mismo mes)" return fila["anio_moda"], fila["mes_moda"], "baja", f"fechas mezcladas ({fila['dias_rango']} días de rango)" def limpiar_nombre(nombre): n = re.sub(r"\s{2,}", " ", nombre).strip(" -_.") return n or nombre def destino_plantilla(tipo, cubeta, **kw): plantilla = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get(cubeta) if not plantilla: plantilla = PLANTILLAS["contenido"].get(cubeta, "_Revisar/{nombre}") anio = kw.get("anio") mes = kw.get("mes") partes = { "anio": str(anio) if anio else "", "mes": R.nombre_mes(mes) if mes else "", "nombre": kw.get("nombre", ""), "dia": kw.get("dia", ""), "sub": kw.get("sub", ""), "rel": kw.get("rel", ""), "fuente": kw.get("fuente", ""), "app": kw.get("app", ""), } # un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion" vacio = "\x00" partes_o = {k: (v if v != "" else vacio) for k, v in partes.items()} segmentos = [] for seg in plantilla.split("/"): s = seg.format(**partes_o) hueco = vacio in s s = re.sub(r"[-_ ]*\x00", "", s) # los separadores sueltos solo se recortan si los dejó una variable vacía: sin esto, una ruta que # de verdad empieza por guion bajo («_Entrada\Tarjeta») llegaba a la papelera como «Entrada\Tarjeta» if hueco and seg.startswith("{"): s = s.lstrip("-_ ") if hueco and seg.endswith("}"): s = s.rstrip("-_ ") if s: segmentos.append(s) if not segmentos: segmentos = ["_Sin fecha"] return os.sep.join(segmentos) # ========================================================================== # Qué es una carpeta por lo que tiene dentro # ========================================================================== def _mes_claro(nombre): """True si la carpeta se llama como un mes sin lugar a dudas («Mayo», «11-Noviembre», «Marzo 2026»). «Mar», «May» o «05» pueden ser otra cosa y no cuentan.""" m, dudoso = R.detectar_mes(re.sub(r"20\d\d", " ", nombre or "").strip(" -_")) return bool(m) and not dudoso class Estructura: """El árbol de una carpeta de cliente tal como quedó en el inventario, con lo que cuelga de cada rama. El nombre de una carpeta no basta para saber qué es. Medido el 4-oct-2026 sobre el material real: «Orden» (533 archivos, 65 GB) se aprendió como grabación porque casi todo su peso eran clips, y el plan la iba a desarmar entera por cámara y por mes; dentro había una organización anterior completa, con sus grabaciones, sus proyectos por mes y sus portadas. Aquí se mira la forma: una tarjeta volcada es casi todo clips de cámara; una pieza trae su carpeta de exportados, de portada y de música; y una carpeta que guarda meses o años dentro es un cajón que hay que abrir, no una cosa que mover.""" def __init__(self, con, raiz, dicc): self.raiz = os.path.normpath(raiz) self.dicc = dicc self.carp = {} # ruta (normcase) -> fila de `carpetas` self.hijos_de = defaultdict(list) # ruta (normcase) -> rutas de sus subcarpetas tope = os.path.normcase(self.raiz) for c in con.execute("SELECT * FROM carpetas WHERE raiz = ?", (self.raiz,)).fetchall(): k = os.path.normcase(os.path.normpath(c["ruta"])) self.carp[k] = c if k != tope: self.hijos_de[os.path.normcase(os.path.dirname(os.path.normpath(c["ruta"])))].append(os.path.normpath(c["ruta"])) # por carpeta, contando lo de sus subcarpetas: [archivos, de cámara, de rodaje, proyectos] self.cuenta = defaultdict(lambda: [0, 0, 0, 0]) self.anios = defaultdict(Counter) # ruta -> cuántos archivos de cada año hay debajo for f in con.execute("SELECT dir, rol, fuente, substr(fecha, 1, 4) anio, COUNT(*) n FROM archivos WHERE raiz = ? " "AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol, fuente, anio", (self.raiz,)).fetchall(): cam = f["fuente"] in R.FUENTE_CARPETA or f["rol"] in R.ROLES_CAMARA rod = f["rol"] in R.ROLES_RODAJE proy = f["rol"] in ("proyecto", "diseno") d = os.path.normpath(f["dir"]) while True: e = self.cuenta[os.path.normcase(d)] e[0] += f["n"] e[1] += f["n"] if cam else 0 e[2] += f["n"] if rod else 0 e[3] += f["n"] if proy else 0 if f["anio"]: self.anios[os.path.normcase(d)][f["anio"]] += f["n"] if os.path.normcase(d) == tope: break padre = os.path.dirname(d) if padre == d: break d = padre def _k(self, ruta): return os.path.normcase(os.path.normpath(ruta)) def utiles(self, ruta): """Archivos que hay debajo, sin contar basura ni proxies.""" return self.cuenta[self._k(ruta)][0] if self._k(ruta) in self.cuenta else 0 def hijos(self, ruta): return self.hijos_de.get(self._k(ruta), []) def anio_de(self, ruta): """El año del que es la mayor parte de lo que hay debajo, o None si la carpeta está vacía.""" c = self.anios.get(self._k(ruta)) if not c: return None try: return int(c.most_common(1)[0][0]) except ValueError: return None def debajo(self, ruta, hondo=3): """Las subcarpetas hasta `hondo` niveles.""" nivel = [os.path.normpath(ruta)] for _ in range(hondo): nivel = [h for p in nivel for h in self.hijos(p)] if not nivel: return yield from nivel def fechas_dentro(self, ruta, hondo=3): """Carpetas de año o de mes que hay debajo y tienen material: la señal de que esto guarda varias cosas de distintas fechas y hay que mirarlo por dentro.""" return [d for d in self.debajo(ruta, hondo) if self.utiles(d) and (R.detectar_anio(os.path.basename(d)) or _mes_claro(os.path.basename(d)))] def partes(self, ruta, hondo=3): """Qué partes de una pieza montada hay entre sus subcarpetas (exportados, portada, stock, música…).""" out = set() for d in self.debajo(ruta, hondo): if not self.utiles(d): continue cub, _ = R.cubeta_carpeta(os.path.basename(d), self.dicc) if cub in R.PARTES_PIEZA: out.add(cub) return out def es_rodaje(self, ruta, por_nombre=False): """¿Una tarjeta volcada, o una carpeta con algo montado dentro? `por_nombre` es True cuando el nombre ya dice que es una grabación con una palabra de fábrica («Grabación», «URSA», «Tomas»): entonces basta con que casi todo sea material, aunque la cámara no se reconozca. Si no, se exige que casi todo sean clips de cámara reconocidos.""" n, cam, rod, proy = self.cuenta[self._k(ruta)] if self._k(ruta) in self.cuenta else (0, 0, 0, 0) if not n: return por_nombre if len(self.partes(ruta)) >= 2: return False return rod >= 0.6 * n if por_nombre else cam >= 0.8 * n def _propias(tipo): """Los nombres de carpeta que ya son de la estructura para este tipo de cliente: los que van en la raíz y los que van dentro de un mes. Salen de las plantillas en uso, no de una lista fija.""" primeros, en_mes = set(), set() for p in PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).values(): segs = p.split("/") if "{" not in segs[0]: primeros.add(segs[0].lower()) for a, b in zip(segs, segs[1:]): if a == "{mes}" and "{" not in b: en_mes.add(b.lower()) return primeros, en_mes def _es_propia(nombre, nivel, primeros, en_mes): """True si la carpeta ya es de la estructura y no hay que volver a ordenarla.""" if nombre.startswith("_") or R.norm(nombre) in R.CARPETAS_PROPIAS: return True n = nombre.lower() return (nivel == "raiz" and n in primeros) or (nivel == "mes" and n in en_mes) def _de_fabrica(nombre, cubeta): """True si el nombre dice esa cubeta por una palabra de fábrica («Grabación», «URSA») y no porque alguien lo haya confirmado en «Por nombrar».""" return R.cubeta_carpeta(nombre, R.DICCIONARIO_SEMILLA)[0] == cubeta def _fecha_de_camara(f): """La fecha de modificación de un original de cámara es la del rodaje: nadie lo reescribe. No vale para lo que sale del convertidor ni para lo que no se sabe de qué cámara viene.""" return (f["fuente"] in R.FUENTE_CARPETA and f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara") and not R.es_conversion(f["nombre"])) _RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I) _RX_FECHA_DIA = re.compile(r"^\d{4}-\d{2}-\d{2}$") # en un banco de material, lo que se reordena por fecha y cámara: el material grabado, no lo montado _ROLES_BANCO = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video") # carpetas que solo dicen con qué se grabó (además de las de R.FUENTE_PALABRAS) _CAMARAS_BANCO = {"fuji", "gopro", "go pro", "mavic", "osmos", "insta360", "iphone", "ronin", "fx3", "fx6", "a7", "a7s"} _NOMBRES_FUENTE = {v.upper() for v in R.FUENTE_CARPETA.values()} | {"AUDIO", "OTROS", "FOTOS"} def _solo_fuente(seg): """True si la carpeta solo dice la cámara, la tarjeta, el día o que son repetidos: no es un tema.""" s = (seg or "").strip() n = R.norm(s) return (n in R.FUENTE_PALABRAS or n in _CAMARAS_BANCO or R.es_carpeta_de_tarjeta(s) or bool(_RX_DIA.match(s)) or bool(_RX_FECHA_DIA.match(s)) or s.upper() in _NOMBRES_FUENTE or n == R.norm(R.DUPLICADOS)) def _bajo_alguna(ruta, conjunto): """True si `ruta` o alguna carpeta por encima de ella está en `conjunto` (rutas en minúsculas).""" d = os.path.normpath(ruta).lower() while True: if d in conjunto: return True p = os.path.dirname(d) if p == d: return False d = p def _tallo(nombre): """«C0001_Proxy.mov», «C0001S03.MP4» (el proxy que graba la Sony en M4ROOT/SUB) y «C0001.MP4» -> «c0001»: para emparejar un proxy con su original.""" t = re.sub(r"[ _.-]?proxy$", "", os.path.splitext(nombre or "")[0].lower()) m = re.match(r"^(c\d{4})s\d{2}$", t) return m.group(1) if m else t # ========================================================================== # Planificador # ========================================================================== class Plan: def __init__(self, rcfg): self.raiz = os.path.normpath(rcfg["ruta"]) self.cliente = rcfg["cliente"] self.tipo = rcfg.get("tipo", "contenido") self.dicc = DICC["carpetas"] self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)] self.filas = [] self.bancos = [] # (ruta normcase + sep, nombre, protegido): los bancos de material self.bancos_info = [] # lo mismo para la interfaz: nombre, si dice «No tocar», y cómo se ordena self._carpeta_existe = {} # destino -> si ya existe en el disco (para no preguntar dos veces) self._bytes = None # ruta -> bytes, para partir filas de archivos self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos self.con = db() self.avisos = [] self.dias_mes = self._dias_de_rodaje() self.est = Estructura(self.con, self.raiz, self.dicc) # Las carpetas que ya son de la estructura salen de las plantillas de este tipo de cliente, no de # una lista fija: «Exportados» es canónica en la raíz de un cliente interno y no lo es en la de # uno de contenido, donde lo canónico es «_Exportados» dentro del mes. pl = PLANTILLAS.get(self.tipo, PLANTILLAS["contenido"]) self.primeros, self.en_mes = _propias(self.tipo) self.por_mes = {c for c, p in pl.items() if "{mes}" in p} # lo que en este tipo de cliente va dentro del mes self.cajon = 0 # cuántos cajones hay abiertos por encima de donde se está mirando # Abrir una carpeta porque guarda meses dentro solo tiene sentido donde las piezas se ordenan por # mes. En un cliente de campañas o de un proyecto puntual, «Campaña X\2025\Noviembre» es una # campaña con sus fechas, no un cajón: se mueve entera. self.abre_cajones = "{mes}" in (pl.get("PIEZA") or "") # dónde vive el material ya ordenado: «{anio}/{mes}/00_Grabacion», «Material/Grabaciones/{anio}/{mes}»… segs = (pl.get("GRABACION") or "").split("/") while segs and segs[-1] in ("{dia}", "{sub}", "{fuente}", "{nombre}"): segs.pop() self.patron_grab = segs if "{mes}" in segs and "{anio}" in segs else [] def _dias_de_rodaje(self): """Para cada mes, los días distintos en que se grabó algo en esta carpeta de cliente. Sirve para decidir si el mes necesita carpetas por día. Medido el 16-sep-2026 sobre el material real: David Reyes tiene un solo día de rodaje en 7 de sus 8 meses (ahí la carpeta del día sería estorbo), mientras que Danney tiene 36 meses con varios días y Camilo llega a 13 días distintos en un mismo mes, 907 archivos mezclados en la misma carpeta.""" dias = defaultdict(set) for f in self.con.execute( "SELECT fecha FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN " "('camara_raw','camara_video','camara_sidecar','audio_grabado','celular_video')", (self.raiz,)): f0 = f["fecha"] try: dias[(int(f0[:4]), int(f0[5:7]))].add(f0) except (TypeError, ValueError): pass return dias def dia_de(self, fecha, anio, mes): """El nombre de la carpeta del día de rodaje («13- Jueves»). Hasta la 3.5.0 se omitía cuando ese mes solo tenía un día de rodaje. Munka, 14-oct-2026: las grabaciones van «siempre según la cámara, organizado por fecha y por carpetas», así que la carpeta del día va siempre, y dentro una por fuente (URSA_BRAW, SONY, DJI_MIC, TASCAM…).""" return R.nombre_dia(fecha) # ---- helpers ---- def rel(self, ruta): return os.path.relpath(ruta, self.raiz) def abs_(self, rel): return os.path.normpath(os.path.join(self.raiz, rel)) def regla_para(self, ruta): rel = R.norm(self.rel(ruta)) for r in self.reglas: if R.norm(r.get("origen", "")) == rel: return r return None def _hay_carpeta(self, d): d = os.path.normcase(os.path.normpath(d)) if d not in self._carpeta_existe: self._carpeta_existe[d] = os.path.isdir(d) return self._carpeta_existe[d] def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None, marcado=None, kw_destinos=None, paso=None): destino = os.path.normpath(destino) if archivos is None and os.path.normpath(origen).lower() == destino.lower(): # el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» -> # «10- Octubre»); antes se descartaba también ese, y el plan prometía meses «con nombre # completo» que nunca se normalizaban if accion != "renombrar" or os.path.normpath(origen) == destino: return solo_mayusculas = os.path.normpath(origen).lower() == destino.lower() if n is None or bytes_ is None: if archivos is not None: n = len(archivos) bytes_ = sum(a[1] for a in archivos) else: fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone() n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0) aviso_colision = "" # Lo que hay que preguntarle al disco (¿ya hay en el destino un archivo con ese nombre?, ¿la carpeta # destino existe?) se apunta y se pregunta al final, todo a la vez y en paralelo (`comprobar_destinos`). # Medido el 14-oct-2026 en Vía Sumapaz, después de aplicar: 1.362 preguntas una detrás de otra, a 24-46 ms # cada una con el NAS ocupado; 43 de los 58 segundos que tardaba «Calcular de nuevo». chk = None if archivos is not None and accion != "renombrar": # solo los primeros archivos de cada fila: para avisar basta con encontrar uno chk = {"tipo": "archivos", "primeros": [a[0] for a in archivos[:40]], "kw": dict(kw_destinos or {})} elif archivos is None and accion != "renombrar": chk = {"tipo": "carpeta", "fusion": not solo_mayusculas} self.filas.append({ "id": len(self.filas) + 1, "origen": origen, "origen_rel": self.rel(origen) if archivos is None else self.rel(origen), "destino": destino, "destino_rel": self.rel(destino), "cubeta": cubeta, "accion": accion, "motivo": motivo + aviso_colision, "confianza": confianza, "n": n, "bytes": bytes_, "archivos": [a[0] for a in archivos] if archivos else None, "marcado": (confianza in ("alta", "media")) if marcado is None else marcado, "cosmetico": marcado is False and accion == "renombrar", # en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones, # piezas), 3 el resto (exportados, proyectos, marca, música, dudosos) "paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"), "_chk": chk, }) def suelto(self, fila, cubeta, destino_rel, motivo, confianza): clave = (cubeta, destino_rel, motivo, confianza, os.path.normcase(os.path.dirname(fila["ruta"]))) self.sueltos[clave].append((fila["ruta"], fila["bytes"])) def volcar_sueltos(self): for (cubeta, destino_rel, motivo, confianza, _dir), archivos in self.sueltos.items(): origen = os.path.dirname(archivos[0][0]) self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos) self.sueltos.clear() def es_propia(self, nombre, nivel): return _es_propia(nombre, nivel, self.primeros, self.en_mes) def pieza_en(self, carpeta_mes, titulo, salvo=None): """La pieza con ese título que ya hay en un mes («02- Asimetría» para «Asimetría»), o None. Así el material de una pieza que estaba en otro sitio se junta con ella en vez de abrir una carpeta gemela al lado.""" t = R.norm(titulo) if not t: return None def rango(ruta): # cuál de dos gemelas se queda: la que ya tiene el nombre de la casa, luego la de número más bajo nombre = os.path.basename(ruta) p = R.detectar_pieza(nombre) canonica = bool(p) and nombre == R.nombre_pieza(p[0], limpiar_nombre(p[1])) return (0 if canonica else 1, p[0] if p else 99, nombre.lower()) mejor = None for h in self.est.hijos(carpeta_mes): if salvo and os.path.normcase(h) == os.path.normcase(salvo): continue nombre = os.path.basename(h) p = R.detectar_pieza(nombre) if R.norm(p[1] if p else nombre) == t and os.path.isdir(h) and (mejor is None or rango(h) < rango(mejor)): mejor = h # si las dos están en la misma carpeta, solo se mueve una: la otra es la que se queda y no debe # proponer a su vez irse con la primera if mejor and salvo and os.path.normcase(os.path.dirname(mejor)) == os.path.normcase(os.path.dirname(salvo)) \ and rango(salvo) < rango(mejor): return None return mejor # ---- niveles ---- def correr(self): esc = self.con.execute("SELECT * FROM escaneos WHERE raiz = ?", (self.raiz,)).fetchone() if esc and (esc["reglas"] or 0) < R.VERSION_REGLAS: self.avisos.append("El inventario de esta carpeta se hizo con una versión anterior del programa, que " "reconocía peor las cámaras y las fechas. Escanéala otra vez antes de aplicar nada.") self.plan_dir(self.raiz, "raiz", None, None) self.plan_auditoria() self.volcar_sueltos() self.plan_basura() self.comprobar_destinos() self.revisar_proxies() self.proteger_bancos() self.con.close() # primero las filas de archivos sueltos (rutas exactas), luego carpetas de más profunda a menos, # para que un renombrado de mes no deje rutas viejas en filas que aún no se aplicaron self.filas.sort(key=lambda f: (0 if f["archivos"] else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower())) self.marcar_colisiones() for i, f in enumerate(self.filas, 1): f["id"] = i return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos, "bancos": self.bancos_info} def marcar_colisiones(self): """Dos movimientos del mismo plan que acaban en el mismo archivo. La comprobación de `agregar` solo mira el disco tal como está ahora, así que no ve lo que van a hacer las demás filas. Caso típico: «Grabacion mañana» y «Grabacion tarde» del mismo mes van las dos a 00_Grabacion/SONY, y las dos tarjetas traen un C0001.MP4. Antes chocaban al aplicar.""" destinos = {} # archivo destino (en minúsculas) -> primera fila que lo reclama carpetas = {} # carpeta destino -> primera fila que la reclama (solo renombrados) for f in self.filas: if f["archivos"]: especiales = f.get("destinos_archivo") or {} for a in f["archivos"]: d = (especiales.get(a) or os.path.join(f["destino"], os.path.basename(a))).lower() otra = destinos.get(d) if otra is not None and otra is not f: self.avisar_colision(f, otra, os.path.basename(d)) break if otra is f: self.avisar_repetido(f, os.path.basename(d)) break destinos.setdefault(d, f) elif f["accion"] == "renombrar": d = f["destino"].lower() otra = carpetas.get(d) if otra is not None: self.avisar_colision(f, otra, os.path.basename(f["destino"])) else: carpetas[d] = f def avisar_repetido(self, fila, que): aviso = f" · OJO: dentro de «{fila['origen_rel']}» hay dos archivos «{que}» que acabarían en la misma carpeta" if aviso not in fila["motivo"]: fila["motivo"] += aviso fila["confianza"] = "baja" fila["marcado"] = False self.avisos.append(f"En «{fila['origen_rel']}» hay dos archivos que se llaman «{que}» (por ejemplo, la misma foto " f"en dos tamaños) y los dos irían a «{fila['destino_rel']}». Uno no cabría: la fila queda sin " f"marcar para que lo mires antes.") def avisar_colision(self, fila, otra, que): aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»" if aviso not in fila["motivo"]: fila["motivo"] += aviso fila["confianza"] = "baja" fila["marcado"] = False self.avisos.append(f"«{fila['origen_rel']}» y «{otra['origen_rel']}» acabarían las dos en " f"«{fila['destino_rel']}» con archivos del mismo nombre ({que}), y dos archivos que se " f"llaman igual no caben en la misma carpeta. La segunda se mueve; la primera queda sin " f"marcar para que decidas qué versión se queda.") def entradas(self, ruta): try: with os.scandir(ruta) as it: ents = [e for e in it if e.name.lower() not in ("#recycle", "@eadir")] except OSError as ex: self.avisos.append(f"No se pudo leer {ruta}: {ex}") return [], [] dirs = sorted([e for e in ents if e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower()) files = sorted([e for e in ents if not e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower()) return dirs, files def plan_dir(self, ruta, nivel, anio, mes, mes_destino=None): """`mes_destino` es la carpeta de mes a la que va a parar `ruta` cuando ella misma se mueve (un mes suelto que entra en su año): sirve para juntar sus piezas con las que ya haya allí.""" dirs, files = self.entradas(ruta) # numeración de piezas dentro de un mes num = {"max": 0} if nivel == "mes": numeros = defaultdict(list) for e in dirs: p = R.detectar_pieza(e.name) if p: num["max"] = max(num["max"], p[0]) numeros[p[0]].append(e.name) for n, nombres in sorted(numeros.items()): if len({R.norm(R.detectar_pieza(x)[1]) for x in nombres}) > 1: self.avisos.append(f"En «{self.rel(ruta)}» hay {len(nombres)} piezas con el número {n:02d}: " + ", ".join(f"«{x}»" for x in nombres) + ". La app no las renumera sola: cámbiale el número a una.") for e in dirs: self.plan_subdir(e, nivel, anio, mes, ruta, num, mes_destino) for e in files: self.plan_archivo(e, nivel, anio, mes, ruta) def abrir(self, e): """Una carpeta que guarda meses o años dentro no es una cosa que mover: es un cajón. Se abre, y lo de dentro se ordena por su cuenta, sin heredar el mes en el que el cajón estuviera metido.""" if not self.cajon: # un cajón dentro de otro no hace falta contarlo otra vez dentro = self.est.fechas_dentro(e.path) nombres = list(dict.fromkeys(os.path.basename(d) for d in dentro)) self.avisos.append(f"«{self.rel(e.path)}» guarda dentro carpetas de otras fechas (" + ", ".join(f"«{x}»" for x in nombres[:3]) + ("…" if len(nombres) > 3 else "") + "): la app la abre y ordena lo de dentro, cada cosa a su mes.") self.cajon += 1 try: self.plan_dir(e.path, "contenedor", None, None) finally: self.cajon -= 1 def plan_subdir(self, e, nivel, anio, mes, padre, num, mes_destino=None): nombre = e.name nn = R.norm(nombre) regla = self.regla_para(e.path) if regla: if regla.get("destino") in ("", None, "IGNORAR"): return self.agregar(e.path, self.abs_(regla["destino"]), regla.get("cubeta", "REVISAR"), "mover carpeta", "regla aprendida", "alta") return if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA): # la bandeja de entrada: se abre siempre, y lo de dentro se fecha solo self.cajon += 1 try: self.plan_dir(e.path, "contenedor", None, None) finally: self.cajon -= 1 return if self.es_propia(nombre, nivel): return # estructura ya canónica if R.es_basura_dir(nombre): self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "BASURA", rel=self.rel(e.path))), "BASURA", "papelera", "carpeta de caché / autoguardado", "alta") return if R.es_proxy_dir(nombre): self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "PROXY", rel=self.rel(e.path))), "PROXY", "papelera", "proxies: se regeneran desde Resolve/Premiere", "alta") return a = R.detectar_anio(nombre) if a and nivel in ("raiz", "contenedor", "grabacion"): if nivel == "raiz": self.plan_dir(e.path, "anio", a, None) else: # un año dentro de un contenedor: mirar adentro con el año fijado self.plan_dir(e.path, "contenedor", a, None) return if nivel == "anio": m, conflicto = R.detectar_mes(nombre) if m: canon = R.nombre_mes(m) real, real_origen = self.mes_real(e.path) discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, m)) if nombre != canon: motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "") if discrepa: motivo += (f" · ojo: lo que hay dentro se grabó en {R.nombre_mes(real[1])}, y se archiva " f"en ese mes aunque la carpeta se llame así") self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if (conflicto or discrepa) else "alta") self.plan_dir(e.path, "mes", anio, m) return if nivel == "raiz" or (nivel == "contenedor" and not anio): # un mes suelto ("marzo", "Marzo 2026", "03- Marzo") fuera de su carpeta de año sin_anio = re.sub(r"20\d\d", " ", nombre).strip(" -_") m, conflicto = R.detectar_mes(sin_anio) if m: m_anio = re.search(r"(20\d\d)", nombre) # el año, por este orden: el que diga el nombre, el de la mayoría de lo que hay dentro fa = int(m_anio.group(1)) if m_anio else (self.est.anio_de(e.path) or fecha_carpeta(self.con, e.path)[0]) hoy = datetime.date.today() if fa and (fa, m) > (hoy.year, hoy.month): fa -= 1 if fa: canon = self.abs_(os.path.join(str(fa), R.nombre_mes(m))) self.agregar(e.path, canon, "MES", "mover carpeta", f"mes suelto fuera de su año: entra en {fa}", "media") self.plan_dir(e.path, "mes", fa, m, mes_destino=canon) return if nivel == "contenedor" and anio: m, conflicto = R.detectar_mes(nombre) if m: # un mes guardado dentro de "Nuevo 2026": sale al año que le toca canon = self.abs_(os.path.join(str(anio), R.nombre_mes(m))) self.agregar(e.path, canon, "MES", "mover carpeta", f"mes guardado dentro de «{os.path.basename(padre)}»", "media") self.plan_dir(e.path, "mes", anio, m, mes_destino=canon) return if nivel == "mes": p = R.detectar_pieza(nombre) if p: cub_t, _ = R.cubeta_carpeta(p[1], self.dicc) # "03 - Grabacion Agosto": es la sesión del mes, no una pieza. Pero solo si de verdad es # una tarjeta volcada: una pieza a la que alguien llamó grabación no se desarma. if cub_t == "GRABACION" and self.est.es_rodaje(e.path, por_nombre=_de_fabrica(p[1], "GRABACION")): self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación") return if cub_t in ("GRABACION", "CONTENEDOR") and self.est.fechas_dentro(e.path): self.abrir(e) # no es una pieza: es un cajón con cosas de varios meses return canon = R.nombre_pieza(p[0], limpiar_nombre(p[1])) gemela = self.pieza_en(mes_destino or padre, p[1], salvo=e.path) if gemela and os.path.basename(gemela) != nombre: self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar", f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media") elif nombre != canon: self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar", "cosmético: nombre de pieza a «NN- Título» (sin marcar por defecto)", "media", marcado=False) if cub_t != "PIEZA" and self.est.fechas_dentro(e.path): self.avisos.append(f"«{self.rel(e.path)}» está numerada como una pieza, pero dentro guarda carpetas " f"de varias fechas. Si es un cajón con cosas de distintos meses, dilo en «Por " f"nombrar» y la app lo abrirá para ordenar lo de dentro.") return # dentro de una pieza no se toca cub, motivo = R.cubeta_carpeta(nombre, self.dicc) if cub == "IGNORAR": return if cub == "BANCO": self.plan_banco(e) return if cub == "CONTENEDOR": m_anio = re.search(r"(20\d\d)", nombre) if self.est.fechas_dentro(e.path): # trae sus propios meses o años: lo de dentro se fecha solo, sin heredar el mes de fuera self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else None, None) else: self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else anio, mes) return if cub == "REVISAR": self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "REVISAR", nombre=nombre)), "REVISAR", "mover carpeta", motivo, "baja") return montada = "" if cub == "GRABACION": if self.est.es_rodaje(e.path, por_nombre=_de_fabrica(nombre, "GRABACION")): self.plan_grabacion(e, nivel, anio, mes, motivo) return # se llama (o alguien dijo que era) una grabación, pero dentro hay proyectos, exportados o # portadas: desarmarla por cámara y por mes deshace un trabajo montado partes = self.est.partes(e.path) montada = ("tiene nombre de grabación, pero dentro hay algo montado (" + ", ".join(sorted(_PARTE_FRASE.get(x, x.lower()) for x in partes)) + "): no se desarma" if len(partes) >= 2 else "tiene nombre de grabación, pero casi nada de lo que hay dentro es material de cámara: no se reparte") cub = None # La forma manda sobre el nombre: una carpeta con meses o años dentro se abre, aunque se llame # «Proyectos» o «Edición». Vale para lo que no tiene sitio propio y para lo que en este tipo de # cliente va dentro de cada mes (exportados, portadas…); la música o el stock no van por fecha. if self.abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in self.por_mes) \ and self.est.fechas_dentro(e.path) and (montada or cub is not None or not self.est.es_rodaje(e.path)): self.abrir(e) return generico = nn in self.dicc and not montada limpio = limpiar_nombre(nombre) p = R.detectar_pieza(nombre) if p and cub in ("CAMPANA", "PIEZA", None): limpio = limpiar_nombre(p[1]) # "01- Dove" -> "Dove" sub = "" if generico else limpio fa, fm, fconf, fmot = fecha_carpeta(self.con, e.path) con_fecha = cub in ("PIEZA", "CAMPANA", "EXPORTADOS", "PORTADAS", "VOZ", "IA", "GUION", "FOTOS", "CELULAR", None) if anio: fa = anio if mes: fm = mes elif fconf == "baja" and cub not in ("PIEZA", None): fm = None # fechas mezcladas: se queda a nivel de año ("2025/_Exportados") fconf = "media" if fa else "baja" hoy = datetime.date.today() if fa and fm and (fa, fm) > (hoy.year, hoy.month): fa -= 1 # un mes en el futuro no existe: era del año pasado if cub is None: if not montada and self.est.es_rodaje(e.path): self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara") return if nivel == "mes": gemela = self.pieza_en(mes_destino or padre, limpio, salvo=e.path) if gemela: self.agregar(e.path, os.path.join(padre, os.path.basename(gemela)), "PIEZA", "renombrar", f"la misma pieza ya está en «{self.rel(os.path.dirname(gemela))}»: se junta con ella", "media") return num["max"] += 1 canon = R.nombre_pieza(num["max"], limpio) self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar", "carpeta sin número dentro del mes: se numera como pieza", "media") return cub = "PIEZA" motivo = montada or "no reconocida: se propone como pieza por la fecha de sus archivos" fconf = "baja" sub = limpio if con_fecha and not (anio and mes): conf = fconf motivo = f"{motivo}; {fmot}" else: conf = "alta" if generico else "media" if cub == "MARCA": sub = R.MARCA_SUB.get(nn, sub or limpio) conf = "alta" if generico or nn in R.MARCA_SUB else "media" if cub in ("PIEZA", "CAMPANA", "PROYECTOS"): sub = sub or limpio destino = self.abs_(destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))) if cub == "PIEZA": gemela = self.pieza_en(os.path.dirname(destino), limpio, salvo=e.path) if gemela: destino = gemela motivo += f" · la misma pieza ya está en ese mes: se junta con «{os.path.basename(gemela)}»" self.agregar(e.path, destino, cub, "mover carpeta", motivo, conf) def mes_real(self, ruta): """((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar.""" cnt = Counter() origenes = Counter() for f in archivos_bajo(self.con, ruta): if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime": continue if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"): cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1 origenes[f["fecha_origen"]] += 1 if not cnt: return None, "" (a, m), n = cnt.most_common(1)[0] if n < 3 or n / sum(cnt.values()) < 0.7: return None, "" return (a, m), origenes.most_common(1)[0][0] def plan_banco(self, e): """Un banco de material por temas («Material Vía (No tocar)\\Lugares\\Peajes»): el cliente lo consulta por tema, así que los temas se quedan donde están. Lo que se ordena son las grabaciones de dentro de cada tema: por fecha de rodaje y por cámara, como en cualquier 00_Grabacion. «Lugares\\Peajes\\Drone» pasa a «Lugares\\Peajes\\2024-03-12\\DJI». Las carpetas que solo dicen la cámara, la tarjeta o el día («Drone», «URSA», «Pocket», «100MEDIA», «Grabado») no son un tema: se funden en el tema de encima. Medido el 14-oct-2026 en Vía Sumapaz: el banco eran 3,6 TB, 50.076 archivos, con temas como Lugares, Social, Ambiental o Unidades Funcionales; tratado como piezas sueltas, el plan quería llevar cada tema entero a un mes inventado. Si el nombre dice «No tocar», todo lo de dentro va sin marcar.""" protegido = R.es_no_tocar(e.name) self.bancos.append((os.path.normcase(os.path.normpath(e.path)) + os.sep, e.name, protegido)) # cómo se ordena: por temas (lo de siempre) o por fecha, a la 00_Grabacion de su mes con el tema de etiqueta modo = ((CONFIG.get("bancos") or {}).get(self.raiz.lower()) or {}).get(R.norm(e.name), "temas") self.bancos_info.append({"nombre": e.name, "protegido": protegido, "modo": modo}) grupos = defaultdict(list) for f in archivos_bajo(self.con, e.path): if f["es_basura"] or f["es_proxy"] or f["rol"] not in _ROLES_BANCO or not f["fecha"]: continue rel = os.path.relpath(f["dir"], e.path) segs = [] if rel == "." else rel.split(os.sep) repetido = False while segs and _solo_fuente(segs[-1]): repetido = repetido or R.norm(segs[-1]) == R.norm(R.DUPLICADOS) segs.pop() fuente = R.FUENTE_CARPETA.get(f["fuente"]) or ("AUDIO" if f["rol"] == "audio_grabado" else "OTROS") fiable = f["fecha_origen"] != "mtime" or _fecha_de_camara(f) # una cámara con el reloj sin poner fecha en 2008 o 2009: medido en Vía Sumapaz, 250 clips de la # Blackmagic «del 28 de julio de 2008» y 100 del dron «del 19 de enero de 2009» reloj = f["fecha_origen"] not in ("nombre", "corregida") and int(f["fecha"][:4]) < 2014 tema = segs[-1] if segs else e.name if modo == "fecha": ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) sub = os.path.join(*[x for x in (limpiar_nombre(tema), fuente, R.DUPLICADOS if repetido else "") if x]) destino = self.abs_(destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=self.dia_de(f["fecha"], ay, am), sub=sub, nombre=limpiar_nombre(tema))) else: destino = os.path.join(e.path, *segs, f["fecha"][:10], fuente, *([R.DUPLICADOS] if repetido else [])) if os.path.normcase(os.path.normpath(destino)) == os.path.normcase(os.path.normpath(f["dir"])): continue por_que = "reloj" if reloj else "" if fiable else "mtime" grupos[(f["dir"], destino, tema, "media" if fiable and not reloj else "baja", por_que)].append((f["ruta"], f["bytes"])) for (origen, destino, tema, conf, por_que), archivos in sorted(grupos.items()): self.agregar(origen, destino, "GRABACION", "mover archivos", (f"banco de material «{e.name}», por fecha: a la grabación de su mes, con «{tema}» de etiqueta" if modo == "fecha" else f"banco de material «{e.name}»: el tema «{tema}» se queda; dentro, por fecha de rodaje y cámara") + {"": "", "mtime": " (la fecha sale de la fecha de modificación: puede fallar)", "reloj": f" (la fecha, {next((x for x in destino.split(os.sep) if _RX_FECHA_DIA.match(x)), '')}, parece de una cámara con el reloj sin poner: no es de fiar)"}[por_que], conf, archivos=archivos, marcado=False if protegido else None, paso="banco") self.filas[-1]["no_tocar"] = protegido self.filas[-1]["banco"] = e.name def comprobar_destinos(self): """Las comprobaciones contra el disco de todas las filas, a la vez: primero qué carpetas existen y luego, solo en las que existen, qué archivos de destino ya están. Lo que choca queda sin marcar y dudoso.""" pend, dirs = [], set() for f in self.filas: chk = f.pop("_chk", None) if not chk: continue if chk["tipo"] == "archivos": esp = dict(f.get("destinos_archivo") or {}) esp.update(chk["kw"]) dests = [esp.get(a) or os.path.join(f["destino"], os.path.basename(a)) for a in chk["primeros"]] dirs.update(os.path.dirname(d) for d in dests) pend.append((f, "archivos", dests)) else: dirs.update((f["destino"], f["origen"])) pend.append((f, "carpeta", chk["fusion"])) if not pend: return hay_dir = _existen(dirs) candidatos = {d for f, t, x in pend if t == "archivos" for d in x if hay_dir.get(os.path.dirname(d))} candidatos |= {f["destino"] for f, t, x in pend if t == "carpeta" and not hay_dir.get(f["destino"])} hay_archivo = _son_archivos(candidatos) for f, t, x in pend: if t == "archivos": choques = [os.path.basename(d) for d in x if hay_archivo.get(d)] if choques: f["motivo"] += f" · OJO: en el destino ya hay archivos con el mismo nombre («{choques[0]}»): esos no se moverían" f["confianza"], f["marcado"] = "baja", False elif hay_archivo.get(f["destino"]): f["motivo"] += " · ya existe un archivo con ese nombre en el destino: no se moverá" f["confianza"], f["marcado"] = "baja", False elif x and hay_dir.get(f["destino"]) and hay_dir.get(f["origen"]): f["motivo"] += " · la carpeta destino ya existe: se fusionan" def _partir(self, f, aparte, motivo, confianza=None, no_tocar=False): """Saca de una fila de archivos los que cumplen `aparte` a una fila propia, sin marcar. Si son todos, la fila entera queda sin marcar. Devuelve la fila nueva, si hace falta una.""" sep = [a for a in f["archivos"] if aparte(a)] if not sep: return None if len(sep) == len(f["archivos"]): f["marcado"] = False f["no_tocar"] = f.get("no_tocar") or no_tocar f["confianza"] = confianza or f["confianza"] if motivo not in f["motivo"]: f["motivo"] += motivo return None if self._bytes is None: self._bytes = {r["ruta"]: r["bytes"] or 0 for r in self.con.execute("SELECT ruta, bytes FROM archivos WHERE raiz = ?", (self.raiz,))} quedan = [a for a in f["archivos"] if not aparte(a)] nueva = dict(f) destinos = dict(f.get("destinos_archivo") or {}) for fila, lista in ((f, quedan), (nueva, sep)): fila["archivos"] = lista fila["n"] = len(lista) fila["bytes"] = sum(self._bytes.get(a, 0) for a in lista) if destinos: fila["destinos_archivo"] = {a: destinos[a] for a in lista if a in destinos} nueva.update(marcado=False, confianza=confianza or f["confianza"], motivo=f["motivo"] + motivo, cosmetico=False, no_tocar=f.get("no_tocar") or no_tocar) return nueva def revisar_proxies(self): """Un proxy sin su original puede ser la única copia que queda de ese clip. Medido el 14-oct-2026 en Vía Sumapaz: de 12.645 proxies (449 GB), 126 (4,4 GB) no tenían el original en ningún sitio de la carpeta (clips Fuji de una carpeta «Proxy», el proxy de un reel cuyo master ya no está, .LRF de un dron sin su MP4). Esos van aparte y sin marcar; los demás siguen yendo a _Para borrar como siempre.""" filas = [f for f in self.filas if f["cubeta"] == "PROXY"] if not filas: return originales = {_tallo(r["nombre"]) for r in self.con.execute( "SELECT nombre FROM archivos WHERE raiz = ? AND es_proxy = 0 AND es_basura = 0", (self.raiz,))} def huerfano(ruta): n = os.path.basename(ruta) ext = os.path.splitext(n)[1].lower() return (ext in R.EXT["video"] or ext == ".lrf") and _tallo(n) not in originales bajo = defaultdict(list) # carpeta (minúsculas) -> los proxies que tiene debajo tope = self.raiz.lower() for r in self.con.execute("SELECT ruta, nombre, bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", (self.raiz,)).fetchall(): d = os.path.dirname(r["ruta"]).lower() while True: bajo[d].append(r) p = os.path.dirname(d) if d == tope or p == d: break d = p nuevas, n_sin, b_sin = [], 0, 0 for f in filas: if f["archivos"]: sin = [a for a in f["archivos"] if huerfano(a)] if not sin: continue nueva = self._partir(f, huerfano, f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la " f"carpeta (p. ej. «{os.path.basename(sin[0])}»): puede ser la única copia; míralos antes", "baja") if nueva: nuevas.append(nueva) n_sin += len(sin) else: sin = [r for r in bajo.get(os.path.normpath(f["origen"]).lower(), []) if huerfano(r["ruta"])] if not sin: continue f["motivo"] += (f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la carpeta " f"(p. ej. «{sin[0]['nombre']}»): puede ser la única copia; míralos antes") f["confianza"], f["marcado"] = "baja", False n_sin += len(sin) b_sin += sum(r["bytes"] or 0 for r in sin) self.filas += nuevas if n_sin: self.avisos.append(f"{_plural(n_sin, 'proxy no tiene', 'proxies no tienen')} su original en ningún sitio de esta carpeta. " f"Pueden ser la única copia de esos clips: van sin marcar, para que los mires antes de apartarlos.") def proteger_bancos(self): """Todo lo que el plan propone dentro de una carpeta «No tocar» (sus proxies, su basura) va sin marcar.""" prot = [(p, nombre) for p, nombre, protegido in self.bancos if protegido] if not prot: return def de_cual(ruta): rc = os.path.normcase(os.path.normpath(ruta)) return next((nombre for p, nombre in prot if rc.startswith(p)), None) nuevas = [] for f in list(self.filas): if f.get("paso") == "banco": continue if f["archivos"]: dentro = [a for a in f["archivos"] if de_cual(a)] if dentro: nueva = self._partir(f, lambda a: de_cual(a) is not None, f" · dentro de «{de_cual(dentro[0])}», que dice «No tocar»: márcalo tú si quieres", no_tocar=True) if nueva: nuevas.append(nueva) else: nombre = de_cual(f["origen"]) if nombre: f["marcado"] = False f["no_tocar"] = True f["motivo"] += f" · dentro de «{nombre}», que dice «No tocar»: márcalo tú si quieres" self.filas += nuevas def plan_grabacion(self, e, nivel, anio, mes, motivo): """Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto).""" nombre = e.name nn = R.norm(nombre) # solo los nombres de fábrica («Grabación», «URSA») son genéricos: «Videos Maquinas», aunque # alguien haya confirmado que es una grabación, sigue siendo el nombre de esa sesión generico = (nn in R.DICCIONARIO_SEMILLA or nn in ("grabacion", "grabado", "grabaciones") or R.es_carpeta_de_tarjeta(nombre)) # «DCIM», «PRIVATE»: la tarjeta tal cual filas = archivos_bajo(self.con, e.path) if not filas: return etiqueta_base = "" if generico else R.etiqueta_grabacion(nombre) # Subcarpetas que no son rodaje (los exportados de esa sesión, su música, un proyecto): cada una # va a lo suyo en vez de acabar revuelta en 00_Grabacion\OTROS. aparte = [] subdirs, _ = self.entradas(e.path) for d in subdirs: cub_d, _m = R.cubeta_carpeta(d.name, self.dicc) if cub_d in R.PARTES_PIEZA and self.est.utiles(d.path): self.plan_subdir(d, "contenedor", anio, mes, e.path, {"max": 0}) aparte.append(os.path.normcase(d.path) + os.sep) grupos = defaultdict(list) desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta aqui = os.path.normcase(os.path.normpath(e.path)) for f in filas: if f["es_basura"] or f["es_proxy"]: continue if aparte and (os.path.normcase(f["dir"]) + os.sep).startswith(tuple(aparte)): continue if f["rol"] not in R.ROLES_RODAJE: # un proyecto, un guion o una imagen de IA que estaban de paso: van donde iría cualquier # archivo suelto de su clase, con el mes de esta carpeta self.plan_fila(f, "contenedor", anio, mes) continue fuente = R.FUENTE_CARPETA.get(f["fuente"], None) if fuente is None: if f["rol"] in ("audio", "audio_grabado"): fuente = "AUDIO" elif f["rol"] in ("imagen", "foto_camara", "celular_foto"): fuente = "FOTOS" else: fuente = "OTROS" # lo que digan las carpetas intermedias: el año o el mes («Blackmagic (BRAW)/2023/…»), el # nombre de la sesión («Grabaciones/Tomas consultorio/…») y si ya estaba apartado por repetido anio_ruta, mes_ruta, etiqueta, repetido = None, None, etiqueta_base, False resto = [] # subcarpetas con sentido debajo de la sesión: se conservan debajo de la cámara segs = [] if os.path.normcase(os.path.normpath(f["dir"])) == aqui else os.path.relpath(f["dir"], e.path).split(os.sep) for seg in segs: if R.detectar_anio(seg): anio_ruta = R.detectar_anio(seg) elif R.detectar_mes(seg)[0]: mes_ruta = R.detectar_mes(seg)[0] elif R.norm(seg) == R.norm(R.DUPLICADOS): repetido = True elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg): etiqueta = R.etiqueta_grabacion(seg) elif not _solo_fuente(seg) and R.norm(seg) not in R.DICCIONARIO_SEMILLA: # Medido el 14-oct-2026 en Vía Sumapaz: cada evento de «Fotos varias» guarda la misma foto # en «Alta» y en «Baja», con el mismo nombre. Juntarlas en una sola carpeta hacía chocar # una con otra: 753 fotos no se movieron. Lo que no dice cámara, tarjeta ni día se conserva. resto.append(limpiar_nombre(seg)) fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7]) por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» if anio and mes: # La carpeta dice un mes y el archivo puede decir otro. Medido el 16-sep-2026 sobre el # material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes que # anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre de 2025). # # Manda el archivo, no la carpeta: el nombre lo pone la cámara y la fecha de creación la # pone el disco, mientras que el nombre de la carpeta lo escribió una persona con prisa. # Así no hay que preguntarle a nadie cuál es el mes bueno. Y el año también es el del # archivo: antes se deducía del año de la carpeta, y unos clips de diciembre de 2025 # metidos en «2025\03- Marzo» salían hacia diciembre de 2024. ay, am, fconf = anio, mes, "alta" if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes): ay, am, fconf = fa, fm, "media" desacuerdo[(fa, fm)] += 1 else: ay, am = fa, fm # la fecha de un original de cámara es la del rodaje aunque no esté en el nombre fconf = "media" if por_nombre or _fecha_de_camara(f) else "baja" # el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación, # pero no sobre una fecha escrita en el nombre del archivo if anio_ruta and not por_nombre: ay = anio_ruta fconf = "media" if anio and not por_nombre: ay = anio if mes_ruta and not (por_nombre and _discrepa_mes(f["fecha"], f["fecha_origen"], anio_ruta or anio or fa, mes_ruta)): am = mes_ruta fconf = "media" hoy = datetime.date.today() if (ay, am) > (hoy.year, hoy.month): ay -= 1 grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, tuple(resto), repetido, fconf)].append((f["ruta"], f["bytes"])) aviso_fecha = "" # hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar # toda la carpeta sin marcar. n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime") if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables): (da, dm), cuantos = desacuerdo.most_common(1)[0] aviso_fecha = (f" · la carpeta decía {R.nombre_mes(mes)}, pero {cuantos} de sus archivos son de " f"{R.nombre_mes(dm)} de {da}: van a su mes de verdad") self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de " f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que " f"se grabaron, no en el que dice la carpeta.") for (ay, am, dia, etiqueta, fuente, resto, repetido, fconf), archivos in grupos.items(): sub = os.path.join(*[x for x in (etiqueta, fuente, *resto, R.DUPLICADOS if repetido else "") if x]) destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub, nombre=limpiar_nombre(nombre)) # si todos ya están en esa carpeta, no hay nada que hacer if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos): continue mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else "")) if dia: mot += " · y por día de rodaje" self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf, archivos=archivos) def plan_archivo(self, e, nivel, anio, mes, padre): fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone() if fila: self.plan_fila(fila, nivel, anio, mes) def plan_fila(self, fila, nivel, anio, mes): """Un archivo suelto: a dónde va por lo que es.""" if fila["es_basura"] or fila["es_proxy"]: return # los recoge plan_basura if nivel == "pieza": return rol = fila["rol"] cub = R.ROL_A_CUBETA.get(rol, "REVISAR") conf = "media" motivo = f"archivo suelto ({rol})" fa, fm = int(fila["fecha"][:4]), int(fila["fecha"][5:7]) if anio and fila["fecha_origen"] == "mtime": fa = anio if mes: # el archivo manda sobre la carpeta cuando su propia fecha es de fiar, igual que dentro de una # carpeta de grabación: si no, un clip de diciembre traspapelado en la carpeta de marzo se # quedaba archivado en marzo, que es justo lo que 2.2.0 dejó de hacer con las grabaciones. if _discrepa_mes(fila["fecha"], fila["fecha_origen"], anio or fa, mes): conf = "media" motivo += (f" · la carpeta dice {R.nombre_mes(mes)} y el archivo se grabó en " f"{R.nombre_mes(fm)}: manda el archivo") else: fm = mes elif fila["fecha_origen"] == "mtime" and not _fecha_de_camara(fila): conf = "baja" motivo += " · mes por fecha de modificación" hoy = datetime.date.today() if (fa, fm) > (hoy.year, hoy.month): fa -= 1 sub = "" if cub == "GRABACION": sub = R.FUENTE_CARPETA.get(fila["fuente"], "OTROS") conf = "alta" if (anio and mes) else conf if cub == "CELULAR": if self.tipo == "contenido": cub = "GRABACION" sub = "CELULAR" else: sub = fila["fecha"][:7] if not (anio and mes) else "" if cub == "PORTADAS" and nivel not in ("mes",): cub = "REVISAR" if rol == "imagen" and nivel == "raiz" else cub conf = "baja" if cub == "REVISAR" else conf if cub == "EXPORTADOS": conf = "media" if nivel == "mes" else "baja" motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "") if cub == "PROYECTOS": # el programa manda sobre el rol: un .aep y un .psd sueltos en la misma carpeta van cada uno # a la suya, que es justo lo que cuesta encontrar cuando están mezclados sub = R.app_proyecto(fila["ext"]) or ("Diseño" if rol == "diseno" else "Edicion") motivo = f"proyecto de {sub} suelto" if R.app_proyecto(fila["ext"]) else "proyecto de edición / diseño suelto" conf = "alta" if cub == "MUSICA" and rol == "audio": conf = "baja" if cub == "REVISAR": destino = "_Revisar" elif cub == "PROYECTOS": destino = destino_plantilla(self.tipo, "PROYECTOS", app=sub) else: destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="", dia=self.dia_de(fila["fecha"], fa, fm) if cub in ("GRABACION", "CELULAR") else "") self.suelto(fila, cub, destino, motivo, conf) def plan_auditoria(self): """Lo que ya está en su carpeta de grabación, ¿está en el mes en que se grabó? Las carpetas de la estructura no se vuelven a ordenar, así que lo que una versión anterior (o una persona) dejó en el mes equivocado se quedaba ahí para siempre. Medido el 4-oct-2026 sobre el material real: 160 clips de diciembre de 2025 (429 GB) vivían en la 00_Grabacion de marzo de 2025, y había material de marzo en octubre, de junio en septiembre y de septiembre en noviembre. Aquí se repasa solo lo que tiene la fecha en el nombre, que es lo que no admite discusión.""" pat = self.patron_grab if not pat: return ia, im = pat.index("{anio}"), pat.index("{mes}") roles = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video", "foto_camara") por_mes = defaultdict(list) # (año, mes) de la carpeta -> [(archivo, lo que cuelga por debajo)] for f in self.con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN (%s)" % ",".join("?" * len(roles)), (self.raiz,) + roles).fetchall(): rel = os.path.relpath(f["dir"], self.raiz) segs = [] if rel == "." else rel.split(os.sep) if len(segs) < len(pat): continue for s, p in zip(segs, pat): if not (R.detectar_anio(s) if p == "{anio}" else R.detectar_mes(s)[0] if p == "{mes}" else R.norm(s) == R.norm(p)): break else: resto = segs[len(pat):] tenia_dia = bool(resto and _RX_DIA.match(resto[0])) if tenia_dia: resto = resto[1:] # la carpeta del día se vuelve a calcular en destino por_mes[(R.detectar_anio(segs[ia]), R.detectar_mes(segs[im])[0])].append((f, resto, tenia_dia)) grupos = defaultdict(list) grupos_dia = defaultdict(list) # ya en su mes, pero sin carpeta de día o con la grabadora en «AUDIO» for (anio_c, mes_c), fs in por_mes.items(): fuera = [(f, resto) for f, resto, _ in fs if _discrepa_mes(f["fecha"], f["fecha_origen"], anio_c, mes_c)] if fuera: # lo que solo tiene fecha de modificación pero es del mismo día que lo que se va (la Sony que # rodó junto a la URSA) se va con ello: si no, el rodaje quedaría partido entre dos meses dias = {f["fecha"] for f, _ in fuera} fuera += [(f, resto) for f, resto, _ in fs if f["fecha_origen"] == "mtime" and f["fecha"] in dias and _fecha_de_camara(f)] for f, resto in fuera: ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=self.dia_de(f["fecha"], ay, am), sub=os.sep.join(resto)) grupos[(f["dir"], destino, anio_c, mes_c, ay, am)].append((f["ruta"], f["bytes"], f["fecha_origen"] == "nombre")) # Lo que sí está en su mes: desde la 3.6.0 las grabaciones van siempre dentro de su día de # rodaje y cada grabadora en su carpeta (DJI_MIC, TASCAM, ZOOM), así que lo ordenado con # versiones anteriores (sin carpeta de día, o con la grabadora en «AUDIO») se vuelve a mirar. # Solo lo que tiene fecha de fiar (en el nombre, o un original de cámara, cuya fecha de # modificación es la del rodaje): lo demás se queda donde está. idos = {f["ruta"] for f, _ in fuera} for f, resto, tenia_dia in fs: if f["ruta"] in idos or (f["fecha_origen"] == "mtime" and not _fecha_de_camara(f)): continue nuevo = list(resto) fu = R.FUENTE_CARPETA.get(f["fuente"]) if fu and fu != "AUDIO" and f["rol"] == "audio_grabado": for i in range(len(nuevo)): if R.norm(nuevo[i]) == "audio": nuevo[i] = fu if tenia_dia and nuevo == resto: continue destino = destino_plantilla(self.tipo, "GRABACION", anio=anio_c, mes=mes_c, dia=self.dia_de(f["fecha"], anio_c, mes_c), sub=os.sep.join(nuevo)) if self.abs_(destino).lower() == os.path.normpath(f["dir"]).lower(): continue grupos_dia[(f["dir"], destino, not tenia_dia, nuevo != resto)].append((f["ruta"], f["bytes"])) for (origen, destino, sin_dia, grabadora), archivos in grupos_dia.items(): partes = (["sin la carpeta del día de rodaje"] if sin_dia else []) + (["la grabadora va en su propia carpeta, no en «AUDIO»"] if grabadora else []) self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos", "ya en su mes, pero " + " y ".join(partes), "media", archivos=archivos) n = b = 0 for (origen, destino, anio_c, mes_c, ay, am), archivos in grupos.items(): n += len(archivos) b += sum(a[1] for a in archivos) self.agregar(origen, self.abs_(destino), "GRABACION", "mover archivos", f"ya estaba ordenado, pero en {R.nombre_mes(mes_c)} de {anio_c}: se grabó en {R.nombre_mes(am)} de {ay}", "alta" if all(a[2] for a in archivos) else "media", archivos=[(a[0], a[1]) for a in archivos]) if n: self.avisos.append(f"{_plural(n, 'clip ya ordenado estaba', 'clips ya ordenados estaban')} en un mes que no es el " f"de su rodaje ({_gb(b)}). El plan los lleva al mes en que se grabaron, que es lo que dice " f"el nombre que les puso la cámara.") def plan_basura(self): con = self.con # «_Papelera» era el nombre de antes. Las carpetas ordenadas con versiones viejas tienen las dos, y # lo apartado quedaba repartido entre una y otra: se junta todo en la de ahora. vieja = os.path.join(self.raiz, R.PAPELERAS[1]) if os.path.isdir(vieja): self.agregar(vieja, self.abs_(R.PAPELERA), "BASURA", "papelera", f"la papelera con el nombre antiguo se junta con «{R.PAPELERA}»: era lo mismo en dos sitios", "alta") # carpetas enteras de basura / proxy ya salieron en plan_subdir si estaban a la vista; # aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() cubiertas = [] # se mira subiendo por la ruta de cada uno, no recorriendo la lista entera: en Vía Sumapaz (13.899 # archivos de proxy o basura, 540 carpetas cubiertas) eran 5 millones de comparaciones y 13 s cub_set = set() origenes = {os.path.normpath(f["origen"]).lower() for f in self.filas} for c in sorted(carpetas, key=lambda c: len(c["ruta"])): if _bajo_alguna(os.path.dirname(c["ruta"]), cub_set): continue if c["ruta"].lower() in origenes: cubiertas.append(c["ruta"]) cub_set.add(c["ruta"].lower()) continue if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS): continue cub = "PROXY" if c["es_proxy"] else "BASURA" self.agregar(c["ruta"], self.abs_(destino_plantilla(self.tipo, cub, rel=self.rel(c["ruta"]))), cub, "papelera", "proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta") cubiertas.append(c["ruta"]) cub_set.add(c["ruta"].lower()) sueltos = con.execute("SELECT * FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() grupos = defaultdict(list) for f in sueltos: if _bajo_alguna(f["dir"], cub_set): continue if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS): continue cub = "PROXY" if f["es_proxy"] else "BASURA" grupos[cub].append(f) # una sola fila por tipo: cada archivo conserva su ruta relativa dentro de _Para borrar for cub, fs in grupos.items(): destinos = {} for f in fs: destinos[f["ruta"]] = self.abs_(os.path.join(destino_plantilla(self.tipo, cub, rel=self.rel(f["dir"])), f["nombre"])) archivos = [(f["ruta"], f["bytes"]) for f in fs] self.agregar(self.raiz, self.abs_(destino_plantilla(self.tipo, cub, rel="")), cub, "papelera", f"basura de sistema en {len(set(f['dir'] for f in fs))} carpetas (.DS_Store, Thumbs.db, .tmp, ._*)" if cub == "BASURA" else "proxies sueltos", "alta", archivos=archivos) self.filas[-1]["destinos_archivo"] = destinos self.filas[-1]["origen_rel"] = "(toda la raíz)" # ========================================================================== # Aplicar el plan (con bitácora y deshacer) # ========================================================================== def bitacora(entrada): entrada["ts"] = time.time() with open(BITACORA_PATH, "a", encoding="utf-8") as f: f.write(json.dumps(entrada, ensure_ascii=False) + "\n") def leer_bitacora(limite=2000): if not os.path.exists(BITACORA_PATH): return [] with open(BITACORA_PATH, "r", encoding="utf-8") as f: lineas = f.readlines() out = [] for l in lineas[-limite:]: try: out.append(json.loads(l)) except ValueError: pass return out def nombre_libre(destino): if not os.path.exists(destino): return destino base, ext = os.path.splitext(destino) i = 2 while os.path.exists(f"{base} ({i}){ext}"): i += 1 return f"{base} ({i}){ext}" def _renombrar(origen, destino, intentos=4): """os.rename, esperando un momento si el archivo lo tiene abierto un instante otro programa: la lectura de clips del escaneo profundo, el antivirus o la vista previa del Explorador. Windows no deja mover un archivo abierto (error 32), y antes eso contaba como fallo aunque al segundo siguiente ya estuviera libre. Lo que sigue abierto de verdad (un proyecto de Resolve) falla igual, tras menos de dos segundos.""" for i in range(intentos): try: os.rename(origen, destino) return except OSError as ex: if getattr(ex, "winerror", None) not in (32, 33) or i == intentos - 1: raise time.sleep(0.3 * (i + 1)) def mover(origen, destino, lote, movidos): """mueve archivo o carpeta; si la carpeta destino existe, fusiona""" origen = os.path.normpath(origen) destino = os.path.normpath(destino) if origen == destino: return if origen.lower() == destino.lower(): # solo cambian mayúsculas («10- octubre» -> «10- Octubre»). En Windows es el mismo nombre, así # que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una # carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que # nunca ocurría: os.rename sí sabe hacerlo en NTFS. _renombrar(origen, destino) bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino}) movidos.append((origen, destino)) return if os.path.isdir(origen) and os.path.isdir(destino): # fusión: cada hijo va por su cuenta. Si uno tropieza (ya existe con ese nombre, está abierto), # los demás siguen; antes el primero que fallaba cortaba el bucle y la carpeta quedaba medio # vaciada, con el aviso hablando de un solo archivo. fallos = [] for nombre in os.listdir(origen): try: mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos) except OSError as ex: fallos.append(f"{nombre}: {ex}") try: os.rmdir(origen) bitacora({"lote": lote, "op": "rmdir", "de": origen}) except OSError: pass if fallos: raise OSError(f"{len(fallos)} de «{os.path.basename(origen)}» se quedaron: " + "; ".join(fallos[:3]) + (f"; y {len(fallos) - 3} más" if len(fallos) > 3 else "")) return os.makedirs(os.path.dirname(destino), exist_ok=True) if os.path.exists(destino): # dentro de _Para borrar o Duplicados el nombre da igual: se numera. Fuera, NUNCA se renombra # (Resolve/Premiere enlazan por nombre y los sidecar Sony van emparejados): se deja donde está y se avisa. if _fuera_de_dup(destino): raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo") destino = nombre_libre(destino) try: _renombrar(origen, destino) except OSError: # entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen if os.path.isdir(origen): shutil.move(origen, destino) else: shutil.copy2(origen, destino) if os.path.getsize(destino) != os.path.getsize(origen): os.remove(destino) raise OSError(f"la copia de «{os.path.basename(origen)}» quedó incompleta; el original no se toca") try: os.remove(origen) except OSError as ex: os.remove(destino) raise OSError(f"«{os.path.basename(origen)}» está en uso (¿abierto en Resolve o Premiere?); no se mueve") from ex bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino}) movidos.append((origen, destino)) # hechas/total cuentan filas del plan (es lo que miran «en marcha» y la barra de tareas); unidades cuenta # archivo a archivo dentro de las filas de archivos sueltos, para que la barra avance suave en una fila de # 400 clips; actual/archivo dicen qué se está moviendo; lote/fin, qué tanda fue y cuándo acabó. APLICANDO = {"hechas": 0, "total": 0, "actual": "", "archivo": "", "unidades": 0, "unidades_total": 0, "movidos": 0, "fallos": 0, "lote": "", "inicio": 0, "fin": 0} def _motivo_fallo(ex): """Por qué no se pudo mover algo, en una palabra: para agrupar los fallos y decir qué hacer con cada clase.""" t = str(ex).lower() w = getattr(ex, "winerror", None) if isinstance(ex, FileExistsError) or "ya existe" in t: return "existe" if "en uso" in t or w in (32, 33): return "en_uso" if isinstance(ex, PermissionError) or w == 5 or "acceso denegado" in t: return "permiso" if isinstance(ex, FileNotFoundError) or "ya no está" in t or w in (2, 3): return "no_esta" if "incompleta" in t: return "copia" if w in (53, 59, 64, 67, 1231) or "red" in t: return "red" return "otro" def aplicar(raiz, filas): lote = time.strftime("%Y%m%d-%H%M%S") movidos = [] errores = [] motivos = {} # motivo -> {"n", "ejemplos"}: los fallos agrupados, para explicarlos hechas = 0 def fallo(ex, que): m = motivos.setdefault(_motivo_fallo(ex), {"n": 0, "ejemplos": []}) m["n"] += 1 if len(m["ejemplos"]) < 8: m["ejemplos"].append(que) filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower())) unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas) fallos = 0 APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0, unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0) for f in filas: APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "", archivo="") if f.get("archivos"): # cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los # ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía # que había fallado uno solo. especiales = f.get("destinos_archivo") or {} fallos_fila = 0 try: if not especiales: os.makedirs(f["destino"], exist_ok=True) except Exception as ex: # noqa errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}") continue for a in f["archivos"]: APLICANDO.update(unidades=APLICANDO["unidades"] + 1, archivo=os.path.basename(a)) if not os.path.exists(a): continue try: mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos) except Exception as ex: # noqa fallos_fila += 1 fallo(ex, os.path.relpath(a, raiz)) if fallos_fila <= 5: errores.append(f"{os.path.basename(a)}: {ex}") APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila) fallos += fallos_fila if fallos_fila > 5: errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover") if not fallos_fila: hechas += 1 else: try: if os.path.exists(f["origen"]): mover(f["origen"], f["destino"], lote, movidos) hechas += 1 else: fallos += 1 errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)") fallo(FileNotFoundError("ya no está"), f.get("origen_rel") or "") except Exception as ex: # noqa fallos += 1 errores.append(f"{f.get('origen_rel')}: {ex}") fallo(ex, f.get("origen_rel") or "") APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos) # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo» APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos), fallos=fallos, fin=time.time()) # carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos) for f in filas: base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"]) if os.path.normpath(base).lower() != os.path.normpath(raiz).lower(): quitar_vacias(base, raiz) actualizar_inventario(movidos) # qué pasos del plan entraron (limpiar, fechas, resto, banco): Inicio les pone su ✓ con esto pasos = dict(Counter(f.get("paso") or "resto" for f in filas)) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos, "motivos": motivos}) return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores, "motivos": motivos, "fallos": sum(m["n"] for m in motivos.values())} def actualizar_inventario(movidos): """Refleja en la base de datos los movimientos hechos, sin re-escanear. Las filas de `carpetas` se reescriben con su ruta nueva en vez de borrarse: la interfaz recalcula el plan tras cada paso SIN volver a escanear, y si el subárbol movido desaparecía del inventario, los pasos siguientes salían con «0 archivos · 0 B» y sin fecha. La marca de basura/proxy heredada de una carpeta (por ejemplo lo que cuelga de «Adobe Premiere Pro Auto-Save») también se conserva: antes se recalculaba desde cero y se perdía.""" if not movidos: return con = db() marcas = [os.sep + p + os.sep for p in R.PAPELERAS] # Antes de tomar la base, qué carpetas tocadas siguen existiendo: eso hay que preguntarlo al disco, pero # todo de una vez y en paralelo. Medido el 14-oct-2026 en Vía Sumapaz, 2.493 movimientos: se preguntaba al # disco de red dos veces por movimiento, a 41 ms cada vez con el NAS ocupado, y todo con la base tomada; # más de diez minutos en los que «Buscar clips» se rendía con «database is locked». Lo que hay dentro de # cada carpeta se mira en el inventario, por índice, movimiento a movimiento (no una vez al principio: una # carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior). tocadas = _tocadas(movidos) existe = _existen({d for d, _ in tocadas}) with con: for de, a in movidos: en_papelera = 1 if any(m in a for m in marcas) else 0 con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, " "es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", (a, os.path.dirname(a), os.path.basename(a), en_papelera, de)) lo, hi = _rango(de + os.sep) for r in con.execute("SELECT ruta FROM archivos WHERE ruta >= ? AND ruta < ?", (lo, hi)).fetchall(): nueva = a + r["ruta"][len(de):] con.execute("UPDATE archivos SET ruta = ?, dir = ?, " "es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", (nueva, os.path.dirname(nueva), en_papelera, r["ruta"])) # las carpetas siguen al material: se mueven de ruta, no se borran viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi)).fetchall() con.execute("DELETE FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi)) if viejas: for v in viejas: nueva = a if v["ruta"] == de else a + v["ruta"][len(de):] fila = dict(v) fila["ruta"] = nueva fila["nombre"] = os.path.basename(nueva) if en_papelera: fila["es_basura"] = 1 con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", (fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"], fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"], fila["es_proxy"], fila["es_basura"])) _recontar_carpetas(con, tocadas, existe) con.close() # el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}: olvidar_plan(k) def _rango(prefijo): """(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta». «LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento.""" return prefijo, prefijo[:-1] + chr(ord(prefijo[-1]) + 1) def _existen(rutas): """{carpeta: si existe}, preguntando al disco en paralelo: por la red cada pregunta tarda decenas de ms.""" rutas = list(rutas) if not rutas: return {} from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=24) as ex: return dict(zip(rutas, ex.map(os.path.isdir, rutas))) def _son_archivos(rutas): """{ruta: si es un archivo}, preguntando al disco en paralelo.""" rutas = list(rutas) if not rutas: return {} from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=24) as ex: return dict(zip(rutas, ex.map(os.path.isfile, rutas))) def _tocadas(movidos): """Las carpetas cuyo recuento cambia con estos movimientos: las de origen y destino y todas las de encima. El destino mismo entra siempre: si era un archivo, el disco dirá que no es una carpeta y no se apunta nada.""" tocadas = set() for de, a in movidos: for ruta in (os.path.dirname(de), os.path.dirname(a), a): if not ruta: continue raiz = _raiz_de(ruta) if not raiz: continue d = os.path.normpath(ruta) while d.lower().startswith(raiz.lower()) and d.lower() != raiz.lower(): tocadas.add((d, raiz)) padre = os.path.dirname(d) if padre == d: break d = padre return tocadas def _recontar_carpetas(con, tocadas, existe): """Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del escaneo. Si existen o no ya se preguntó al disco antes (`existe`), fuera de la base.""" marcas = [os.sep + p + os.sep for p in R.PAPELERAS] for d, raiz in tocadas: if not existe.get(d): con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) continue lo, hi = _rango(d + os.sep) fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR (dir >= ? AND dir < ?)", (d, lo, hi)).fetchone() previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone() en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0 con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", (d, raiz, os.path.basename(d), fila["n"], fila["b"], previa["anio_moda"] if previa else None, previa["mes_moda"] if previa else None, previa["frac_moda"] if previa else 0, previa["dias_rango"] if previa else 0, previa["es_proxy"] if previa else (1 if R.es_proxy_dir(os.path.basename(d)) else 0), 1 if en_papelera else (previa["es_basura"] if previa else 0))) def _raiz_de(ruta): """La carpeta de cliente registrada a la que pertenece una ruta, o None.""" r = os.path.normpath(ruta).lower() for c in CONFIG.get("raices", []): k = os.path.normpath(c["ruta"]).lower() if r == k or r.startswith(k + os.sep): return os.path.normpath(c["ruta"]) return None def quitar_vacias(ruta, tope): """borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)""" ruta = os.path.normpath(ruta) tope = os.path.normpath(tope) while ruta.lower() != tope.lower() and ruta.lower().startswith(tope.lower()): if R.norm(os.path.basename(ruta)) == R.norm(R.ENTRADA): break # la bandeja de entrada se queda aunque esté vacía try: if os.path.isdir(ruta) and not os.listdir(ruta): os.rmdir(ruta) else: break except OSError: break ruta = os.path.dirname(ruta) def _sin_archivos(ruta): """True si debajo de `ruta` no hay un solo archivo ni ningún enlace: nada que perder al borrarla. Un enlace o una unión de Windows cuentan como contenido aunque no sean archivos: apuntan a otro árbol y borrarlos sí perdería algo.""" try: with os.scandir(ruta) as it: for e in it: if _es_enlace(e) or not e.is_dir(follow_symlinks=False): return False if not _sin_archivos(e.path): return False except OSError: return False # no se puede leer: se da por ocupada return True def _pieza_nueva(ruta, desde): """True si la carpeta es una pieza creada después de `desde`, o el mes o el año que la contienen.""" try: st = os.stat(ruta) except OSError: return False if (getattr(st, "st_birthtime", None) or st.st_ctime) <= desde: return False nombre = os.path.basename(ruta) if R.detectar_pieza(nombre): return True if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]: try: return any(_pieza_nueva(os.path.join(ruta, x), desde) for x in os.listdir(ruta)) except OSError: return False return False def carpetas_vacias(raiz): """Carpetas sin un solo archivo dentro, en todo el árbol. Ordenar deja huecos: repartida «Grabacion» por mes y por cámara, la carpeta original se queda sin nada. `quitar_vacias` solo limpia el camino de lo que se acaba de mover, así que lo que ya estaba vacío antes se queda ahí para siempre. Los candidatos salen del inventario (instantáneo: una carpeta está vacía si ninguna ruta de `archivos` cuelga de ella) y cada uno se comprueba en el disco antes de enseñarlo, porque el inventario puede tener días. Solo se devuelve la de más arriba de cada rama: si «2026\03- Marzo» está vacía entera, no hace falta enumerar sus seis subcarpetas.""" raiz = _clave(raiz) con = db() carpetas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()] con_archivos = set() for r in con.execute("SELECT DISTINCT dir FROM archivos WHERE raiz = ?", (raiz,)).fetchall(): d = os.path.normpath(r["dir"]) while d and d.lower().startswith(raiz.lower()): # el directorio y todos sus padres tienen algo if d.lower() in con_archivos: break con_archivos.add(d.lower()) padre = os.path.dirname(d) if padre == d: break d = padre con.close() vacias = sorted({os.path.normpath(c) for c in carpetas if os.path.normpath(c).lower() not in con_archivos and os.path.normpath(c).lower() != raiz.lower()}, key=lambda x: (len(x), x.lower())) out, tomadas = [], [] reciente = time.time() - 30 * 86400 for c in vacias: if any(c.lower().startswith(t + os.sep) for t in tomadas): continue # ya cuelga de otra que se va entera if c.lower() == os.path.join(raiz, R.ENTRADA).lower(): continue # la bandeja de entrada está vacía a propósito if not os.path.isdir(c) or not _sin_archivos(c): continue # ya no existe, o alguien dejó algo dentro # una pieza recién creada tiene sus subcarpetas vacías porque aún no se ha trabajado en ella: no # es un hueco que dejó el orden. Lo mismo el mes o el año que se crearon para ella. if _pieza_nueva(c, reciente): tomadas.append(c.lower()) continue dentro = sum(len(d) for _, d, _ in os.walk(c)) out.append({"ruta": c, "rel": os.path.relpath(c, raiz), "subcarpetas": dentro}) tomadas.append(c.lower()) return {"raiz": raiz, "carpetas": out, "n": len(out)} def borrar_vacias(raiz, rutas): """Borra carpetas vacías. Es lo único que la app borra, y solo porque no hay nada dentro que perder: cada carpeta queda apuntada en la bitácora y «Deshacer» las vuelve a crear.""" lote = "vacias-" + time.strftime("%Y%m%d-%H%M%S") raiz = _clave(raiz) borradas, errores = 0, [] for r in sorted({os.path.normpath(x) for x in rutas}, key=lambda x: -len(x)): if not dentro_de(raiz, r) or r.lower() == raiz.lower(): errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca") continue if not os.path.isdir(r): continue # ya no está: nada que hacer if not _sin_archivos(r): errores.append(f"{os.path.basename(r)}: ya no está vacía, se deja como está") continue try: for base, _, _ in sorted(os.walk(r), key=lambda x: -len(x[0])): os.rmdir(base) bitacora({"lote": lote, "op": "rmdir", "de": base}) borradas += 1 except OSError as ex: errores.append(f"{os.path.basename(r)}: {ex}") if borradas: con = db() with con: for e in leer_bitacora(100000): if e.get("lote") == lote and e.get("op") == "rmdir": con.execute("DELETE FROM carpetas WHERE ruta = ?", (e["de"],)) con.close() if borradas: olvidar_plan(raiz) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": borradas, "movimientos": borradas, "errores": errores}) return {"lote": lote, "borradas": borradas, "errores": errores} def deshacer(lote): todas = leer_bitacora(100000) entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir", "mkdir")] fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {}) raiz = fin.get("raiz") or "" deshechos, errores, movidos, recreadas, quitadas = 0, [], [], [], [] for e in reversed(entradas): try: if e["op"] == "mkdir": # una carpeta que creó la app (una pieza nueva): se quita solo si sigue vacía if os.path.isdir(e["de"]): if os.listdir(e["de"]): errores.append(f"{os.path.basename(e['de'])}: ya tiene cosas dentro; se deja") else: os.rmdir(e["de"]) quitadas.append(e["de"]) deshechos += 1 elif e["op"] == "rmdir": os.makedirs(e["de"], exist_ok=True) if lote.startswith("vacias-"): # aquí la carpeta ERA el cambio, no el rastro de uno: se cuenta y vuelve al inventario recreadas.append(e["de"]) deshechos += 1 else: if not os.path.exists(e["a"]): errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve") elif e["a"].lower() == e["de"].lower(): os.rename(e["a"], e["de"]) # solo cambiaban las mayúsculas: exists() diría que está ocupado movidos.append((e["a"], e["de"])) deshechos += 1 elif os.path.exists(e["de"]): errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve") else: os.makedirs(os.path.dirname(e["de"]), exist_ok=True) try: os.rename(e["a"], e["de"]) except OSError: shutil.move(e["a"], e["de"]) # el respaldo copió entre discos: se vuelve de la misma forma movidos.append((e["a"], e["de"])) deshechos += 1 if raiz: quitar_vacias(os.path.dirname(e["a"]), raiz) except OSError as ex: errores.append(f"{e.get('a')}: {ex}") actualizar_inventario(movidos) if (recreadas or quitadas) and raiz: con = db() with con: for d in recreadas: con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d))) for d in quitadas: con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) con.close() bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores}) return {"deshechos": deshechos, "errores": errores} # ========================================================================== # Vocabulario (lo que la app "ve" que usamos como nombres) # ========================================================================== def vocabulario(raiz): """Las carpetas sobre las que hay algo que decidir, con lo que la app cree que es cada una. Recorre el inventario igual que el plan recorre el disco y se queda solo con lo que el plan llega a ver: no entra en las carpetas de la propia app (_Para borrar, 00_Grabacion…), ni dentro de las piezas, ni en lo que ya sabe ordenar solo (una carpeta llena de clips de cámara, un cajón con meses dentro). Hasta la 3.1.1 salían todas las carpetas del inventario, y eso preguntaba cosas que no cambiaban nada o que no eran del cliente. Medido el 4-oct-2026 sobre el material real: de 6 preguntas, 6 sobraban —«basura» era la _Para borrar\\basura de la app, «Orden» sus propias etiquetas dentro de 00_Grabacion, y el resto carpetas que solo existían ya dentro de la papelera—, y contestarlas enseñó al diccionario que «orden», «videos» y «basura» eran grabaciones.""" raiz = os.path.normpath(raiz) tipo = (raiz_cfg(raiz) or {}).get("tipo", "contenido") dicc = DICC["carpetas"] con = db() est = Estructura(con, raiz, dicc) resumen = _resumen_carpetas(con, raiz) con.close() primeros, en_mes = _propias(tipo) pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]) por_mes = {c for c, p in pl.items() if "{mes}" in p} abre_cajones = "{mes}" in (pl.get("PIEZA") or "") tokens = {} def apunta(ruta, titulo, cub, motivo, nota=""): tok = R.norm(titulo) if not tok or len(tok) > 60: return c = est.carp.get(os.path.normcase(ruta)) t = tokens.get(tok) if t is None: t = tokens[tok] = {"token": tok, "ejemplo": os.path.basename(ruta), "n": 0, "archivos": 0, "bytes": 0, "rutas": [], "dentro_pieza": False, "cubeta": cub or "", "motivo": motivo, "nota": nota, "aprendido": tok in dicc and tok not in R.DICCIONARIO_SEMILLA} t["n"] += 1 t["archivos"] += (c["n"] if c else 0) or 0 t["bytes"] += (c["bytes"] if c else 0) or 0 if len(t["rutas"]) < 4: t["rutas"].append(os.path.relpath(ruta, raiz)) def baja(ruta, nivel, anio): for h in sorted(est.hijos(ruta), key=str.lower): nombre = os.path.basename(h) if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA): baja(h, "contenedor", None) # la bandeja de entrada: el plan la abre siempre continue if _es_propia(nombre, nivel, primeros, en_mes) or R.es_basura_dir(nombre) or R.es_proxy_dir(nombre): continue if not est.utiles(h): continue # vacía: nada que decidir (se ofrece en «Carpetas vacías») a = R.detectar_anio(nombre) if a and nivel in ("raiz", "contenedor"): baja(h, "anio" if nivel == "raiz" else "contenedor", a) continue if nivel == "anio" and R.detectar_mes(nombre)[0]: baja(h, "mes", anio) continue if nivel in ("raiz", "contenedor") and R.detectar_mes(re.sub(r"20\d\d", " ", nombre).strip(" -_"))[0]: baja(h, "mes", anio) continue if nivel == "mes": p = R.detectar_pieza(nombre) if p: cub_t, mot = R.cubeta_carpeta(p[1], dicc) if cub_t == "GRABACION" and est.es_rodaje(h, por_nombre=_de_fabrica(p[1], "GRABACION")): continue # la sesión del mes: el plan la reparte sola if cub_t in ("GRABACION", "CONTENEDOR") and est.fechas_dentro(h): apunta(h, p[1], "CONTENEDOR", mot) baja(h, "contenedor", None) elif cub_t != "PIEZA" and est.fechas_dentro(h): apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas") continue # una pieza: no se toca por dentro cub, mot = R.cubeta_carpeta(nombre, dicc) if cub in ("IGNORAR", "BANCO"): apunta(h, nombre, cub, mot) continue if cub == "CONTENEDOR": apunta(h, nombre, cub, mot) m_anio = re.search(r"(20\d\d)", nombre) baja(h, "contenedor", int(m_anio.group(1)) if m_anio else (None if est.fechas_dentro(h) else anio)) continue montada = False if cub == "GRABACION": if est.es_rodaje(h, por_nombre=_de_fabrica(nombre, "GRABACION")): apunta(h, nombre, cub, mot) continue cub, montada = None, True if abre_cajones and (cub in (None, "PROYECTOS", "PIEZA", "CAMPANA") or cub in por_mes) and est.fechas_dentro(h) \ and (montada or cub is not None or not est.es_rodaje(h)): baja(h, "contenedor", None) # un cajón con meses dentro: el plan lo abre solo continue if cub is None: if not montada and est.es_rodaje(h): continue # llena de clips de cámara: el plan la reparte sola if nivel == "mes": continue # dentro de un mes, lo que no se reconoce es una pieza apunta(h, nombre, "", "", "tiene nombre de grabación, pero no es una tarjeta volcada" if montada else "") else: apunta(h, nombre, cub, mot) baja(raiz, "raiz", None) for t in tokens.values(): if not t["cubeta"]: _sugerir(est, resumen, raiz, t) return sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) _ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición / diseño", "audio": "audio", "imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"} def _discrepa_mes(fecha, origen, anio, mes): """True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta. Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de modificación, que no es de fiar, así que en ese caso solo se compara el mes.""" if origen == "mtime": return False # la fecha de modificación no prueba nada ra, rm = int(fecha[:4]), int(fecha[5:7]) if origen == "nombre+mtime": d = abs(rm - mes) return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2 def _clase_rol(rol): return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else "proyecto" if rol in ("proyecto", "diseno") else "audio" if rol == "audio" else "imagen" if rol in ("imagen", "foto_camara", "celular_foto") else "documento" if rol == "documento" else "video" if rol in ("video", "celular_video") else "otro") def _resumen_carpetas(con, raiz): """Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas. Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s).""" agg = defaultdict(lambda: defaultdict(lambda: [0, 0])) filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos " "WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall() tope = os.path.normcase(os.path.normpath(raiz)) for f in filas: k = _clase_rol(f["rol"]) d = os.path.normpath(f["dir"]) while True: e = agg[os.path.normcase(d)][k] e[0] += f["n"] e[1] += f["b"] if os.path.normcase(d) == tope: break padre = os.path.dirname(d) if padre == d: break d = padre return agg _PARTE_FRASE = {"EXPORTADOS": "exportados", "PORTADAS": "portada", "STOCK": "stock", "IA": "IA", "GUION": "guion", "PROYECTOS": "proyectos", "MUSICA": "música", "SFX": "sonidos"} def _sugerir(est, resumen, raiz, t): """Qué es probablemente esta carpeta: por su forma, por lo que tiene dentro y por su nombre. Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible). Lo de dentro se cuenta por número de archivos, no por peso: los clips de cámara pesan cien veces más que todo lo demás, así que por peso casi cualquier carpeta grande «era» una grabación, y aceptar esa propuesta la mandaba a desarmar. Una grabación de verdad el plan ya la reconoce solo; aquí nunca se propone.""" import difflib rutas = [os.path.join(raiz, rel) for rel in t["rutas"][:3]] conteo = defaultdict(lambda: [0, 0]) total = [0, 0] for r in rutas: for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(r)), {}).items(): conteo[k][0] += n conteo[k][1] += b total[0] += n total[1] += b orden = sorted(conteo.items(), key=lambda kv: -kv[1][0]) t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas" sug, conf, por_que = "", "", "" fechas = list(dict.fromkeys(os.path.basename(d) for r in rutas for d in est.fechas_dentro(r))) partes = set() for r in rutas: partes |= est.partes(r) if fechas: # a) por la forma: guarda meses o años dentro sug, conf = "CONTENEDOR", "alta" if len(fechas) >= 2 else "media" por_que = ("dentro guarda carpetas de otras fechas (" + ", ".join(f"«{x}»" for x in fechas[:3]) + ("…" if len(fechas) > 3 else "") + "): es un cajón que hay que abrir, no una cosa que mover entera") elif len(partes) >= 2: # b) por la forma: trae las carpetas de una pieza montada sug, conf = "PIEZA", "alta" if len(partes) >= 3 else "media" por_que = ("dentro tiene carpetas de " + ", ".join(sorted(_PARTE_FRASE.get(p, p.lower()) for p in partes)) + ": es una pieza montada, se mueve entera") else: # c) por el nombre: se parece a una palabra del diccionario dicc = DICC["carpetas"] cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82) if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION", "BANCO"): sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»" # d) por el contenido (manda si es contundente) if total[0] >= 3 and orden: k, (n, b) = orden[0] frac = n / total[0] if frac >= 0.75: por_cont = {"proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS", "documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k) if por_cont: if sug and sug != por_cont: por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}" conf = "baja" else: # «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos # los clientes: exige que sea contundente y que haya bastantes archivos. seguro = frac >= 0.92 and n >= 8 and total[0] >= 10 sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)" t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que # ========================================================================== # Mapa # ========================================================================== def mapa(raiz): con = db() raiz = os.path.normpath(raiz) esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() if not esc: con.close() return {"escaneado": False} q = lambda sql, *a: [dict(r) for r in con.execute(sql, a).fetchall()] total = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ?", raiz)[0] por_ext = q("SELECT ext, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY ext ORDER BY bytes DESC LIMIT 14", raiz) por_rol = q("SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY rol ORDER BY bytes DESC", raiz) por_fuente = q("SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND fuente != '' GROUP BY fuente ORDER BY bytes DESC", raiz) por_anio = q("SELECT substr(fecha,1,4) anio, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY anio ORDER BY anio", raiz) por_mes = q("SELECT substr(fecha,1,7) mes, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado') GROUP BY mes ORDER BY mes", raiz) basura = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_basura = 1", raiz)[0] proxies = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", raiz)[0] dd = duplicados(raiz) dup_ej = [{"tipo": g["tipo"], "nombre": g["nombre"], "bytes": g["bytes"], "copias": len(g["mueven"]) + 1, "queda": os.path.relpath(g["queda"], raiz), "rutas": [os.path.relpath(r, raiz) for r in g["mueven"]]} for g in dd["grupos"][:12]] raw = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND rol = 'camara_raw'", raiz)[0] grandes = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND bytes > 1000000000 AND rol IN ('video','camara_video') AND es_proxy = 0", raiz)[0] sin_fecha = q("SELECT COUNT(*) n FROM archivos WHERE raiz = ? AND fecha_origen = 'mtime' AND rol IN ('camara_raw','camara_video')", raiz)[0] # árbol de 2 niveles para el treemap hijos = q("SELECT * FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC", raiz, esc_like(con_sep(raiz)) + "%", esc_like(con_sep(raiz)) + "%" + esc_like(os.sep) + "%") arbol = [] for h in hijos: nietos = q("SELECT nombre, n, bytes FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC LIMIT 40", raiz, esc_like(h["ruta"] + os.sep) + "%", esc_like(h["ruta"] + os.sep) + "%" + esc_like(os.sep) + "%") cub, _ = R.cubeta_carpeta(h["nombre"], DICC["carpetas"]) if R.detectar_anio(h["nombre"]): cub = "ANIO" elif R.norm(h["nombre"]) in R.PAPELERAS_NORM or R.norm(h["nombre"]) == R.norm(R.DUPLICADOS): cub = "APARTADO" # carpetas de la propia app: no son «sin reconocer» arbol.append({"nombre": h["nombre"], "n": h["n"], "bytes": h["bytes"], "cubeta": cub or "", "hijos": nietos, "anio": h["anio_moda"], "mes": h["mes_moda"]}) sueltos_raiz = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, raiz)[0] con.close() return { "escaneado": True, "raiz": raiz, "escaneo": dict(esc), "total": total, "por_ext": por_ext, "por_rol": por_rol, "por_fuente": por_fuente, "por_anio": por_anio, "por_mes": por_mes, "basura": basura, "proxies": proxies, "duplicados": {"grupos": len(dd["grupos"]), "n": dd["n"], "bytes": dd["bytes"], "identicos": dd["identicos"], "versiones": dd["versiones"], "por_comparar": dd["por_comparar"], "ejemplos": dup_ej}, "raw": raw, "grandes": grandes, "sin_fecha": sin_fecha, "arbol": arbol, "sueltos_raiz": sueltos_raiz, } def buscar_carpeta(nombre, profundidad=3): """Busca una carpeta por nombre en todos los discos (hasta 3 niveles), para el arrastrar-y-soltar.""" objetivo = R.norm(nombre) if not objetivo: return [] saltar = {"#recycle", "@eadir", "windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "appdata", "node_modules", "system volume information"} hits = [] inicio = time.time() def rec(path, nivel): if time.time() - inicio > 25 or len(hits) >= 20: return try: with os.scandir(path) as it: ents = [e for e in it if e.is_dir(follow_symlinks=False) and not e.name.startswith((".", "$", "~"))] except OSError: return for e in ents: if R.norm(e.name) == objetivo: hits.append(e.path) if nivel < profundidad: for e in ents: if e.name.lower() not in saltar: rec(e.path, nivel + 1) for letra in "DEFGHIJKLMNOPQRSTUVWXYZC": raiz = letra + ":" + os.sep if os.path.exists(raiz): rec(raiz, 1) return hits _DISCOS_CACHE = {"ts": 0, "datos": None} def discos(): if time.time() - _DISCOS_CACHE["ts"] < 120 and _DISCOS_CACHE["datos"]: return _DISCOS_CACHE["datos"] unc = {} try: out = subprocess.run(["net", "use"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA).stdout for l in out.splitlines(): m = re.search(r"\b([A-Z]):\s+(\\\\\S+)", l) if m: unc[m.group(1) + ":"] = m.group(2) except Exception: # noqa pass alias = {} for cli, info in CONFIG["clientes"].items(): for a in info.get("alias", []) + [cli]: alias[R.norm(a)] = cli res = [] for letra in "CDEFGHIJKLMNOPQRSTUVWXYZ": d = letra + ":" raiz = d + os.sep if not os.path.exists(raiz): continue try: u = shutil.disk_usage(raiz) except OSError: continue carpetas = [] try: with os.scandir(raiz) as it: for e in it: if e.is_dir(follow_symlinks=False) and not e.name.startswith(("#", ".", "$", "@")): cli = alias.get(R.norm(e.name)) carpetas.append({"nombre": e.name, "cliente": cli}) except OSError: pass cfg = CONFIG["discos"].get(d, {}) res.append({"letra": d, "unc": unc.get(d, ""), "nombre": cfg.get("nombre", ""), "nivel": cfg.get("nivel", "" if unc.get(d) else "local"), "total": u.total, "libre": u.free, "carpetas": sorted(carpetas, key=lambda c: c["nombre"].lower())}) # dónde vive cada cliente por_cliente = defaultdict(list) for disco in res: for c in disco["carpetas"]: if c["cliente"]: por_cliente[c["cliente"]].append(f"{disco['letra']}\\{c['nombre']}") for r in CONFIG["raices"]: por_cliente[r["cliente"]].append(r["ruta"]) datos = {"discos": res, "clientes": {k: sorted(set(v)) for k, v in por_cliente.items()}} _DISCOS_CACHE.update(ts=time.time(), datos=datos) return datos # ========================================================================== # Optimización: análisis (ffprobe), candidatos, cola, motores # ========================================================================== ANALISIS = {} # raiz -> progreso def ffprobe(ruta): cmd = [ruta_herramienta("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,bit_rate,nb_frames", "-show_entries", "format=duration,bit_rate", "-of", "json", ruta] try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA).stdout j = json.loads(out or "{}") except Exception: # noqa return None v = next((s for s in j.get("streams", []) if s.get("codec_type") == "video"), None) a = next((s for s in j.get("streams", []) if s.get("codec_type") == "audio"), None) fmt = j.get("format", {}) if not v: return None fr = v.get("r_frame_rate", "0/1") try: num, den = fr.split("/") fps = float(num) / float(den) if float(den) else 0 except ValueError: fps = 0 dur = float(fmt.get("duration") or 0) kbps = int(int(fmt.get("bit_rate") or v.get("bit_rate") or 0) / 1000) pix = v.get("pix_fmt") or "" return { "codec": v.get("codec_name") or "", "perfil": v.get("profile") or "", "pix_fmt": pix, "ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur, "kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0, "acodec": (a or {}).get("codec_name") or "", "pistas_audio": sum(1 for x in j.get("streams", []) if x.get("codec_type") == "audio"), } HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red def analizar(raiz, min_mb, reintentar=False): """Lee con ffprobe los clips que faltan (códec, duración, resolución). Va en paralelo porque cada lectura es sobre todo espera de red: medido, 0,23 s por clip; 15.423 clips en serie pasan de la hora y con ocho a la vez bajan a unos ocho minutos. Con `reintentar` también vuelve sobre los que no se pudieron leer (analizado = 2), por si el problema era un ffprobe viejo o una unidad que estaba desconectada.""" prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time(), "cancelar": False, "ilegibles": 0} con = db() estados = "(0, 2)" if reintentar else "(0)" lee_braw = bool(decodificador_braw().get("ok")) # los BRAW que solo leyó ffprobe se miden otra vez con el decodificador: se les nota en que no tienen # «perfil», que es donde queda apuntada su curva remedir = " OR (ext = '.braw' AND analizado = 1 AND COALESCE(perfil, '') = '')" if lee_braw else "" pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND (analizado IN {estados}{remedir}) AND es_proxy = 0 " "AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') " "ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall() prog["total"] = len(pend) try: import concurrent.futures as cf lote = [] def guardar(lote): # si la base está ocupada (otro proceso guardando, por ejemplo después de «Aplicar»), se espera y # se reintenta: antes «Buscar clips» se rendía al minuto con «database is locked» for intento in range(40): try: _guardar(lote) prog.pop("esperando", None) return except sqlite3.OperationalError as ex: if "locked" not in str(ex) or intento == 39: raise prog["esperando"] = "la base está ocupada guardando otros cambios; se espera" time.sleep(3) def _guardar(lote): with con: for ruta, info in lote: if info: con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, " "kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", (info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], info.get("pistas_audio", 1), ruta)) else: con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (ruta,)) def leer(f): info = ffprobe(f["ruta"]) if info and f["ext"] == ".braw": info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec m = medir_braw(f["ruta"]) if lee_braw else {} if m.get("ok"): info.update(ancho=m["ancho"], alto=m["alto"], fps=m["fps"] or info["fps"], dur=m["dur"] or info["dur"], perfil=_curva(m)) return f["ruta"], info with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool: for ruta, info in pool.map(leer, pend): if prog["cancelar"]: break if info is None: prog["ilegibles"] += 1 lote.append((ruta, info)) prog["hechos"] += 1 if len(lote) >= 50: # se guarda por tandas: una transacción por clip es mucho más lento guardar(lote) lote = [] guardar(lote) prog["estado"] = "cancelado" if prog["cancelar"] else "listo" if prog["ilegibles"]: prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)" except Exception as ex: # noqa prog["estado"] = "error" prog["error"] = ("la base de datos siguió ocupada guardando otros cambios más de dos minutos; vuelve a pulsar «Buscar clips»" if "locked" in str(ex) else str(ex)) finally: con.close() def kbps_objetivo(ancho, alto, fps, perfil): bpp = PERFILES.get(perfil, PERFILES["equilibrado"])["bpp"] fps = fps or 30 return max(1500, int(ancho * alto * fps * bpp / 1000)) # Desde cuántas veces la tasa objetivo compensa recomprimir un clip que ya viene comprimido entre cuadros. # H.265, AV1 y VP9 rinden como la salida: de ahí solo se baja quitando calidad, y hace falta que el origen # vaya muy holgado (el doble). Un H.264 necesita unas 1,6 veces los bits de H.265 para verse igual: basta # con que no esté ya por debajo del objetivo. Medido el 5-oct-2026 con clips de V:\Galeano contra su original: # un H.264 4.6K de 33 Mb/s (1,08 veces el objetivo) queda en la mitad con nota 96,7 sobre 100; uno de # 121 Mb/s, en la quinta parte. Lo que guarda cada cuadro entero (XAVC-I, ProRes, BRAW) no pasa por aquí. RECOMPRIMIR_DESDE = {"hevc": 2.0, "av1": 2.0, "vp9": 2.0, "h264": 1.0, "mpeg4": 0.7, "mpeg2video": 0.5} NOMBRE_CODEC = {"hevc": "H.265", "av1": "AV1", "vp9": "VP9", "h264": "H.264", "mpeg4": "MPEG-4", "mpeg2video": "MPEG-2"} def poco_que_ganar(f, objetivo): """True si el clip ya viene comprimido y tan cerca de lo que pesaría convertido que no compensa tocarlo.""" if not f["kbps"] or f["codec"] not in RECOMPRIMIR_DESDE or "intra" in (f["perfil"] or "").lower(): return False return f["kbps"] <= RECOMPRIMIR_DESDE[f["codec"]] * objetivo def _salio_del_convertidor(f, salidas): """Lleva el sufijo del convertidor y además está en el códec que ese sufijo produce: un «Entrevista_HQ.mp4» en H.264, o un «clip_proxy.mp4» de la cámara, no los hizo este programa.""" if os.path.normcase(os.path.normpath(f["ruta"])) in salidas: return True if not R.es_conversion(f["nombre"]): return False base = os.path.splitext(f["nombre"])[0].lower() sufijo = next((s for s in R.SUFIJOS_CONVERSION if base.endswith(s)), "") return f["codec"] == {"_h264": "h264", "_proxy": "prores", "_lt": "prores", "_hq": "prores"}.get(sufijo, "hevc") def estimar_bytes(f, perfil, formato): """peso aproximado de la salida para un archivo del inventario""" fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) dur = f["dur"] or 0 if fm.get("mbps_1080p30"): pix = (f["ancho"] or 1920) * (f["alto"] or 1080) * (f["fps"] or 30) return int(fm["mbps_1080p30"] * 1e6 / 8 * dur * pix / (1920 * 1080 * 30)) obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0) return int(obj * 1000 / 8 * dur) + int(256000 / 8 * dur) def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"): fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) lee_braw = bool(decodificador_braw().get("ok")) con = db() tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 " "AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall() # solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe # poder convertirse también a H.265, y uno que falló debe poder reintentarse. en_cola = {r["ruta"]: r["estado"] for r in con.execute( "SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (formato,)).fetchall()} # lo que ya salió del convertidor no vuelve a entrar. Medido el 4-oct-2026 sobre el material real: 22 # clips estaban convertidos dos veces y algunos tres («…_10bit_10bit_10bit.mp4»), cada pasada con su # pérdida y sus horas, porque una conversión con buen bitrate no pasaba por «ya está en HEVC eficiente». salidas = {os.path.normcase(os.path.normpath(r["salida"])) for r in con.execute( "SELECT salida FROM trabajos WHERE estado IN ('listo','verificado','reemplazado') AND salida IS NOT NULL").fetchall()} # y lo que ya se probó con esta misma calidad y no compensó, tampoco probados = {r["ruta"]: r["log"] or "" for r in con.execute( "SELECT ruta, log FROM trabajos WHERE formato = ? AND perfil = ? AND estado = 'descartado'", (formato, perfil)).fetchall()} con.close() out = [] for f in filas: motivo_no = "" # los BRAW los lee el decodificador de Blackmagic; solo si falta se intenta por el guion de Resolve motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg" obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) if _salio_del_convertidor(f, salidas): motivo_no = "ya es una conversión hecha con este programa" elif f["es_stock"]: motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)" elif f["alfa"]: motivo_no = "tiene canal alfa (HEVC lo perdería)" elif f["ruta"] in probados: motivo_no = "ya se probó con esta calidad: " + probados[f["ruta"]].replace("no compensa: ", "").split(". ")[0] elif fm["codec"] == "hevc" and poco_que_ganar(f, obj): motivo_no = (f"ya viene comprimido ({NOMBRE_CODEC.get(f['codec'], f['codec'])} a {f['kbps'] / 1000:.0f} Mb/s): " "recomprimirlo ahorraría poco y le quitaría calidad") elif motor == "resolve" and formato != "h265_10bit": motivo_no = "sin el decodificador de Blackmagic los BRAW van por Resolve, y solo salen a H.265 10 bits" elif tope and not es_viejo(f, tope): motivo_no = f"tiene menos de {meses} meses" elif f["contexto"] == "EXPORTADOS" and not incluir_exportados: motivo_no = "es un exportado / master" elif not f["ancho"] or not f["dur"]: motivo_no = "no se pudo leer (ffprobe)" est = estimar_bytes(f, perfil, formato) out.append({ "ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"], "pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"], "kbps_objetivo": obj, "estimado": est, "motor": motor, "excluido": motivo_no, "fecha": f["fecha"], "rol": f["rol"], "contexto": f["contexto"], "en_cola": en_cola.get(f["ruta"], ""), }) return out def salida_para(ruta, raiz, formato="h265_10bit"): o = CONFIG["optimizar"] fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) base, _ = os.path.splitext(ruta) sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"] if o.get("salida") == "espejo": rel = os.path.relpath(base, raiz) # el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"]) return base + sufijo + fm["ext"] def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"): if formato not in FORMATOS: formato = "h265_10bit" fm = FORMATOS[formato] lee_braw = bool(decodificador_braw().get("ok")) con = db() ids = [] lote, movidos, errores, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "" with con: for r in rutas: f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone() if not f: continue ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone() if ya: continue motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg" if motor == "resolve" and formato != "h265_10bit": continue # por el guion de Resolve los BRAW solo salen a H.265 10 bits salida = salida_para(r, raiz, formato) if os.path.exists(salida): previo = con.execute("SELECT * FROM trabajos WHERE ruta = ? AND salida = ? AND estado = 'descartado' ORDER BY id DESC", (r, salida)).fetchone() if previo: try: if _apartar_convertido(dict(previo), lote, movidos): raiz_desc = raiz_desc or previo["raiz"] except OSError as ex: errores.append(f"{os.path.basename(salida)}: {ex}") # el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola. otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone() if otro or os.path.exists(salida): continue kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) # los intentos fallidos de este mismo clip se sustituyen por el nuevo (el 5-oct-2026 había 174 # filas de error para 87 clips: cada tanda dejaba las suyas) con.execute("DELETE FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('error','cancelado','descartado')", (r, formato)) cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz, formato)) ids.append(cur.lastrowid) con.close() _cerrar_lote(lote, raiz_desc or raiz, movidos, errores) return ids # empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en # esta tanda, para decir cuánto falta COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": [], "esperando": "", "hechos_ids": [], "ultima": None} def _opt(k, defecto=None): v = (CONFIG.get("optimizar") or {}).get(k) return defecto if v is None else v def en_horario(ahora=None): """Si la cola tiene horario, ¿estamos dentro? (True, "") o (False, "hasta las 20:00"). El horario puede pasar por medianoche (20:00 a 07:00).""" h = _opt("horario") or {} if not h.get("activo"): return True, "" try: d1, m1 = map(int, (h.get("desde") or "20:00").split(":")) d2, m2 = map(int, (h.get("hasta") or "07:00").split(":")) except ValueError: return True, "" t = time.localtime(ahora or time.time()) x, a, b = t.tm_hour * 60 + t.tm_min, d1 * 60 + m1, d2 * 60 + m2 dentro = (a <= x < b) if a < b else (x >= a or x < b) return (True, "") if dentro else (False, f"hasta las {h.get('desde') or '20:00'}") APPS_EDICION = {"resolve.exe": "DaVinci Resolve", "adobe premiere pro.exe": "Premiere Pro", "afterfx.exe": "After Effects"} def editando(): """Qué programa de edición está usando la tarjeta ahora mismo, o "". Lo dice nvidia-smi pmon (uso de cómputo, codificador y decodificador por proceso); si no hay nvidia-smi, basta con que el programa esté abierto. Solo cuenta si está activado optimizar.pausar_si_edito.""" if not _opt("pausar_si_edito", True): return "" try: r = subprocess.run(["nvidia-smi", "pmon", "-c", "1", "-s", "u"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA) if r.returncode == 0 and r.stdout: for linea in r.stdout.splitlines(): partes = linea.split(None, 9) if linea.startswith("#") or len(partes) < 10: continue nombre = partes[9].strip().lower() uso = [float(u) for u in partes[3:7] if u.replace(".", "", 1).isdigit()] for exe, bonito in APPS_EDICION.items(): if nombre and exe.startswith(nombre[:10]) and any(u > 5 for u in uso): # pmon recorta el nombre return bonito return "" except (OSError, subprocess.SubprocessError, ValueError): pass try: r = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA) bajo = (r.stdout or "").lower() for exe, bonito in APPS_EDICION.items(): if f'"{exe}"' in bajo: return bonito except (OSError, subprocess.SubprocessError): pass return "" def _esperar_turno(): """Antes de cada clip: si hay horario y estamos fuera, o si un programa de edición está usando la tarjeta, la cola espera (sigue activa) y lo dice. Nunca corta un clip a medias. False si la detuvieron.""" while COLA["activa"] and not COLA["cancelar"]: dentro, hasta = en_horario() motivo = "" if dentro else f"espera {hasta}, por el horario de la cola" if not motivo: app = editando() motivo = f"espera a que {app} deje la tarjeta" if app else "" COLA["esperando"] = motivo if not motivo: return True COLA["mensaje"] = motivo time.sleep(20) COLA["esperando"] = "" return False def notificar(titulo, texto): """Un aviso de Windows (la burbuja de la esquina), con la identidad del programa. Va en un PowerShell aparte y sin esperar: si falla, no pasa nada.""" if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"): return False esc_ = lambda s: str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace("'", "'") ps = ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n" "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n" f"$xml.LoadXml('{esc_(titulo)}{esc_(texto)}')\n" "$t = New-Object Windows.UI.Notifications.ToastNotification $xml\n" "try { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Labstream.OrganiLAB').Show($t) }\n" "catch { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($t) }\n") try: ruta = os.path.join(DATOS, "aviso.ps1") with open(ruta, "w", encoding="utf-8-sig") as fh: fh.write(ps.replace("\\n", "\n")) subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ruta], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=SIN_VENTANA) return True except OSError: return False def _resumen_tanda(): """Al acabar la cola: qué se hizo en esta tanda (clips, pesos, tiempo, nota media, fallos, por carpeta). Queda en la bitácora (op «tanda»), se recuerda para la interfaz y, si está activado, se avisa.""" ids = list(COLA.get("hechos_ids") or []) if not ids: return None con = db() ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta " f"WHERE t.id IN ({','.join('?' * len(ids))})", ids).fetchall()] con.close() bien = [t for t in ts if t["estado"] in ("listo", "verificado", "reemplazado")] notas = [t["nota"] for t in bien if t.get("nota")] carpetas = {} for t in bien: try: rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"])) except ValueError: rel = os.path.dirname(t["ruta"]) c = carpetas.setdefault(rel, {"carpeta": rel, "clips": 0, "bytes_in": 0, "bytes_out": 0}) c["clips"] += 1 c["bytes_in"] += t["bytes_in"] or 0 c["bytes_out"] += t["bytes_out"] or 0 raices = sorted({t["raiz"] for t in ts if t.get("raiz")}) seg = round(time.time() - (COLA["inicio"] or time.time())) r = {"lote": "tanda-" + time.strftime("%Y%m%d-%H%M%S"), "inicio": COLA["inicio"], "seg": seg, "clips": len(bien), "pedidos": len(ts), "bytes_in": sum(t["bytes_in"] or 0 for t in bien), "bytes_out": sum(t["bytes_out"] or 0 for t in bien), "fallos": sum(1 for t in ts if t["estado"] in ("error", "cancelado")), "descartados": sum(1 for t in ts if t["estado"] == "descartado"), "reemplazados": sum(1 for t in ts if t["estado"] == "reemplazado"), "verificados": sum(1 for t in ts if t["estado"] == "verificado"), "nota": round(sum(notas) / len(notas), 1) if notas else None, "nota_min": min(notas) if notas else None, "raiz": raices[0] if len(raices) == 1 else "", "raices": raices, "cliente": ", ".join((raiz_cfg(x) or {}).get("cliente") or os.path.basename(x) for x in raices), "carpetas": sorted(carpetas.values(), key=lambda c: -c["bytes_in"]), "ids": ids} bitacora({"op": "tanda", **r}) COLA["ultima"] = dict(r, ts=time.time()) if _opt("avisar", True) and (r["clips"] or r["fallos"]): que = f"{r['clips']} clip{'s' if r['clips'] != 1 else ''} convertido{'s' if r['clips'] != 1 else ''}" if r["clips"]: que += f" y verificado{'s' if r['clips'] != 1 else ''} en {_tiempo_llano(seg)}. " + ("Ya reemplazados." if r["reemplazados"] == r["clips"] else "Falta reemplazar.") if r["fallos"]: que += f" {r['fallos']} fallaron." notificar("OrganiLAB terminó" if r["clips"] else "OrganiLAB: la tanda no salió", que) return r def _tiempo_llano(seg): seg = int(seg or 0) if seg < 60: return f"{seg} s" if seg < 3600: return f"{seg // 60} min" return f"{seg // 3600} h {round(seg % 3600 / 60)} min" def _ultima_tanda_guardada(): for e in reversed(leer_bitacora(3000)): if e.get("op") == "tanda": return e return {} def tanda_csv(lote): """Los clips de una tanda, en CSV (para el cliente o para mirar en Excel).""" import csv, io e = next((x for x in reversed(leer_bitacora(3000)) if x.get("op") == "tanda" and x.get("lote") == lote), None) if not e: return None ids = e.get("ids") or [] con = db() ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta " f"WHERE t.id IN ({','.join('?' * len(ids))}) ORDER BY t.id", ids).fetchall()] if ids else [] con.close() s = io.StringIO() w = csv.writer(s, delimiter=";", lineterminator="\n") w.writerow(["clip", "carpeta", "estado", "antes (MB)", "después (MB)", "ahorro %", "duración (s)", "nota de calidad", "formato", "salida"]) for t in ts: try: rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"])) except ValueError: rel = os.path.dirname(t["ruta"]) bi, bo = t["bytes_in"] or 0, t["bytes_out"] or 0 w.writerow([os.path.basename(t["ruta"]), rel, EST_CSV.get(t["estado"], t["estado"]), round(bi / 1e6, 1), round(bo / 1e6, 1) if bo else "", round((1 - bo / bi) * 100) if bi and bo else "", round(t["dur"] or 0, 1) if t.get("dur") else "", t.get("nota") or "", formato_de(t), t["salida"] or ""]) return s.getvalue() EST_CSV = {"pendiente": "en espera", "corriendo": "convirtiendo", "listo": "convertido", "verificado": "verificado", "reemplazado": "reemplazado", "error": "falló", "cancelado": "cancelado", "descartado": "no compensa"} def set_trabajo(id_, **kw): con = db() with con: sets = ", ".join(f"{k} = ?" for k in kw) con.execute(f"UPDATE trabajos SET {sets} WHERE id = ?", (*kw.values(), id_)) con.close() def formato_de(t): """el formato de un trabajo, tolerando filas viejas sin columna""" try: f = t["formato"] if "formato" in t.keys() else None except AttributeError: f = t.get("formato") return f or "h265_10bit" def _motor_de(formato, chroma, ancho=0, alto=0): """Con qué se codifica un formato en este equipo, «gpu» o «cpu». Si no se puede: None y el porqué.""" fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) _m = motores() motor = _m["formatos"].get(formato) lado = 4096 if fm["codec"] == "h264" else 8192 if motor == "gpu" and max(ancho or 0, alto or 0) > lado: # la tarjeta no codifica imágenes de más de 4096 píxeles de lado en H.264 (un clip de 4.6K ya se pasa: # «No capable devices found») ni de más de 8192 en H.265 (un BRAW de 12K): esos van por CPU motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None if motor is None: return None, f"la tarjeta gráfica no codifica {fm['codec'].upper()} de más de {lado} píxeles de lado y no hay codificador por CPU" if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"): # la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None if motor is None: return None, "esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0" if not motor: return None, f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)" return motor, "" def _args_video(t, fm, motor, fps): """Los argumentos de ffmpeg que codifican la imagen —los mismos venga el clip de un archivo o del lector de BRAW— y el formato de píxel en que la quiere el codificador.""" chroma = t["chroma"] or "420" cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] # el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir» # salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca) cq = max(14, min(38, round(cq + fm.get("cq_mas", 0)))) maxrate = int(t["kbps"] * 1.8) gop = str(int(round((fps or 30) * 2))) diez = fm["bits"] == 10 if fm["codec"] == "prores": # sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no # salía a la tasa de un HQ (que es justo lo que promete el formato) return ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"], "yuv422p10le" if motor == "gpu": pix = ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p" # «uhq» es el ajuste de más rendimiento de las tarjetas recientes. Medido el 5-oct-2026 con tres clips # del estudio (BRAW de la URSA, BRAW vertical de la Pocket y XAVC-I 4K60 de la Sony), cada uno contra # su referencia sin pérdida y a la misma calidad pedida: pesa entre un 19 y un 55 % menos que «hq» con # la misma nota (VMAF −0,4 a +0,9 sobre 100), y en el cuadro peor sale igual o mejor en dos de los # tres. No deja fijar los cuadros B, y solo se usa donde se midió: H.265 de 10 bits en 4:2:0. uhq = bool(fm["codec"] == "hevc" and diez and chroma != "422" and motores()["gpu"].get("uhq")) args = ["-c:v", fm["gpu"], "-pix_fmt", pix, "-preset", "p6", "-tune", "uhq" if uhq else "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0", "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32"] args += ([] if uhq else ["-bf", "3", "-b_ref_mode", "middle"]) + ["-g", gop] if fm["codec"] == "hevc": args += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"] else: args += ["-profile:v", "high"] return args, pix pix = ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p" args = ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), "-pix_fmt", pix, "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop] if fm["codec"] == "hevc": args += ["-tag:v", "hvc1"] if diez: args += ["-profile:v", "main422-10" if chroma == "422" else "main10"] return args, pix def _args_salida(fm, f): """Lo que no es imagen: el contenedor y el audio.""" # use_metadata_tags: sin ella el MP4 solo guarda las etiquetas que conoce y se pierden las de la cámara # (ISO, balance de blancos, obturación, óptica, rollo y toma): medido el 5-oct-2026, 48 de 188 # conversiones de clips de Blackmagic habían perdido entre 24 y 36 etiquetas cada una. contenedor = ["-movflags", "+faststart+use_metadata_tags" if fm["ext"] == ".mp4" else "use_metadata_tags"] # …pero con ella también viajarían, como si fueran etiquetas, las marcas de formato del original # («major_brand = XAVC» dentro de un archivo que ya no es XAVC): esas tres se quitan contenedor += ["-metadata", "major_brand=", "-metadata", "minor_version=", "-metadata", "compatible_brands="] if fm["codec"] == "prores": audio = ["-c:a", "pcm_s16le"] elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1: audio = ["-c:a", "copy"] else: # con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4 audio = ["-c:a", "aac", "-b:a", "320k"] return contenedor, audio def _salida_ajena(t): """True si en la ruta de salida hay un archivo que no dejó este mismo trabajo: ese no se pisa. El que dejó él (un intento que no pasó la verificación, y que «Reintentar» rehace) sí.""" salida = t["salida"] if not os.path.exists(salida): return False try: return not (t["bytes_out"] and os.path.getsize(salida) == t["bytes_out"]) except OSError: return True def _quitar_parcial(tmp): """Borra el archivo a medio escribir. Si ffmpeg aún lo tiene cogido (lo acaban de cortar), espera.""" for _ in range(40): try: if os.path.exists(tmp): os.remove(tmp) return except OSError: time.sleep(0.25) def correr_ffmpeg(t): con = db() f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() con.close() dur = (f["dur"] if f else 0) or 0 fps = (f["fps"] if f else 30) or 30 formato = formato_de(t) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) motor, pega = _motor_de(formato, t["chroma"] or "420", f["ancho"] if f else 0, f["alto"] if f else 0) if not motor: set_trabajo(t["id"], estado="error", log=pega) return salida = t["salida"] if _salida_ajena(t): set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola") return os.makedirs(os.path.dirname(salida), exist_ok=True) tmp = salida + ".parcial" + fm["ext"] video, _ = _args_video(t, fm, motor, fps) contenedor, audio = _args_salida(fm, f) cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"] cmd += video + ["-map_metadata", "0"] + contenedor + audio cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] set_trabajo(t["id"], log=" ".join(cmd)[:4000]) try: p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) except OSError as ex: set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}") return ultimo = 0 for linea in p.stdout: if COLA["cancelar"]: p.kill() break if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="): try: us = int(linea.split("=")[1]) if linea.startswith("out_time_ms="): us = us # ffmpeg reporta microsegundos en ambas claves pct = min(99.0, us / 1e6 / dur * 100) if dur else 0 if time.time() - ultimo > 1: set_trabajo(t["id"], pct=pct) ultimo = time.time() except ValueError: pass err = p.stderr.read() p.wait() if COLA["cancelar"]: set_trabajo(t["id"], estado="cancelado", pct=0) _quitar_parcial(tmp) return if p.returncode != 0 or not os.path.exists(tmp): set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) _quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video return os.replace(tmp, salida) try: st = os.stat(t["ruta"]) os.utime(salida, (st.st_atime, st.st_mtime)) except OSError: pass set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time()) def correr_braw(t): """Un BRAW. Lo lee el decodificador de Blackmagic en un proceso aparte (braw.py), que le va pasando los cuadros ya revelados a ffmpeg; el codificador y la calidad son los de cualquier otro clip. No hace falta tener Resolve abierto, y si lo está no se le toca nada.""" formato = formato_de(t) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) dec = decodificador_braw() if not dec.get("ok"): set_trabajo(t["id"], estado="error", log=dec.get("motivo") or "no está el decodificador de Blackmagic RAW") return salida = t["salida"] if _salida_ajena(t): set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola") return m = medir_braw(t["ruta"]) if not m.get("ok"): set_trabajo(t["id"], estado="error", log=m.get("error") or "el lector de BRAW no pudo abrir el clip") return motor, pega = _motor_de(formato, t["chroma"] or "420", m["ancho"], m["alto"]) if not motor: set_trabajo(t["id"], estado="error", log=pega) return # la tasa se calcula con el tamaño de verdad del clip, que es el que dice el decodificador kbps = int(kbps_objetivo(m["ancho"], m["alto"], m["fps"], t["perfil"]) * fm.get("bpp_mult", 1.0)) t = dict(t, kbps=kbps) con = db() with con: con.execute("UPDATE archivos SET ancho = ?, alto = ?, fps = ?, dur = ?, perfil = ? WHERE ruta = ?", (m["ancho"], m["alto"], m["fps"], m["dur"], _curva(m), t["ruta"])) con.execute("UPDATE trabajos SET kbps = ? WHERE id = ?", (kbps, t["id"])) f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() con.close() os.makedirs(os.path.dirname(salida), exist_ok=True) tmp = salida + ".parcial" + fm["ext"] video, pix = _args_video(t, fm, motor, m["fps"]) contenedor, audio = _args_salida(fm, f) ficha = os.path.join(DATOS, f"braw_{t['id']}.json") bandera = ficha + ".cancelar" guardar_json(ficha, {"src": t["ruta"], "tmp": tmp, "ffmpeg": ruta_herramienta("ffmpeg"), "pix": pix, "video": video, "contenedor": contenedor, "audio": audio, "color": t["color"] or "rec709", "cancelar": bandera, "sdk": dec.get("carpeta") or ""}) try: os.remove(bandera) except OSError: pass errores = tempfile.TemporaryFile() try: p = subprocess.Popen([sys.executable, os.path.join(BASE, "braw.py"), "convertir", ficha], stdout=subprocess.PIPE, stderr=errores, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) except OSError as ex: errores.close() set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}") return def vigia(): # Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se corta while p.poll() is None: if COLA["cancelar"]: try: open(bandera, "w").close() p.wait(20) except (OSError, subprocess.TimeoutExpired): p.kill() return time.sleep(0.5) threading.Thread(target=vigia, daemon=True).start() fin, dicho = {}, "" for linea in p.stdout: try: ev = json.loads(linea) except ValueError: continue if ev.get("estado"): fin = ev elif "pct" in ev: set_trabajo(t["id"], pct=ev["pct"]) elif ev.get("msg"): dicho = ev["msg"] set_trabajo(t["id"], log=dicho) p.wait() p.stdout.close() try: errores.seek(0) cola_err = errores.read().decode("utf-8", "replace").strip()[-2000:] except (OSError, ValueError): cola_err = "" errores.close() for resto in (ficha, bandera): try: os.remove(resto) except OSError: pass if COLA["cancelar"] or fin.get("estado") == "cancelado": set_trabajo(t["id"], estado="cancelado", pct=0) _quitar_parcial(tmp) return if fin.get("estado") != "listo" or not os.path.exists(tmp): set_trabajo(t["id"], estado="error", log=(fin.get("log") or cola_err or f"el lector de BRAW se cerró sin acabar el clip (código {p.returncode})")[-3000:]) _quitar_parcial(tmp) return os.replace(tmp, salida) try: st = os.stat(t["ruta"]) os.utime(salida, (st.st_atime, st.st_mtime)) except OSError: pass set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time(), log=(f"{dicho} · {fin.get('segundos', 0)} s · " + " ".join(video))[:4000]) def correr_resolve(trabajos): """Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte).""" o = CONFIG["optimizar"] con = db() jobs = [] for t in trabajos: f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() jobs.append({"id": t["id"], "src": t["ruta"], "dst": t["salida"], "w": (f["ancho"] if f else 0) or 0, "h": (f["alto"] if f else 0) or 0, "fps": (f["fps"] if f else 0) or 0, "kbps": t["kbps"], "color": t["color"] or "rec709"}) set_trabajo(t["id"], estado="corriendo", pct=0) con.close() lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json") with open(lote, "w", encoding="utf-8") as fh: json.dump(jobs, fh, ensure_ascii=False) # el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete) cmd = [o.get("python_resolve") or sys.executable, os.path.join(BASE, "resolve_bridge.py"), lote] try: p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) except OSError as ex: for t in trabajos: set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el puente de Resolve: {ex}") return log_general = [] for linea in p.stdout: linea = linea.strip() if not linea: continue if linea.startswith("{"): try: ev = json.loads(linea) except ValueError: log_general.append(linea) continue if "id" in ev: kw = {} if "pct" in ev: kw["pct"] = ev["pct"] if ev.get("estado"): kw["estado"] = ev["estado"] if ev.get("log"): kw["log"] = ev["log"][-3000:] if ev.get("estado") == "listo": kw["fin"] = time.time() try: kw["bytes_out"] = os.path.getsize(ev.get("salida") or "") except OSError: pass set_trabajo(ev["id"], **kw) else: COLA["mensaje"] = ev.get("msg", "") log_general.append(ev.get("msg", "")) else: log_general.append(linea) if COLA["cancelar"]: p.kill() break p.wait() try: os.remove(lote) except OSError: pass con = db() for t in trabajos: r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone() if r and r["estado"] == "corriendo": set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error", log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo") con.close() def _verificar_listos(ids): """al acabar cada conversión se verifica sola (códec, 10 bits, duración): el usuario solo decide si reemplaza""" try: con = db() listos = [r["id"] for r in con.execute(f"SELECT id FROM trabajos WHERE estado = 'listo' AND id IN ({','.join('?' * len(ids))})", ids).fetchall()] if ids else [] con.close() if listos: verificar(listos) except Exception as ex: # noqa COLA["mensaje"] = f"no se pudo verificar: {ex}" def bucle_cola(): try: while COLA["activa"]: con = db() t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY orden DESC, id LIMIT 1").fetchone() lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()] con.close() if t and not _esperar_turno(): break if t: COLA["actual"] = t["id"] COLA["empezo"] = time.time() set_trabajo(t["id"], estado="corriendo", pct=0) try: (correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t)) except Exception as ex: # noqa set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}") _verificar_listos([t["id"]]) _anotar_ritmo(t["id"], time.time() - COLA["empezo"]) if CONFIG["optimizar"].get("auto_reemplazar"): try: # solo reemplaza lo verificado; lo demás lo deja como está reemplazar([t["id"]]) except Exception as ex: # noqa COLA["mensaje"] = f"no se pudo reemplazar: {ex}" COLA["hechos"] += 1 COLA["hechos_ids"].append(t["id"]) elif lote_resolve: COLA["actual"] = lote_resolve[0]["id"] try: correr_resolve(lote_resolve) except Exception as ex: # noqa for r in lote_resolve: set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}") _verificar_listos([r["id"] for r in lote_resolve]) COLA["hechos"] += len(lote_resolve) COLA["hechos_ids"].extend(r["id"] for r in lote_resolve) else: break COLA["actual"] = None if COLA["cancelar"]: COLA["cancelar"] = False break except Exception as ex: # noqa COLA["mensaje"] = f"la cola se detuvo por un error: {ex}" finally: # pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa) COLA["activa"] = False COLA["actual"] = None COLA["hilo"] = None COLA["esperando"] = "" try: _resumen_tanda() except Exception as ex: # noqa COLA["mensaje"] = f"no se pudo apuntar el resumen de la tanda: {ex}" COLA["hechos_ids"] = [] def _anotar_ritmo(id_, segundos): """Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta.""" try: con = db() t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone() f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None con.close() if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0: COLA["ritmo"].append((float(f["dur"]), float(segundos))) except Exception: # noqa pass def eta_cola(trabajos): """Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya hechos en esta tanda; si no hay ninguno, el avance del que va; si tampoco, a tiempo real los BRAW y a 2,5× la tarjeta). Devuelve también el clip que va, con lo que le queda.""" pend = [t for t in trabajos if t["estado"] in ("pendiente", "corriendo")] if not pend: return None con = db() durs = {} for t in pend: f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() durs[t["id"]] = float((f["dur"] if f else 0) or 0) con.close() ritmo = COLA.get("ritmo") or [] clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo) vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else "" falta, actual, ahora = 0.0, None, time.time() for t in pend: d = durs[t["id"]] if t["estado"] == "corriendo": pct = float(t["pct"] or 0) resto = d * max(0.0, 1 - pct / 100) falta += resto llevado = ahora - (COLA.get("empezo") or ahora) if not vel and pct >= 3 and llevado > 5: vel, medido = (d * pct / 100) / llevado, "clip" actual = {"id": t["id"], "dur": d, "resto": resto, "llevado": llevado} else: falta += d if not vel: vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5 seg = falta / vel if falta else 0.0 if actual: actual["resto_seg"] = actual["resto"] / vel return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel, 2), "medido": medido, "termina": ahora + seg if seg else 0, "actual": actual, "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)} def _ruta_original(t): """Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales.""" if os.path.isfile(t["ruta"]): return t["ruta"] try: otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"])) except ValueError: return "" return otra if os.path.isfile(otra) else "" def cuadro_de(id_, cual, segundo, ancho=1600): """Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError.""" con = db() t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None con.close() if not t: raise ValueError("no existe ese trabajo") segundo = max(0.0, float(segundo or 0)) carpeta = os.path.join(DATOS, "cuadros") os.makedirs(carpeta, exist_ok=True) salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg") if os.path.isfile(salida) and os.path.getsize(salida) > 0: return salida if cual == "original": src = _ruta_original(dict(t)) if not src: raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)") else: src = t["salida"] if not os.path.isfile(src): raise ValueError("el convertido ya no está donde se hizo") if cual == "original" and t["motor"] == "braw": dec = decodificador_braw() if not dec.get("ok"): raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW") ficha = os.path.join(DATOS, f"cuadro_{id_}.json") guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"), "color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho}) try: ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")] finally: try: os.remove(ficha) except OSError: pass if not ev or ev[-1].get("estado") != "listo": raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro") else: cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src, "-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida] r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA) if r.returncode != 0 or not os.path.isfile(salida): raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro") return salida def calidad_de(id_, segundos=10, ancho=1920): """Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede.""" con = db() t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None con.close() if not t: raise ValueError("no existe ese trabajo") src = _ruta_original(dict(t)) if not src: raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)") if not os.path.isfile(t["salida"]): raise ValueError("el convertido ya no está donde se hizo") fps = float((f["fps"] if f else 0) or 0) or 25.0 dur = float((f["dur"] if f else 0) or 0) total = int(dur * fps) if dur else 0 n = max(24, min(int(segundos * fps), 300, total or 300)) desde = max(0, (total - n) // 2) if total else 0 hilos = max(2, min(16, (os.cpu_count() or 8) - 2)) carpeta = os.path.join(DATOS, "cuadros") os.makedirs(carpeta, exist_ok=True) log = f"calidad_{id_}.json" t0 = time.time() if t["motor"] == "braw": dec = decodificador_braw() if not dec.get("ok"): raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW") ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json") guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"), "color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos}) try: ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")] finally: try: os.remove(ficha) except OSError: pass if not ev or ev[-1].get("estado") != "listo": raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir") nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"] else: fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];" f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];" f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}") ss = f"{desde / fps:.6f}" cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"], "-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"] r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA) ruta_log = os.path.join(carpeta, log) if r.returncode != 0 or not os.path.isfile(ruta_log): raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)") with open(ruta_log, "r", encoding="utf-8") as fh: j = json.load(fh) v = (j.get("pooled_metrics") or {}).get("vmaf") or {} nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or []) try: os.remove(os.path.join(carpeta, log)) except OSError: pass set_trabajo(id_, nota=nota, nota_min=minimo) return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)} def recuperar_huerfanas(): """Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10). Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero.""" if COLA["hilo"] and COLA["hilo"].is_alive(): return 0 con = db() with con: filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall() for f in filas: con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?", (f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"])) con.close() return len(filas) def braws_al_lector(): """Las conversiones de BRAW que quedaron en espera o fallidas por la vía de Resolve pasan al lector directo. De cada clip se queda una sola, la última; las que apuntan a un clip que ya no está en esa ruta —la carpeta se reordenó después— se quitan, porque reintentarlas no podía salir bien.""" if not decodificador_braw().get("ok"): return 0 con = db() n = 0 with con: vistos = set() for t in con.execute("SELECT id, ruta, raiz, formato FROM trabajos WHERE motor = 'resolve' " "AND estado IN ('pendiente','error','cancelado') ORDER BY id DESC").fetchall(): if not os.path.isdir(t["raiz"] or ""): continue # unidad desconectada: no se sabe si el clip sigue ahí clave = (os.path.normcase(t["ruta"]), t["formato"] or "h265_10bit") if clave in vistos or not os.path.isfile(t["ruta"]): con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],)) else: vistos.add(clave) con.execute("UPDATE trabajos SET motor = 'braw' WHERE id = ?", (t["id"],)) con.execute("UPDATE trabajos SET log = ? WHERE id = ? AND estado = 'error'", ("no salió por la vía de Resolve; ahora se lee sin Resolve: pulsa «Reintentar los que fallaron»", t["id"])) n += 1 con.close() return n class ErrorCola(ValueError): """Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto.""" def __init__(self, mensaje, datos): super().__init__(mensaje) self.datos = datos _COLA_LOCK = threading.Lock() def iniciar_cola(): # el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que # alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal. with _COLA_LOCK: if COLA["hilo"] and COLA["hilo"].is_alive(): return False recuperar_huerfanas() try: braws_al_lector() except Exception: # noqa pass COLA["activa"] = True COLA["cancelar"] = False COLA["hechos"] = 0 COLA["inicio"] = time.time() COLA["ritmo"] = [] COLA["hechos_ids"] = [] COLA["esperando"] = "" COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) COLA["hilo"].start() return True def _no_compensa(t, sale=None): """Por qué no merece la pena dar por buena esta conversión, o «» si sí. Solo cuenta para los formatos que existen para pesar menos (H.265). El 5-oct-2026 había 8 conversiones verificadas esperando en la cola de V:\\Galeano con el botón «Reemplazar 8 verificados»: seis eran reconversiones de un clip ya convertido y pesaban entre el 85 y el 99 % de él. Reemplazar habría cambiado cada uno por una copia de su copia.""" if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc": return "" entra, sale = t["bytes_in"] or 0, sale or t["bytes_out"] or 0 if not entra or not sale: return "" repetida = R.es_conversion(os.path.basename(t["ruta"])) if not repetida and sale <= 0.8 * entra: return "" return (f"no compensa: pesa el {round(sale / entra * 100)} % del clip del que salió" + (", que ya era una conversión de este programa" if repetida else "") + ". Se queda el que estaba.") DESCARTADOS = "descartados" def _apartar_convertido(t, lote, movidos): """Lo que salió de una conversión que no se usa va a _Para borrar\\descartados, con su ruta: no se borra, y «Deshacer» lo devuelve. Solo si sigue siendo el archivo que dejó este trabajo. Devuelve dónde quedó.""" salida, raiz = t["salida"] or "", t["raiz"] or "" if not os.path.isfile(salida) or not raiz or not dentro_de(raiz, salida): return "" if t["bytes_out"] and os.path.getsize(salida) != t["bytes_out"]: return "" antes = len(movidos) mover(salida, os.path.join(raiz, R.PAPELERA, DESCARTADOS, os.path.relpath(salida, raiz)), lote, movidos) return movidos[-1][1] if len(movidos) > antes else "" def _cerrar_lote(lote, raiz, movidos, errores): if movidos or errores: actualizar_inventario(movidos) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos), "movimientos": len(movidos), "errores": errores}) def _descartar(t, pega, lote, movidos, errores, **kw): """Marca el trabajo como «no compensa» y aparta lo que salió de él.""" nota = "" try: if _apartar_convertido(t, lote, movidos): nota = f" Lo convertido quedó en «{R.PAPELERA}\\{DESCARTADOS}»." except OSError as ex: errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}") nota = " Lo convertido sigue junto al original (no se pudo apartar: ¿está abierto?)." set_trabajo(t["id"], estado="descartado", log=pega + nota, **kw) return pega + nota def apartar_descartados(ids): """«Limpiar terminados»: lo que salió de las conversiones que no compensaron y sigue junto a su original se aparta antes de quitarlas de la cola. Devuelve también las que no se pudieron apartar: esas se quedan.""" con = db() filas = [dict(r) for r in con.execute(f"SELECT * FROM trabajos WHERE estado = 'descartado' AND id IN ({','.join('?' * len(ids))})", list(ids)).fetchall()] if ids else [] con.close() lote, movidos, errores, raiz, quedan = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "", [] for t in filas: try: if _apartar_convertido(t, lote, movidos): raiz = raiz or t["raiz"] except OSError as ex: errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}") quedan.append(t["id"]) _cerrar_lote(lote, raiz or (filas[0]["raiz"] if filas else ""), movidos, errores) return {"movidos": len(movidos), "errores": errores, "quedan": quedan} def repasar_verificados(): """Las conversiones verificadas con una versión anterior que no compensan dejan de ofrecerse para reemplazar. Al arrancar solo cambia su estado: lo que salió de ellas se aparta cuando alguien limpia la cola o vuelve a convertir el clip, no sin que nadie lo pida.""" con = db() n = 0 for t in con.execute("SELECT * FROM trabajos WHERE estado = 'verificado'").fetchall(): pega = _no_compensa(t) if pega: with con: con.execute("UPDATE trabajos SET estado = 'descartado', log = ? WHERE id = ?", (pega, t["id"])) n += 1 con.close() return n def verificar(ids): con = db() res = [] lote, movidos, errores, raiz_lote = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "" for id_ in ids: t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() if not t or not os.path.exists(t["salida"]): res.append({"id": id_, "ok": False, "motivo": "no existe la salida"}) continue f = con.execute("SELECT dur, ancho, alto FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() info = ffprobe(t["salida"]) if not info: res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"}) set_trabajo(id_, estado="error", log="verificación: ffprobe no pudo leer la salida") continue dur_src = (f["dur"] if f else 0) or 0 tol = max(0.5, dur_src * 0.01) problemas = [] if dur_src and abs(info["dur"] - dur_src) > tol: problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s") fm = FORMATOS.get((t["formato"] if "formato" in t.keys() else None) or "h265_10bit", FORMATOS["h265_10bit"]) if info["codec"] != fm["codec"]: problemas.append(f"códec {info['codec']} (se esperaba {fm['codec']})") if fm["bits"] == 10 and "10" not in info["pix_fmt"]: problemas.append(f"no es 10 bits ({info['pix_fmt']})") if t["motor"] == "braw" and f and f["ancho"] and (info["ancho"], info["alto"]) != (f["ancho"], f["alto"]): problemas.append(f"imagen de {info['ancho']}×{info['alto']} (el clip es de {f['ancho']}×{f['alto']})") if os.path.getsize(t["salida"]) < 100000: problemas.append("archivo demasiado pequeño") if problemas: set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas)) res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)}) else: sale = os.path.getsize(t["salida"]) pega = _no_compensa(t, sale) if pega: # está bien hecha, pero no merece ocupar el sitio del original raiz_lote = raiz_lote or t["raiz"] res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t, bytes_out=sale), pega, lote, movidos, errores, bytes_out=sale)}) else: set_trabajo(id_, estado="verificado", bytes_out=sale) res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"}) con.close() _cerrar_lote(lote, raiz_lote, movidos, errores) return res def reemplazar(ids): """El original se va a _Para borrar/originales (misma raíz). Nunca se borra.""" con = db() res = [] lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia movimientos, raiz_lote = 0, "" # lo que no compensa va en su propio lote: deshacer el reemplazo no debe devolver también eso lote_desc, movidos_desc, errores_desc, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "" for id_ in ids: t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() if not t or t["estado"] != "verificado": res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"}) continue if ((t["formato"] if "formato" in t.keys() else "") or "").startswith("prores"): res.append({"id": id_, "ok": False, "motivo": "ProRes es un formato de edición: queda junto al original, no lo reemplaza"}) continue pega = _no_compensa(t) if pega: raiz_desc = raiz_desc or t["raiz"] res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t), pega, lote_desc, movidos_desc, errores_desc)}) continue if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024: res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"}) set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar") continue raiz = t["raiz"] raiz_lote = raiz_lote or raiz rel = os.path.relpath(t["ruta"], raiz) destino = os.path.join(raiz, R.PAPELERA, "originales", rel) try: os.makedirs(os.path.dirname(destino), exist_ok=True) destino = nombre_libre(destino) os.rename(t["ruta"], destino) movimientos += 1 bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_}) # el sidecar .xml de Sony y el .sidecar de un BRAW (los ajustes que le dejó Resolve) se van con el original base, ext = os.path.splitext(t["ruta"]) for extra in (base + "M01.xml", base + "M01.XML", base + ".sidecar"): if os.path.exists(extra): d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra)) os.rename(extra, d2) movimientos += 1 bitacora({"lote": lote, "op": "mover", "de": extra, "a": d2, "trabajo": id_}) set_trabajo(id_, estado="reemplazado") with con: con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = 1 WHERE ruta = ?", (destino, os.path.dirname(destino), t["ruta"])) res.append({"id": id_, "ok": True, "motivo": f"original en {os.path.relpath(destino, raiz)}"}) except OSError as ex: res.append({"id": id_, "ok": False, "motivo": str(ex)}) con.close() if movimientos: bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos, "errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"] and not r["motivo"].startswith("no compensa")]}) _cerrar_lote(lote_desc, raiz_desc, movidos_desc, errores_desc) return res # ========================================================================== # Stock de Envato en proyectos viejos, papelera y respaldo # ========================================================================== def tipo_medio(rol, ext): e = (ext or "").lower() if e in R.EXT["video"] or e in R.EXT["video_raw"]: return "Video" if e in R.EXT["audio"]: return "Audio" if e in R.EXT["imagen"] or e in R.EXT["foto_raw"]: return "Imagenes" if e in R.EXT["diseno"]: return "Diseño" if e in R.EXT["proyecto"]: return "Proyectos" if e in R.EXT["documento"]: return "Documentos" if e in R.EXT["fuente"] or e in R.EXT["lut"]: return "Fuentes" return "Otros" def limite_meses(meses): hoy = datetime.date.today() m = hoy.year * 12 + hoy.month - 1 - int(meses) return m // 12, m % 12 + 1 # (año, mes) tope: lo anterior es "viejo" def es_viejo(f, tope): """un archivo es viejo si el mes de su pieza (o su fecha) es anterior al tope""" if f["anio_ruta"] and f["mes_ruta"]: return (f["anio_ruta"], f["mes_ruta"]) < tope return (int(f["fecha"][:4]), int(f["fecha"][5:7])) < tope def stock_viejo(raiz, meses, tipos): con = db() tope = limite_meses(meses) filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0", (raiz,)).fetchall() con.close() grupos = {} for f in filas: t = tipo_medio(f["rol"], f["ext"]) # la licencia, el «leeme» y el .zip que vienen dentro de un paquete de Envato se van con él: si # no, la lista de acciones seguía contando stock viejo que ningún botón llegaba a apartar if t not in tipos and not (t in ("Otros", "Documentos") and tipos): continue if not es_viejo(f, tope): continue rel = os.path.relpath(f["dir"], raiz) g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "meses": Counter()}) g["meses"][f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]] += 1 g["n"] += 1 g["bytes"] += f["bytes"] g["rutas"].append(f["ruta"]) if len(g["ejemplos"]) < 3: g["ejemplos"].append(f["nombre"]) for g in grupos.values(): g["mes"] = g.pop("meses").most_common(1)[0][0] out = sorted(grupos.values(), key=lambda g: -g["bytes"]) return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"} def stock_a_papelera(raiz, rutas): lote = "stock-" + time.strftime("%Y%m%d-%H%M%S") movidos, errores = [], [] for r in rutas: try: if not os.path.exists(r): continue if not dentro_de(raiz, r): errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca") continue rel = os.path.relpath(r, raiz) mover(r, os.path.join(raiz, R.PAPELERA, "stock", rel), lote, movidos) quitar_vacias(os.path.dirname(r), raiz) except Exception as ex: # noqa errores.append(f"{r}: {ex}") actualizar_inventario(movidos) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(rutas), "movimientos": len(movidos), "errores": errores}) return {"lote": lote, "movimientos": len(movidos), "errores": errores} def papelera(raiz): """Qué hay en _Para borrar (y en la _Papelera vieja si existe). Solo lee: borrar es cosa de una persona.""" out = [] for carpeta in R.PAPELERAS: base = os.path.join(raiz, carpeta) if not os.path.isdir(base): continue for nombre in sorted(os.listdir(base)): p = os.path.join(base, nombre) if not os.path.isdir(p): continue n, b = 0, 0 for d, _, fs in os.walk(p): for f in fs: try: b += os.path.getsize(os.path.join(d, f)) n += 1 except OSError: pass out.append({"carpeta": carpeta, "nombre": nombre, "ruta": p, "n": n, "bytes": b}) return {"subcarpetas": out, "bytes": sum(x["bytes"] for x in out), "carpeta": os.path.join(raiz, R.PAPELERA)} def abrir_carpeta(ruta): """Abre una carpeta en el Explorador (solo dentro de alguna raíz configurada).""" ruta = os.path.normpath(ruta) permitido = any(ruta.lower().startswith(os.path.normpath(r["ruta"]).lower()) for r in CONFIG.get("raices", [])) if not permitido or not os.path.isdir(ruta): return {"ok": False, "motivo": "esa carpeta no está dentro de una raíz"} os.startfile(ruta) return {"ok": True} # ========================================================================== # Duplicados: se juntan en una subcarpeta «Duplicados» junto al que se queda. Nunca se borran. # ========================================================================== _EXT_VIDEO_DUP = {".mp4", ".mov", ".mxf", ".braw", ".avi", ".mkv", ".m4v", ".mts", ".r3d"} def _fuera_de_dup(ruta): partes = {R.norm(p) for p in os.path.normpath(ruta).split(os.sep)} return not (partes & (R.PAPELERAS_NORM | {R.norm(R.DUPLICADOS)})) def _mejor(rutas): """Cuál se queda: el que ya esté en una carpeta de grabación, luego el de ruta más corta.""" def clave(r): en_grab = 0 if "grabacion" in R.norm(r.replace(os.sep, " ")) else 1 return (en_grab, len(r.split(os.sep)), len(r), r.lower()) return sorted(rutas, key=clave)[0] def _medir(rutas): """Duración y tamaño de imagen de unos clips. Lo que ya leyó ffprobe está en el inventario; lo demás se lee ahora (solo la cabecera) y se apunta para la próxima vez.""" import concurrent.futures as cf out, faltan = {}, [] con = db() for r in rutas: f = con.execute("SELECT analizado, dur, ancho, alto, kbps FROM archivos WHERE ruta = ?", (r,)).fetchone() if f and f["analizado"] == 1 and f["dur"]: out[r] = {"dur": f["dur"], "ancho": f["ancho"] or 0, "alto": f["alto"] or 0, "kbps": f["kbps"] or 0} else: faltan.append(r) if faltan and herramientas()["ffprobe"]["ok"]: with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool: leidos = list(zip(faltan, pool.map(ffprobe, faltan))) with con: for r, info in leidos: if not info or not info.get("dur"): con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ? AND analizado = 0", (r,)) continue out[r] = {"dur": info["dur"], "ancho": info["ancho"], "alto": info["alto"], "kbps": info["kbps"]} con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, " "acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", (info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], info.get("pistas_audio", 1), r)) con.close() return out _ROLES_TOMA = ("camara_raw", "camara_video", "celular_video") def _por_comparar(filas): """Clips de cámara que se llaman igual y no pesan lo mismo: candidatos a ser el mismo clip con otra calidad. Solo material de cámara: dos exportados con el mismo nombre son dos versiones de una pieza (la vertical y la horizontal, la de antes y la de después de un cambio), y cuál es «la buena» no lo decide el peso.""" por_nombre = defaultdict(list) for f in filas: if (f["ext"] or "").lower() in _EXT_VIDEO_DUP and f["rol"] in _ROLES_TOMA: por_nombre[f["nombre"].lower()].append(f) return [fs for fs in por_nombre.values() if len(fs) > 1 and len({f["bytes"] for f in fs}) > 1] def _mismas_tomas(candidatos, tope=400): """El mismo clip guardado dos veces con distinta calidad. Medido el 4-oct-2026 sobre el material real: 39 clips de un rodaje estaban en dos carpetas con el mismo nombre, unos de 220 MB y otros de 76 MB, y el plan no podía juntarlos en su mes porque dos archivos que se llaman igual no caben en una carpeta. Que se llamen igual no basta: dos tarjetas de la misma cámara traen las dos un C0001.MP4. Tienen que durar lo mismo. De los que duran lo mismo se queda el de más imagen y, a igualdad, el que más pesa; los demás van a «Duplicados» a su lado, para mirarlos a mano. No se borra nada.""" candidatos = candidatos[:tope] medidas = _medir([f["ruta"] for fs in candidatos for f in fs]) grupos = [] for fs in candidatos: def forma(m): # 1.78 en horizontal, 0.56 en vertical return round(m["ancho"] / m["alto"], 1) if m["alto"] else 0 medidos = sorted(((f, medidas[f["ruta"]]) for f in fs if f["ruta"] in medidas), key=lambda x: (forma(x[1]), x[1]["dur"])) racimos, actual = [], [] for f, m in medidos: if actual and (forma(m) != forma(actual[0][1]) or abs(m["dur"] - actual[0][1]["dur"]) > max(0.5, 0.01 * actual[0][1]["dur"])): racimos.append(actual) actual = [] actual.append((f, m)) if actual: racimos.append(actual) for rc in racimos: if len(rc) < 2 or len({f["bytes"] for f, _ in rc}) < 2: continue rc.sort(key=lambda x: (-(x[1]["ancho"] * x[1]["alto"]), -(x[0]["bytes"] or 0), len(x[0]["ruta"]))) (fq, mq), resto = rc[0], rc[1:] def dice(f, m): return f"{m['ancho']}x{m['alto']} · {_gb(f['bytes'])}" grupos.append({"tipo": "calidad", "nombre": fq["nombre"], "queda": fq["ruta"], "mueven": [f["ruta"] for f, _ in resto], "bytes": sum(f["bytes"] or 0 for f, _ in resto), "destino": os.path.join(os.path.dirname(fq["ruta"]), R.DUPLICADOS), "detalle": "se queda la de " + dice(fq, mq) + "; se aparta la de " + ", ".join(dice(f, m) for f, m in resto)}) return grupos _DUP_CACHE = {} def duplicados(raiz, minimo=5_000_000, calidades=False): """Los repetidos de una carpeta, guardados hasta que algo se mueva en ella (o pasen 10 minutos): Inicio, «Qué hay» y el diagnóstico los piden cada vez, y en Vía Sumapaz (76.559 archivos) costaban 22 s cada vez.""" k = (os.path.normcase(os.path.normpath(raiz)), minimo, bool(calidades)) marca = (sum(v for kk, v in PLAN_GEN.items() if kk.lower() == k[0]), _ts_escaneo(raiz)) c = _DUP_CACHE.get(k) if c and c[0] == marca and time.time() - c[1] < 600: return c[2] r = _duplicados(raiz, minimo, calidades) _DUP_CACHE[k] = (marca, time.time(), r) return r def _ts_escaneo(raiz): con = db() f = con.execute("SELECT ts FROM escaneos WHERE raiz = ?", (os.path.normpath(raiz),)).fetchone() con.close() return f["ts"] if f else 0 def _solo_existentes(filas): """Quita los que ya no están en el disco, pero solo pregunta por los que pueden formar grupo (mismo nombre y peso, el mismo clip en dos formatos, o con dos pesos), y en paralelo. Medido el 14-oct-2026 en Vía Sumapaz: comprobar uno a uno los 30.963 archivos de más de 5 MB por la red tardaba 20 de 22 segundos.""" por_nb = Counter((f["nombre"].lower(), f["bytes"]) for f in filas) exts, pesos = defaultdict(set), defaultdict(set) for f in filas: e = (f["ext"] or "").lower() if e in _EXT_VIDEO_DUP: exts[os.path.splitext(f["nombre"])[0].lower()].add(e) pesos[f["nombre"].lower()].add(f["bytes"]) def puede(f): if por_nb[(f["nombre"].lower(), f["bytes"])] > 1: return True return (f["ext"] or "").lower() in _EXT_VIDEO_DUP and ( len(exts[os.path.splitext(f["nombre"])[0].lower()]) > 1 or len(pesos[f["nombre"].lower()]) > 1) dudosos = [f["ruta"] for f in filas if puede(f)] if not dudosos: return filas from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=24) as ex: existe = dict(zip(dudosos, ex.map(os.path.exists, dudosos))) return [f for f in filas if existe.get(f["ruta"], True)] def _duplicados(raiz, minimo=5_000_000, calidades=False): """Grupos de archivos repetidos. Tres clases: identico = mismo nombre y mismo tamaño en sitios distintos. version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede cambiar el color o el peso, así que se deja para revisar a mano. calidad = mismo nombre y misma duración, distinto peso: el mismo clip con otra calidad. Hay que leer los clips para saberlo, así que solo se calcula si se pide (`calidades`); si no, se dice cuántos hay por comparar.""" con = db() filas = [dict(r) for r in con.execute( "SELECT ruta, dir, nombre, ext, bytes, rol FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0", (os.path.normpath(raiz), minimo)).fetchall()] con.close() filas = _solo_existentes([f for f in filas if _fuera_de_dup(f["ruta"])]) grupos, usados = [], set() por_nombre = {} for f in filas: por_nombre.setdefault((f["nombre"].lower(), f["bytes"]), []).append(f) for (nombre, b), fs in por_nombre.items(): if len(fs) < 2: continue rutas = [f["ruta"] for f in fs] queda = _mejor(rutas) mueven = [r for r in rutas if r != queda] usados.update(rutas) grupos.append({"tipo": "identico", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven, "bytes": b * len(mueven), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)}) por_raiz = {} for f in filas: if f["ruta"] in usados or (f["ext"] or "").lower() not in _EXT_VIDEO_DUP: continue stem = os.path.splitext(f["nombre"])[0].lower() if len(stem) < 6: continue por_raiz.setdefault(stem, []).append(f) for stem, fs in por_raiz.items(): if len({(f["ext"] or "").lower() for f in fs}) < 2: continue fs = sorted(fs, key=lambda f: -(f["bytes"] or 0)) queda = fs[0]["ruta"] # el más pesado suele ser el original mueven = [f["ruta"] for f in fs[1:]] grupos.append({"tipo": "version", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven, "bytes": sum(f["bytes"] or 0 for f in fs[1:]), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)}) for g in grupos: if g["tipo"] == "version": usados.add(g["queda"]) usados.update(g["mueven"]) candidatos = _por_comparar([f for f in filas if f["ruta"] not in usados]) if calidades and candidatos: grupos += _mismas_tomas(candidatos) grupos.sort(key=lambda g: -g["bytes"]) return {"grupos": grupos, "n": sum(len(g["mueven"]) for g in grupos), "bytes": sum(g["bytes"] for g in grupos), "identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version"), "calidades": sum(1 for g in grupos if g["tipo"] == "calidad"), # clips que se llaman igual y pesan distinto, sin comparar todavía "por_comparar": 0 if calidades else sum(len(fs) - 1 for fs in candidatos)} def huella_rapida(ruta, trozo=1 << 20): """sha256 del primer MB, uno del centro y el último (y del tamaño): suficiente para distinguir dos archivos que solo coinciden en nombre y peso, sin leer gigas por la red.""" h = hashlib.sha256() tam = os.path.getsize(ruta) h.update(str(tam).encode()) with open(ruta, "rb") as fh: h.update(fh.read(trozo)) if tam > trozo * 3: fh.seek(tam // 2) h.update(fh.read(trozo)) if tam > trozo * 2: fh.seek(tam - trozo) h.update(fh.read(trozo)) return h.hexdigest() def apartar_duplicados(raiz, grupos=None): """Mueve cada copia a /Duplicados. Reversible por lote; nada se borra. Las copias «idénticas» se comprueban por contenido antes de moverlas: si difieren, se dejan y se avisa.""" raiz = os.path.normpath(raiz) if grupos is None: grupos = duplicados(raiz, calidades=True)["grupos"] lote = "dup-" + time.strftime("%Y%m%d-%H%M%S") movidos, errores, distintos = [], [], 0 for g in grupos: ref = None if g.get("tipo") == "identico": try: ref = huella_rapida(g["queda"]) except OSError as ex: errores.append(f"{g['queda']}: no se pudo leer para comparar ({ex}); el grupo se deja") continue for r in g["mueven"]: try: if not os.path.exists(r): continue if ref is not None and huella_rapida(r) != ref: distintos += 1 errores.append(f"{os.path.relpath(r, raiz)}: mismo nombre y peso pero contenido distinto; se deja donde está") continue mover(r, os.path.join(g["destino"], os.path.basename(r)), lote, movidos) quitar_vacias(os.path.dirname(r), raiz) except Exception as ex: # noqa errores.append(f"{r}: {ex}") actualizar_inventario(movidos) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(grupos), "movimientos": len(movidos), "errores": errores}) return {"lote": lote, "grupos": len(grupos), "movimientos": len(movidos), "errores": errores, "distintos": distintos} RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0} def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True, meses=0): con = db() rc = raiz_cfg(raiz) cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz" base = os.path.join(destino, re.sub(r'[<>:"/\\|?*]', "_", cliente)) conv = {} for t in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('verificado','reemplazado')").fetchall(): if os.path.exists(t["salida"]): conv[os.path.normpath(t["ruta"]).lower()] = t["salida"] filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0", (raiz,)).fetchall() con.close() marcas_p = [os.sep + p + os.sep for p in R.PAPELERAS] marca_a = os.sep + "_Archivo" + os.sep items, vistos = [], set() omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0 tope = limite_meses(meses) if int(meses or 0) > 0 else None # solo lo anterior a este (año, mes) omit_reciente_n, omit_reciente_b = 0, 0 def agregar(ruta, rol, ext, bytes_, mtime): k = os.path.normpath(ruta).lower() if k in vistos: return vistos.add(k) rel_dir = os.path.relpath(os.path.dirname(ruta), raiz) rel_dir = "" if rel_dir == "." else rel_dir tipo = tipo_medio(rol, ext) items.append({"origen": ruta, "rel_dir": rel_dir, "nombre": os.path.basename(ruta), "bytes": bytes_, "tipo": tipo, "mtime": mtime, "rel": os.path.relpath(ruta, raiz)}) for f in filas: if any(m in f["ruta"] for m in marcas_p) or marca_a in f["ruta"]: continue if sin_stock and f["es_stock"]: omit_stock_b += f["bytes"] omit_stock_n += 1 continue if tope and not es_viejo(f, tope): omit_reciente_n += 1 omit_reciente_b += f["bytes"] continue k = os.path.normpath(f["ruta"]).lower() if k in conv: sal = conv[k] try: st = os.stat(sal) except OSError: continue convertidos += 1 vistos.add(k) agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime) continue if f["rol"] == "camara_raw" and not incluir_raw: omit_raw.append({"rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"]}) continue agregar(f["ruta"], f["rol"], f["ext"], f["bytes"], f["mtime"]) for k, sal in conv.items(): if os.path.normpath(sal).lower().startswith(os.path.normpath(raiz).lower() + os.sep) and not any(m in sal for m in marcas_p): try: st = os.stat(sal) agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime) except OSError: pass # la subcarpeta por tipo solo se añade donde una carpeta mezcla tipos de medio # ("Audio/" ya es solo audio: no hace falta "Audio/Audio/") tipos_por_dir = defaultdict(set) for it in items: tipos_por_dir[it["rel_dir"]].add(it["tipo"]) for it in items: mezcla = por_tipo and len(tipos_por_dir[it["rel_dir"]]) > 1 partes = [base, it["rel_dir"], it["tipo"] if mezcla else "", it["nombre"]] it["destino"] = os.path.normpath(os.path.join(*[p for p in partes if p])) por_tipo_res = defaultdict(lambda: {"n": 0, "bytes": 0}) for it in items: por_tipo_res[it["tipo"]]["n"] += 1 por_tipo_res[it["tipo"]]["bytes"] += it["bytes"] omit_raw.sort(key=lambda x: -x["bytes"]) return { "cliente": cliente, "base": base, "destino_base": destino, "n": len(items), "bytes": sum(i["bytes"] for i in items), "por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])], "convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]}, "stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b}, "reciente_omitido": {"n": omit_reciente_n, "bytes": omit_reciente_b, "meses": int(meses or 0)}, "muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]], "items": items, } def copiar_respaldo(raiz, plan): RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[], destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0) indice = [] # un respaldo por lotes (solo lo de más de 6 meses, luego el resto) reescribía el índice con lo de la # última tanda y dejaba fuera todo lo anterior: se arranca de lo que ya había. previo = {} try: import csv as _csv with open(os.path.join(plan["base"], "INDICE.csv"), newline="", encoding="utf-8-sig") as fh: filas = list(_csv.reader(fh, delimiter=";")) for fila in filas[1:]: if len(fila) >= 5: previo[fila[0]] = tuple(fila) except (OSError, ValueError): pass for it in plan["items"]: if RESPALDO["cancelar"]: RESPALDO["estado"] = "cancelado" break d = it["destino"] RESPALDO["actual"] = it["rel"] try: os.makedirs(os.path.dirname(d), exist_ok=True) ya = False if os.path.exists(d): st = os.stat(d) # contra el archivo de ORIGEN tal como está ahora, no contra lo que decía el último escaneo: # si se editó después de escanear, la copia vieja «coincidía» y no se volvía a copiar nunca try: so = os.stat(it["origen"]) tam_o, mt_o = so.st_size, so.st_mtime except OSError: tam_o, mt_o = it["bytes"], it["mtime"] ya = st.st_size == tam_o and abs(st.st_mtime - mt_o) < 2 if ya: RESPALDO["saltados"] += 1 else: tmp = d + ".parcial" shutil.copy2(it["origen"], tmp) if os.path.getsize(tmp) != os.path.getsize(it["origen"]): raise OSError("tamaño distinto tras copiar") os.replace(tmp, d) RESPALDO["copiados"] += 1 indice.append((os.path.relpath(d, plan["base"]), it["rel"], it["tipo"], it["bytes"], datetime.datetime.fromtimestamp(it["mtime"]).strftime("%Y-%m-%d"))) except Exception as ex: # noqa RESPALDO["errores"].append(f"{it['rel']}: {ex}") RESPALDO["hechos"] += 1 RESPALDO["bytes"] += it["bytes"] try: os.makedirs(plan["base"], exist_ok=True) import csv # lo de esta tanda manda sobre lo que ya había; lo anterior se conserva for fila in indice: previo[fila[0]] = fila todo = sorted(previo.values(), key=lambda x: str(x[0]).lower()) with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh: w = csv.writer(fh, delimiter=";") w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"]) w.writerows(todo) resumen = defaultdict(lambda: defaultdict(lambda: [0, 0])) for rel_d, rel_o, tipo, b, _ in todo: carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else "" resumen[carpeta][tipo][0] += 1 resumen[carpeta][tipo][1] += int(b or 0) with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh: fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n" f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n") fh.write("Los clips de cámara están en MP4 H.265 10 bits (Main10). Los BRAW/originales pesados no se copian salvo que se pida.\n\n") fh.write("| Carpeta | Tipo | Archivos | GB |\n|---|---|---:|---:|\n") for carpeta in sorted(resumen): for tipo, (n, b) in sorted(resumen[carpeta].items()): fh.write(f"| {carpeta or '(raíz)'} | {tipo} | {n} | {b/1e9:.2f} |\n") with open(os.path.join(plan["base"], "manifest.json"), "w", encoding="utf-8") as fh: json.dump({"cliente": plan["cliente"], "origen": raiz, "fecha": time.time(), "archivos": len(indice), "bytes": sum(i[3] for i in indice), "errores": RESPALDO["errores"]}, fh, ensure_ascii=False, indent=1) except Exception as ex: # noqa RESPALDO["errores"].append(f"índice: {ex}") if RESPALDO["estado"] == "corriendo": RESPALDO["estado"] = "listo" bitacora({"lote": "respaldo", "op": "respaldo", "raiz": raiz, "a": plan["base"], "archivos": RESPALDO["hechos"], "copiados": RESPALDO["copiados"], "saltados": RESPALDO["saltados"], "bytes": RESPALDO["bytes"], "errores": len(RESPALDO["errores"])}) # ========================================================================== # Servidor HTTP # ========================================================================== PLANES = {} # raiz -> plan PLAN_GEN = defaultdict(int) # raiz -> cuántas veces se ha olvidado su plan: un recálculo viejo no pisa uno nuevo REPLAN = {} # raiz -> el hilo que está volviendo a pensar el plan después de mover algo PLANES_RESPALDO = {} def olvidar_plan(raiz, recalcular=True): """Después de mover algo, el plan calculado ya no describe la carpeta: se olvida y, salvo que se diga lo contrario, se vuelve a pensar en segundo plano. Así Inicio dice lo que de verdad queda (o que no queda nada) en vez de ofrecer otra vez lo que se acaba de hacer, o de pedir que se calcule. Medido el 14-oct-2026 en el laboratorio: tras «Hacer las 5 de una vez», Inicio seguía diciendo «Ordenar por año y mes · 5 cambios · Ver y aplicar» y «Plan listo: 7 de 9 movimientos marcados» con todo ya movido, porque el plan solo se olvidaba al aplicar desde la pestaña «Qué va a pasar»; y tras «Deshacer», al revés: «Lo seguro ya está hecho» con la carpeta otra vez en desorden.""" k = _clave(raiz) PLANES.pop(k, None) PLAN_GEN[k] += 1 if not recalcular or not raiz_cfg(k): return gen = PLAN_GEN[k] def _pensar(): try: plan = Plan(raiz_cfg(k)).correr() if PLAN_GEN[k] == gen: # nadie movió nada más mientras tanto PLANES[k] = plan except Exception as ex: # noqa print(f"no se pudo volver a pensar el plan de {k}: {ex}", file=sys.stderr) finally: if REPLAN.get(k) is threading.current_thread(): REPLAN.pop(k, None) t = threading.Thread(target=_pensar, daemon=True) REPLAN[k] = t t.start() def plan_en_marcha(raiz): """Si el plan de esta carpeta se está volviendo a pensar, espera a que acabe y lo devuelve; si no, None. Lo usan «Calcular» y la pasada automática para no recorrer la carpeta dos veces seguidas.""" k = _clave(raiz) t = REPLAN.get(k) if t is None or t is threading.current_thread(): return None t.join() return PLANES.get(k) def _clave(ruta): return os.path.normpath(ruta) _ELIGIENDO = threading.Lock() VENTANA = None # la ventana del programa (ventana.Ventana) mientras está abierta ULTIMA_PETICION = [0.0] # cuándo preguntó algo la interfaz por última vez def elegir_carpeta(titulo="Elige la carpeta del cliente"): """Abre el selector de carpetas de Windows y devuelve la ruta o None. Con la ventana del programa abierta es el suyo, modal sobre ella; si la interfaz va en el navegador, uno aparte en PowerShell.""" if not _ELIGIENDO.acquire(blocking=False): raise ValueError("ya hay un selector de carpetas abierto") try: v = VENTANA if v is not None: ruta = v.elegir_carpeta(titulo) return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None ps = ("Add-Type -AssemblyName System.Windows.Forms; " "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " "$d.Description = '" + titulo.replace("'", "''") + "'; $d.ShowNewFolderButton = $false; " "$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; " "if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }") flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", ps], capture_output=True, timeout=600, creationflags=flags) ruta = out.stdout.decode("utf-8", "replace").strip() or out.stdout.decode("mbcs", "replace").strip() return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None finally: _ELIGIENDO.release() def _anidadas(raices): """(externa, interna) si alguna carpeta de la lista está dentro de otra. El inventario va por raíz y `archivos.ruta` es única: al escanear la externa, las filas de la interna cambian de dueño y la interna se queda vacía sin decir nada (su respaldo copiaría cero archivos y terminaría «bien»).""" rutas = [os.path.normpath(r["ruta"]) for r in raices if r.get("ruta")] for a in rutas: for b in rutas: if a is not b and a.lower() != b.lower() and dentro_de(a, b): return a, b return None def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False): """Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear). Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están convirtiendo ahora mismo nunca se descartan desde aquí: hay que detener la cola primero.""" k = _clave(ruta) r = raiz_cfg(ruta) if not r: raise ValueError("esa carpeta no está registrada") if (PROGRESO.get(k) or {}).get("estado") == "corriendo" or (ANALISIS.get(k) or {}).get("estado") == "corriendo": raise ValueError("la carpeta se está escaneando o analizando; espera a que termine") if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"] and (APLICANDO.get("raiz") or "").lower() == k.lower(): raise ValueError("se está aplicando el plan de esa carpeta; espera a que termine") recuperar_huerfanas() con = db() activos = [dict(x) for x in con.execute("SELECT id, ruta, estado, pct FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo') ORDER BY id", (k.lower(),)).fetchall()] if activos: en_curso = [x for x in activos if x["estado"] == "corriendo"] if en_curso or not descartar_cola: con.close() n = len(activos) por_estado = {"corriendo": len(en_curso), "pendiente": n - len(en_curso)} que = ", ".join(f"{v} {'convirtiéndose ahora' if e == 'corriendo' else 'en espera'}" for e, v in por_estado.items() if v) # El texto dice QUÉ hay y QUÉ hacer; los datos (archivos, estados) van aparte para que la # interfaz los enseñe y ofrezca descartar. Antes solo decía «detén la cola o límpiala». if en_curso: msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): detén la cola en Convertir a 10 bits y vuelve a intentarlo" else: msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): termínalas con Reanudar en Convertir a 10 bits, o descártalas al desconectar" raise ErrorCola(msg, {"cola": {"n": n, "por_estado": por_estado, "en_curso": bool(en_curso), "archivos": [os.path.basename(x["ruta"]) for x in activos[:6]]}}) with con: con.execute("DELETE FROM trabajos WHERE lower(raiz) = ? AND estado = 'pendiente'", (k.lower(),)) bitacora({"op": "cola_descartada", "raiz": k, "n": len(activos), "archivos": [x["ruta"] for x in activos]}) CONFIG["raices"] = [x for x in CONFIG["raices"] if _clave(x["ruta"]).lower() != k.lower()] guardar_json(CONFIG_PATH, CONFIG) PLANES.pop(k, None); PROGRESO.pop(k, None); ANALISIS.pop(k, None) if borrar_datos: for t in ("archivos", "carpetas", "escaneos"): con.execute(f"DELETE FROM {t} WHERE lower(raiz) = ?", (k.lower(),)) con.commit() con.close() bitacora({"op": "raiz_quitada", "raiz": k, "datos_borrados": bool(borrar_datos)}) return {"ok": True, "raices": len(CONFIG["raices"])} def estado_general(): """Todo lo que la interfaz sondea cada pocos segundos, en una sola respuesta.""" con = db() esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()} cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()} actual = None if COLA["actual"]: t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if t: actual = dict(t) con.close() # «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0) esc_ci = {k.lower(): v for k, v in esc.items()} raices = [] for r in CONFIG["raices"]: k = _clave(r["ruta"]) raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)}) return {"config": CONFIG, "raices": raices, "herramientas": herramientas(), "version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO, "cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n, "hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]}, "aplicando": APLICANDO, "asistente": asistente.ESTADO, "respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])}, "traer": {k: v for k, v in TRAER.items() if k != "errores"} | {"errores_n": len(TRAER.get("errores") or [])}, "automatico": dict(AUTO), "cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS} def _plural(n, uno, varios): return f"{n:,}".replace(",", ".") + " " + (uno if n == 1 else varios) # Qué enseñar en lugar de cada variable de una plantilla, y cómo se llama en llano. EJEMPLO_VAR = { "anio": ("2026", "el año del material"), "mes": ("09- Septiembre", "el mes del material"), "dia": ("15- Viernes", "el día en que se grabó"), "nombre": ("Nombre de la pieza", "el nombre de la pieza o de la carpeta"), "fuente": ("URSA_BRAW", "la cámara de la que salió"), "app": ("Premiere", "el programa que abre el proyecto"), } # Estas dos casi siempre están vacías (el archivo cae directo en la carpeta de arriba), así que no son # un nivel más del árbol: son una nota en el nivel que las contiene. VAR_NOTA = { "sub": "y si el material venía dentro de una subcarpeta, se conserva", "rel": "se copia la ruta tal cual venía, para poder devolverla a su sitio", } def mapa_estructura(tipo, raiz=None): """El árbol de carpetas que produce una plantilla, con qué cae en cada una y cuánto hay ya. Es la respuesta a «¿dónde va a acabar mi material?» antes de mover nada. Se arma con las plantillas de verdad, así que lo que enseña es exactamente lo que la app va a hacer, y si se edita una plantilla el árbol cambia con ella.""" tipo = tipo if tipo in PLANTILLAS else "contenido" pl = PLANTILLAS[tipo] # qué hay hoy en la carpeta, repartido por cubeta según el rol de cada archivo hay, apps, cliente = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0]), "" if raiz: raiz = _clave(raiz) r = raiz_cfg(raiz) cliente = r["cliente"] if r else "" con = db() for f in con.execute("SELECT rol, ext, COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos " "WHERE raiz = ? GROUP BY rol, ext", (raiz,)).fetchall(): cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR") hay[cub][0] += f["n"] hay[cub][1] += f["b"] if cub == "PROYECTOS": a = R.app_proyecto(f["ext"]) or "Sin programa reconocido" apps[a][0] += f["n"] apps[a][1] += f["b"] con.close() # nombres de archivo de verdad, para poder probar el mapa con lo que hay en el disco ejemplos = defaultdict(list) if raiz: con = db() for f in con.execute("SELECT rol, nombre, MAX(bytes) b FROM archivos WHERE raiz = ? AND es_basura = 0 " "AND es_proxy = 0 GROUP BY rol, ext ORDER BY b DESC", (raiz,)).fetchall(): cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR") if len(ejemplos[cub]) < 4: ejemplos[cub].append(f["nombre"]) con.close() # cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta roles = defaultdict(list) for rol, cub in R.ROL_A_CUBETA.items(): roles[cub].append(R.ROL_QUE.get(rol, rol)) raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente", "hijos": [], "cubetas": [], "variable": "", "nota": "", "crudo": [], "color": "", "n": 0, "bytes": 0} def bajar(nodo, nombre, variable, crudo): for h in nodo["hijos"]: if h["nombre"].lower() == nombre.lower(): return h h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "", "crudo": list(crudo), "color": "", "n": 0, "bytes": 0} nodo["hijos"].append(h) return h cubetas = [] for cub, titulo in R.CUBETAS.items(): plantilla = pl.get(cub, "") n, b = hay.get(cub, [0, 0]) cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla, "asignable": cub not in ("CONTENEDOR", "IGNORAR", "BANCO"), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]), "ejemplos": ejemplos.get(cub, []), "defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""), "guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b, "apps": (sorted(({"nombre": k, "n": v[0], "bytes": v[1]} for k, v in apps.items()), key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])}) if not plantilla: continue nodo, crudo = raiz_nodo, [] for seg in plantilla.split("/"): if seg == "{app}": crudo.append(seg) nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1], crudo) nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia " "carpeta, se queda con el nombre de esa carpeta") nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?» continue nota = next((v for k, v in VAR_NOTA.items() if seg == "{" + k + "}"), "") if nota: nodo["nota"] = nota # no baja un nivel: se queda en la carpeta de arriba continue texto, hint = seg, "" for k, (ej, que) in EJEMPLO_VAR.items(): if "{" + k + "}" in texto: texto = texto.replace("{" + k + "}", ej) hint = que if not hint else hint + " y " + que crudo.append(seg) nodo = bajar(nodo, texto, hint, crudo) nodo["cubetas"].append(cub) nodo["n"] += n nodo["bytes"] += b def ordenar(nodo): # una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower())) cols = {R.COLOR_CUBETA.get(c, "") for c in nodo["cubetas"]} for h in nodo["hijos"]: ordenar(h) nodo["n"] += h["n"] nodo["bytes"] += h["bytes"] cols.add(h["color"]) cols.discard("") # una carpeta toma el color de lo que guarda; si mezcla tipos de distinto color, se queda neutra nodo["color"] = cols.pop() if len(cols) == 1 else "" ordenar(raiz_nodo) return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente, "arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()}, "colores": R.COLOR_CUBETA, "pintado": _esta_pintada(raiz) if raiz else False} def donde_iria(nombre, tipo="contenido"): """Qué es un archivo y en qué carpeta lo dejaría la app, solo por su nombre. Es el mapa al revés: en vez de «esta carpeta guarda esto», «esto va a esta carpeta». Vale para cualquier nombre, exista o no, porque lo decide el mismo clasificador que usa el escaneo.""" nombre = (nombre or "").strip().strip('"').replace("/", os.sep).split(os.sep)[-1] if not nombre: return {"error": "escribe el nombre de un archivo"} ext = os.path.splitext(nombre)[1].lower() firma = R.firma_archivo(nombre) rol = R.rol_archivo(nombre, ext, firma) cub = R.ROL_A_CUBETA.get(rol, "REVISAR") fecha, origen = R.fecha_de_nombre(nombre, time.time()) kw = {"anio": int(fecha[:4]), "mes": int(fecha[5:7])} if cub == "GRABACION": kw["sub"] = R.FUENTE_CARPETA.get(firma, "AUDIO" if rol == "audio_grabado" else "OTROS") if cub == "CELULAR" and tipo == "contenido": cub, kw["sub"] = "GRABACION", "CELULAR" if cub in ("GRABACION", "CELULAR"): kw["dia"] = R.nombre_dia(fecha) if cub == "PROYECTOS": kw["app"] = R.app_proyecto(ext) or ("Diseño" if rol == "diseno" else "Edicion") destino = "_Revisar" if cub == "REVISAR" else destino_plantilla(tipo, cub, **kw) dia = kw.get("dia", "") return {"nombre": nombre, "dia": dia, "rol": rol, "que": R.ROL_QUE.get(rol, rol), "cubeta": cub, "titulo": R.CUBETAS.get(cub, cub), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]), "destino": destino, "crudo": [x for x in (PLANTILLAS.get(tipo, {}).get(cub, "") or "").split("/") if x], "fecha": fecha, "por_fecha": origen, "camara": firma or "", "app": R.app_proyecto(ext) or ""} def renombrar_nodo(tipo, ruta, nombre, raiz=None, en_disco=False): """Le cambia el nombre a una carpeta del mapa: en las plantillas y, si se pide, en el disco. `ruta` son los segmentos CRUDOS del mapa («{anio}», «{mes}», «00_Grabacion»), así que el cambio alcanza a todas las plantillas que pasan por esa carpeta, no solo a una.""" tipo = tipo if tipo in PLANTILLAS else "contenido" nombre = (nombre or "").strip().strip(". ") if not nombre: return {"error": "el nombre no puede quedar vacío"} if any(c in nombre for c in '\\/:*?"<>|'): return {"error": 'un nombre de carpeta no puede llevar \\ / : * ? " < > |'} if "{" in nombre or "}" in nombre: return {"error": "aquí va un nombre, no una variable: las variables se editan en la plantilla"} if not ruta or "{" in ruta[-1]: return {"error": "esa carpeta la nombra el material (el año, el mes, la pieza): no se puede cambiar a mano"} viejo = ruta[-1] if R.norm(viejo) == R.norm(nombre): return {"error": "ya se llama así"} cambiadas = [] for cub, pl in list(PLANTILLAS[tipo].items()): segs = pl.split("/") if len(segs) >= len(ruta) and [x.lower() for x in segs[:len(ruta)]] == [x.lower() for x in ruta]: segs[len(ruta) - 1] = nombre PLANTILLAS[tipo][cub] = "/".join(segs) cambiadas.append(cub) if not cambiadas: return {"error": "esa carpeta ya no está en ninguna plantilla; vuelve a abrir el mapa"} guardar_json(PLANTILLAS_PATH, PLANTILLAS) PLANES.clear() movidas, errores = 0, [] if en_disco and raiz: movidas, errores = _renombrar_en_disco(_clave(raiz), ruta, nombre) return {"ok": True, "cubetas": cambiadas, "viejo": viejo, "nuevo": nombre, "movidas": movidas, "errores": errores} def _casan(segmentos, patron): """True si los segmentos de una carpeta real encajan con un patron de plantilla. «{anio}» y «{mes}» solo valen por algo que de verdad parezca un ano o un mes: sin eso, «{anio}» encajaba con cualquier carpeta del primer nivel («Efectos» salia como si fuera un ano).""" if len(segmentos) != len(patron): return False for g, p in zip(segmentos, patron): if p == "{anio}": if not R.detectar_anio(g): return False elif p == "{mes}": if not R.detectar_mes(g)[0]: return False elif p.startswith("{"): continue elif R.norm(p) != R.norm(g): return False return True def carpetas_como(raiz, ruta): """Las carpetas que hay HOY en el disco en el sitio que describe `ruta` (segmentos crudos).""" raiz = _clave(raiz) con = db() filas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()] con.close() out = [] for c in filas: rel = os.path.relpath(c, raiz) if rel == "." or not os.path.isdir(c): continue if _casan(rel.split(os.sep), ruta): out.append(c) return sorted(out, key=lambda x: x.lower()) def _renombrar_en_disco(raiz, ruta, nombre): """Renombra en el disco todas las carpetas que estaban en ese sitio del mapa. Queda en la bitácora como un movimiento normal, así que se deshace desde Historial.""" lote = "renombrar-" + time.strftime("%Y%m%d-%H%M%S") movidos, errores = [], [] for c in carpetas_como(raiz, ruta): destino = os.path.join(os.path.dirname(c), nombre) if os.path.normcase(c) == os.path.normcase(destino): continue try: mover(c, destino, lote, movidos) except Exception as ex: # noqa errores.append(f"{os.path.relpath(c, raiz)}: {ex}") if movidos: actualizar_inventario(movidos) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos), "movimientos": len(movidos), "errores": errores}) return len(movidos), errores # ========================================================================== # Color de carpeta en el Explorador de Windows # ========================================================================== # # Windows no tiene «color de carpeta», pero sí icono por carpeta: un desktop.ini que apunta a un .ico # y la carpeta marcada como solo lectura para que el Explorador lo lea. La app genera un icono por # color dentro de la propia carpeta del cliente y lo referencia con ruta relativa, así que los colores # viajan con el disco y se ven igual desde cualquier PC que lo tenga montado. F_READONLY, F_HIDDEN, F_SYSTEM = 0x1, 0x2, 0x4 FCSM_ICONFILE, FCS_READ, FCS_FORCEWRITE = 0x10, 0x1, 0x2 SHCNE_UPDATEDIR, SHCNF_PATHW = 0x1000, 0x0005 class _FCS(ctypes.Structure): """SHFOLDERCUSTOMSETTINGS: lo que hay en el desktop.ini de una carpeta, visto por el Explorador.""" _fields_ = [("dwSize", ctypes.c_uint32), ("dwMask", ctypes.c_uint32), ("pvid", ctypes.c_void_p), ("pszWebViewTemplate", ctypes.c_wchar_p), ("cchWebViewTemplate", ctypes.c_uint32), ("pszWebViewTemplateVersion", ctypes.c_wchar_p), ("pszInfoTip", ctypes.c_wchar_p), ("cchInfoTip", ctypes.c_uint32), ("pclsid", ctypes.c_void_p), ("dwFlags", ctypes.c_uint32), ("pszIconFile", ctypes.c_wchar_p), ("cchIconFile", ctypes.c_uint32), ("iIconIndex", ctypes.c_int), ("pszLogo", ctypes.c_wchar_p), ("cchLogo", ctypes.c_uint32)] def _fcs(): f = ctypes.windll.shell32.SHGetSetFolderCustomSettings f.argtypes = [ctypes.POINTER(_FCS), ctypes.c_wchar_p, ctypes.c_uint32] f.restype = ctypes.c_long return f def _poner_icono(carpeta, icono): """Le pone a la carpeta ese .ico (la ruta puede ser relativa, y así los colores viajan con el disco).""" w = _FCS() w.dwSize = ctypes.sizeof(_FCS) w.dwMask = FCSM_ICONFILE w.pszIconFile = icono w.iIconIndex = 0 try: if _fcs()(ctypes.byref(w), carpeta, FCS_FORCEWRITE) != 0: return False ctypes.windll.shell32.SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, ctypes.c_wchar_p(carpeta), None) return True except Exception: # noqa return False def _icono_de(carpeta): """El .ico que tiene puesto la carpeta, o "" si no tiene ninguno.""" buf = ctypes.create_unicode_buffer(600) r = _FCS() r.dwSize = ctypes.sizeof(_FCS) r.dwMask = FCSM_ICONFILE r.pszIconFile = ctypes.cast(buf, ctypes.c_wchar_p) r.cchIconFile = 600 try: return buf.value if _fcs()(ctypes.byref(r), carpeta, FCS_READ) == 0 else "" except Exception: # noqa return "" def _ini_nuestro(ruta): """Un desktop.ini es de la app si apunta a un icono de su propia carpeta.""" try: with open(ruta, "rb") as fh: return R.CARPETA_APP.encode() in fh.read(1024).lower() except OSError: return False def _atributos(ruta, poner=0, quitar=0): """Pone o quita atributos y devuelve si de verdad quedaron puestos. En un NAS por SMB, SetFileAttributesW dice que sí y el atributo no queda: medido el 16-sep-2026, Z: acepta SYSTEM pero no READONLY, y W: no acepta ninguno de los dos. Por eso se relee.""" try: a = ctypes.windll.kernel32.GetFileAttributesW(str(ruta)) if a == -1: return False ctypes.windll.kernel32.SetFileAttributesW(str(ruta), (a | poner) & ~quitar) if not poner: return True b = ctypes.windll.kernel32.GetFileAttributesW(str(ruta)) return b != -1 and bool(b & poner) except Exception: # noqa return False def _marcar_carpeta(carpeta): """El Explorador solo lee desktop.ini si la carpeta está marcada como solo lectura O como sistema. Vale cualquiera de las dos: se intentan las dos y basta con que una quede.""" return (_atributos(carpeta, poner=F_READONLY) or _atributos(carpeta, poner=F_SYSTEM)) def _png_carpeta(n, rgb): """Una carpeta del color pedido, n×n RGBA, con la pestaña un punto más oscura.""" u = n / 32.0 base = rgb tab = tuple(int(c * 0.78) for c in rgb) def rect(px, py, x0, y0, x1, y1, r): if not (x0 <= px <= x1 and y0 <= py <= y1): return False cx = min(max(px, x0 + r), x1 - r) cy = min(max(py, y0 + r), y1 - r) return (px - cx) ** 2 + (py - cy) ** 2 <= r * r filas = [] for y in range(n): fila = bytearray() for x in range(n): dentro_b = dentro_t = 0 for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)): px, py = (x + sx) / u, (y + sy) / u if rect(px, py, 2, 8.5, 30, 27, 2.2): dentro_b += 1 elif rect(px, py, 2, 5, 14.5, 11, 1.4): dentro_t += 1 if dentro_b: a, col = dentro_b / 4, base elif dentro_t: a, col = dentro_t / 4, tab else: fila += bytes((0, 0, 0, 0)) continue fila += bytes((col[0], col[1], col[2], int(255 * a))) filas.append(bytes(fila)) raw = b"".join(b"\x00" + f for f in filas) def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) def _ico_carpeta(hexcol): rgb = tuple(int(hexcol.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)) tam = (256, 48, 32, 16) imgs = [_png_carpeta(t, rgb) for t in tam] cab = struct.pack("|]') _SERIE = [0] def _subcarpetas_pieza(): """Las subcarpetas con las que nace una pieza. Se cambian en config.json («pieza» › «subcarpetas»).""" subs = (CONFIG.get("pieza") or {}).get("subcarpetas") if not isinstance(subs, list): subs = CONFIG_DEFECTO["pieza"]["subcarpetas"] return [s.strip() for s in subs if isinstance(s, str) and s.strip() and not _RX_NOMBRE_MALO.search(s)][:20] def _sitio_pieza(raiz, tipo, titulo, anio, mes): """(carpeta donde va, nombre que le toca, la que ya existe con ese título o None).""" pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get("PIEZA") or "{anio}/{mes}/{nombre}" destino = os.path.join(raiz, destino_plantilla(tipo, "PIEZA", anio=anio, mes=mes, nombre=titulo, sub=titulo)) padre = os.path.dirname(destino) if "{mes}" not in pl: # campañas y proyectos: sin número return padre, os.path.basename(destino), destino if os.path.isdir(destino) else None numeros, ya = [], None try: with os.scandir(padre) as it: for e in it: if not e.is_dir(follow_symlinks=False): continue p = R.detectar_pieza(e.name) if p: numeros.append(p[0]) if R.norm(p[1] if p else e.name) == R.norm(titulo): ya = e.path except OSError: pass # el mes todavía no existe: será la primera return padre, R.nombre_pieza(max(numeros, default=0) + 1, titulo), ya def nueva_pieza(raiz, titulo, anio=None, mes=None, crear=True): """Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas. Las piezas se venían creando a mano, y de ahí salían dos «01» en el mismo mes, «03 - Título» junto a «03- Título» y subcarpetas con el nombre a medio escribir («portasda», «Imagnes»). Con `crear=False` solo dice cómo quedaría.""" raiz = _clave(raiz) r = raiz_cfg(raiz) if not r: return {"error": "carpeta no registrada"} titulo = limpiar_nombre(re.sub(r"\s+", " ", titulo or "")).strip(". ") p = R.detectar_pieza(titulo) if p: titulo = limpiar_nombre(p[1]) # «03- Botox»: el número lo pone la app if not titulo: return {"error": "ponle un título a la pieza"} if _RX_NOMBRE_MALO.search(titulo): return {"error": 'el título no puede llevar \\ / : * ? " < > |'} if len(titulo) > 80: return {"error": "el título es demasiado largo: 80 letras como mucho"} hoy = datetime.date.today() try: anio, mes = int(anio or hoy.year), int(mes or hoy.month) except (TypeError, ValueError): return {"error": "el año o el mes no valen"} if not (2000 <= anio <= 2100 and 1 <= mes <= 12): return {"error": "el año o el mes no valen"} padre, nombre, ya = _sitio_pieza(raiz, r.get("tipo", "contenido"), titulo, anio, mes) destino = os.path.join(padre, nombre) subs = _subcarpetas_pieza() out = {"raiz": raiz, "ruta": destino, "rel": os.path.relpath(destino, raiz), "nombre": nombre, "subcarpetas": subs, "existe": os.path.relpath(ya, raiz) if ya else ""} if not crear: return out if ya: return {**out, "error": f"esa pieza ya existe: «{os.path.relpath(ya, raiz)}»"} if not os.path.isdir(raiz): return {**out, "error": "la carpeta del cliente no está conectada"} _SERIE[0] += 1 lote = "pieza-" + time.strftime("%Y%m%d-%H%M%S") + f"-{_SERIE[0]}" # dos seguidas no comparten lote faltan, d = [], destino # las de encima que aún no existen (el año, el mes) while not os.path.isdir(d) and dentro_de(raiz, d) and d.lower() != raiz.lower(): faltan.append(d) d = os.path.dirname(d) creadas = [] try: for d in list(reversed(faltan)) + [os.path.join(destino, s) for s in subs]: os.mkdir(d) bitacora({"lote": lote, "op": "mkdir", "de": d}) creadas.append(d) except OSError as ex: bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 0, "movimientos": len(creadas), "errores": [str(ex)]}) return {**out, "error": f"no se pudo crear: {ex.strerror or ex}"} con = db() with con: for d in creadas: con.execute("INSERT OR IGNORE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d))) con.close() bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 1, "movimientos": len(creadas), "errores": []}) olvidar_plan(raiz) return {**out, "ok": True, "lote": lote, "creadas": len(creadas)} # -------------------------------------------------------------------------- # La bandeja de entrada # -------------------------------------------------------------------------- # # El desorden nace el día que se vuelca la tarjeta: se copia donde se puede, con el nombre que sale. La # bandeja es una carpeta «_Entrada» dentro de la del cliente; lo que se deje ahí —a mano, o trayéndolo # desde la tarjeta con la copia comprobada— el plan lo abre siempre y lo reparte por rodaje y cámara. TRAER = {"estado": "", "raiz": "", "origen": "", "destino": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "copiados": 0, "saltados": 0, "errores": [], "cancelar": False, "inicio": 0, "fin": 0, "ordenado": None} _NO_TARJETA = {"system volume information", "$recycle.bin", ".trashes", ".spotlight-v100", ".fseventsd", ".temporaryitems", "lost.dir", ".trash-1000"} def _ruta_entrada(raiz): return os.path.join(_clave(raiz), R.ENTRADA) def entrada(raiz): """Qué hay esperando en la bandeja. Mira el disco y no el inventario, que puede ser de ayer.""" raiz = _clave(raiz) base = _ruta_entrada(raiz) n = b = 0 ejemplos = [] if os.path.isdir(base): for d, _dirs, fs in os.walk(base): for f in fs: if R.es_basura_archivo(f) or f.endswith(".copiando"): continue try: b += os.path.getsize(os.path.join(d, f)) except OSError: continue n += 1 if len(ejemplos) < 5: ejemplos.append(os.path.relpath(os.path.join(d, f), base)) return {"raiz": raiz, "carpeta": base, "existe": os.path.isdir(base), "n": n, "bytes": b, "ejemplos": ejemplos, "copia": dict(TRAER) if TRAER.get("raiz") == raiz and TRAER.get("estado") else None} def abrir_entrada(raiz): """Crea la bandeja si no existe y la abre en el Explorador, para soltar material dentro.""" raiz = _clave(raiz) if not raiz_cfg(raiz) or not os.path.isdir(raiz): return {"error": "la carpeta del cliente no está conectada"} base = _ruta_entrada(raiz) os.makedirs(base, exist_ok=True) try: os.startfile(base) except OSError: pass return {"ok": True, "carpeta": base} def _podar(base, lote): """Quita de la bandeja las carpetas que se quedaron sin un solo archivo: el esqueleto de una tarjeta ya repartida (PRIVATE, M4ROOT, CLIP…). Queda apuntado en el mismo lote, así que «Deshacer» las vuelve a crear antes de devolver los archivos.""" quitadas = [] for d, _dirs, _fs in os.walk(base, topdown=False): if os.path.normcase(d) == os.path.normcase(base): continue try: if not os.listdir(d): os.rmdir(d) bitacora({"lote": lote, "op": "rmdir", "de": d}) quitadas.append(d) except OSError: pass if quitadas: con = db() with con: for d in quitadas: con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) con.close() def ordenar_entrada(raiz): """Lee la carpeta otra vez y aplica, del plan, lo que sale de la bandeja y va marcado.""" raiz = _clave(raiz) r = raiz_cfg(raiz) if not r: return {"error": "carpeta no registrada"} if asistente.ESTADO["activo"] or (APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]): return {"error": "ya hay algo en marcha en esta carpeta"} escanear(raiz) prog = PROGRESO.get(raiz) or {} if prog.get("estado") != "listo": return {"error": "no se pudo leer la carpeta: " + (prog.get("error") or "¿está conectada?")} plan = Plan(r).correr() base = _ruta_entrada(raiz) filas, dudosos = [], 0 for f in plan["filas"]: if f.get("archivos"): mios = [a for a in f["archivos"] if dentro_de(base, a)] if not mios: continue if len(mios) < len(f["archivos"]): # la fila de «basura repartida por la carpeta»: solo lo de aquí f = dict(f, archivos=mios) if f.get("destinos_archivo"): f["destinos_archivo"] = {a: d for a, d in f["destinos_archivo"].items() if a in set(mios)} elif not dentro_de(base, f["origen"]): continue if f["marcado"]: filas.append(f) else: dudosos += 1 res = aplicar(raiz, filas) if filas else {"lote": None, "movimientos": 0, "errores": []} PLANES.pop(raiz, None) try: os.makedirs(base, exist_ok=True) # la bandeja se queda, vacía, para la próxima vez except OSError: pass if res.get("lote"): _podar(base, res["lote"]) return {"ok": True, "lote": res.get("lote"), "movimientos": res.get("movimientos", 0), "errores": res.get("errores", []), "quedan": entrada(raiz)["n"], "dudosos": dudosos, "avisos": [a for a in plan["avisos"] if R.ENTRADA in a]} def _archivos_de(origen): """Los archivos de una tarjeta o de una carpeta cualquiera, sin lo que es del sistema.""" for d, dirs, fs in os.walk(origen): dirs[:] = sorted(x for x in dirs if x.lower() not in _NO_TARJETA) for f in sorted(fs): if f.lower() in ("thumbs.db", "desktop.ini", ".ds_store") or f.startswith("._"): continue yield os.path.join(d, f) def plan_traer(raiz, origen, nombre=""): """Qué se copiaría de una tarjeta a la bandeja, y si cabe. No copia nada.""" raiz = _clave(raiz) if not raiz_cfg(raiz): return {"error": "carpeta no registrada"} if not os.path.isdir(raiz): return {"error": "la carpeta del cliente no está conectada"} origen = os.path.normpath(origen or "") if not origen or not os.path.isdir(origen): return {"error": "elige la tarjeta o la carpeta de la que traer el material"} if dentro_de(raiz, origen) or dentro_de(origen, raiz): return {"error": "eso ya está dentro de la carpeta del cliente: no hace falta traerlo"} if _raiz_de(origen): return {"error": "esa carpeta es de otro cliente registrado; desde aquí solo se trae material de fuera"} nombre = limpiar_nombre(re.sub(r"\s+", " ", nombre or "")).strip(". ") if _RX_NOMBRE_MALO.search(nombre): return {"error": 'el nombre no puede llevar \\ / : * ? " < > |'} # sin nombre, la carpeta se llama «Tarjeta »: un nombre que el plan no toma por el de una sesión carpeta = nombre or ("Tarjeta " + time.strftime("%Y-%m-%d %H%M")) n = b = 0 for a in _archivos_de(origen): try: b += os.path.getsize(a) n += 1 except OSError: pass if not n: return {"error": "ahí no hay ningún archivo"} try: libre = shutil.disk_usage(raiz).free except OSError: libre = None out = {"raiz": raiz, "origen": origen, "destino": os.path.join(_ruta_entrada(raiz), carpeta), "carpeta": carpeta, "n": n, "bytes": b, "libre": libre} if libre is not None and b > libre: out["error"] = f"no cabe: son {_gb(b)} y en el disco del cliente quedan {_gb(libre)}" return out def _copiar_comprobando(origen, destino, estado): """Copia un archivo y vuelve a leer lo copiado para comprobar que quedó igual. True si copió, False si ya estaba. Del origen solo se lee: ni se mueve ni se borra.""" st = os.stat(origen) try: sd = os.stat(destino) if sd.st_size == st.st_size and abs(sd.st_mtime - st.st_mtime) < 2: estado["bytes"] += st.st_size return False # ya se trajo antes: una copia a medias se retoma raise FileExistsError("en la bandeja ya hay otro archivo distinto con ese nombre") except FileNotFoundError: pass os.makedirs(os.path.dirname(destino), exist_ok=True) tmp = destino + ".copiando" antes = estado["bytes"] try: h1 = hashlib.blake2b(digest_size=16) with open(origen, "rb") as a, open(tmp, "wb") as b: while True: if estado.get("cancelar"): raise InterruptedError("cancelado") trozo = a.read(4 << 20) if not trozo: break h1.update(trozo) b.write(trozo) estado["bytes"] += len(trozo) b.flush() os.fsync(b.fileno()) h2 = hashlib.blake2b(digest_size=16) with open(tmp, "rb") as b: while True: if estado.get("cancelar"): raise InterruptedError("cancelado") trozo = b.read(4 << 20) if not trozo: break h2.update(trozo) if h1.digest() != h2.digest() or os.path.getsize(tmp) != st.st_size: raise OSError("lo copiado no quedó igual que el original") os.replace(tmp, destino) shutil.copystat(origen, destino) # la fecha del archivo es la del rodaje: se conserva return h1.hexdigest() except BaseException: estado["bytes"] = antes try: os.remove(tmp) except OSError: pass raise def traer(raiz, origen, nombre="", ordenar=True, en_hilo=False): """Trae a la bandeja lo que hay en una tarjeta (o en cualquier carpeta de fuera), comprobando cada archivo, y al terminar lo reparte. El origen no se toca.""" p = plan_traer(raiz, origen, nombre) if p.get("error"): return p if TRAER.get("estado") in ("copiando", "ordenando"): return {"error": "ya se está trayendo material"} TRAER.update(estado="copiando", raiz=p["raiz"], origen=p["origen"], destino=p["destino"], hechos=0, total=p["n"], bytes=0, bytes_total=p["bytes"], actual="", copiados=0, saltados=0, errores=[], cancelar=False, inicio=time.time(), fin=0, ordenado=None) if en_hilo: threading.Thread(target=_traer, args=(p, ordenar), daemon=True).start() return {"lanzado": True, **p} return _traer(p, ordenar) def _traer(p, ordenar): raiz = p["raiz"] lote = "entrada-" + time.strftime("%Y%m%d-%H%M%S") comprobados = [] try: for a in _archivos_de(p["origen"]): if TRAER["cancelar"]: break rel = os.path.relpath(a, p["origen"]) TRAER["actual"] = rel try: h = _copiar_comprobando(a, os.path.join(p["destino"], rel), TRAER) if h: TRAER["copiados"] += 1 comprobados.append((rel, os.path.getsize(a), h)) else: TRAER["saltados"] += 1 except InterruptedError: break except Exception as ex: # noqa TRAER["errores"].append(f"{rel}: {getattr(ex, 'strerror', None) or ex}") TRAER["hechos"] += 1 # la lista de lo que se copió y con qué huella queda en la carpeta de la app dentro de la del cliente if comprobados: try: d = os.path.join(raiz, R.CARPETA_APP, "entradas") os.makedirs(d, exist_ok=True) with open(os.path.join(d, lote + ".csv"), "w", encoding="utf-8-sig", newline="") as fh: fh.write("archivo;bytes;huella\r\n") for rel, tam, h in comprobados: fh.write(f"{rel};{tam};{h}\r\n") except OSError: pass bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": TRAER["hechos"], "movimientos": TRAER["copiados"], "errores": list(TRAER["errores"]), "origen": p["origen"], "destino": p["destino"]}) if TRAER["cancelar"]: TRAER["estado"] = "cancelado" elif TRAER["errores"]: TRAER["estado"] = "error" else: if ordenar: TRAER.update(estado="ordenando", actual="repartiendo por rodaje y cámara") TRAER["ordenado"] = ordenar_entrada(raiz) TRAER["estado"] = "listo" except Exception as ex: # noqa TRAER["errores"].append(str(ex)) TRAER["estado"] = "error" TRAER.update(fin=time.time(), actual="") return dict(TRAER) # -------------------------------------------------------------------------- # Orden automático: todas las carpetas, lo seguro, sin que nadie pulse nada # -------------------------------------------------------------------------- AUTOMATICO_PATH = os.path.join(DATOS, "automatico.json") AUTO = {"activo": False, "raiz": "", "hechas": 0, "total": 0, "inicio": 0} TAREA_WINDOWS = "OrganiLAB - orden automatico" def automatico_una(raiz, limite): """Pone al día una carpeta sin preguntar: la lee y hace solo lo que el plan da por seguro. Si no se puede leer entera, o si el plan quiere mover demasiado para hacerlo sin que alguien lo mire, no toca nada y lo dice.""" raiz = _clave(raiz) r = raiz_cfg(raiz) or {} out = {"raiz": raiz, "cliente": r.get("cliente", ""), "ts": time.time(), "estado": "", "detalle": "", "movimientos": 0, "lotes": [], "dudosos": 0, "avisos": [], "errores": []} if not os.path.isdir(raiz): out.update(estado="sin conexion", detalle="la carpeta no está conectada") return out escanear(raiz) prog = PROGRESO.get(raiz) or {} if prog.get("estado") != "listo" or prog.get("fallos"): out.update(estado="sin leer", detalle=prog.get("error") or "no se pudo leer entera") return out plan = Plan(r).correr() marcadas = [f for f in plan["filas"] if f["marcado"]] n = sum(f["n"] or 0 for f in marcadas) if n > limite: out.update(estado="demasiado", dudosos=sum(1 for f in plan["filas"] if not f["marcado"]), avisos=plan["avisos"], detalle=f"el plan mueve {_plural(n, 'archivo', 'archivos')}: es mucho para hacerlo sin que lo mires") return out dec = {p["clave"]: p["defecto"] for p in asistente.pasos(asistente.diagnostico(raiz), plan["filas"])} dec.update(convertir="no", respaldo="no", carpetas="seguras") res = asistente.ejecutar(raiz, dec, plan["filas"]) out["movimientos"] = (res.get("movimientos") or 0) + (res.get("stock") or 0) out["lotes"] = res.get("lotes") or [] out["errores"] = (res.get("errores") or [])[:20] if asistente.ESTADO.get("error"): out["errores"].append(asistente.ESTADO["error"]) despues = plan_en_marcha(raiz) or Plan(r).correr() out["dudosos"] = sum(1 for f in despues["filas"] if not f["marcado"]) out["avisos"] = despues["avisos"][:20] out["estado"] = "con errores" if out["errores"] else "con dudas" if out["dudosos"] else "al dia" PLANES[despues["raiz"]] = despues # se queda: así Inicio dice por la mañana qué quedó, sin pedir que se calcule return out def automatico(en_hilo=False, origen="a mano"): """Todas las carpetas registradas, una tras otra. Deja el resumen en datos\\automatico.json.""" ocupado = en_marcha() or (COLA["activa"] and "hay una conversión en marcha") or \ (TRAER.get("estado") in ("copiando", "ordenando") and "se está trayendo material") if AUTO["activo"] or ocupado: return {"error": "ahora no: " + (ocupado or "el orden automático ya está en marcha")} raices = [_clave(r["ruta"]) for r in CONFIG.get("raices", [])] AUTO.update(activo=True, raiz="", hechas=0, total=len(raices), inicio=time.time()) if en_hilo: threading.Thread(target=_automatico, args=(raices, origen, False), daemon=True).start() return {"lanzado": True, "carpetas": len(raices)} return _automatico(raices, origen, True) def _automatico(raices, origen, sincrono=False): limite = int((CONFIG.get("automatico") or {}).get("limite") or 3000) resumen = {"ts": time.time(), "origen": origen, "version": VERSION, "carpetas": []} try: for raiz in raices: AUTO["raiz"] = raiz try: resumen["carpetas"].append(automatico_una(raiz, limite)) except Exception as ex: # noqa resumen["carpetas"].append({"raiz": raiz, "cliente": (raiz_cfg(raiz) or {}).get("cliente", ""), "ts": time.time(), "estado": "con errores", "detalle": repr(ex), "movimientos": 0, "lotes": [], "dudosos": 0, "avisos": [], "errores": [repr(ex)]}) AUTO["hechas"] += 1 try: aligerar_automatico(resumen, sincrono=sincrono) except Exception as ex: # noqa resumen["aligerar"] = {"error": repr(ex)} resumen["seg"] = round(time.time() - resumen["ts"], 1) guardar_json(AUTOMATICO_PATH, resumen) finally: AUTO.update(activo=False, raiz="") return resumen def aligerar_automatico(resumen, sincrono=False): """Con optimizar.aligerar activado, la pasada mete en cola lo de más de N meses, de más pesado a menos, hasta el tope de GB por noche, en todas las carpetas conectadas. Convierte y verifica; no reemplaza (salvo que esté puesto auto_reemplazar). Si el programa corre sin ventana (la tarea de Windows), la cola va aquí mismo, en este proceso; si no, arranca la cola de siempre.""" a = _opt("aligerar") or {} if not a.get("activo"): return o = CONFIG["optimizar"] meses, tope = int(a.get("meses") or 6), float(a.get("gb_noche") or 100) * 1e9 total_b, por_raiz = 0, {} for r in CONFIG.get("raices", []): raiz = _clave(r["ruta"]) if not os.path.isdir(raiz): continue AUTO["raiz"] = raiz analizar(raiz, o["min_mb"]) # lee con ffprobe lo que falte, aquí mismo for c in sorted(candidatos(raiz, meses, o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")), key=lambda c: -c["bytes"]): if c["excluido"] or c["en_cola"] or total_b + c["bytes"] > tope: continue total_b += c["bytes"] por_raiz.setdefault(raiz, []).append(c["ruta"]) n = 0 for raiz, rutas in por_raiz.items(): n += len(encolar(rutas, o["perfil"], o.get("chroma", "420"), o.get("color", "rec709"), raiz, o.get("formato", "h265_10bit")) or []) resumen["aligerar"] = {"clips": n, "bytes": total_b, "meses": meses, "tope_gb": a.get("gb_noche") or 100} if not n: return if sincrono: with _COLA_LOCK: COLA.update(activa=True, cancelar=False, hechos=0, inicio=time.time(), ritmo=[], hechos_ids=[], esperando="") bucle_cola() else: iniciar_cola() def estado_automatico(): c = CONFIG.get("automatico") or {} return {"activo": bool(c.get("activo")), "hora": c.get("hora") or "02:00", "limite": int(c.get("limite") or 3000), "en_marcha": dict(AUTO), "ultimo": cargar_json(AUTOMATICO_PATH, None)} def _orden_de_la_tarea(): """Lo que tiene que lanzar Windows: este mismo programa con la orden «automatico».""" import instalar exe, args = instalar.orden(BASE) return f'"{exe}" {args} automatico' if args else f'"{exe}" automatico' def programar_automatico(activo, hora): """Da de alta (o de baja) en el Programador de tareas de Windows la pasada diaria. La tarea es del usuario: corre con su sesión abierta, que es cuando están montados los discos del NAS.""" hora = (hora or "").strip() if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", hora): return False, "la hora va como 02:00" ensayo = os.environ.get("ORGANILAB_ENSAYO", "") if ensayo: # las pruebas no tocan el Programador de tareas guardar_json(os.path.join(ensayo, "tarea.json"), {"activo": bool(activo), "hora": hora, "orden": _orden_de_la_tarea()}) return True, "" if activo: cmd = ["schtasks", "/Create", "/F", "/TN", TAREA_WINDOWS, "/SC", "DAILY", "/ST", hora, "/TR", _orden_de_la_tarea()] else: cmd = ["schtasks", "/Delete", "/F", "/TN", TAREA_WINDOWS] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SIN_VENTANA) except (OSError, subprocess.SubprocessError) as ex: return False, str(ex) if r.returncode != 0 and activo: return False, (r.stderr or r.stdout or "Windows no aceptó la tarea").strip() return True, "" def configurar_automatico(activo, hora): ok, error = programar_automatico(activo, hora or (CONFIG.get("automatico") or {}).get("hora") or "02:00") if not ok: return {"error": "no se pudo programar: " + error} CONFIG.setdefault("automatico", {}).update(activo=bool(activo), hora=(hora or "02:00").strip()) guardar_json(CONFIG_PATH, CONFIG) return {"ok": True, **estado_automatico()} def automatico_por_comando(): """Lo que lanza la tarea programada. Si el programa ya está abierto con estos datos, se lo pide a él (dos procesos moviendo lo mismo se pisarían); si no, lo hace este mismo, sin ventana ni consola.""" if _soy_la_unica(): r = automatico(origen="programado") return 1 if r.get("error") else 0 puerto = cargar_json(SESION_PATH, {}).get("puerto") if not puerto: return 1 import urllib.request try: rq = urllib.request.Request(f"http://127.0.0.1:{puerto}/api/automatico/ahora", b'{"origen": "programado"}', {"Content-Type": "application/json"}) urllib.request.urlopen(rq, timeout=30).read() return 0 except Exception: # noqa return 1 _LOTES_OTROS = ("stock-", "dup-", "vacias-", "entrada-", "optimizar", "tanda-", "pieza-", "descartados-", "colores-", "respaldo") def _ultimo_orden(raiz): """El último lote de orden (mover y reorganizar) aplicado sobre esta carpeta y no deshecho: cuándo y cuánto.""" ent = leer_bitacora(3000) deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"} for e in reversed(ent): l = e.get("lote") or "" if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower() and l and not l.startswith(_LOTES_OTROS) and l not in deshechos: return {"lote": l, "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])} return None def _nombre_tipo(t): return (R.TIPOS_CLIENTE.get(t) or t or "").split(" · ")[0] # el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece _ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock", "repetidos", "convertir", "juntar", "respaldar"] def _historial_carpeta(raiz): """Lo que ya se hizo en esta carpeta, por acción de Inicio: la última vez y qué fue. Con esto cada paso hecho sale con su ✓ en vez de desaparecer de la lista (antes el stock, los repetidos, la bandeja, el tipo de trabajo o la conversión, al terminarlos, simplemente se iban, y no se sabía por dónde se iba).""" ent = leer_bitacora(20000) rl = raiz.lower() deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"} h = {} def pon(clave, ts, texto, n=1): x = h.setdefault(clave, {"ts": 0, "texto": "", "veces": 0}) x["veces"] += n if (ts or 0) >= x["ts"]: x["ts"], x["texto"] = ts or 0, texto for e in ent: op, l, ts = e.get("op"), e.get("lote") or "", e.get("ts") if (e.get("raiz") or "").lower() != rl and rl not in [x.lower() for x in (e.get("raices") or [])]: continue if l and l in deshechos: continue if op == "tipo": pon("tipo", ts, f"elegiste «{_nombre_tipo(e.get('tipo'))}»") elif op == "reloj": pon("relojes", ts, f"{_plural(e.get('n') or 0, 'clip corrido', 'clips corridos')} {e.get('dias') or 0:+d} días") elif op == "banco_modo": pon("banco", ts, f"elegiste ordenarlo por {e.get('modo')}") elif op == "nombrar": pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»") elif op == "respaldo": pon("respaldar", ts, f"{_plural(e.get('copiados') or 0, 'archivo copiado', 'archivos copiados')} a {e.get('a') or 'el respaldo'}") elif op == "tanda": pon("convertir", ts, _plural(e.get("clips") or 0, "clip convertido", "clips convertidos")) elif op == "fin": m = e.get("movimientos") or 0 if l.startswith("stock-"): pon("stock", ts, _plural(m, "archivo apartado", "archivos apartados") + " a «_Para borrar\\stock»") elif l.startswith("dup-"): pon("repetidos", ts, _plural(m, "copia juntada", "copias juntadas") + " en «Duplicados»") elif l.startswith("vacias-"): pon("vacias", ts, _plural(m, "carpeta vacía borrada", "carpetas vacías borradas")) elif l.startswith("entrada-"): pon("entrada", ts, _plural(m, "archivo traído", "archivos traídos")) elif l.startswith(("optimizar", "descartados-")): pon("convertir", ts, _plural(m, "original reemplazado", "originales reemplazados")) elif not l.startswith(("pieza-", "colores-", "tanda-", "respaldo")): # aplicar el plan: los pasos que entraron (los lotes de antes de la 3.7.1 no lo apuntaban) for p, n in (e.get("pasos") or {"limpiar": 0, "fechas": 0, "resto": 0}).items(): pon(p, ts, _plural(n, "cambio aplicado", "cambios aplicados") if n else _plural(m, "movimiento", "movimientos")) return h def _traidos_de(raiz): """origen (minúsculas) -> cuándo se trajo, de los «Traer material» hechos a esta carpeta.""" out = {} for e in leer_bitacora(20000): if e.get("op") == "fin" and (e.get("lote") or "").startswith("entrada-") and (e.get("raiz") or "").lower() == raiz.lower() and e.get("origen"): out[os.path.normpath(e["origen"]).lower()] = e.get("ts") return out def _cuando(ts): """«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40».""" if not ts: return "" t = time.localtime(ts) hoy = time.localtime() hora = time.strftime("%H:%M", t) if t[:3] == hoy[:3]: return f"hoy a las {hora}" if t[:3] == time.localtime(time.time() - 86400)[:3]: return f"ayer a las {hora}" return f"el {t.tm_mday} de {R.MESES[t.tm_mon - 1]}" + (f" de {t.tm_year}" if t.tm_year != hoy.tm_year else "") + f" a las {hora}" def acciones(raiz): """Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra. Es la vista principal: en lugar de repartir el trabajo entre pestañas dentro de pestañas, una sola lista dice qué falta y cuánto pesa. Todo sale de consultas baratas sobre el inventario; lo único que puede costar es el plan de orden, que solo se usa si ya estaba calculado.""" raiz = _clave(raiz) r = raiz_cfg(raiz) if not r: return {"error": "carpeta no registrada"} con = db() esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() out = [] def añadir(**kw): kw.setdefault("estado", "toca") # toca | hecho | espera out.append(kw) if not esc: con.close() añadir(clave="escanear", titulo="Escanear la carpeta", estado="toca", boton="Escanear", que="Lee nombres, tamaños y fechas de todos los archivos. No mueve nada.", cifra="", detalle="En una carpeta grande del NAS tarda unos minutos.") return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": None} en_papelera = " AND " + " AND ".join( ["ruta NOT LIKE ? ESCAPE '!'"] * len(R.PAPELERAS)) args_pap = [esc_like(os.path.join(raiz, pp)) + "%" for pp in R.PAPELERAS] # --- 0. el inventario es de una versión que reconocía peor las cámaras y las fechas --- if (esc["reglas"] or 0) < R.VERSION_REGLAS: añadir(clave="escanear", titulo="Escanear otra vez", boton="Escanear", que="Esta versión reconoce mejor las cámaras, los clips ya convertidos y el año de cada rodaje.", cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.", ir="escanear") # --- el tipo de trabajo de la carpeta no es el del cliente --- # Vía Sumapaz estaba guardado como «proyecto» y su carpeta como «contenido»: el plan, el mapa y «Organizar # carpetas» usan el de la carpeta, y nada decía que no cuadraban. tc = ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo") tr = r.get("tipo", "contenido") if tc and tc != tr and tc in PLANTILLAS: nom = lambda t: (R.TIPOS_CLIENTE.get(t) or t).split(" · ")[0] # noqa añadir(clave="tipo", titulo="Elegir cómo se ordena esta carpeta", boton="Elegir", que=f"{r['cliente']} está guardado como «{nom(tc)}», pero esta carpeta se ordena como «{nom(tr)}».", cifra="", detalle="El plan, el mapa y «Organizar carpetas» usan el de la carpeta. Elige uno y quedan los dos iguales.", ir="tipo", tipos=[tr, tc]) # --- la bandeja de entrada: material nuevo esperando a que lo repartan --- ent = entrada(raiz) if ent["n"]: añadir(clave="entrada", titulo="Repartir lo que hay en la bandeja de entrada", boton="Repartir", que=f"Material nuevo en «{R.ENTRADA}»: va a su mes, a su día de rodaje y a su cámara.", cifra=f"{_plural(ent['n'], 'archivo', 'archivos')} · {_gb(ent['bytes'])}", detalle="La app vuelve a leer la carpeta y mueve lo que da por seguro; lo dudoso se queda en la bandeja.", ir="entrada") # --- 1. basura y proxies que siguen fuera de la papelera --- fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? " "AND (es_basura = 1 OR es_proxy = 1)" + en_papelera, [raiz] + args_pap).fetchone() vieja = con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.join(raiz, R.PAPELERAS[1]),)).fetchone() plan_l = PLANES.get(raiz) marc_l = [f for f in plan_l["filas"] if f.get("paso") == "limpiar" and f.get("marcado")] if plan_l else None sin_l = [f for f in plan_l["filas"] if f.get("paso") == "limpiar" and not f.get("marcado")] if plan_l else [] if fila["n"] and marc_l is not None and not marc_l: # lo que queda no lo aparta la app sola (está en «No tocar», o es un proxy sin su original): está hecho # lo seguro; queda mirar añadir(clave="limpiar", titulo="Basura y proxies", boton="Ver", estado="espera", que="Lo seguro ya está apartado. Lo que queda no se aparta sin que lo marques tú.", cifra=f"{_plural(len(sin_l), 'cambio sin marcar', 'cambios sin marcar')} · {_gb(sum(f['bytes'] for f in sin_l))}", detalle=("Hay proxies sin su original: pueden ser la única copia." if any("OJO" in f["motivo"] for f in sin_l) else "") + _no_tocar_dentro(con, raiz), ir="plan", paso="limpiar") elif fila["n"]: añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar", que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.", cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}", detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada." + _no_tocar_dentro(con, raiz), ir="plan", paso="limpiar") elif vieja: añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar", que=f"Lo apartado está repartido entre «{R.PAPELERAS[1]}», el nombre de antes, y «{R.PAPELERA}».", cifra=f"{_plural(vieja['n'] or 0, 'archivo', 'archivos')} · {_gb(vieja['bytes'] or 0)}", detalle=f"Todo pasa a «{R.PAPELERA}». No se borra nada.", ir="plan", paso="limpiar") else: # lo ya apartado se dice, para que se vea que el cambio se hizo (antes la ficha quedaba muda) apartado = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? AND (" + " OR ".join(["ruta LIKE ? ESCAPE '!'"] * len(R.PAPELERAS)) + ")", [raiz] + args_pap).fetchone() añadir(clave="limpiar", titulo="Basura y proxies", estado="hecho", que="No queda nada suelto que apartar.", cifra="", detalle=f"{_plural(apartado['n'], 'archivo', 'archivos')} ya en «{R.PAPELERA}» ({_gb(apartado['b'])}): se borran cuando tú quieras." if apartado["n"] else "") # --- 2. carpetas que la app no reconoce --- voc = vocabulario(raiz) sin = [t for t in voc if not t.get("cubeta") and not t.get("dentro_pieza")] seguras = [t for t in sin if t.get("sug_conf") == "alta"] if sin: # sin sumar bytes: una carpeta sin reconocer dentro de otra sin reconocer contaría dos veces # el mismo material, y salían totales mayores que el propio disco con_sug = [t for t in sin if t.get("sugerencia")] añadir(clave="nombrar", titulo=("Decir qué son " + _plural(len(sin), "carpeta", "carpetas")) if len(sin) > 1 else "Decir qué es una carpeta", boton="Nombrarlas" if len(sin) > 1 else "Nombrarla", que="Sin esto la app no las mueve: se quedan donde están.", cifra=_plural(len(sin), "carpeta", "carpetas") + (f" · {len(seguras)} de un clic" if seguras else ""), detalle=(f"La app propone qué son {len(con_sug)} de ellas mirando lo que tienen dentro" + (f", y {len(seguras)} con bastante seguridad como para aceptarlas todas juntas." if seguras else ".") if con_sug else "Puedes dejarlas sin nombrar: simplemente no se moverán."), ir="nombrar") else: añadir(clave="nombrar", titulo="Carpetas reconocidas", estado="hecho", que="La app sabe qué es cada carpeta.", cifra="", detalle="") # --- 3 y 4. ordenar (usa el plan solo si ya estaba calculado) --- plan = PLANES.get(raiz) ultimo = _ultimo_orden(raiz) hecho_det = f"Hecho {_cuando(ultimo['ts'])}: {_plural(ultimo['movimientos'], 'movimiento', 'movimientos')}." if ultimo else "" for clave, titulo, que in (("fechas", "Ordenar por año y mes", "Meses con nombre completo, grabaciones repartidas por mes y cámara, piezas numeradas."), ("resto", "Ordenar lo demás", "Exportados, proyectos, música y marca a su carpeta.")): if plan: fs = [f for f in plan["filas"] if f.get("paso") == clave] marc = [f for f in fs if f.get("marcado")] if fs and not marc: # solo queda lo que la app no mueve sola: no es una tarea pendiente, es algo que mirar. # Si contara como pendiente, la carpeta no llegaría nunca a estar «al día». añadir(clave=clave, titulo=titulo, boton="Ver", que=que, estado="espera", cifra=_plural(len(fs), "dudoso", "dudosos"), detalle=(hecho_det + " " if hecho_det else "Lo seguro ya está hecho. ") + "Queda lo que la app no se atreve a mover sola: míralo y marca lo que quieras.", ir="plan", paso=clave) elif fs: añadir(clave=clave, titulo=titulo, boton="Ver y aplicar", que=que, cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}", detalle=(f"{len(marc)} están marcados; el resto son dudosos y no se mueven salvo que los marques." if len(marc) < len(fs) else "Todo se mueve dentro de la misma carpeta."), ir="plan", paso=clave) else: añadir(clave=clave, titulo=titulo, estado="hecho", que="Nada que mover.", cifra="", detalle=hecho_det) elif REPLAN.get(raiz): # se acaba de mover algo y el plan se está volviendo a pensar: en un momento se sabe qué queda añadir(clave=clave, titulo=titulo, que=que, cifra="", estado="espera", calculando=True, detalle=(hecho_det + " " if hecho_det else "") + "Comprobando qué queda…", ir="plan", paso=clave) else: # sin el plan calculado no se sabe si queda algo: se ofrece calcularlo, pero no cuenta como # pendiente (si no, después de ordenarlo todo la lista seguía diciendo que faltaban dos cosas) añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera", detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave) # --- clips con fecha imposible: una cámara con el reloj sin poner --- rr = con.execute(f"SELECT COUNT(*) n, COUNT(DISTINCT dir || '|' || fuente) g, COALESCE(SUM(bytes), 0) b FROM archivos " f"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) " f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?", (raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchone() if rr["n"]: añadir(clave="relojes", titulo=f"Corregir la fecha de {_plural(rr['n'], 'clip', 'clips')} de cámara", boton="Corregir", que="Su cámara tenía el reloj sin poner (2008, 2009…): se ordenarían en un año que no es.", cifra=f"{_plural(rr['g'], 'tanda', 'tandas')} · {_gb(rr['b'])}", estado="espera", detalle="Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. Se deshace.", ir="relojes") # --- el banco de material: los temas se quedan; dentro, por fecha y cámara --- bancos = _bancos_de(con, raiz) if bancos: b0 = bancos[0] nombre_b = b0["nombre"] prot = any(b["protegido"] for b in bancos) que_b = "Los temas se quedan como están; dentro de cada uno, las grabaciones por fecha de rodaje y cámara." nota_b = ("Su nombre dice «No tocar»: la app lo propone todo sin marcar y no entra en «hacer de una vez». " "Revísalo en «Qué va a pasar» y marca lo que quieras." if prot else "") if plan: fs = [f for f in plan["filas"] if f.get("paso") == "banco"] marc = [f for f in fs if f.get("marcado")] if fs: añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), boton="Ver", que=que_b, estado="toca" if marc else "espera", cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}", detalle=nota_b or f"{len(marc)} marcados.", ir="plan", paso="banco") else: añadir(clave="banco", titulo=f"Banco de material «{nombre_b}»", estado="hecho", que=que_b, cifra="", detalle="Sus grabaciones ya están por fecha y cámara.") else: añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), que=que_b, estado="espera", cifra=_gb(sum(b["bytes"] for b in bancos)), calculando=bool(REPLAN.get(raiz)), boton="" if REPLAN.get(raiz) else "Calcular", detalle=("Comprobando qué queda… " if REPLAN.get(raiz) else "Hay que calcular el plan para verlo. ") + nota_b, ir="plan", paso="banco") # --- 5. carpetas que quedaron vacías --- vac = carpetas_vacias(raiz) if vac["n"]: dentro = sum(c["subcarpetas"] for c in vac["carpetas"]) añadir(clave="vacias", titulo="Borrar carpetas vacías", boton="Ver y borrar", que="Carpetas sin un solo archivo dentro: lo que queda después de mover el material.", cifra=_plural(vac["n"], "carpeta", "carpetas") + (f" · {dentro} dentro" if dentro else ""), detalle="Es lo único que la app borra, y solo porque no hay nada dentro. Se revisan una a " "una y se deshace desde Historial.", ir="vacias") else: añadir(clave="vacias", titulo="Carpetas vacías", estado="hecho", que="No queda ninguna carpeta hueca.", cifra="", detalle="") # --- 6. stock de Envato viejo --- tope = limite_meses(3) viejo = [f for f in con.execute( "SELECT bytes, fecha, anio_ruta, mes_ruta FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0" + en_papelera, [raiz] + args_pap).fetchall() if es_viejo(f, tope)] if viejo: añadir(clave="stock", titulo="Apartar stock de Envato viejo", boton="Ver y apartar", que="De proyectos ya publicados: se vuelve a descargar con la suscripción.", cifra=f"{_plural(len(viejo), 'archivo', 'archivos')} · {_gb(sum(f['bytes'] for f in viejo))}", detalle="Son los de proyectos de más de 3 meses. Puedes ajustar el corte.", ir="stock") # --- 6. material repetido --- # lo que ya está apartado en una carpeta «Duplicados» no cuenta: si contara, esto no acabaría nunca sin_dup = " AND instr(lower(ruta), ?) = 0" arg_dup = [os.sep + R.DUPLICADOS.lower() + os.sep] rep_fila = con.execute( "SELECT COALESCE(SUM(n - 1), 0) n, COALESCE(SUM((n - 1) * bytes), 0) b FROM " "(SELECT nombre, bytes, COUNT(*) n FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 " "AND bytes > 5000000" + en_papelera + sin_dup + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)", [raiz] + args_pap + arg_dup).fetchone() # el mismo clip con otra calidad: se llama igual y pesa distinto. Cuenta lo que aún no se ha leído y # lo que, ya leído, dura lo mismo; dos clips que se llaman igual y duran distinto son clips distintos. por_nombre = defaultdict(list) for f in con.execute( "SELECT lower(nombre) nm, bytes, dur, analizado FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 " "AND bytes > 5000000 AND rol IN ('camara_raw','camara_video','celular_video') AND ext IN (%s)" % ",".join("?" * len(_EXT_VIDEO_DUP)) + en_papelera + sin_dup, [raiz] + sorted(_EXT_VIDEO_DUP) + args_pap + arg_dup).fetchall(): if f["analizado"] != 2: # 2 = no se pudo leer: no hay con qué comparar por_nombre[f["nm"]].append(f) por_comparar = 0 for fs in por_nombre.values(): if len(fs) < 2 or len({f["bytes"] for f in fs}) < 2: continue if any(f["analizado"] != 1 or not f["dur"] for f in fs): por_comparar += len(fs) - 1 else: durs = sorted(f["dur"] for f in fs) por_comparar += sum(1 for a, b in zip(durs, durs[1:]) if b - a <= max(0.5, 0.01 * a)) if rep_fila["n"] or por_comparar: partes = ([f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}"] if rep_fila["n"] else []) \ + ([f"{_plural(por_comparar, 'clip', 'clips')} con otra calidad por comparar"] if por_comparar else []) añadir(clave="repetidos", titulo="Juntar material repetido", boton="Ver repetidos", que="Mismo nombre y mismo peso en varias carpetas, o el mismo clip guardado con dos calidades.", cifra=" · ".join(partes), detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está y la otra va a «Duplicados» a su lado.", ir="repetidos") # --- 7. convertir clips viejos --- o = CONFIG["optimizar"] # los clips que ya salieron del convertidor siguen siendo de cámara, pero no están por aligerar por_aligerar = [f for f in con.execute( "SELECT nombre, bytes, analizado FROM archivos WHERE raiz = ? " "AND rol IN ('camara_raw','camara_video') AND es_proxy = 0 AND es_basura = 0 AND bytes >= ?" + en_papelera, [raiz, int(o.get("min_mb", 300)) * 1024 * 1024] + args_pap).fetchall() if not R.es_conversion(f["nombre"])] cam = {"n": len(por_aligerar), "b": sum(f["bytes"] or 0 for f in por_aligerar), "ana": sum(1 for f in por_aligerar if f["analizado"] == 1)} hechos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado = 'reemplazado'", (raiz.lower(),)).fetchone()["n"] if cam["n"]: if not (cam["ana"] or 0): añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips", que="Pasan a un formato unas 10 veces más ligero. El original no se toca hasta que reemplaces.", cifra=f"{_plural(cam['n'], 'clip', 'clips')} · {_gb(cam['b'])}", detalle="Primero hay que leerlos para saber cuáles conviene convertir. Va en segundo plano.", ir="convertir") else: # ya leídos: cuentan solo los que de verdad adelgazan (ni conversiones, ni lo que ya viene comprimido) cands = [c for c in candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")) if not c["excluido"] and not c["en_cola"] and c["rol"] in ("camara_raw", "camara_video")] sin_leer = cam["n"] - cam["ana"] if cands: añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips", que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.", cifra=f"{_plural(len(cands), 'clip', 'clips')} · {_gb(sum(c['bytes'] for c in cands))} → unos {_gb(sum(c['estimado'] for c in cands))}", detalle=(f"{sum(1 for c in cands if c['motor'] != 'ffmpeg')} son BRAW. " if any(c["motor"] != "ffmpeg" for c in cands) else "") + (f"{hechos} ya reemplazados." if hechos else "El resto ya viene comprimido o es reciente."), ir="convertir") elif sin_leer > 0: añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips", que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.", cifra=f"{_plural(sin_leer, 'clip', 'clips')} por leer", detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.", ir="convertir") # --- el mismo cliente en otros discos --- sitios = otros_sitios(raiz) if sitios: traidos = _traidos_de(raiz) for s in sitios: s["traido"] = traidos.get(os.path.normpath(s["ruta"]).lower()) faltan = [s for s in sitios if not s["traido"]] añadir(clave="juntar", titulo=(f"{r['cliente']} también está en {_plural(len(faltan), 'sitio', 'sitios')} más" if faltan else f"Lo de {r['cliente']} en otros discos"), boton="Ver" if faltan else "", estado="espera" if faltan else "hecho", que=("Se puede traer aquí con copia comprobada: el original no se toca, y lo que llega se reparte por mes y cámara." if faltan else "Ya está traído todo lo que había en otros discos."), cifra=" · ".join(s["ruta"] for s in faltan[:3]) + (f" y {len(faltan) - 3} más" if len(faltan) > 3 else ""), detalle="Míralo antes: una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está." if faltan else "", ir="juntar", sitios=sitios) # --- 8. respaldo, al final --- dest = CONFIG.get("respaldo", {}).get("destino", "") añadir(clave="respaldar", titulo="Copiar el respaldo a otro disco", boton="Preparar respaldo", que="Una copia compacta con los clips ya convertidos, y un índice.", cifra="", estado="espera" if any(a["estado"] == "toca" for a in out) else "toca", detalle=(f"Destino: {dest}" if dest else "Falta indicar el disco de destino.") + " Conviene hacerlo cuando lo demás esté hecho.", ir="respaldar") con.close() hist = _historial_carpeta(raiz) hay = {a["clave"] for a in out} for clave, titulo, que in (("tipo", "Cómo se ordena esta carpeta", f"Como «{_nombre_tipo(r.get('tipo', 'contenido'))}», igual que su cliente."), ("entrada", "Bandeja de entrada", "Vacía: lo que entró ya está repartido."), ("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."), ("repetidos", "Material repetido", "No quedan copias de más por juntar."), ("convertir", "Clips de cámara", "No quedan clips que compense convertir."), ("relojes", "Fechas de cámara", "No queda ningún clip con fecha imposible.")): if clave in hist and clave not in hay: añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="") for a in out: x = hist.get(a["clave"]) if not x or "Hecho " in (a.get("detalle") or ""): continue cuando = _cuando(x["ts"]) if a["clave"] == "nombrar" and x["veces"] > 1: a["hecho"] = f"Ya nombraste {x['veces']}; la última {cuando}." elif a["estado"] == "hecho": a["hecho"] = f"Hecho {cuando}: {x['texto']}." else: a["hecho"] = f"Última vez {cuando}: {x['texto']}." out.sort(key=lambda a: _ORDEN_ACCIONES.index(a["clave"]) if a["clave"] in _ORDEN_ACCIONES else 99) # «lo seguro» son las acciones que se pueden hacer sin elegir nada: no entran convertir ni respaldar auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")] if (esc["reglas"] or 0) < R.VERSION_REGLAS: auto = [] # con un inventario viejo no se hace nada «de una vez»: primero se relee return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), "ultimo": ultimo, "calculando": REPLAN.get(raiz) is not None, "automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None} def _no_tocar_dentro(con, raiz): """« De ellos, 366 GB están dentro de «Material Vía (No tocar)»: van sin marcar.» para la ficha de basura y proxies.""" partes = [] for b in _bancos_de(con, raiz): if not b["protegido"]: continue x = con.execute("SELECT COALESCE(SUM(bytes), 0) b FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1) " "AND ruta LIKE ? ESCAPE '!'", (raiz, esc_like(con_sep(b["ruta"])) + "%")).fetchone()["b"] if x: partes.append(f"{_gb(x)} están dentro de «{b['nombre']}», que dice «No tocar»") return (" De ellos, " + "; y ".join(partes) + ": van sin marcar, para que decidas tú.") if partes else "" def _bancos_de(con, raiz): """Los bancos de material de una carpeta de cliente: los de su primer nivel. Una copia dentro de otra carpeta (en Vía Sumapaz, «_Duplicados_para_borrar\\Material Vía (No tocar)») no es el banco.""" base = raiz.rstrip(os.sep).count(os.sep) + 1 out = [] for c in con.execute("SELECT ruta, nombre, n, bytes FROM carpetas WHERE raiz = ? ORDER BY length(ruta)", (raiz,)).fetchall(): if c["ruta"].count(os.sep) != base: continue if R.cubeta_carpeta(c["nombre"], DICC["carpetas"])[0] == "BANCO": out.append({"ruta": c["ruta"], "nombre": c["nombre"], "n": c["n"] or 0, "bytes": c["bytes"] or 0, "protegido": R.es_no_tocar(c["nombre"])}) return out def carpeta_detalle(raiz, ruta=None): """Lo que hay dentro de cualquier carpeta del cliente, a cualquier profundidad: sus subcarpetas con su peso (para entrar en «Qué pesa» nivel a nivel), de qué es lo que guarda, de qué cámaras y de qué fechas, y qué hace la app con ella. Antes «Qué pesa» solo enseñaba el primer nivel, y una carpeta de 3,6 TB era un bloque gris sin nada dentro: parecía que el escaneo no había entrado.""" raiz = _clave(raiz) ruta = os.path.normpath(ruta or raiz) if ruta.lower() != raiz.lower() and not dentro_de(raiz, ruta): return {"error": "esa carpeta no es de este cliente"} con = db() q = lambda sql, *a: [dict(x) for x in con.execute(sql, a).fetchall()] # noqa pref = esc_like(con_sep(ruta)) + "%" hijos = q("SELECT ruta, nombre, n, bytes, es_proxy, es_basura FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' " "AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC", raiz, pref, pref + esc_like(os.sep) + "%") rel0 = os.path.relpath(ruta, raiz) en_banco = rel0 != "." and any(R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO" for s in rel0.split(os.sep)) for h in hijos: cub = "PROXY" if h["es_proxy"] else "BASURA" if h["es_basura"] else \ "BANCO" if en_banco else (R.cubeta_carpeta(h["nombre"], DICC["carpetas"])[0] or "") h.update(cubeta=cub, color=R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""])) donde = "(dir = ? OR dir LIKE ? ESCAPE '!')" total = q(f"SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0] sueltos = q("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, ruta)[0] roles = q(f"SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} GROUP BY rol ORDER BY bytes DESC", raiz, ruta, pref) nombres_f = {} for k, _, d in R.FIRMAS: nombres_f.setdefault(k, d) fuentes = q(f"SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} AND fuente != '' " f"AND fuente != 'STOCK' GROUP BY fuente ORDER BY bytes DESC", raiz, ruta, pref) fechas = q(f"SELECT MIN(substr(fecha,1,7)) desde, MAX(substr(fecha,1,7)) hasta, COUNT(DISTINCT substr(fecha,1,7)) meses " f"FROM archivos WHERE raiz = ? AND {donde} AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado')", raiz, ruta, pref)[0] apartable = q(f"SELECT COALESCE(SUM(CASE WHEN es_proxy = 1 THEN bytes END), 0) proxies, " f"COALESCE(SUM(CASE WHEN es_basura = 1 AND es_proxy = 0 THEN bytes END), 0) basura FROM archivos " f"WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0] sub = [x["ruta"] for x in q("SELECT ruta FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!'", raiz, pref)] niveles = max((s.count(os.sep) - ruta.rstrip(os.sep).count(os.sep) for s in sub), default=0) grandes = q("SELECT nombre, bytes FROM archivos WHERE raiz = ? AND dir = ? ORDER BY bytes DESC LIMIT 6", raiz, ruta) con.close() # qué hace la app con ella: si está dentro de un banco de material, eso manda trato = {"cubeta": "", "texto": ""} rel = os.path.relpath(ruta, raiz) if rel != ".": segs = rel.split(os.sep) for s in segs: if R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO": trato = {"cubeta": "BANCO", "texto": f"Está en el banco de material «{s}»: los temas se quedan como están y, " f"dentro, las grabaciones van por fecha de rodaje y cámara." + (" Su nombre dice «No tocar»: todo se propone sin marcar." if R.es_no_tocar(s) else "")} break else: cub, mot = R.cubeta_carpeta(segs[-1], DICC["carpetas"]) trato = {"cubeta": cub or "", "texto": (f"La app la reconoce como «{R.CUBETAS.get(cub, cub)}» ({mot})." if cub else "La app no reconoce el nombre: el plan decide por lo que tiene dentro.")} plan = PLANES.get(raiz) en_plan = None if plan: rc = os.path.normcase(con_sep(ruta)) if rel != "." else "" fs = [f for f in plan["filas"] if not rc or os.path.normcase(f["origen"] + os.sep).startswith(rc) or any(os.path.normcase(a).startswith(rc) for a in (f["archivos"] or [])[:1])] en_plan = {"filas": len(fs), "marcadas": sum(1 for f in fs if f["marcado"]), "bytes": sum(f["bytes"] for f in fs)} return {"raiz": raiz, "ruta": ruta, "rel": "" if rel == "." else rel, "nombre": os.path.basename(ruta) or ruta, "n": total["n"], "bytes": total["bytes"], "sueltos": sueltos, "hijos": hijos, "niveles": niveles, "carpetas": len(sub), "roles": [dict(x, que=R.ROL_QUE.get(x["rol"], x["rol"])) for x in roles], "fuentes": [dict(x, que=nombres_f.get(x["fuente"], x["fuente"])) for x in fuentes], "fechas": fechas, "apartable": apartable, "grandes": grandes, "trato": trato, "en_plan": en_plan} def _inventario_resumen(raiz): """Cuántas carpetas leyó el escaneo y hasta qué profundidad: para que se vea que entró en todas.""" con = db() rutas = [x[0] for x in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()] con.close() base = raiz.rstrip(os.sep).count(os.sep) return {"carpetas": len(rutas), "niveles": max((x.count(os.sep) - base for x in rutas), default=0)} def estado_carpeta(ruta): """El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea.""" raiz = _clave(ruta) r = raiz_cfg(raiz) if not r: return {"error": "carpeta no registrada"} con = db() esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() trabajos = {x["estado"]: {"n": x["n"], "bytes_in": x["bi"] or 0, "bytes_out": x["bo"] or 0} for x in con.execute("SELECT estado, COUNT(*) n, SUM(bytes_in) bi, SUM(bytes_out) bo FROM trabajos WHERE raiz = ? GROUP BY estado", (raiz,)).fetchall()} analizados = con.execute("SELECT SUM(analizado = 1) ok, SUM(analizado = 0) sin FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','video')", (raiz,)).fetchone() con.close() prog = PROGRESO.get(raiz) diag = asistente.diagnostico(raiz) if esc else None g = {x["clave"]: x for x in (diag["grupos"] if diag else [])} # último lote aplicado sobre esta carpeta lotes = [] for e in leer_bitacora(3000): if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower(): lotes.append({"lote": e.get("lote"), "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])}) lotes.sort(key=lambda x: -(x["ts"] or 0)) plan = PLANES.get(raiz) plan_res = None if plan: filas = plan["filas"] plan_res = {"filas": len(filas), "marcadas": sum(1 for f in filas if f.get("marcado")), "bytes": sum(f["bytes"] for f in filas), "alta": sum(1 for f in filas if f["confianza"] == "alta"), "baja": sum(1 for f in filas if f["confianza"] == "baja"), "avisos": plan.get("avisos") or []} sin_nombre = [t for t in vocabulario(raiz) if not t.get("cubeta") and not t.get("dentro_pieza")] if esc else [] cam_bytes = (g.get("camara_raw", {}).get("bytes", 0) + g.get("camara_video", {}).get("bytes", 0)) resp_plan = PLANES_RESPALDO.get(raiz) return { "raiz": raiz, "cliente": r["cliente"], "tipo": r["tipo"], "escaneo": dict(esc) if esc else None, "progreso": prog, "diagnostico": diag, "sin_nombre": [{"token": t["token"], "ejemplo": t["ejemplo"], "n": t["n"], "bytes": t["bytes"]} for t in sin_nombre[:12]], "sin_nombre_n": len(sin_nombre), "plan": plan_res, "lotes": lotes[:6], "inventario": _inventario_resumen(raiz) if esc else None, "tipo_cliente": ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo"), "convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos, "analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0, "cola_activa": COLA["activa"]}, "respaldo": {"destino": CONFIG.get("respaldo", {}).get("destino", ""), "plan": ({"n": resp_plan["n"], "bytes": resp_plan["bytes"], "base": resp_plan.get("base")} if resp_plan else None), "estado": RESPALDO.get("estado"), "raw_pendientes": (resp_plan or {}).get("raw_omitidos", {}).get("n", 0)}, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None, } class ServidorLocal(ThreadingHTTPServer): """El servidor de la interfaz, con el puerto para él solo. ThreadingHTTPServer pide «reutilizar dirección», y en Windows eso significa que dos programas pueden escuchar a la vez en el mismo puerto y las peticiones van a cualquiera de los dos: dos OrganiLAB con datos distintos (el instalado y una copia suelta, o el de ensayo y el de verdad) se contestaban el uno por el otro sin que nada fallara a la vista.""" allow_reuse_address = False def server_bind(self): if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) super().server_bind() class Manejador(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, fmt, *args): pass def _json(self, datos, codigo=200): cuerpo = json.dumps(datos, ensure_ascii=False, default=str).encode("utf-8") self.send_response(codigo) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(cuerpo))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(cuerpo) def _cuerpo(self): n = int(self.headers.get("Content-Length") or 0) if not n: return {} try: return json.loads(self.rfile.read(n).decode("utf-8")) except ValueError: return {} def _es_local(self): """Solo acepta peticiones que vienen de la propia página (evita que otra web abierta en el navegador dé órdenes).""" host = (self.headers.get("Host") or "").lower() if host not in (f"127.0.0.1:{PUERTO}", f"localhost:{PUERTO}"): return False origen = self.headers.get("Origin") if origen and origen.lower() not in (f"http://127.0.0.1:{PUERTO}", f"http://localhost:{PUERTO}"): return False return True def do_GET(self): ULTIMA_PETICION[0] = time.time() if self.path.startswith("/api/") and not self._es_local(): return self._json({"error": "petición no permitida"}, 403) u = urlparse(self.path) q = {k: v[0] for k, v in parse_qs(u.query).items()} ruta = u.path try: if ruta in ("/", "/index.html"): with open(os.path.join(BASE, "static", "index.html"), "rb") as f: cuerpo = f.read() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(cuerpo))) self.end_headers() self.wfile.write(cuerpo) return if ruta.startswith("/static/") and re.fullmatch(r"/static/[A-Za-z0-9_.-]+", ruta): p = os.path.join(BASE, "static", ruta[len("/static/"):]) if not os.path.isfile(p): return self._json({"error": "no existe"}, 404) tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css", ".js": "text/javascript", ".woff2": "font/woff2"} with open(p, "rb") as f: cuerpo = f.read() self.send_response(200) self.send_header("Content-Type", tipos.get(os.path.splitext(p)[1].lower(), "application/octet-stream")) self.send_header("Content-Length", str(len(cuerpo))) self.send_header("Cache-Control", "max-age=86400") self.end_headers() self.wfile.write(cuerpo) return if ruta == "/api/estado": return self._json(estado_general()) if ruta == "/api/aplicando": # la barra de «Aplicar», sondeada mientras corre return self._json(dict(APLICANDO)) if ruta == "/api/soltado": r = soltado(q.get("nombre", "")) return self._json({"ruta": r, "es_archivo": bool(r) and os.path.isfile(r)}) if ruta == "/api/carpeta/acciones": return self._json(acciones(q["raiz"])) if ruta == "/api/carpeta/estado": return self._json(estado_carpeta(q["raiz"])) if ruta == "/api/mapa": return self._json(mapa(q["raiz"])) if ruta == "/api/relojes": return self._json(relojes(q["raiz"])) if ruta == "/api/buscar": r = buscar(q.get("q", "")) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/mapa/carpeta": # «Qué pesa», nivel a nivel r = carpeta_detalle(q["raiz"], q.get("ruta")) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/actualizacion": return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta if ruta == "/api/asistente/pasos": raiz = os.path.normpath(q["raiz"]) r = raiz_cfg(raiz) if not r: return self._json({"error": "raíz no registrada"}, 400) d = asistente.diagnostico(raiz) filas = Plan(r).correr()["filas"] asistente.PLAN_CACHE[raiz] = filas return self._json({"diagnostico": d, "pasos": asistente.pasos(d, filas), "movimientos": len(filas)}) if ruta == "/api/discos": return self._json(discos()) if ruta == "/api/vocabulario": return self._json({"tokens": vocabulario(os.path.normpath(q["raiz"])), "diccionario": DICC}) if ruta == "/api/diccionario": return self._json(DICC) if ruta == "/api/plan": r = raiz_cfg(q["raiz"]) if not r: return self._json({"error": "raíz no configurada"}, 400) plan = plan_en_marcha(r["ruta"]) or Plan(r).correr() PLANES[plan["raiz"]] = plan return self._json(plan) if ruta == "/api/bitacora": ent = leer_bitacora(3000) lotes = {} for e in ent: l = e.get("lote") if not l: continue lo = lotes.setdefault(l, {"lote": l, "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []}) if e.get("op") == "mover": lo["movimientos"] += 1 if len(lo["ejemplos"]) < 5: lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")}) if e.get("op") == "mkdir": # una pieza nueva: cuentan las carpetas creadas lo["movimientos"] += 1 if e.get("op") == "tanda": # una tanda del convertidor: su resumen lo["raiz"] = e.get("raiz", "") or ", ".join(e.get("raices") or []) lo["movimientos"] = e.get("clips", 0) lo["errores"] = e.get("fallos", 0) lo["tanda"] = {k: e.get(k) for k in ("clips", "bytes_in", "bytes_out", "seg", "nota", "reemplazados", "descartados", "cliente")} if e.get("op") == "fin": lo["raiz"] = e.get("raiz", "") lo["errores"] = len(e.get("errores") or []) if l.startswith("entrada-"): # lo traído de una tarjeta: copias, no movimientos lo["movimientos"] = e.get("movimientos", 0) if e.get("origen") and e.get("destino"): lo["ejemplos"].append({"de": e["origen"], "a": e["destino"]}) if e.get("op") == "deshacer": lotes.setdefault(e.get("de_lote"), {"lote": e.get("de_lote"), "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})["deshecho"] = True return self._json({"lotes": sorted([v for k, v in lotes.items() if not k.endswith("-deshecho")], key=lambda x: -(x["ts"] or 0))}) if ruta == "/api/optimizar/candidatos": o = CONFIG["optimizar"] return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]), q.get("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")))}) if ruta == "/api/optimizar/motores": return self._json(motores()) if ruta == "/api/optimizar/cola": con = db() # con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar» filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta " "ORDER BY t.id DESC LIMIT 500").fetchall()] for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí if f.get("dur") is None and f["estado"] == "reemplazado": otra = _ruta_original(f) a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None if a: f["dur"], f["fps"] = a["dur"], a["fps"] con.close() try: eta = eta_cola(filas) if COLA["activa"] else None except Exception: # noqa eta = None if COLA.get("ultima") is None: COLA["ultima"] = _ultima_tanda_guardada() return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta, "esperando": COLA.get("esperando") or "", "ultima": COLA.get("ultima") or None}}) if ruta == "/api/optimizar/tanda.csv": # la tanda, en CSV csv_ = tanda_csv(q.get("lote") or "") if csv_ is None: return self._json({"error": "no hay una tanda con ese nombre"}, 404) cuerpo = csv_.encode("utf-8-sig") self.send_response(200) self.send_header("Content-Type", "text/csv; charset=utf-8") self.send_header("Content-Disposition", f"attachment; filename=\"OrganiLAB-{q.get('lote') or 'tanda'}.csv\"") self.send_header("Content-Length", str(len(cuerpo))) self.end_headers() self.wfile.write(cuerpo) return if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar» try: jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0)) except (ValueError, TypeError) as ex: return self._json({"error": str(ex)}, 400) with open(jpg, "rb") as fh: cuerpo = fh.read() self.send_response(200) self.send_header("Content-Type", "image/jpeg") self.send_header("Content-Length", str(len(cuerpo))) self.send_header("Cache-Control", "private, max-age=86400") self.end_headers() self.wfile.write(cuerpo) return if ruta == "/api/stock": tipos = [t for t in (q.get("tipos") or "Video").split(",") if t] return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos)) if ruta == "/api/papelera": return self._json(papelera(os.path.normpath(q["raiz"]))) if ruta == "/api/vacias": return self._json(carpetas_vacias(os.path.normpath(q["raiz"]))) if ruta == "/api/estructura/archivo": return self._json(donde_iria(q.get("nombre", ""), q.get("tipo") or "contenido")) if ruta == "/api/estructura/existen": try: segs = json.loads(q.get("ruta") or "[]") except ValueError: segs = [] cs = carpetas_como(os.path.normpath(q["raiz"]), segs) if segs else [] base = os.path.normpath(q["raiz"]) return self._json({"n": len(cs), "carpetas": [os.path.relpath(c, base) for c in cs[:20]]}) if ruta == "/api/estructura": raiz = q.get("raiz") tipo = q.get("tipo") if raiz and not tipo: r = raiz_cfg(os.path.normpath(raiz)) tipo = r.get("tipo") if r else None return self._json(mapa_estructura(tipo or "contenido", os.path.normpath(raiz) if raiz else None)) if ruta == "/api/duplicados": return self._json(duplicados(os.path.normpath(q["raiz"]), calidades=q.get("calidades") == "1")) if ruta == "/api/respaldo/plan": raiz = os.path.normpath(q["raiz"]) destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "") if not destino: return self._json({"error": "indica la carpeta de destino del respaldo"}, 400) plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0)) PLANES_RESPALDO[raiz] = plan # calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura # reescribía config.json) return self._json({k: v for k, v in plan.items() if k != "items"}) if ruta == "/api/respaldo/estado": return self._json(RESPALDO) if ruta == "/api/buscar-carpeta": return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))}) if ruta == "/api/entrada": return self._json(entrada(q["raiz"])) if ruta == "/api/automatico": return self._json(estado_automatico()) if ruta == "/api/pieza/previa": return self._json(nueva_pieza(q["raiz"], q.get("titulo", ""), q.get("anio"), q.get("mes"), crear=False)) return self._json({"error": "no existe"}, 404) except Exception as ex: # noqa import traceback return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500) def do_POST(self): ULTIMA_PETICION[0] = time.time() u = urlparse(self.path) ruta = u.path if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"): return self._json({"error": "petición no permitida"}, 403) d = self._cuerpo() try: if ruta == "/api/escanear": return self._json({"lanzado": lanzar_escaneo(d["raiz"])}) if ruta == "/api/elegir-carpeta": try: que = "Elige la tarjeta o la carpeta con el material nuevo" if d.get("para") == "traer" else "Elige la carpeta del cliente" return self._json({"ruta": elegir_carpeta(que)}) except ValueError as e: return self._json({"error": str(e)}, 409) if ruta == "/api/raiz/quitar": try: return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")), bool(d.get("descartar_cola")))) except ErrorCola as e: return self._json({"error": str(e), **e.datos}, 409) except ValueError as e: return self._json({"error": str(e)}, 409) if ruta == "/api/raices": choque = _anidadas(d.get("raices") or []) if choque: return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. " "Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400) CONFIG["raices"] = d["raices"] guardar_json(CONFIG_PATH, CONFIG) return self._json({"ok": True}) if ruta == "/api/relojes": r = corregir_reloj(d.get("raiz") or "", d.get("dir") or "", d.get("fuente") or "", d.get("fecha") or "") return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/relojes/deshacer": r = deshacer_reloj(d.get("raiz") or "", d.get("grupo") or "") return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/banco/modo": k = _clave(d.get("raiz") or "") modo = d.get("modo") if not raiz_cfg(k) or modo not in ("temas", "fecha") or not d.get("nombre"): return self._json({"error": "carpeta, banco o forma de ordenar desconocida"}, 400) CONFIG.setdefault("bancos", {}).setdefault(k.lower(), {})[R.norm(d["nombre"])] = modo guardar_json(CONFIG_PATH, CONFIG) bitacora({"op": "banco_modo", "raiz": k, "banco": d["nombre"], "modo": modo}) olvidar_plan(k) return self._json({"ok": True, "modo": modo}) if ruta == "/api/raiz/tipo": # el tipo de trabajo de una carpeta, y el de su cliente con él: así no vuelven a quedar distintos k = _clave(d.get("raiz") or "") rc = raiz_cfg(k) tipo = d.get("tipo") if not rc or tipo not in PLANTILLAS: return self._json({"error": "carpeta o tipo de trabajo desconocido"}, 400) antes = rc.get("tipo", "contenido") rc["tipo"] = tipo CONFIG.setdefault("clientes", {}).setdefault(rc["cliente"], {})["tipo"] = tipo guardar_json(CONFIG_PATH, CONFIG) bitacora({"op": "tipo", "raiz": k, "tipo": tipo, "antes": antes}) olvidar_plan(k) return self._json({"ok": True, "tipo": tipo}) if ruta == "/api/clientes": CONFIG["clientes"] = d["clientes"] guardar_json(CONFIG_PATH, CONFIG) _DISCOS_CACHE["ts"] = 0 return self._json({"ok": True}) if ruta == "/api/config": # solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar", "horario", "pausar_si_edito", "aligerar", "avisar"}, "respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}} for k, v in d.items(): if k in permitidas and isinstance(v, dict): CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]}) if isinstance(d.get("actualizaciones"), dict) and "auto" in d["actualizaciones"]: CONFIG["actualizaciones"]["elegido"] = True # lo decidió una persona, no un valor por defecto _sufijos_conversion() guardar_json(CONFIG_PATH, CONFIG) return self._json({"ok": True, "config": CONFIG}) if ruta == "/api/diccionario": tok = R.norm(d.get("token", "")) cub = d.get("cubeta") if not tok: return self._json({"error": "token vacío"}, 400) if cub and tok in R.NOMBRES_DE_LA_APP: return self._json({"error": f"«{d.get('token')}» es una carpeta de la propia app: no hay nada que enseñarle"}, 400) with LOCK: if cub in (None, ""): DICC["carpetas"].pop(tok, None) else: DICC["carpetas"][tok] = cub DICC["aprendido"].append({"ts": time.time(), "token": tok, "cubeta": cub}) guardar_json(DICC_PATH, DICC) if cub and d.get("raiz"): # para el ✓ de «Decir qué son» en Inicio bitacora({"op": "nombrar", "raiz": _clave(d["raiz"]), "token": tok, "cubeta": cub, "ejemplo": d.get("ejemplo") or tok}) return self._json({"ok": True}) if ruta == "/api/regla": with LOCK: DICC["reglas"] = [r for r in DICC["reglas"] if not (R.norm(r.get("origen", "")) == R.norm(d.get("origen", "")) and r.get("cliente") == d.get("cliente"))] if d.get("destino") is not None: DICC["reglas"].append({"cliente": d.get("cliente"), "origen": d.get("origen"), "destino": d.get("destino"), "cubeta": d.get("cubeta", "REVISAR"), "ts": time.time()}) guardar_json(DICC_PATH, DICC) return self._json({"ok": True, "reglas": DICC["reglas"]}) if ruta == "/api/plantillas": for t, cubs in (d.get("plantillas") or {}).items(): PLANTILLAS.setdefault(t, {}).update(cubs or {}) guardar_json(PLANTILLAS_PATH, PLANTILLAS) PLANES.clear() # los planes calculados apuntaban a los destinos viejos return self._json({"ok": True}) if ruta == "/api/aplicar": raiz = os.path.normpath(d["raiz"]) plan = PLANES.get(raiz) if not plan: # el plan se olvidó porque la carpeta cambió (se movió algo, se volvió a escanear): los # números de fila que trae la interfaz ya no dicen nada; se pide calcularlo otra vez if PLAN_GEN.get(raiz): return self._json({"error": "la carpeta cambió desde que se calculó el plan: hay que volver a calcular «Qué va a pasar»", "recalcular": True}, 409) return self._json({"error": "primero genera el plan"}, 400) ids = set(d.get("ids", [])) filas = [f for f in plan["filas"] if f["id"] in ids] # respetar destinos editados en la interfaz for f in filas: ed = (d.get("destinos") or {}).get(str(f["id"])) if ed: nuevo = os.path.normpath(os.path.join(raiz, ed)) if not dentro_de(raiz, nuevo): return self._json({"error": f"el destino «{ed}» queda fuera de la carpeta del cliente"}, 400) f["destino"] = nuevo res = aplicar(raiz, filas) if not res.get("movimientos"): olvidar_plan(raiz) # si algo se movió, ya lo olvidó el inventario return self._json(res) if ruta == "/api/deshacer": return self._json(deshacer(d["lote"])) if ruta == "/api/actualizacion/buscar": actualizador.comprobar() return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta if ruta == "/api/actualizacion/instalar": ok = actualizador.descargar_y_aplicar(en_hilo=True) return self._json({"lanzado": bool(ok), **actualizador.ESTADO}) if ruta == "/api/acciones/hacer": # «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la # lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático: # convertir (tarda horas y hay que elegir formato) y respaldar (necesita otro disco). raiz = os.path.normpath(d["raiz"]) if asistente.ESTADO["activo"]: return self._json({"error": "ya hay algo en marcha en esta carpeta"}, 409) if ((acciones(raiz).get("escaneo") or {}).get("reglas") or 0) < R.VERSION_REGLAS: return self._json({"error": "el inventario de esta carpeta es de una versión anterior: escanéala otra vez antes"}, 409) diag = asistente.diagnostico(raiz) filas = Plan(raiz_cfg(raiz)).correr()["filas"] dec = {p["clave"]: p["defecto"] for p in asistente.pasos(diag, filas)} dec["convertir"] = "no" dec["respaldo"] = "no" dec["carpetas"] = "seguras" # nunca las dudosas sin que alguien las mire asistente.ejecutar(raiz, dec, filas, en_hilo=True) return self._json({"lanzado": True, "hara": {k: v for k, v in dec.items() if v not in ("no", "dejar")}}) if ruta == "/api/asistente": raiz = os.path.normpath(d["raiz"]) if asistente.ESTADO["activo"]: return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"}) asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True) return self._json({"lanzado": True}) if ruta == "/api/optimizar/analizar/detener": a = ANALISIS.get(os.path.normpath(d["raiz"])) if a: a["cancelar"] = True return self._json({"ok": bool(a)}) if ruta == "/api/optimizar/analizar": raiz = os.path.normpath(d["raiz"]) p = ANALISIS.get(raiz) if p and p["estado"] == "corriendo": return self._json({"lanzado": False}) threading.Thread(target=analizar, args=(raiz, d.get("min_mb", CONFIG["optimizar"]["min_mb"])), daemon=True).start() return self._json({"lanzado": True}) if ruta == "/api/optimizar/encolar": o = CONFIG["optimizar"] ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]), d.get("formato", o.get("formato", "h265_10bit"))) if d.get("iniciar"): iniciar_cola() return self._json({"ids": ids}) if ruta == "/api/optimizar/control": acc = d.get("accion") if acc == "iniciar": return self._json({"ok": iniciar_cola()}) if acc == "detener": COLA["cancelar"] = True COLA["activa"] = False return self._json({"ok": True}) if acc == "quitar": ap = apartar_descartados(d.get("ids", [])) ids = [i for i in d.get("ids", []) if i not in ap["quedan"]] con = db() with con: con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado','descartado') AND id IN ({','.join('?' * len(ids))})", ids) con.close() return self._json({"ok": True, "apartados": ap}) if acc == "primero": # «primero este»: pasa delante de los que esperan con = db() with con: con.execute("UPDATE trabajos SET orden = (SELECT COALESCE(MAX(orden), 0) + 1 FROM trabajos) WHERE id = ? AND estado = 'pendiente'", (int(d.get("id") or 0),)) con.close() return self._json({"ok": True}) if acc == "reintentar": con = db() with con: con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", [])) con.close() return self._json({"ok": True}) if ruta == "/api/optimizar/verificar": return self._json({"resultados": verificar(d.get("ids", []))}) if ruta == "/api/optimizar/calidad": # la nota VMAF de un convertido: tarda unos segundos try: return self._json(calidad_de(int(d.get("id") or 0))) except (ValueError, TypeError, subprocess.TimeoutExpired) as ex: return self._json({"error": str(ex)}, 400) if ruta == "/api/optimizar/reemplazar": return self._json({"resultados": reemplazar(d.get("ids", []))}) if ruta == "/api/stock/papelera": return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", []))) if ruta == "/api/estructura/renombrar": return self._json(renombrar_nodo(d.get("tipo") or "contenido", d.get("ruta") or [], d.get("nombre", ""), d.get("raiz"), bool(d.get("en_disco")))) if ruta == "/api/colores/pintar": return self._json(pintar_carpetas(os.path.normpath(d["raiz"]))) if ruta == "/api/colores/quitar": return self._json(despintar_carpetas(os.path.normpath(d["raiz"]))) if ruta == "/api/vacias/borrar": return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", []))) if ruta == "/api/duplicados/apartar": return self._json(apartar_duplicados(os.path.normpath(d["raiz"]))) if ruta == "/api/abrir": return self._json(abrir_carpeta(d.get("ruta", ""))) if ruta == "/api/pieza": r = nueva_pieza(d["raiz"], d.get("titulo", ""), d.get("anio"), d.get("mes")) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/entrada/abrir": r = abrir_entrada(d["raiz"]) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/entrada/ordenar": r = ordenar_entrada(d["raiz"]) return self._json(r, 409 if r.get("error") else 200) if ruta == "/api/entrada/plan": r = plan_traer(d["raiz"], d.get("origen", ""), d.get("nombre", "")) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/entrada/traer": r = traer(d["raiz"], d.get("origen", ""), d.get("nombre", ""), d.get("ordenar", True) is not False, en_hilo=True) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/entrada/cancelar": if TRAER.get("estado") == "copiando": TRAER["cancelar"] = True return self._json({"ok": True}) if ruta == "/api/automatico/config": r = configurar_automatico(bool(d.get("activo")), d.get("hora")) return self._json(r, 400 if r.get("error") else 200) if ruta == "/api/automatico/ahora": r = automatico(en_hilo=True, origen=d.get("origen") or "a mano") return self._json(r, 409 if r.get("error") else 200) if ruta == "/api/respaldo/copiar": _rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", ""))) if _rp and _rp.get("destino_base"): CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"] guardar_json(CONFIG_PATH, CONFIG) raiz = os.path.normpath(d["raiz"]) plan = PLANES_RESPALDO.get(raiz) if not plan: return self._json({"error": "primero calcula el respaldo"}, 400) if RESPALDO["estado"] == "corriendo": return self._json({"error": "ya hay una copia en curso"}, 400) threading.Thread(target=copiar_respaldo, args=(raiz, plan), daemon=True).start() return self._json({"ok": True}) if ruta == "/api/respaldo/detener": RESPALDO["cancelar"] = True return self._json({"ok": True}) return self._json({"error": "no existe"}, 404) except Exception as ex: # noqa import traceback return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500) # ========================================================================== # Línea de comandos: organizar una carpeta con un solo comando # ========================================================================== AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando OrganizadorLabstream.exe abre OrganiLAB en su ventana OrganizadorLabstream.exe instalar lo instala en su sitio: carpeta de programas, menú Inicio y Escritorio (desinstalar lo quita) OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar) OrganizadorLabstream.exe organizar "ruta" [opciones] --cliente "Nombre" nombre del cliente (por defecto, el de la carpeta) --tipo contenido|pauta|proyecto|interno (por defecto contenido) --solo-alta aplica solo los movimientos de confianza alta --todo aplica también los de confianza baja y los cosméticos --simular solo muestra el plan, no mueve nada --si no pregunta: aplica directamente OrganizadorLabstream.exe escanear "ruta" solo inventaría (para ver el mapa en la interfaz) OrganizadorLabstream.exe stock "ruta" [--meses 3] [--todo-tipo] [--si] aparta el stock de Envato viejo a _Para borrar/stock OrganizadorLabstream.exe respaldo "ruta" "destino" [--con-raw] [--con-stock] copia compacta a otro disco OrganizadorLabstream.exe deshacer LOTE deshace un lote de la bitácora OrganizadorLabstream.exe actualizar [--si] busca versión nueva en Labstream OS y la instala OrganizadorLabstream.exe duplicados "ruta" [--si] lista los duplicados; con --si los junta en subcarpetas Duplicados OrganizadorLabstream.exe automatico pone al día todas las carpetas registradas: lee cada una y hace solo lo seguro (es lo que lanza Windows cada día si se enciende el orden automático) La app NUNCA borra: lo que sobra va a _Para borrar dentro de la misma carpeta (la borra una persona a mano) y todo queda en datos\\bitacora.jsonl. """ def _gb(b): b = b or 0 if b >= 1e12: return f"{b / 1e12:.2f} TB" return f"{b / 1e9:.1f} GB" if b >= 1e8 else f"{b / 1e6:.0f} MB" def _opcion(args, nombre, defecto=None): if nombre in args: i = args.index(nombre) return args[i + 1] if i + 1 < len(args) else defecto return defecto def _confirmar(pregunta, auto): if auto: return True try: return input(pregunta + " [S/n] ").strip().lower() in ("", "s", "si", "sí", "y") except EOFError: return False def cli_escanear(ruta): ruta = os.path.normpath(ruta) print(f"Escaneando {ruta} …", flush=True) escanear(ruta) p = PROGRESO[ruta] if p["estado"] != "listo": print("ERROR al escanear:", p.get("error")) return False print(f" {p['n']:,} archivos · {_gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s") return True def cli_raiz(ruta, args): """asegura que la carpeta esté registrada como raíz (la crea si es nueva)""" ruta = os.path.normpath(ruta) if not os.path.isdir(ruta): print("No existe la carpeta:", ruta) return None r = raiz_cfg(ruta) if not r: nombre = os.path.basename(ruta.rstrip("\\/")) or ruta cliente = _opcion(args, "--cliente") or next((c for c, i in CONFIG["clientes"].items() if any(R.norm(a) == R.norm(nombre) for a in i.get("alias", []) + [c])), nombre) tipo = _opcion(args, "--tipo") or CONFIG["clientes"].get(cliente, {}).get("tipo", "contenido") r = {"ruta": ruta, "cliente": cliente, "tipo": tipo} CONFIG["raices"].append(r) CONFIG["clientes"].setdefault(cliente, {"tipo": tipo, "alias": [nombre]}) guardar_json(CONFIG_PATH, CONFIG) print(f"Carpeta nueva registrada como raíz: cliente «{cliente}», tipo {tipo}") return r def cli_organizar(ruta, args): r = cli_raiz(ruta, args) if not r or not cli_escanear(r["ruta"]): return 1 plan = Plan(r).correr() filas = plan["filas"] if not filas: print("Nada que mover: la carpeta ya está en orden.") return 0 if "--todo" in args: elegidas = filas elif "--solo-alta" in args: elegidas = [f for f in filas if f["confianza"] == "alta"] else: elegidas = [f for f in filas if f["marcado"]] print(f"\nPLAN: {len(filas)} movimientos propuestos; se aplicarían {len(elegidas)} " f"({_gb(sum(f['bytes'] for f in elegidas))}, {sum(f['n'] for f in elegidas):,} archivos)\n") for f in sorted(elegidas, key=lambda f: -f["bytes"])[:60]: n = f" [{len(f['archivos'])} archivos]" if f["archivos"] else "" print(f" {f['confianza']:5} {f['cubeta']:10} {_gb(f['bytes']):>8} {f['origen_rel']}{n}\n{'':28}-> {f['destino_rel']} ({f['motivo'][:70]})") if len(elegidas) > 60: print(f" … y {len(elegidas) - 60} más") omitidas = [f for f in filas if f not in elegidas] if omitidas: print(f"\n Sin aplicar (confianza baja o cosméticos): {len(omitidas)}. Con --todo se aplican; con la interfaz se revisan uno a uno.") for a in plan["avisos"]: print(" AVISO:", a) if "--simular" in args: print("\nSimulación: no se movió nada.") return 0 if not elegidas: return 0 if not _confirmar("\n¿Aplicar estos movimientos? (los archivos no se renombran; lo sobrante va a _Para borrar)", "--si" in args): print("Cancelado. No se movió nada.") return 0 res = aplicar(r["ruta"], elegidas) print(f"\nHecho: lote {res['lote']} · {res['movimientos']} movimientos · {len(res['errores'])} errores") for e in res["errores"][:20]: print(" ERROR:", e) print(f"Para deshacer: OrganizadorLabstream.exe deshacer {res['lote']}") return 0 if not res["errores"] else 2 def cli_stock(ruta, args): r = cli_raiz(ruta, args) if not r or not cli_escanear(r["ruta"]): return 1 meses = int(_opcion(args, "--meses", CONFIG["optimizar"]["meses_minimos"])) tipos = ["Video", "Audio", "Imagenes"] if "--todo-tipo" in args else ["Video"] s = stock_viejo(r["ruta"], meses, tipos) print(f"\nStock de Envato en proyectos anteriores a {s['tope']}: {s['n']} archivos · {_gb(s['bytes'])}") for g in s["grupos"][:40]: print(f" {_gb(g['bytes']):>8} {g['carpeta']} ({g['n']}: {', '.join(g['ejemplos'])[:80]})") if not s["n"] or "--simular" in args: return 0 if not _confirmar("¿Apartar todo a _Para borrar/stock? (reversible)", "--si" in args): return 0 res = stock_a_papelera(r["ruta"], [x for g in s["grupos"] for x in g["rutas"]]) print(f"Hecho: lote {res['lote']} · {res['movimientos']} archivos apartados. Están en _Para borrar\\stock: revísalos y bórralos a mano.") return 0 def cli_respaldo(ruta, destino, args): r = cli_raiz(ruta, args) if not r or not cli_escanear(r["ruta"]): return 1 plan = plan_respaldo(r["ruta"], destino, True, "--con-raw" in args, "--con-stock" not in args) print(f"\nRespaldo de «{plan['cliente']}» → {plan['base']}: {plan['n']:,} archivos · {_gb(plan['bytes'])}") print(" por tipo:", ", ".join(f"{t['tipo']} {t['n']} ({_gb(t['bytes'])})" for t in plan["por_tipo"])) if plan["raw_omitidos"]["n"]: print(f" NO se copian {plan['raw_omitidos']['n']} originales de cámara sin convertir ({_gb(plan['raw_omitidos']['bytes'])}); conviértelos antes o usa --con-raw") if "--simular" in args or not _confirmar("¿Copiar?", "--si" in args): return 0 hilo = threading.Thread(target=copiar_respaldo, args=(r["ruta"], plan), daemon=True) hilo.start() ultimo = -1 while hilo.is_alive(): time.sleep(2) pct = int(RESPALDO["bytes"] / RESPALDO["bytes_total"] * 100) if RESPALDO["bytes_total"] else 0 if pct != ultimo: print(f" {pct:3d}% {RESPALDO['hechos']}/{RESPALDO['total']} {RESPALDO['actual'][-60:]}", flush=True) ultimo = pct print(f"\n{RESPALDO['estado']}: {RESPALDO['copiados']} copiados, {RESPALDO['saltados']} ya estaban, {len(RESPALDO['errores'])} errores. Índice en {plan['base']}\\INDICE.csv") return 0 def cli(args): cmd = args[0].lower() if args else "" resto = args[1:] if cmd in ("-h", "--help", "ayuda", "/?"): print(AYUDA) return 0 if os.path.isdir(args[0]) and cmd not in ("organizar", "escanear", "stock", "respaldo", "deshacer", "duplicados", "asistente"): return asistente.cli(args[0], resto) # arrastraron una carpeta encima del .exe if cmd == "asistente" and resto: return asistente.cli(resto[0], resto[1:]) if cmd in ("actualizar", "actualizacion"): return actualizador.cli(resto) if cmd == "organizar" and resto: return cli_organizar(resto[0], resto[1:]) if cmd == "escanear" and resto: r = cli_raiz(resto[0], resto[1:]) return 0 if r and cli_escanear(r["ruta"]) else 1 if cmd == "stock" and resto: return cli_stock(resto[0], resto[1:]) if cmd == "respaldo" and len(resto) >= 2: return cli_respaldo(resto[0], resto[1], resto[2:]) if cmd == "deshacer" and resto: res = deshacer(resto[0]) print(f"Deshechos {res['deshechos']} movimientos · {len(res['errores'])} errores") return 0 if cmd == "duplicados" and resto: d = duplicados(os.path.normpath(resto[0])) print(f"{len(d['grupos'])} grupos · {d['n']} copias · {d['bytes'] / 1e9:.1f} GB ({d['identicos']} idénticos, {d['versiones']} versiones MP4/MOV)") for g in d["grupos"][:40]: print(f" [{g['tipo']}] {g['nombre']} queda: {g['queda']} → {len(g['mueven'])} a {g['destino']}") if "--si" in resto and d["grupos"]: print(apartar_duplicados(os.path.normpath(resto[0]), d["grupos"])) return 0 print(AYUDA) return 1 def _asegurar_consola(): """El .exe es de ventana (sin consola). Para los comandos y el arrastrar-carpeta hace falta una: se engancha a la del padre (cmd/PowerShell) o se abre una nueva.""" if not CONGELADO or os.name != "nt": return try: import ctypes k = ctypes.windll.kernel32 if k.GetConsoleWindow(): # ya hay consola return h = k.GetStdHandle(-11) if h and h != -1 and k.GetFileType(h) in (1, 3): # salida a archivo o tubería: se respeta return if not k.AttachConsole(-1): k.AllocConsole() sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", buffering=1) sys.stderr = sys.stdout sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace") k.SetConsoleTitleW("OrganiLAB") except Exception: # noqa pass SOLTADOS = {"ts": 0, "rutas": []} SESION_PATH = os.path.join(DATOS, "sesion.json") # qué copia tiene abiertos estos datos, y en qué puerto atiende _CANDADO = [] # el de «una sola copia abierta»; vive lo que el proceso SILENCIO_NAVEGADOR = 180 # segundos sin que la página pregunte para dar el navegador por cerrado RELEVO = [] # el .exe que toma el relevo al salir (tras instalarse en su sitio) def _al_soltar(rutas): """Lo que se acaba de soltar en la ventana, con su ruta completa: la ventana la sabe, la interfaz no.""" if rutas: SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas]) def soltado(nombre, espera=2.5): """Ruta completa de lo que se acaba de soltar en la ventana. Espera un poco porque el aviso de la ventana y la pregunta de la interfaz no llegan siempre en el mismo orden.""" fin = time.time() + espera while time.time() < fin: if time.time() - SOLTADOS["ts"] < 8: for r in SOLTADOS["rutas"]: if not nombre or os.path.basename(r.rstrip("\\/")).lower() == nombre.lower(): return r time.sleep(0.1) return None def en_marcha(): """Qué está haciendo el programa ahora mismo, dicho en llano, o "" si no hace nada. Mientras haya algo, cerrar la ventana no lo corta.""" que = actualizador.tarea_en_marcha() if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]: que = "se están moviendo archivos" if not que and TRAER.get("estado") in ("copiando", "ordenando"): que = "se está trayendo material de una tarjeta" if not que and AUTO.get("activo"): que = "el orden automático está poniendo al día las carpetas" return que def avance(): """Cuánto lleva lo que está en marcha: de 0 a 1, -1 si no se puede saber y None si no hay nada. Es lo que se ve en el botón de la barra de tareas.""" if COLA["activa"]: try: con = db() espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0] t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None con.close() except sqlite3.Error: return -1 total = COLA["hechos"] + (1 if t else 0) + espera return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1 if RESPALDO.get("estado") == "corriendo": return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1 if TRAER.get("estado") == "copiando": return TRAER["bytes"] / TRAER["bytes_total"] if TRAER.get("bytes_total") else -1 if AUTO.get("activo"): return AUTO["hechas"] / AUTO["total"] if AUTO.get("total") else -1 if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]: return APLICANDO["unidades"] / APLICANDO["unidades_total"] if APLICANDO.get("unidades_total") else APLICANDO["hechas"] / APLICANDO["total"] if asistente.ESTADO.get("activo"): return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1 for a in list(ANALISIS.values()): if (a or {}).get("estado") == "corriendo": return a["hechos"] / a["total"] if a.get("total") else -1 if any((p or {}).get("estado") == "corriendo" for p in list(PROGRESO.values())): return -1 if actualizador.ESTADO.get("estado") == "descargando": return (actualizador.ESTADO.get("pct") or 0) / 100 return None def _marca(): """Un número que identifica esta instalación por su carpeta de datos.""" return (zlib.crc32(os.path.normcase(os.path.abspath(DATOS)).encode("utf-8")) & 0x7FFFFFFF) or 1 def _soy_la_unica(): """False si ya hay otra copia del programa abierta sobre estos mismos datos: dos a la vez se pisarían el inventario, y la segunda daría por huérfanas las conversiones que la primera tiene en marcha.""" if os.name != "nt": return True k = ctypes.WinDLL("kernel32", use_last_error=True) k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}") if h and ctypes.get_last_error() == 183: # ya existía return False _CANDADO.append(h) return True def _limpiar_restos(): """Lo que dejó el .exe de PyInstaller de las versiones hasta la 2.x: su carpeta _internal, que ya no usa nadie (el motor de ahora va en la carpeta runtime).""" viejo = os.path.join(BASE, "_internal") if CONGELADO and not getattr(sys, "frozen", False) and os.path.isfile(os.path.join(viejo, "base_library.zip")): shutil.rmtree(viejo, ignore_errors=True) def _vigilar(v, fin): """Mientras la ventana existe: lleva el avance al botón de la barra de tareas, avisa cuando termina algo largo y, si la ventana se cerró con trabajo pendiente, cierra el programa en cuanto acaba.""" antes, desde = None, 0.0 while not fin.wait(1.0): try: f = avance() except Exception: # noqa f = None if f is None: if antes is not None: v.progreso(None) if time.time() - desde > 20 and not v.oculta: v.avisar() if v.oculta and not en_marcha(): v.cerrar() else: if antes is None: desde = time.time() if not v.oculta: v.progreso(f) antes = f def _recien_abierta(v): """Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece instalarlo: copiarlo a su sitio definitivo, darlo de alta y seguir desde allí.""" try: import ventana import instalar if instalar.al_dia(BASE, VERSION): return oferta = CONFIG.get("instalacion") or {} if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE): return if instalar.instalado() and not instalar.instalado_aqui(BASE): return # ya hay otro instalado en este equipo: esta copia va por libre CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez guardar_json(CONFIG_PATH, CONFIG) time.sleep(1.5) if not instalar.hay_que_mudar(BASE): # ya está en su sitio: solo falta darlo de alta if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n" "Se quita desde Configuración › Aplicaciones.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES: instalar.instalar(BASE, VERSION) v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.") return if v.mensaje("¿Instalar OrganiLAB en este equipo?\n\nSe copia a tu carpeta de programas, queda en el menú Inicio y en el " "Escritorio, y se quita desde Configuración › Aplicaciones. La carpeta desde la que lo has abierto ya no hará falta.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) != ventana.IDYES: return v.evaluar("modal(" + json.dumps("Instalando OrganiLAB…") + ", " + json.dumps( '

Copiando el programa a su sitio definitivo. Tarda unos segundos; al terminar se abre solo desde allí.

') + ", null)") try: r = instalar.poner(BASE, VERSION, avance=v.progreso) except Exception as ex: # noqa v.progreso(None) v.evaluar("document.querySelector('#modal').classList.remove('on')") v.mensaje(f"No se pudo instalar: {ex}\n\nEl programa sigue funcionando desde esta carpeta.", ventana.MB_OK | ventana.MB_ICONWARNING) return v.progreso(None) v.mensaje("OrganiLAB quedó instalado.\n\nAhora se cierra y se abre desde su sitio. La carpeta desde la que lo abriste " "ya no hace falta: puedes borrarla.") RELEVO.append(os.path.join(r["base"], "OrganizadorLabstream.exe")) v.cerrar() except Exception as ex: # noqa print("no se pudo dar de alta en Windows:", ex) def _ventana(url): """Abre la ventana del programa y no vuelve hasta que se cierra. Devuelve False si este Windows no puede abrirla (le falta WebView2): quien llama abre entonces la interfaz en el navegador.""" global VENTANA try: import ventana import instalar ok, detalle = ventana.disponible(BASE) if not ok: print("Sin ventana propia:", detalle) return False v = ventana.Ventana(url, titulo="OrganiLAB", icono=os.path.join(BASE, "static", "organilab.ico"), perfil=os.path.join(DATOS, "ventana"), memoria=os.path.join(DATOS, "ventana.json"), base=BASE, marca=_marca(), relanzar=instalar.orden(BASE), depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR"))) v.al_cerrar, v.al_soltar = en_marcha, _al_soltar v.al_abrir = lambda: _recien_abierta(v) fin = threading.Event() threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start() VENTANA = v try: abierta = v.mostrar() finally: VENTANA = None fin.set() if not abierta: print("La ventana no llegó a abrirse:", v.fallo) return abierta except Exception as ex: # noqa print("Sin ventana propia:", ex) return False def _atender_navegador(): """Sin ventana no hay nada que cerrar: el programa sigue mientras la página del navegador pregunte (lo hace cada pocos segundos) y se va cuando lleva tres minutos callada y no queda trabajo en marcha.""" ULTIMA_PETICION[0] = time.time() try: while time.time() - ULTIMA_PETICION[0] < SILENCIO_NAVEGADOR or en_marcha(): time.sleep(2) except KeyboardInterrupt: pass def main(): global PUERTO args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] if CONGELADO and "--puente" not in sys.argv: import instalar instalada = instalar.mudado(BASE) if instalada: # desde esta carpeta ya se instaló el programa: se abre el instalado if sys.argv[1:]: sys.exit(subprocess.call([instalada] + sys.argv[1:])) subprocess.Popen([instalada], cwd=os.path.dirname(instalada), close_fds=True) return if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv: import instalar # hablan con cuadros de mensaje: no hace falta consola if args_tmp[0].lower() == "instalar" and instalar.hay_que_mudar(BASE) and not _soy_la_unica(): import ventana # la mudanza se lleva los datos: con el programa abierto se quedarían a medias ctypes.windll.user32.MessageBoxW(None, "Cierra OrganiLAB antes de instalarlo.", "OrganiLAB", ventana.MB_OK | ventana.MB_ICONINFORMATION) sys.exit(1) sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv)) if args_tmp and args_tmp[0].lower() == "automatico" and "--puente" not in sys.argv: sys.exit(automatico_por_comando()) # lo lanza la tarea programada: sin consola ni ventana if args_tmp and "--puente" not in sys.argv: _asegurar_consola() try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: # noqa pass if "--puente" in sys.argv: # puente con Resolve, como lo llamaban los .exe hasta la 2.x import resolve_bridge sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1])) args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")] if args: if (CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?")): actualizador.comprobar_en_segundo_plano() codigo = cli(args) d = actualizador.ESTADO.get("disponible") if d: print(f"\nHay una versión nueva del Organizador ({d['version']}). Para instalarla: OrganizadorLabstream.exe actualizar") if CONGELADO and "--si" not in args and sys.stdin and sys.stdin.isatty(): try: input("\nPulsa Enter para cerrar.") except EOFError: pass sys.exit(codigo) sin_ventana = "--sin-navegador" in sys.argv or "--navegador" in sys.argv if not _soy_la_unica(): import ventana if not sin_ventana and ventana.traer_al_frente(_marca()): return # ya estaba abierto: se enseña esa ventana y nada más otra = cargar_json(SESION_PATH, {}).get("puerto") print("Ya hay un OrganiLAB abierto con estos datos" + (f", en el puerto {otra}." if otra else ".")) if otra and "--sin-navegador" not in sys.argv: webbrowser.open(f"http://127.0.0.1:{otra}/") # esa copia no tiene ventana: va en el navegador return h = herramientas() print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"]) print("BRAW:", "se leen con el decodificador de Blackmagic" if h["braw"]["ok"] else "falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve; solo hace falta para BRAW)") for resto in os.listdir(DATOS): # fichas de trabajo que dejó una conversión cortada (o el guion de Resolve, que no las borraba) if resto.endswith((".json", ".cancelar")) and resto.startswith(("resolve_lote_", "braw_")): try: os.remove(os.path.join(DATOS, resto)) except OSError: pass huerfanas = recuperar_huerfanas() threading.Thread(target=lambda: (repasar_verificados(), braws_al_lector()), daemon=True).start() if huerfanas: print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador) except OSError: if os.environ.get("ORGANIZADOR_PUERTO"): print(f"El puerto {PUERTO} está ocupado por otro programa.") sys.exit(1) # el puerto de siempre lo tiene otro programa (u otro OrganiLAB con otros datos): se toma uno libre, # que a la ventana le da igual cuál sea srv = ServidorLocal(("127.0.0.1", 0), Manejador) PUERTO = srv.server_address[1] url = f"http://127.0.0.1:{PUERTO}/" guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO}) if sys.stdout: print(f"OrganiLAB {VERSION} en {url}") if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True: actualizador.comprobar_en_segundo_plano() try: if "--sin-navegador" in sys.argv: # solo el servidor (pruebas y desarrollo) try: srv.serve_forever() except KeyboardInterrupt: pass return hilo = threading.Thread(target=srv.serve_forever, daemon=True) hilo.start() threading.Thread(target=_limpiar_restos, daemon=True).start() if "--navegador" in sys.argv: webbrowser.open(url) try: hilo.join() except KeyboardInterrupt: pass return if not _ventana(url): webbrowser.open(url) _atender_navegador() while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir time.sleep(1) srv.shutdown() srv.server_close() if RELEVO: # se acaba de instalar en su sitio: sigue desde allí subprocess.Popen([RELEVO[0]], cwd=os.path.dirname(RELEVO[0]), close_fds=True) finally: try: os.remove(SESION_PATH) except OSError: pass if __name__ == "__main__": main()