# -*- 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 time import queue import shutil 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 = "1.6.2" 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", "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"}, } 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) 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",): 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 escanear(ruta): raiz = os.path.normpath(ruta) prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""} filas = [] 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): try: it = os.scandir(path) except OSError: 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"): continue 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 s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) or R.norm(nombre) in ("elements", "elemnts") 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 walk(e.path, p, b, s, c2, n2, a2, m2) else: 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, st.st_mtime) 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: continue try: walk(raiz, False, False, False, "", "raiz", None, None) con = db() 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, 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=?, 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"], 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" 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 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", ""), } # 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): 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 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", }) 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())) 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 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) if nombre != canon: motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "") self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if conflicto 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 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) 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: ay, am, fconf = anio, mes, "alta" else: ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) por_nombre = f["fecha_origen"] != "mtime" 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) 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, 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": sub = {"diseno": "Diseño", "proyecto": "Edicion"}.get(rol, "") motivo = "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", nombre=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 "") try: if f.get("archivos"): especiales = f.get("destinos_archivo") or {} if not especiales: os.makedirs(f["destino"], exist_ok=True) for a in f["archivos"]: if os.path.exists(a): mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos) else: if os.path.exists(f["origen"]): mover(f["origen"], f["destino"], lote, movidos) hechas += 1 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""" 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 THEN 1 WHEN 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 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", (nueva, os.path.dirname(nueva), en_papelera, r["ruta"])) con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%")) 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 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 = 0, [], [] for e in reversed(entradas): try: if e["op"] == "rmdir": os.makedirs(e["de"], exist_ok=True) 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) 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) out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) return out # ========================================================================== # 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 "", } def analizar(raiz, min_mb): prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()} con = db() pend = con.execute("SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado = 0 AND es_proxy = 0 AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock')", (raiz, int(min_mb) * 1024 * 1024)).fetchall() prog["total"] = len(pend) try: for f in pend: info = ffprobe(f["ruta"]) with con: if info: if f["ext"] == ".braw": info["codec"] = "braw" con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, 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"], f["ruta"])) else: con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (f["ruta"],)) prog["hechos"] += 1 prog["estado"] = "listo" 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 candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False): con = db() limite = time.time() - int(meses) * 30.4 * 86400 filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall() en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").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 f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * 1.3: motivo_no = "ya está en HEVC/AV1 eficiente" 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 = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) est = int(obj * 1000 / 8 * (f["dur"] or 0)) + int(256000 / 8 * (f["dur"] or 0)) 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): o = CONFIG["optimizar"] base, _ = os.path.splitext(ruta) if o.get("salida") == "espejo": rel = os.path.relpath(base, raiz) return os.path.join(raiz, "_Archivo", rel + ".mp4") return base + o.get("sufijo", "_10bit") + ".mp4" def encolar(rutas, perfil, chroma, color, raiz): 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 estado IN ('pendiente','corriendo','listo','verificado')", (r,)).fetchone() if ya: continue salida = salida_para(r, raiz) # 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 motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" kb = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz)) ids.append(cur.lastrowid) con.close() return ids COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": ""} 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 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"] maxrate = int(t["kbps"] * 1.8) salida = t["salida"] os.makedirs(os.path.dirname(salida), exist_ok=True) tmp = salida + ".parcial.mp4" cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?", "-c:v", "hevc_nvenc", "-pix_fmt", "p210le" if chroma == "422" else "p010le", "-profile:v", "rext" if chroma == "422" else "main10", "-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", str(int(round(fps * 2))), "-tag:v", "hvc1", "-map_metadata", "0", "-movflags", "+faststart"] if f and f["acodec"] == "aac": cmd += ["-c:a", "copy"] else: 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:]) 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"]]) 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]) 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 iniciar_cola(): if COLA["hilo"] and COLA["hilo"].is_alive(): return False COLA["activa"] = True COLA["cancelar"] = False 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") if info["codec"] != "hevc": problemas.append(f"códec {info['codec']}") if "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 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): 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 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 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, "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}, "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 = [] 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) ya = st.st_size == it["bytes"] and abs(st.st_mtime - it["mtime"]) < 2 if ya: RESPALDO["saltados"] += 1 else: tmp = d + ".parcial" shutil.copy2(it["origen"], tmp) if os.path.getsize(tmp) != it["bytes"]: 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 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(indice) resumen = defaultdict(lambda: defaultdict(lambda: [0, 0])) for rel_d, rel_o, tipo, b, _ in indice: 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] += b 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) def quitar_raiz(ruta, borrar_datos=False): """Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).""" 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") con = db() activos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo')", (k.lower(),)).fetchone()["n"] if activos: con.close() raise ValueError(f"hay {activos} conversiones pendientes o en curso de esa carpeta; detén la cola o límpiala primero") 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()} con.close() 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": COLA["actual"], "mensaje": COLA["mensaje"], "n": cola_n}, "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, "plantillas": PLANTILLAS} 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): 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/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")}) 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/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") PLANES_RESPALDO[raiz] = plan CONFIG.setdefault("respaldo", {})["destino"] = destino guardar_json(CONFIG_PATH, CONFIG) 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/raiz/quitar": try: return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")))) except ValueError as e: return self._json({"error": str(e)}, 409) if ruta == "/api/raices": 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", "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": PLANTILLAS.update(d.get("plantillas", {})) guardar_json(PLANTILLAS_PATH, PLANTILLAS) 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/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": 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"])) 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/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": 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): return f"{(b or 0) / 1e9:.1f} GB" if (b or 0) >= 1e8 else f"{(b or 0) / 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 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") 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)") 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()