# -*- coding: utf-8 -*- """ Organizador Labstream — inventario, mapa, orden y optimización del material. Solo librería estándar de Python (3.10+). Arranca un servidor local y abre la interfaz en el navegador: py -3.12 organizador.py (o doble clic en Iniciar.bat) 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 stat as stat_mod import hashlib import sqlite3 import threading import subprocess 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 asistente import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] VERSION = "2.4.0" CONGELADO = getattr(sys, "frozen", False) SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller BASE = os.path.dirname(os.path.abspath(sys.executable if CONGELADO else __file__)) 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 = { "raices": [ {"ruta": r"Z:\Dermatologas\David Reyes", "cliente": "David Reyes", "tipo": "contenido"}, {"ruta": r"W:\Danney", "cliente": "Danney Gómez", "tipo": "contenido"}, {"ruta": "N:\\", "cliente": "Camilo Ortega", "tipo": "pauta"}, ], "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 }, } 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} 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")) 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 _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, ValueError): 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 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 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"} 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) guardar_json(DICC_PATH, DICC) 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 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"): 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, fecha_am): 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 info[2][fecha_am] += 1 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 p = en_proxy or R.es_proxy_dir(nombre) b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM # 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)) fy, fm = int(fecha[:4]), int(fecha[5:7]) 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 d = path while True: agrega_dir(d, st.st_size, (fy, fm)) if os.path.normpath(d).lower() == raiz.lower(): break padre = os.path.dirname(d) if padre == d: break d = padre 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) _heredar_fechas(filas) 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])) 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 / n 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 VALUES (?,?,?,?,?)", (raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"])) con.close() prog["estado"] = "listo" if fallos: prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})" PLANES.pop(raiz, None) # 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): base = R.base_derivado(f[I_NOMBRE]) if not base: continue 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 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 lanzar_escaneo(ruta): raiz = os.path.normpath(ruta) p = PROGRESO.get(raiz) if p and p["estado"] == "corriendo": return False threading.Thread(target=escanear, args=(raiz,), daemon=True).start() return True # ========================================================================== # 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", ""), "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) s = re.sub(r"[-_ ]*\x00", "", s) if seg.startswith("{"): s = s.lstrip("-_ ") if seg.endswith("}"): s = s.rstrip("-_ ") if s: segmentos.append(s) if not segmentos: segmentos = ["_Sin fecha"] return os.sep.join(segmentos) # ========================================================================== # 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.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos self.con = db() self.avisos = [] # ---- 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 agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None, marcado=None, kw_destinos=None): destino = os.path.normpath(destino) if archivos is None and os.path.normpath(origen).lower() == destino.lower(): return 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 = "" if archivos is not None and accion != "renombrar": # también para los grupos de archivos: si el destino ya tiene un archivo con ese nombre, # mover chocaría. Antes solo se comprobaba en las filas de carpeta. especiales = kw_destinos or {} # solo los primeros: cada comprobación es una consulta al disco de red, y para avisar # basta con encontrar uno choques = [os.path.basename(a[0]) for a in archivos[:40] if os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))] if choques: aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre " f"(«{choques[0]}»): esos no se moverían") confianza, marcado = "baja", False elif archivos is None and accion != "renombrar" and os.path.isfile(destino): aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá" confianza, marcado = "baja", False elif archivos is None and os.path.isdir(destino) and os.path.isdir(origen): aviso_colision = " · la carpeta destino ya existe: se fusionan" 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": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto", }) def suelto(self, fila, cubeta, destino_rel, motivo, confianza): clave = (cubeta, destino_rel, motivo, confianza) self.sueltos[clave].append((fila["ruta"], fila["bytes"])) def volcar_sueltos(self): for (cubeta, destino_rel, motivo, confianza), 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() # ---- niveles ---- def correr(self): self.plan_dir(self.raiz, "raiz", None, None) self.volcar_sueltos() self.plan_basura() 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} 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 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_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}). Se dejan sin marcar: " f"cámbiale el destino a una de las dos, o aplícalas por separado.") 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): dirs, files = self.entradas(ruta) # numeración de piezas dentro de un mes max_num = 0 if nivel == "mes": for e in dirs: p = R.detectar_pieza(e.name) if p: max_num = max(max_num, p[0]) for e in dirs: self.plan_subdir(e, nivel, anio, mes, ruta, max_num) if nivel == "mes" and not R.detectar_pieza(e.name): cub, _ = R.cubeta_carpeta(e.name, self.dicc) if cub is None: max_num += 1 for e in files: self.plan_archivo(e, nivel, anio, mes, ruta) def plan_subdir(self, e, nivel, anio, mes, padre, max_num): 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 nn in R.CARPETAS_PROPIAS or nombre.startswith("_"): 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) fa = int(m_anio.group(1)) if m_anio else 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) 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) return if nivel == "mes": p = R.detectar_pieza(nombre) if p: cub_t, _ = R.cubeta_carpeta(p[1], self.dicc) if cub_t == "GRABACION": # "03 - Grabacion Agosto": es la sesión del mes, no una pieza self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación") return canon = R.nombre_pieza(p[0], limpiar_nombre(p[1])) if 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) return # dentro de una pieza no se toca cub, motivo = R.cubeta_carpeta(nombre, self.dicc) if cub == "IGNORAR": return if cub == "CONTENEDOR": m_anio = re.search(r"(20\d\d)", nombre) 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 if cub == "GRABACION": self.plan_grabacion(e, nivel, anio, mes, motivo) return generico = nn in self.dicc 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: filas_c = [f for f in archivos_bajo(self.con, e.path) if not f["es_basura"] and not f["es_proxy"]] cam = [f for f in filas_c if f["fuente"] in R.FUENTE_CARPETA or f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado")] if filas_c and len(cam) >= 0.8 * len(filas_c): self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara") return if nivel == "mes": canon = R.nombre_pieza(max_num + 1, 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 = "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 = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path)) self.agregar(e.path, self.abs_(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_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) generico = nn in self.dicc or nn in ("grabacion", "grabado", "grabaciones") filas = archivos_bajo(self.con, e.path) if not filas: return grupos = defaultdict(list) desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta for f in filas: if f["es_basura"] or f["es_proxy"]: 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 ("video", "camara_video"): fuente = "OTROS" elif f["rol"] in ("imagen", "foto_camara", "celular_foto"): fuente = "FOTOS" else: fuente = "OTROS" # año / mes que digan las carpetas intermedias (p. ej. "Blackmagic (BRAW)/2023/…") anio_ruta, mes_ruta = None, None for seg in os.path.relpath(f["dir"], e.path).split(os.sep): if R.detectar_anio(seg): anio_ruta = R.detectar_anio(seg) elif R.detectar_mes(seg)[0]: mes_ruta = R.detectar_mes(seg)[0] 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. ay, am, fconf = anio, mes, "alta" if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes): ra, rm = int(f["fecha"][:4]), int(f["fecha"][5:7]) if f["fecha_origen"] == "nombre+mtime": ra = anio if rm <= mes else anio - 1 # el nombre de la cámara no trae año ay, am, fconf = ra, rm, "media" desacuerdo[(ra, rm)] += 1 else: ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» fconf = "media" if por_nombre 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: am = mes_ruta fconf = "media" hoy = datetime.date.today() if (ay, am) > (hoy.year, hoy.month): ay -= 1 grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"])) etiqueta = "" if generico else R.etiqueta_grabacion(nombre) 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, fuente, fconf), archivos in grupos.items(): sub = fuente if not etiqueta else os.path.join(etiqueta, fuente) destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, 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 "")) 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 not fila: return 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: fm = mes elif fila["fecha_origen"] == "mtime": 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="") self.suelto(fila, cub, destino, motivo, conf) def plan_basura(self): con = self.con # 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 = [] for c in sorted(carpetas, key=lambda c: len(c["ruta"])): if any(c["ruta"].lower().startswith(p.lower() + os.sep) for p in cubiertas): continue if any(os.path.normpath(f["origen"]).lower() == c["ruta"].lower() for f in self.filas): cubiertas.append(c["ruta"]) 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"]) 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 any(f["dir"].lower() == p.lower() or f["dir"].lower().startswith(p.lower() + os.sep) for p in cubiertas): 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 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.lower() == destino.lower(): return if os.path.isdir(origen) and os.path.isdir(destino): for nombre in os.listdir(origen): mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos) try: os.rmdir(origen) bitacora({"lote": lote, "op": "rmdir", "de": origen}) except OSError: pass 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: os.rename(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)) APLICANDO = {"hechas": 0, "total": 0, "actual": ""} def aplicar(raiz, filas): lote = time.strftime("%Y%m%d-%H%M%S") movidos = [] errores = [] hechas = 0 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())) APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz)) for f in filas: APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "") 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"]: 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 if fallos_fila <= 5: errores.append(f"{os.path.basename(a)}: {ex}") 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: errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)") except Exception as ex: # noqa errores.append(f"{f.get('origen_rel')}: {ex}") APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo» # 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) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores}) return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores} 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.""" con = db() marcas = [os.sep + p + os.sep for p in R.PAPELERAS] 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)) pref = de + os.sep for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).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 LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%")).fetchall() con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%")) if os.path.isdir(a): 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"])) con.close() 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()): 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 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 = [], [] 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 not os.path.isdir(c) or not _sin_archivos(c): continue # ya no existe, o alguien dejó algo dentro 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() 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")] 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 = 0, [], [], [] for e in reversed(entradas): try: if 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 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 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))) 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): con = db() filas = con.execute("SELECT * FROM carpetas WHERE raiz = ?", (raiz,)).fetchall() con.close() tokens = {} for c in filas: nombre = c["nombre"] if os.path.normpath(c["ruta"]).lower() == os.path.normpath(raiz).lower(): continue if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]: continue rel = os.path.relpath(c["ruta"], raiz) padre = os.path.basename(os.path.dirname(c["ruta"])) if R.detectar_pieza(nombre) and R.detectar_mes(padre)[0]: continue if R.norm(nombre) in R.CARPETAS_PROPIAS or nombre.startswith("_"): continue tok = R.norm(nombre) if not tok or len(tok) > 60: continue # ¿está dentro de una pieza (carpeta numerada bajo un mes)? esas no se mueven partes = rel.split(os.sep)[:-1] dentro_pieza = any(R.detectar_pieza(partes[i]) and i > 0 and R.detectar_mes(partes[i - 1])[0] for i in range(len(partes))) t = tokens.get(tok) if t is None: cub, motivo = R.cubeta_carpeta(nombre, DICC["carpetas"]) t = tokens[tok] = {"token": tok, "ejemplo": nombre, "n": 0, "archivos": 0, "bytes": 0, "rutas": [], "dentro_pieza": dentro_pieza, "cubeta": cub or "", "motivo": motivo, "aprendido": tok in DICC["carpetas"] and tok not in R.DICCIONARIO_SEMILLA} t["dentro_pieza"] = t["dentro_pieza"] and dentro_pieza t["n"] += 1 t["archivos"] += c["n"] or 0 t["bytes"] += c["bytes"] or 0 if len(t["rutas"]) < 4: t["rutas"].append(rel) if tokens and any(not t["cubeta"] for t in tokens.values()): con = db() resumen = _resumen_carpetas(con, raiz) con.close() for t in tokens.values(): if not t["cubeta"]: _sugerir(resumen, raiz, t) out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) return out _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 def _sugerir(resumen, raiz, t): """Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre. Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible).""" import difflib conteo = defaultdict(lambda: [0, 0]) total = [0, 0] for rel in t["rutas"][:3]: for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items(): conteo[k][0] += n conteo[k][1] += b total[0] += n total[1] += b orden = sorted(conteo.items(), key=lambda kv: -kv[1][1]) 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 = "", "", "" # a) 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"): sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»" # b) por el contenido (manda si es contundente) if total[0] >= 3 and orden: k, (n, b) = orden[0] frac = b / total[1] if total[1] else n / total[0] if frac >= 0.75: por_cont = {"camara": "GRABACION", "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 varias carpetas iguales. 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"], "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)" pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado IN {estados} 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): 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 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"] = 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)) 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"]) con = db() limite = time.time() - int(meses) * 30.4 * 86400 # 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()} con.close() out = [] for f in filas: motivo_no = "" motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" if 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 fm["codec"] == "hevc" and f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * fm.get("bpp_mult", 1.0) * 1.3: motivo_no = "ya está en HEVC/AV1 eficiente" elif motor == "resolve" and formato != "h265_10bit": motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits" elif f["mtime"] > limite: 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)" obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) 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] con = db() ids = [] 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 = "resolve" if f["ext"] == ".braw" else "ffmpeg" if motor == "resolve" and formato != "h265_10bit": continue # los BRAW solo salen por Resolve a H.265 10 bits salida = salida_para(r, raiz, formato) # 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)) 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() return ids COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0} 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 correr_ffmpeg(t): o = CONFIG["optimizar"] 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 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 + FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]).get("cq_mas", 0)))) maxrate = int(t["kbps"] * 1.8) salida = t["salida"] formato = formato_de(t) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) _m = motores() motor = _m["formatos"].get(formato) 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: set_trabajo(t["id"], estado="error", log="esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0") return if not motor: set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)") return os.makedirs(os.path.dirname(salida), exist_ok=True) tmp = salida + ".parcial" + fm["ext"] gop = str(int(round(fps * 2))) cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"] 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) cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"] elif motor == "gpu": diez = fm["bits"] == 10 cmd += ["-c:v", fm["gpu"], "-pix_fmt", ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p", "-preset", "p6", "-tune", "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", "-bf", "3", "-b_ref_mode", "middle", "-g", gop] if fm["codec"] == "hevc": cmd += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"] else: cmd += ["-profile:v", "high"] else: diez = fm["bits"] == 10 cmd += ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), "-pix_fmt", ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p", "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop] if fm["codec"] == "hevc": cmd += ["-tag:v", "hvc1"] if diez: cmd += ["-profile:v", "main422-10" if chroma == "422" else "main10"] cmd += ["-map_metadata", "0"] if fm["ext"] == ".mp4": cmd += ["-movflags", "+faststart"] if fm["codec"] == "prores": cmd += ["-c:a", "pcm_s16le"] elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1: cmd += ["-c:a", "copy"] else: # con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4 cmd += ["-c:a", "aac", "-b:a", "320k"] 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) try: os.remove(tmp) except OSError: pass return if p.returncode != 0 or not os.path.exists(tmp): set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) try: os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video except OSError: pass 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_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) if CONGELADO and not o.get("python_resolve"): cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente else: py = o.get("python_resolve") or sys.executable cmd = [py, 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() 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 = 'ffmpeg' ORDER BY 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: COLA["actual"] = t["id"] set_trabajo(t["id"], estado="corriendo", pct=0) try: correr_ffmpeg(dict(t)) except Exception as ex: # noqa set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}") _verificar_listos([t["id"]]) COLA["hechos"] += 1 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) 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 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) 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() COLA["activa"] = True COLA["cancelar"] = False COLA["hechos"] = 0 COLA["inicio"] = time.time() COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) COLA["hilo"].start() return True def verificar(ids): con = db() res = [] 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 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 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: set_trabajo(id_, estado="verificado", bytes_out=os.path.getsize(t["salida"])) res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"}) con.close() 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, "" 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 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 .mp4 proxy de Blackmagic se van con el original base, ext = os.path.splitext(t["ruta"]) for extra in (base + "M01.xml", base + "M01.XML"): 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"]]}) 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"]) if t not in tipos and not (t == "Otros" and f["ext"] in (".zip", ".rar") and "Video" in 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": [], "mes": f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]}) g["n"] += 1 g["bytes"] += f["bytes"] g["rutas"].append(f["ruta"]) if len(g["ejemplos"]) < 3: g["ejemplos"].append(f["nombre"]) 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 duplicados(raiz, minimo=5_000_000): """Grupos de archivos repetidos. Dos 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.""" con = db() filas = [dict(r) for r in con.execute( "SELECT ruta, dir, nombre, ext, bytes FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0", (os.path.normpath(raiz), minimo)).fetchall()] con.close() filas = [f for f in filas if _fuera_de_dup(f["ruta"]) and os.path.exists(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)}) 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")} 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)["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 PLANES_RESPALDO = {} def _clave(ruta): return os.path.normpath(ruta) _ELIGIENDO = threading.Lock() def elegir_carpeta(): """Abre el selector de carpetas de Windows (en un PowerShell aparte, para no bloquear el servidor) y devuelve la ruta o None.""" if not _ELIGIENDO.acquire(blocking=False): raise ValueError("ya hay un selector de carpetas abierto") try: ps = ("Add-Type -AssemblyName System.Windows.Forms; " "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " "$d.Description = 'Elige la carpeta del cliente'; $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 [])}, "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"), "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"), "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, "OTROS") if cub == "CELULAR" and tipo == "contenido": cub, kw["sub"] = "GRABACION", "CELULAR" 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) return {"nombre": nombre, "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(" 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) 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: 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="") 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="Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave) # --- 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 --- 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 + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)", [raiz] + args_pap).fetchone() if rep_fila["n"]: añadir(clave="repetidos", titulo="Juntar material repetido", boton="Ver repetidos", que="Mismo nombre y mismo peso en varias carpetas.", cifra=f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}", detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está.", ir="repetidos") # --- 7. convertir clips viejos --- o = CONFIG["optimizar"] cam = con.execute( "SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b, SUM(analizado = 1) ana 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).fetchone() 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: añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver 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=f"{cam['ana']} ya están leídos" + (f" y {hechos} reemplazados." if hechos else "."), ir="convertir") # --- 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() # «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")] return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), "automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None} 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], "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 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): 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"} 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/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/actualizacion": return self._json({"version": VERSION, **actualizador.ESTADO}) if ruta == "/api/diagnostico": return self._json(asistente.diagnostico(q["raiz"])) if ruta == "/api/asistente/estado": return self._json(asistente.ESTADO) 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(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") == "fin": lo["raiz"] = e.get("raiz", "") lo["errores"] = len(e.get("errores") or []) 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() filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()] con.close() return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}}) 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"]))) 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", ""))}) 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): 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: return self._json({"ruta": elegir_carpeta()}) 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/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"}, "respaldo": {"destino"}} 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]}) 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) 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) 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: 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) PLANES.pop(raiz, None) return self._json(res) if ruta == "/api/deshacer": return self._json(deshacer(d["lote"])) if ruta == "/api/actualizacion/buscar": actualizador.comprobar() return self._json({"version": VERSION, **actualizador.ESTADO}) if ruta == "/api/actualizacion/instalar": ok = actualizador.descargar_y_aplicar(en_hilo=True) return self._json({"lanzado": bool(ok), **actualizador.ESTADO}) if ruta == "/api/tour-visto": CONFIG["tour_visto"] = True guardar_json(CONFIG_PATH, CONFIG) return self._json({"ok": True}) 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) 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": con = db() with con: con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", [])) 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/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/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 la interfaz en el navegador 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 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": []} def _al_soltar(e): try: rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")] except Exception: # noqa rutas = [] 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 (llega por el puente WebView2). Espera un poco porque el drop del navegador y el aviso del puente no llegan 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 _ventana(url): """Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador.""" try: import webview except Exception: # noqa webbrowser.open(url) return False try: v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff") def _al_cargar(): # WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da): # se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop. try: v.dom.document.events.drop += _al_soltar except Exception as ex: # noqa print("sin puente de arrastre:", ex) v.events.loaded += _al_cargar webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana")) return True except Exception as ex: # noqa print("Sin WebView2; abro el navegador:", ex) webbrowser.open(url) return False def main(): args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] 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: # modo puente con Resolve (lo usa el propio .exe) 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 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) h = herramientas() print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"]) print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)") huerfanas = recuperar_huerfanas() if huerfanas: print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) except OSError: print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.") webbrowser.open(f"http://127.0.0.1:{PUERTO}/") return url = f"http://127.0.0.1:{PUERTO}/" if sys.stdout: print(f"OrganiLAB {VERSION} en {url} (Ctrl+C para cerrar)") if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto", True): actualizador.comprobar_en_segundo_plano() if "--sin-navegador" in sys.argv: # solo el servidor (desarrollo / vista previa) try: srv.serve_forever() except KeyboardInterrupt: pass return hilo = threading.Thread(target=srv.serve_forever, daemon=True) hilo.start() if "--navegador" in sys.argv: webbrowser.open(url) try: hilo.join() except KeyboardInterrupt: pass return # ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha) _ventana(url) while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"): time.sleep(1) srv.shutdown() if __name__ == "__main__": main()