From 477857db45182327f1ccccc38a86ae7c2c5261f1 Mon Sep 17 00:00:00 2001 From: Labstream Date: Sun, 4 Oct 2026 17:05:57 -0500 Subject: [PATCH] 3.3.0: lo que entra nuevo ya entra ordenado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las cuatro funciones que eligió Munka tras la 3.2.0: - Nueva pieza: crea «NN- Título» en su mes con el número que toca y sus subcarpetas; si la pieza ya existe, lo dice. Se deshace mientras siga vacía. - Bandeja de entrada: lo que se deje en _Entrada se reparte por mes, día de rodaje y cámara. «Traer material» copia una tarjeta comprobando cada archivo (se vuelve a leer y se compara la huella), conserva la fecha del rodaje y no toca el origen. - Versiones del mismo clip: dos clips de cámara que se llaman igual, duran lo mismo y tienen la misma forma son el mismo clip; se queda el de más imagen y el otro va a Duplicados. - Orden automático, apagado de fábrica: una tarea del Programador de tareas de Windows lanza «automatico», que lee todas las carpetas y hace solo lo seguro. No toca una carpeta que no se pueda leer entera ni una cuyo plan mueva más de 3.000 archivos, y si el programa ya está abierto se lo pide a él. Desinstalar quita la tarea. De paso: una ruta que empieza por guion bajo ya no pierde el guion al ir a la papelera. Pruebas: prueba_orden.py 24, probar.py 19, prueba_funcional.py 29, prueba_ventana.py 18 desde el código y 17 con el paquete. Co-Authored-By: Claude Fable 5.1 --- README.md | 53 +++ actualizador.py | 4 + asistente.py | 7 +- instalar.py | 14 +- manifiesto.json | 22 +- organizador.py | 840 ++++++++++++++++++++++++++++++++++++++++++-- probar.py | 10 +- prueba_funcional.py | 83 ++++- prueba_orden.py | 219 ++++++++++++ reglas.py | 9 +- static/index.html | 137 +++++++- 11 files changed, 1344 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index ef1aa6b..5d504bf 100644 --- a/README.md +++ b/README.md @@ -817,6 +817,59 @@ y repartida en sus meses, y una segunda pasada no propuso nada marcado. Pruebas: 19 casos que reproducen cada uno de estos puntos en pequeño; `probar.py` 19 de 19, `prueba_funcional.py` 26 de 26 y `prueba_ventana.py` 18. +3.3.0, lo que entra nuevo ya entra ordenado. Tras la 3.2.0 Munka eligió cuatro funciones de las nueve que +se le propusieron. Las cuatro atacan el sitio donde nace el desorden: el día que se vuelca la tarjeta y el +día que se crea la carpeta de una pieza. + +- **Bandeja de entrada.** La carpeta `_Entrada` dentro de la del cliente. El plan la abre siempre: lo que + se deje ahí (una tarjeta volcada, clips sueltos, una carpeta con el nombre de la sesión) va a su mes, a su + día de rodaje y a su cámara. En la lista de la carpeta sale como «Repartir», que vuelve a leer la carpeta + y aplica solo lo que sale de la bandeja y va marcado (`ordenar_entrada`); lo dudoso se queda dentro. La + bandeja no se borra al vaciarse ni cuenta como carpeta vacía, y el esqueleto de la tarjeta (PRIVATE, + M4ROOT, CLIP…) se quita solo, apuntado en el mismo lote para que «Deshacer» lo rehaga. De una tarjeta + Sony, `SUB` cuenta como proxies y `THMBNL`, `GENERAL` y `MEDIAPRO.XML` como lo que son: índices de la + cámara, a `_Para borrar`. +- **Traer material** (`traer`). Copia una tarjeta, o cualquier carpeta de fuera, a la bandeja y la reparte. + Cada archivo se copia a un nombre provisional, se vuelve a leer entero y se compara su huella (BLAKE2) + con la del original; solo entonces toma su nombre, con la fecha del archivo conservada, que es la del + rodaje. La lista de lo copiado con su huella queda en `.organilab\entradas\` dentro de la carpeta del + cliente. Del origen solo se lee. Una copia cortada se retoma; un archivo que ya está y es distinto no se + pisa. No se trae de dentro de la carpeta del cliente ni de la de otro cliente registrado, y se comprueba + antes que cabe. +- **Nueva pieza** (`nueva_pieza`). Crea «NN- Título» en su mes con el número siguiente al mayor que haya y + las subcarpetas de `config.json` › `pieza.subcarpetas` (las que más se repiten en las piezas del estudio). + Si la pieza ya existe, aunque esté escrita distinto, lo dice y no crea otra. En clientes de campañas o de + proyecto usa su plantilla, sin número. Queda en la bitácora como `mkdir`: «Deshacer» la quita mientras + siga vacía. Una pieza recién creada, y el mes o el año que se crearon para ella, no salen entre las + carpetas vacías por borrar durante 30 días. +- **Versiones del mismo clip** (`_mismas_tomas`). Dos clips de cámara que se llaman igual y pesan distinto + pueden ser el mismo clip con dos calidades, o dos clips de dos tarjetas (las dos traen un C0001.MP4). Se + leen con ffprobe: si duran lo mismo y tienen la misma forma de imagen son el mismo clip, se queda el de + más imagen (a igualdad, el que más pesa) y el otro va a `Duplicados` a su lado; si no, se dejan. Solo + material de cámara: la primera versión comparaba cualquier video, y sobre la carpeta de Galeano juntaba + la entrega vertical y la horizontal de una pieza, que se llaman igual y duran lo mismo. Es la tercera + clase de «Juntar repetidos», que ahora tiene botones propios en «Qué hay» («Comparar y ver todos», + «Juntar en Duplicados»); hasta ahora solo se llegaba por «hacer de una vez». En esa carpeta hay 24 clips + de un rodaje guardados dos veces (los de 99 MB y su copia de 31 MB): con la ligera apartada, la fila de + 39 clips que el plan dejaba sin marcar ya no choca y puede ir a su mes. +- **Orden automático** (`automatico`). Viene apagado. Encendido desde la portada, da de alta una tarea del + usuario en el Programador de tareas de Windows («OrganiLAB - orden automatico», diaria, a la hora + elegida) que lanza `OrganizadorLabstream.exe automatico`, sin consola ni ventana. La pasada recorre las + carpetas registradas: escanea cada una y hace lo mismo que «hacer de una vez». Tres frenos: si la carpeta + no está conectada o no se pudo leer entera, no la toca; si el plan mueve más de `automatico.limite` + archivos (3.000), tampoco, y lo dice; y si el programa ya está abierto, la tarea se lo pide a él por su + puerto en vez de trabajar a la vez. El resumen queda en `datos\automatico.json` y en la portada, carpeta + por carpeta, con «Hacerlo ahora» para lanzarlo a mano. Con `ORGANILAB_ENSAYO` puesto, la tarea se escribe + en un archivo en vez de darse de alta: así lo prueban las pruebas. + +De paso: `destino_plantilla` recortaba el guion bajo inicial de cualquier ruta que entrara en una variable, +así que lo apartado desde `_Entrada\…` llegaba a la papelera como `Entrada\…`; ahora solo recorta lo que deja +una variable vacía. Y cada cuadro de confirmación de la interfaz vuelve a abrir con su botón habilitado. + +Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una copia falseada que tiene +que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py` +29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/actualizador.py b/actualizador.py index 592615d..eacd2f6 100644 --- a/actualizador.py +++ b/actualizador.py @@ -134,6 +134,10 @@ def tarea_en_marcha(): return "hay un escaneo en marcha" if any((a or {}).get("estado") == "corriendo" for a in O.ANALISIS.values()): return "se están leyendo clips" + if getattr(O, "TRAER", {}).get("estado") in ("copiando", "ordenando"): + return "se está trayendo material de una tarjeta" + if getattr(O, "AUTO", {}).get("activo"): + return "el orden automático está en marcha" return "" diff --git a/asistente.py b/asistente.py index d86b5a6..5a11433 100644 --- a/asistente.py +++ b/asistente.py @@ -109,7 +109,8 @@ def diagnostico(raiz): if not frases: frases.append("Nada llamativo: la carpeta no tiene grandes bolsas de peso que recortar.") return {"raiz": raiz, "n": len(filas), "bytes": total_b, "grupos": grupos, "frases": frases, "meses_stock": meses, - "duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"]}, + "duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"], + "por_comparar": dup.get("por_comparar", 0)}, "escaneo": dict(esc) if esc else None} @@ -147,12 +148,14 @@ def pasos(diag, plan_filas): [("no", "Por ahora no (se puede hacer luego desde la pestaña Optimizar)"), ("si", "Sí, convertir y reemplazar los originales (los originales quedan en _Para borrar/originales)")], "no") dup = diag.get("duplicados") or {} - if dup.get("grupos"): + if dup.get("grupos") or dup.get("por_comparar"): det = [] if dup.get("identicos"): det.append(f"{dup['identicos']} idénticos (mismo nombre y peso)") if dup.get("versiones"): det.append(f"{dup['versiones']} en dos formatos (MP4 y MOV del mismo clip)") + if dup.get("por_comparar"): + det.append(f"{dup['por_comparar']} clips que se llaman igual y pesan distinto (se comparan: si duran lo mismo, se queda el de más calidad)") paso("duplicados", "Material repetido", f"{dup['n']} copias en {dup['grupos']} grupos ({O._gb(dup['bytes'])}): {' y '.join(det)}. No se borra nada: cada copia se junta en una subcarpeta Duplicados al lado del que se queda, para revisarla a mano.", [("apartar", "Juntarlas en Duplicados junto al original (recomendado)"), ("dejar", "Dejarlas donde están")], "apartar") diff --git a/instalar.py b/instalar.py index 6841972..c34abf8 100644 --- a/instalar.py +++ b/instalar.py @@ -25,6 +25,7 @@ import ctypes import shutil import winreg import sqlite3 +import subprocess from ctypes import wintypes as W import ventana as V @@ -35,8 +36,10 @@ DESCRIPCION = "OrganiLAB · ordena y aligera el material de video" # «equipo» es esa carpeta (sus programas, su menú Inicio y su Escritorio) y la línea de Aplicaciones va a # una clave aparte del registro. Lo usa prueba_ventana.py. ENSAYO = os.environ.get("ORGANILAB_ENSAYO", "") -CLAVE = r"Software\OrganiLAB-ensayo\Alta" if ENSAYO else r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB" +CLAVE_DE_VERDAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB" +CLAVE = r"Software\OrganiLAB-ensayo\Alta" if ENSAYO else CLAVE_DE_VERDAD MARCA_MUDANZA = "instalado-en.txt" # en la carpeta de origen: adónde se instaló lo que había aquí +TAREA_AUTOMATICO = "OrganiLAB - orden automatico" # la tarea de Windows del orden automático (organizador.TAREA_WINDOWS) INVENTARIO = "inventario.sqlite" NO_VIAJA = {"actualizacion", "__pycache__", MARCA_MUDANZA} # del primer nivel: lo que es de paso NO_VIAJA_DATOS = {"sesion.json", "ventana"} # de datos\: la sesión abierta y el perfil del motor, que se rehace solo @@ -347,6 +350,15 @@ def desinstalar(base=None): winreg.DeleteKey(winreg.HKEY_CURRENT_USER, CLAVE) except OSError: pass + # si estaba encendido el orden automático, su tarea de Windows se va con el programa: si no, + # seguiría intentando abrir cada noche algo que ya no está. Solo cuando se desinstala de verdad: + # las pruebas trabajan contra una clave de ensayo y no deben tocar la tarea de nadie. + if not ENSAYO and CLAVE == CLAVE_DE_VERDAD: + try: + subprocess.run(["schtasks", "/Delete", "/F", "/TN", TAREA_AUTOMATICO], capture_output=True, timeout=30, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except (OSError, subprocess.SubprocessError): + pass return {"accesos": quitados} diff --git a/manifiesto.json b/manifiesto.json index 62279c2..f05bfae 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "3.2.0", - "builtAt": "2026-10-04T21:33:00.000Z", - "archivo": "OrganizadorLabstream-portatil-3.2.0.zip", - "size": 195403245, - "sha256": "e029a6010a72817b719fdefc66a9669f11e9cd037a48e8c76f5f91c99f82ee58", + "version": "3.3.0", + "builtAt": "2026-10-04T22:05:28.000Z", + "archivo": "OrganizadorLabstream-portatil-3.3.0.zip", + "size": 195421712, + "sha256": "452fea4d67e9e1fc675813ef766a10c1fea4c9e810391faa3cf49d84144738dd", "app": { - "archivo": "OrganizadorLabstream-app-3.2.0.zip", - "size": 11590666, - "sha256": "dcef264edc282a83f00073c6d7bec8aa6a799996e49a255483c1ea6187c6720e", - "url": "/api/herramientas/OrganizadorLabstream-app-3.2.0.zip" + "archivo": "OrganizadorLabstream-app-3.3.0.zip", + "size": 11609133, + "sha256": "b321a8c9b2ef166f8d291c31fe66388abf7675f568b71f5f6f0dff36e6bfceb4", + "url": "/api/herramientas/OrganizadorLabstream-app-3.3.0.zip" }, - "notes": "El orden mira la forma de las carpetas y no solo su nombre: «Por nombrar» pregunta solo lo que importa, una pieza o un cajón con meses dentro ya no se desarman, se repasa lo ya ordenado, y el convertidor no repite lo convertido. Pide escanear otra vez cada carpeta.", + "notes": "Lo nuevo entra ya ordenado: «Nueva pieza» crea la carpeta con su número y sus subcarpetas, la bandeja de entrada reparte lo que se deje en _Entrada, «Traer material» copia una tarjeta comprobando cada archivo, «Juntar repetidos» reconoce el mismo clip con dos calidades, y el orden automático (apagado de fábrica) pone al día todas las carpetas cada día.", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "2d0254434190db0e98469d7988923419aff8364bf0b72378aa528dfa0ef3fb5abf9c1620b11ebfa7ba679f9e7863c81edab027c0ce783f31fb61032b938ecee86e0f3bdf9a8d999cebe1701668983c4b3df48db2061ff011ab1a9b026ca2d6b124fdf92b6ea6df0d75d69c41d7e6ac3235bd9b8a058cf20b98bed026d2ff574802314b6542eeed1f89df914f777c3d06c0aaa9c656b64972edd274cb1b41c2baf94c9d4d05381241c56e4b621681ec8eafb29f88b7c64a111665c47247b05e584f19f4e70ee06ee3d163600dac3f70515f8fec4d09dfc1c151337058189bf02d5f5064112ee4d0989c2118d7f5e981a9fa77938d5aa8e839c58092250a1f29ac" + "valor": "14cf8c856a36db6c1f5a876b1f2418e5a15fe08e6c911593fbb14b49cf4f42af9e8e26f77fac63629212e2a2a71b2a4089dd40bc2cdf703f5263572a07e59b317294b41462543015d51d8db15121fd9c1a944b6f9efaa9c2cd32d7b171468ca9fd18367bfbed5be5b5f9c676d4db50928c33f1808b8eb5097ce59fce2b6077baee7d8ca05989d303b043d7cb48e1c9c56904df1cffd7ab568d6d398ff21fe89de9c62c89190cfaa29ac814a98c534f4d56b8af75a2fa7281075124d564b41b9a8939aa8594234402d5a0bb08633ce43eaddd68b1ff8c12b53d6d0a496d664e7eed5e069661229dfa69bdcdfaaceff89a7b2a727286622c83d3e856bb3724bff8" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 9784be1..662e7a5 100644 --- a/organizador.py +++ b/organizador.py @@ -40,7 +40,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "3.2.0" +VERSION = "3.3.0" BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es @@ -119,6 +119,11 @@ CONFIG_DEFECTO = { "ffprobe": "ffprobe", "python_resolve": "", # vacío = el mismo intérprete }, + # las subcarpetas con las que nace una pieza nueva (las que más se repiten en las piezas del estudio) + "pieza": {"subcarpetas": ["Guion", "Videos", "Imagenes", "Musica", "Elements", "IA", "Portada", "Exportado"]}, + # el orden automático viene apagado: lo enciende una persona, y entonces Windows lanza la pasada a esa hora. + # «limite» es cuántos archivos puede mover de una carpeta sin que nadie lo mire. + "automatico": {"activo": False, "hora": "02:00", "limite": 3000}, } PERFILES = { @@ -494,8 +499,12 @@ def escanear(ruta): if e.is_dir(follow_symlinks=False): if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP): continue # lo de la app (sus iconos de color) no es material del cliente - p = en_proxy or R.es_proxy_dir(nombre) - b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM + # dentro de la M4ROOT de una tarjeta Sony, SUB son los proxies que graba la cámara y + # THMBNL y GENERAL sus miniaturas e índices: no son material + sony = os.path.basename(path).upper() == "M4ROOT" + p = en_proxy or R.es_proxy_dir(nombre) or (sony and nombre.upper() == "SUB") + b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM \ + or (sony and nombre.upper() in ("THMBNL", "GENERAL")) # una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro: # medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de # material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye @@ -755,10 +764,13 @@ def destino_plantilla(tipo, cubeta, **kw): segmentos = [] for seg in plantilla.split("/"): s = seg.format(**partes_o) + hueco = vacio in s s = re.sub(r"[-_ ]*\x00", "", s) - if seg.startswith("{"): + # los separadores sueltos solo se recortan si los dejó una variable vacía: sin esto, una ruta que + # de verdad empieza por guion bajo («_Entrada\Tarjeta») llegaba a la papelera como «Entrada\Tarjeta» + if hueco and seg.startswith("{"): s = s.lstrip("-_ ") - if seg.endswith("}"): + if hueco and seg.endswith("}"): s = s.rstrip("-_ ") if s: segmentos.append(s) @@ -1207,6 +1219,14 @@ class Plan: self.agregar(e.path, self.abs_(regla["destino"]), regla.get("cubeta", "REVISAR"), "mover carpeta", "regla aprendida", "alta") return + if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA): + # la bandeja de entrada: se abre siempre, y lo de dentro se fecha solo + self.cajon += 1 + try: + self.plan_dir(e.path, "contenedor", None, None) + finally: + self.cajon -= 1 + return if self.es_propia(nombre, nivel): return # estructura ya canónica if R.es_basura_dir(nombre): @@ -1404,7 +1424,8 @@ class Plan: nn = R.norm(nombre) # solo los nombres de fábrica («Grabación», «URSA») son genéricos: «Videos Maquinas», aunque # alguien haya confirmado que es una grabación, sigue siendo el nombre de esa sesión - generico = nn in R.DICCIONARIO_SEMILLA or nn in ("grabacion", "grabado", "grabaciones") + generico = (nn in R.DICCIONARIO_SEMILLA or nn in ("grabacion", "grabado", "grabaciones") + or R.es_carpeta_de_tarjeta(nombre)) # «DCIM», «PRIVATE»: la tarjeta tal cual filas = archivos_bajo(self.con, e.path) if not filas: return @@ -1925,6 +1946,8 @@ def quitar_vacias(ruta, tope): ruta = os.path.normpath(ruta) tope = os.path.normpath(tope) while ruta.lower() != tope.lower() and ruta.lower().startswith(tope.lower()): + if R.norm(os.path.basename(ruta)) == R.norm(R.ENTRADA): + break # la bandeja de entrada se queda aunque esté vacía try: if os.path.isdir(ruta) and not os.listdir(ruta): os.rmdir(ruta) @@ -1952,6 +1975,25 @@ def _sin_archivos(ruta): return True +def _pieza_nueva(ruta, desde): + """True si la carpeta es una pieza creada después de `desde`, o el mes o el año que la contienen.""" + try: + st = os.stat(ruta) + except OSError: + return False + if (getattr(st, "st_birthtime", None) or st.st_ctime) <= desde: + return False + nombre = os.path.basename(ruta) + if R.detectar_pieza(nombre): + return True + if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]: + try: + return any(_pieza_nueva(os.path.join(ruta, x), desde) for x in os.listdir(ruta)) + except OSError: + return False + return False + + def carpetas_vacias(raiz): """Carpetas sin un solo archivo dentro, en todo el árbol. @@ -1982,11 +2024,19 @@ def carpetas_vacias(raiz): if os.path.normpath(c).lower() not in con_archivos and os.path.normpath(c).lower() != raiz.lower()}, key=lambda x: (len(x), x.lower())) out, tomadas = [], [] + reciente = time.time() - 30 * 86400 for c in vacias: if any(c.lower().startswith(t + os.sep) for t in tomadas): continue # ya cuelga de otra que se va entera + if c.lower() == os.path.join(raiz, R.ENTRADA).lower(): + continue # la bandeja de entrada está vacía a propósito if not os.path.isdir(c) or not _sin_archivos(c): continue # ya no existe, o alguien dejó algo dentro + # una pieza recién creada tiene sus subcarpetas vacías porque aún no se ha trabajado en ella: no + # es un hueco que dejó el orden. Lo mismo el mes o el año que se crearon para ella. + if _pieza_nueva(c, reciente): + tomadas.append(c.lower()) + continue dentro = sum(len(d) for _, d, _ in os.walk(c)) out.append({"ruta": c, "rel": os.path.relpath(c, raiz), "subcarpetas": dentro}) tomadas.append(c.lower()) @@ -2028,13 +2078,22 @@ def borrar_vacias(raiz, rutas): 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")] + entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir", "mkdir")] fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {}) raiz = fin.get("raiz") or "" - deshechos, errores, movidos, recreadas = 0, [], [], [] + deshechos, errores, movidos, recreadas, quitadas = 0, [], [], [], [] for e in reversed(entradas): try: - if e["op"] == "rmdir": + if e["op"] == "mkdir": + # una carpeta que creó la app (una pieza nueva): se quita solo si sigue vacía + if os.path.isdir(e["de"]): + if os.listdir(e["de"]): + errores.append(f"{os.path.basename(e['de'])}: ya tiene cosas dentro; se deja") + else: + os.rmdir(e["de"]) + quitadas.append(e["de"]) + deshechos += 1 + elif e["op"] == "rmdir": os.makedirs(e["de"], exist_ok=True) if lote.startswith("vacias-"): # aquí la carpeta ERA el cambio, no el rastro de uno: se cuenta y vuelve al inventario @@ -2062,12 +2121,14 @@ def deshacer(lote): except OSError as ex: errores.append(f"{e.get('a')}: {ex}") actualizar_inventario(movidos) - if recreadas and raiz: + if (recreadas or quitadas) and raiz: con = db() with con: for d in recreadas: con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d))) + for d in quitadas: + con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) con.close() bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores}) return {"deshechos": deshechos, "errores": errores} @@ -2121,6 +2182,9 @@ def vocabulario(raiz): def baja(ruta, nivel, anio): for h in sorted(est.hijos(ruta), key=str.lower): nombre = os.path.basename(h) + if nivel == "raiz" and R.norm(nombre) == R.norm(R.ENTRADA): + baja(h, "contenedor", None) # la bandeja de entrada: el plan la abre siempre + continue if _es_propia(nombre, nivel, primeros, en_mes) or R.es_basura_dir(nombre) or R.es_proxy_dir(nombre): continue if not est.utiles(h): @@ -2341,7 +2405,8 @@ def mapa(raiz): 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, + "duplicados": {"grupos": len(dd["grupos"]), "n": dd["n"], "bytes": dd["bytes"], "identicos": dd["identicos"], "versiones": dd["versiones"], + "por_comparar": dd["por_comparar"], "ejemplos": dup_ej}, "raw": raw, "grandes": grandes, "sin_fecha": sin_fecha, "arbol": arbol, "sueltos_raiz": sueltos_raiz, } @@ -3158,14 +3223,100 @@ def _mejor(rutas): return sorted(rutas, key=clave)[0] -def duplicados(raiz, minimo=5_000_000): - """Grupos de archivos repetidos. Dos clases: +def _medir(rutas): + """Duración y tamaño de imagen de unos clips. Lo que ya leyó ffprobe está en el inventario; lo demás se + lee ahora (solo la cabecera) y se apunta para la próxima vez.""" + import concurrent.futures as cf + out, faltan = {}, [] + con = db() + for r in rutas: + f = con.execute("SELECT analizado, dur, ancho, alto, kbps FROM archivos WHERE ruta = ?", (r,)).fetchone() + if f and f["analizado"] == 1 and f["dur"]: + out[r] = {"dur": f["dur"], "ancho": f["ancho"] or 0, "alto": f["alto"] or 0, "kbps": f["kbps"] or 0} + else: + faltan.append(r) + if faltan and herramientas()["ffprobe"]["ok"]: + with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool: + leidos = list(zip(faltan, pool.map(ffprobe, faltan))) + with con: + for r, info in leidos: + if not info or not info.get("dur"): + con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ? AND analizado = 0", (r,)) + continue + out[r] = {"dur": info["dur"], "ancho": info["ancho"], "alto": info["alto"], "kbps": info["kbps"]} + con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, " + "acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", + (info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], + info["kbps"], info["alfa"], info["acodec"], info.get("pistas_audio", 1), r)) + con.close() + return out + + +_ROLES_TOMA = ("camara_raw", "camara_video", "celular_video") + + +def _por_comparar(filas): + """Clips de cámara que se llaman igual y no pesan lo mismo: candidatos a ser el mismo clip con otra + calidad. Solo material de cámara: dos exportados con el mismo nombre son dos versiones de una pieza + (la vertical y la horizontal, la de antes y la de después de un cambio), y cuál es «la buena» no lo + decide el peso.""" + por_nombre = defaultdict(list) + for f in filas: + if (f["ext"] or "").lower() in _EXT_VIDEO_DUP and f["rol"] in _ROLES_TOMA: + por_nombre[f["nombre"].lower()].append(f) + return [fs for fs in por_nombre.values() if len(fs) > 1 and len({f["bytes"] for f in fs}) > 1] + + +def _mismas_tomas(candidatos, tope=400): + """El mismo clip guardado dos veces con distinta calidad. Medido el 4-oct-2026 sobre el material real: + 39 clips de un rodaje estaban en dos carpetas con el mismo nombre, unos de 220 MB y otros de 76 MB, y + el plan no podía juntarlos en su mes porque dos archivos que se llaman igual no caben en una carpeta. + + Que se llamen igual no basta: dos tarjetas de la misma cámara traen las dos un C0001.MP4. Tienen que + durar lo mismo. De los que duran lo mismo se queda el de más imagen y, a igualdad, el que más pesa; + los demás van a «Duplicados» a su lado, para mirarlos a mano. No se borra nada.""" + candidatos = candidatos[:tope] + medidas = _medir([f["ruta"] for fs in candidatos for f in fs]) + grupos = [] + for fs in candidatos: + def forma(m): # 1.78 en horizontal, 0.56 en vertical + return round(m["ancho"] / m["alto"], 1) if m["alto"] else 0 + medidos = sorted(((f, medidas[f["ruta"]]) for f in fs if f["ruta"] in medidas), key=lambda x: (forma(x[1]), x[1]["dur"])) + racimos, actual = [], [] + for f, m in medidos: + if actual and (forma(m) != forma(actual[0][1]) + or abs(m["dur"] - actual[0][1]["dur"]) > max(0.5, 0.01 * actual[0][1]["dur"])): + racimos.append(actual) + actual = [] + actual.append((f, m)) + if actual: + racimos.append(actual) + for rc in racimos: + if len(rc) < 2 or len({f["bytes"] for f, _ in rc}) < 2: + continue + rc.sort(key=lambda x: (-(x[1]["ancho"] * x[1]["alto"]), -(x[0]["bytes"] or 0), len(x[0]["ruta"]))) + (fq, mq), resto = rc[0], rc[1:] + + def dice(f, m): + return f"{m['ancho']}x{m['alto']} · {_gb(f['bytes'])}" + grupos.append({"tipo": "calidad", "nombre": fq["nombre"], "queda": fq["ruta"], "mueven": [f["ruta"] for f, _ in resto], + "bytes": sum(f["bytes"] or 0 for f, _ in resto), + "destino": os.path.join(os.path.dirname(fq["ruta"]), R.DUPLICADOS), + "detalle": "se queda la de " + dice(fq, mq) + "; se aparta la de " + ", ".join(dice(f, m) for f, m in resto)}) + return grupos + + +def duplicados(raiz, minimo=5_000_000, calidades=False): + """Grupos de archivos repetidos. Tres clases: identico = mismo nombre y mismo tamaño en sitios distintos. version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede - cambiar el color o el peso, así que se deja para revisar a mano.""" + cambiar el color o el peso, así que se deja para revisar a mano. + calidad = mismo nombre y misma duración, distinto peso: el mismo clip con otra calidad. Hay que leer + los clips para saberlo, así que solo se calcula si se pide (`calidades`); si no, se dice + cuántos hay por comparar.""" con = db() filas = [dict(r) for r in con.execute( - "SELECT ruta, dir, nombre, ext, bytes FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0", + "SELECT ruta, dir, nombre, ext, bytes, rol FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0", (os.path.normpath(raiz), minimo)).fetchall()] con.close() filas = [f for f in filas if _fuera_de_dup(f["ruta"]) and os.path.exists(f["ruta"])] @@ -3198,9 +3349,19 @@ def duplicados(raiz, minimo=5_000_000): mueven = [f["ruta"] for f in fs[1:]] grupos.append({"tipo": "version", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven, "bytes": sum(f["bytes"] or 0 for f in fs[1:]), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)}) + for g in grupos: + if g["tipo"] == "version": + usados.add(g["queda"]) + usados.update(g["mueven"]) + candidatos = _por_comparar([f for f in filas if f["ruta"] not in usados]) + if calidades and candidatos: + grupos += _mismas_tomas(candidatos) grupos.sort(key=lambda g: -g["bytes"]) return {"grupos": grupos, "n": sum(len(g["mueven"]) for g in grupos), "bytes": sum(g["bytes"] for g in grupos), - "identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version")} + "identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version"), + "calidades": sum(1 for g in grupos if g["tipo"] == "calidad"), + # clips que se llaman igual y pesan distinto, sin comparar todavía + "por_comparar": 0 if calidades else sum(len(fs) - 1 for fs in candidatos)} def huella_rapida(ruta, trozo=1 << 20): @@ -3225,7 +3386,7 @@ def apartar_duplicados(raiz, grupos=None): 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"] + grupos = duplicados(raiz, calidades=True)["grupos"] lote = "dup-" + time.strftime("%Y%m%d-%H%M%S") movidos, errores, distintos = [], [], 0 for g in grupos: @@ -3443,7 +3604,7 @@ VENTANA = None # la ventana del programa (ventana.Ventana) mientras e ULTIMA_PETICION = [0.0] # cuándo preguntó algo la interfaz por última vez -def elegir_carpeta(): +def elegir_carpeta(titulo="Elige la carpeta del cliente"): """Abre el selector de carpetas de Windows y devuelve la ruta o None. Con la ventana del programa abierta es el suyo, modal sobre ella; si la interfaz va en el navegador, uno aparte en PowerShell.""" if not _ELIGIENDO.acquire(blocking=False): @@ -3451,11 +3612,11 @@ def elegir_carpeta(): try: v = VENTANA if v is not None: - ruta = v.elegir_carpeta("Elige la carpeta del cliente") + ruta = v.elegir_carpeta(titulo) return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None ps = ("Add-Type -AssemblyName System.Windows.Forms; " "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " - "$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; " + "$d.Description = '" + titulo.replace("'", "''") + "'; $d.ShowNewFolderButton = $false; " "$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; " "if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }") flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) @@ -3547,6 +3708,8 @@ def estado_general(): "hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]}, "aplicando": APLICANDO, "asistente": asistente.ESTADO, "respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])}, + "traer": {k: v for k, v in TRAER.items() if k != "errores"} | {"errores_n": len(TRAER.get("errores") or [])}, + "automatico": dict(AUTO), "cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS} @@ -4091,6 +4254,544 @@ def despintar_carpetas(raiz): return {"quitadas": quitadas, "errores": errores} +# ========================================================================== +# Material nuevo: pieza nueva, bandeja de entrada y orden automático +# ========================================================================== + +_RX_NOMBRE_MALO = re.compile(r'[\\/:*?"<>|]') +_SERIE = [0] + + +def _subcarpetas_pieza(): + """Las subcarpetas con las que nace una pieza. Se cambian en config.json («pieza» › «subcarpetas»).""" + subs = (CONFIG.get("pieza") or {}).get("subcarpetas") + if not isinstance(subs, list): + subs = CONFIG_DEFECTO["pieza"]["subcarpetas"] + return [s.strip() for s in subs if isinstance(s, str) and s.strip() and not _RX_NOMBRE_MALO.search(s)][:20] + + +def _sitio_pieza(raiz, tipo, titulo, anio, mes): + """(carpeta donde va, nombre que le toca, la que ya existe con ese título o None).""" + pl = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get("PIEZA") or "{anio}/{mes}/{nombre}" + destino = os.path.join(raiz, destino_plantilla(tipo, "PIEZA", anio=anio, mes=mes, nombre=titulo, sub=titulo)) + padre = os.path.dirname(destino) + if "{mes}" not in pl: # campañas y proyectos: sin número + return padre, os.path.basename(destino), destino if os.path.isdir(destino) else None + numeros, ya = [], None + try: + with os.scandir(padre) as it: + for e in it: + if not e.is_dir(follow_symlinks=False): + continue + p = R.detectar_pieza(e.name) + if p: + numeros.append(p[0]) + if R.norm(p[1] if p else e.name) == R.norm(titulo): + ya = e.path + except OSError: + pass # el mes todavía no existe: será la primera + return padre, R.nombre_pieza(max(numeros, default=0) + 1, titulo), ya + + +def nueva_pieza(raiz, titulo, anio=None, mes=None, crear=True): + """Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas. + + Las piezas se venían creando a mano, y de ahí salían dos «01» en el mismo mes, «03 - Título» junto a + «03- Título» y subcarpetas con el nombre a medio escribir («portasda», «Imagnes»). Con `crear=False` + solo dice cómo quedaría.""" + raiz = _clave(raiz) + r = raiz_cfg(raiz) + if not r: + return {"error": "carpeta no registrada"} + titulo = limpiar_nombre(re.sub(r"\s+", " ", titulo or "")).strip(". ") + p = R.detectar_pieza(titulo) + if p: + titulo = limpiar_nombre(p[1]) # «03- Botox»: el número lo pone la app + if not titulo: + return {"error": "ponle un título a la pieza"} + if _RX_NOMBRE_MALO.search(titulo): + return {"error": 'el título no puede llevar \\ / : * ? " < > |'} + if len(titulo) > 80: + return {"error": "el título es demasiado largo: 80 letras como mucho"} + hoy = datetime.date.today() + try: + anio, mes = int(anio or hoy.year), int(mes or hoy.month) + except (TypeError, ValueError): + return {"error": "el año o el mes no valen"} + if not (2000 <= anio <= 2100 and 1 <= mes <= 12): + return {"error": "el año o el mes no valen"} + padre, nombre, ya = _sitio_pieza(raiz, r.get("tipo", "contenido"), titulo, anio, mes) + destino = os.path.join(padre, nombre) + subs = _subcarpetas_pieza() + out = {"raiz": raiz, "ruta": destino, "rel": os.path.relpath(destino, raiz), "nombre": nombre, + "subcarpetas": subs, "existe": os.path.relpath(ya, raiz) if ya else ""} + if not crear: + return out + if ya: + return {**out, "error": f"esa pieza ya existe: «{os.path.relpath(ya, raiz)}»"} + if not os.path.isdir(raiz): + return {**out, "error": "la carpeta del cliente no está conectada"} + _SERIE[0] += 1 + lote = "pieza-" + time.strftime("%Y%m%d-%H%M%S") + f"-{_SERIE[0]}" # dos seguidas no comparten lote + faltan, d = [], destino # las de encima que aún no existen (el año, el mes) + while not os.path.isdir(d) and dentro_de(raiz, d) and d.lower() != raiz.lower(): + faltan.append(d) + d = os.path.dirname(d) + creadas = [] + try: + for d in list(reversed(faltan)) + [os.path.join(destino, s) for s in subs]: + os.mkdir(d) + bitacora({"lote": lote, "op": "mkdir", "de": d}) + creadas.append(d) + except OSError as ex: + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 0, "movimientos": len(creadas), "errores": [str(ex)]}) + return {**out, "error": f"no se pudo crear: {ex.strerror or ex}"} + con = db() + with con: + for d in creadas: + con.execute("INSERT OR IGNORE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d))) + con.close() + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 1, "movimientos": len(creadas), "errores": []}) + PLANES.pop(raiz, None) + return {**out, "ok": True, "lote": lote, "creadas": len(creadas)} + + +# -------------------------------------------------------------------------- +# La bandeja de entrada +# -------------------------------------------------------------------------- +# +# El desorden nace el día que se vuelca la tarjeta: se copia donde se puede, con el nombre que sale. La +# bandeja es una carpeta «_Entrada» dentro de la del cliente; lo que se deje ahí —a mano, o trayéndolo +# desde la tarjeta con la copia comprobada— el plan lo abre siempre y lo reparte por rodaje y cámara. + +TRAER = {"estado": "", "raiz": "", "origen": "", "destino": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, + "actual": "", "copiados": 0, "saltados": 0, "errores": [], "cancelar": False, "inicio": 0, "fin": 0, + "ordenado": None} +_NO_TARJETA = {"system volume information", "$recycle.bin", ".trashes", ".spotlight-v100", ".fseventsd", + ".temporaryitems", "lost.dir", ".trash-1000"} + + +def _ruta_entrada(raiz): + return os.path.join(_clave(raiz), R.ENTRADA) + + +def entrada(raiz): + """Qué hay esperando en la bandeja. Mira el disco y no el inventario, que puede ser de ayer.""" + raiz = _clave(raiz) + base = _ruta_entrada(raiz) + n = b = 0 + ejemplos = [] + if os.path.isdir(base): + for d, _dirs, fs in os.walk(base): + for f in fs: + if R.es_basura_archivo(f) or f.endswith(".copiando"): + continue + try: + b += os.path.getsize(os.path.join(d, f)) + except OSError: + continue + n += 1 + if len(ejemplos) < 5: + ejemplos.append(os.path.relpath(os.path.join(d, f), base)) + return {"raiz": raiz, "carpeta": base, "existe": os.path.isdir(base), "n": n, "bytes": b, "ejemplos": ejemplos, + "copia": dict(TRAER) if TRAER.get("raiz") == raiz and TRAER.get("estado") else None} + + +def abrir_entrada(raiz): + """Crea la bandeja si no existe y la abre en el Explorador, para soltar material dentro.""" + raiz = _clave(raiz) + if not raiz_cfg(raiz) or not os.path.isdir(raiz): + return {"error": "la carpeta del cliente no está conectada"} + base = _ruta_entrada(raiz) + os.makedirs(base, exist_ok=True) + try: + os.startfile(base) + except OSError: + pass + return {"ok": True, "carpeta": base} + + +def _podar(base, lote): + """Quita de la bandeja las carpetas que se quedaron sin un solo archivo: el esqueleto de una tarjeta + ya repartida (PRIVATE, M4ROOT, CLIP…). Queda apuntado en el mismo lote, así que «Deshacer» las + vuelve a crear antes de devolver los archivos.""" + quitadas = [] + for d, _dirs, _fs in os.walk(base, topdown=False): + if os.path.normcase(d) == os.path.normcase(base): + continue + try: + if not os.listdir(d): + os.rmdir(d) + bitacora({"lote": lote, "op": "rmdir", "de": d}) + quitadas.append(d) + except OSError: + pass + if quitadas: + con = db() + with con: + for d in quitadas: + con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) + con.close() + + +def ordenar_entrada(raiz): + """Lee la carpeta otra vez y aplica, del plan, lo que sale de la bandeja y va marcado.""" + raiz = _clave(raiz) + r = raiz_cfg(raiz) + if not r: + return {"error": "carpeta no registrada"} + if asistente.ESTADO["activo"] or (APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]): + return {"error": "ya hay algo en marcha en esta carpeta"} + escanear(raiz) + prog = PROGRESO.get(raiz) or {} + if prog.get("estado") != "listo": + return {"error": "no se pudo leer la carpeta: " + (prog.get("error") or "¿está conectada?")} + plan = Plan(r).correr() + base = _ruta_entrada(raiz) + filas, dudosos = [], 0 + for f in plan["filas"]: + if f.get("archivos"): + mios = [a for a in f["archivos"] if dentro_de(base, a)] + if not mios: + continue + if len(mios) < len(f["archivos"]): # la fila de «basura repartida por la carpeta»: solo lo de aquí + f = dict(f, archivos=mios) + if f.get("destinos_archivo"): + f["destinos_archivo"] = {a: d for a, d in f["destinos_archivo"].items() if a in set(mios)} + elif not dentro_de(base, f["origen"]): + continue + if f["marcado"]: + filas.append(f) + else: + dudosos += 1 + res = aplicar(raiz, filas) if filas else {"lote": None, "movimientos": 0, "errores": []} + PLANES.pop(raiz, None) + try: + os.makedirs(base, exist_ok=True) # la bandeja se queda, vacía, para la próxima vez + except OSError: + pass + if res.get("lote"): + _podar(base, res["lote"]) + return {"ok": True, "lote": res.get("lote"), "movimientos": res.get("movimientos", 0), "errores": res.get("errores", []), + "quedan": entrada(raiz)["n"], "dudosos": dudosos, + "avisos": [a for a in plan["avisos"] if R.ENTRADA in a]} + + +def _archivos_de(origen): + """Los archivos de una tarjeta o de una carpeta cualquiera, sin lo que es del sistema.""" + for d, dirs, fs in os.walk(origen): + dirs[:] = sorted(x for x in dirs if x.lower() not in _NO_TARJETA) + for f in sorted(fs): + if f.lower() in ("thumbs.db", "desktop.ini", ".ds_store") or f.startswith("._"): + continue + yield os.path.join(d, f) + + +def plan_traer(raiz, origen, nombre=""): + """Qué se copiaría de una tarjeta a la bandeja, y si cabe. No copia nada.""" + raiz = _clave(raiz) + if not raiz_cfg(raiz): + return {"error": "carpeta no registrada"} + if not os.path.isdir(raiz): + return {"error": "la carpeta del cliente no está conectada"} + origen = os.path.normpath(origen or "") + if not origen or not os.path.isdir(origen): + return {"error": "elige la tarjeta o la carpeta de la que traer el material"} + if dentro_de(raiz, origen) or dentro_de(origen, raiz): + return {"error": "eso ya está dentro de la carpeta del cliente: no hace falta traerlo"} + if _raiz_de(origen): + return {"error": "esa carpeta es de otro cliente registrado; desde aquí solo se trae material de fuera"} + nombre = limpiar_nombre(re.sub(r"\s+", " ", nombre or "")).strip(". ") + if _RX_NOMBRE_MALO.search(nombre): + return {"error": 'el nombre no puede llevar \\ / : * ? " < > |'} + # sin nombre, la carpeta se llama «Tarjeta »: un nombre que el plan no toma por el de una sesión + carpeta = nombre or ("Tarjeta " + time.strftime("%Y-%m-%d %H%M")) + n = b = 0 + for a in _archivos_de(origen): + try: + b += os.path.getsize(a) + n += 1 + except OSError: + pass + if not n: + return {"error": "ahí no hay ningún archivo"} + try: + libre = shutil.disk_usage(raiz).free + except OSError: + libre = None + out = {"raiz": raiz, "origen": origen, "destino": os.path.join(_ruta_entrada(raiz), carpeta), "carpeta": carpeta, + "n": n, "bytes": b, "libre": libre} + if libre is not None and b > libre: + out["error"] = f"no cabe: son {_gb(b)} y en el disco del cliente quedan {_gb(libre)}" + return out + + +def _copiar_comprobando(origen, destino, estado): + """Copia un archivo y vuelve a leer lo copiado para comprobar que quedó igual. True si copió, False + si ya estaba. Del origen solo se lee: ni se mueve ni se borra.""" + st = os.stat(origen) + try: + sd = os.stat(destino) + if sd.st_size == st.st_size and abs(sd.st_mtime - st.st_mtime) < 2: + estado["bytes"] += st.st_size + return False # ya se trajo antes: una copia a medias se retoma + raise FileExistsError("en la bandeja ya hay otro archivo distinto con ese nombre") + except FileNotFoundError: + pass + os.makedirs(os.path.dirname(destino), exist_ok=True) + tmp = destino + ".copiando" + antes = estado["bytes"] + try: + h1 = hashlib.blake2b(digest_size=16) + with open(origen, "rb") as a, open(tmp, "wb") as b: + while True: + if estado.get("cancelar"): + raise InterruptedError("cancelado") + trozo = a.read(4 << 20) + if not trozo: + break + h1.update(trozo) + b.write(trozo) + estado["bytes"] += len(trozo) + b.flush() + os.fsync(b.fileno()) + h2 = hashlib.blake2b(digest_size=16) + with open(tmp, "rb") as b: + while True: + if estado.get("cancelar"): + raise InterruptedError("cancelado") + trozo = b.read(4 << 20) + if not trozo: + break + h2.update(trozo) + if h1.digest() != h2.digest() or os.path.getsize(tmp) != st.st_size: + raise OSError("lo copiado no quedó igual que el original") + os.replace(tmp, destino) + shutil.copystat(origen, destino) # la fecha del archivo es la del rodaje: se conserva + return h1.hexdigest() + except BaseException: + estado["bytes"] = antes + try: + os.remove(tmp) + except OSError: + pass + raise + + +def traer(raiz, origen, nombre="", ordenar=True, en_hilo=False): + """Trae a la bandeja lo que hay en una tarjeta (o en cualquier carpeta de fuera), comprobando cada + archivo, y al terminar lo reparte. El origen no se toca.""" + p = plan_traer(raiz, origen, nombre) + if p.get("error"): + return p + if TRAER.get("estado") in ("copiando", "ordenando"): + return {"error": "ya se está trayendo material"} + TRAER.update(estado="copiando", raiz=p["raiz"], origen=p["origen"], destino=p["destino"], hechos=0, total=p["n"], + bytes=0, bytes_total=p["bytes"], actual="", copiados=0, saltados=0, errores=[], cancelar=False, + inicio=time.time(), fin=0, ordenado=None) + if en_hilo: + threading.Thread(target=_traer, args=(p, ordenar), daemon=True).start() + return {"lanzado": True, **p} + return _traer(p, ordenar) + + +def _traer(p, ordenar): + raiz = p["raiz"] + lote = "entrada-" + time.strftime("%Y%m%d-%H%M%S") + comprobados = [] + try: + for a in _archivos_de(p["origen"]): + if TRAER["cancelar"]: + break + rel = os.path.relpath(a, p["origen"]) + TRAER["actual"] = rel + try: + h = _copiar_comprobando(a, os.path.join(p["destino"], rel), TRAER) + if h: + TRAER["copiados"] += 1 + comprobados.append((rel, os.path.getsize(a), h)) + else: + TRAER["saltados"] += 1 + except InterruptedError: + break + except Exception as ex: # noqa + TRAER["errores"].append(f"{rel}: {getattr(ex, 'strerror', None) or ex}") + TRAER["hechos"] += 1 + # la lista de lo que se copió y con qué huella queda en la carpeta de la app dentro de la del cliente + if comprobados: + try: + d = os.path.join(raiz, R.CARPETA_APP, "entradas") + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, lote + ".csv"), "w", encoding="utf-8-sig", newline="") as fh: + fh.write("archivo;bytes;huella\r\n") + for rel, tam, h in comprobados: + fh.write(f"{rel};{tam};{h}\r\n") + except OSError: + pass + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": TRAER["hechos"], "movimientos": TRAER["copiados"], + "errores": list(TRAER["errores"]), "origen": p["origen"], "destino": p["destino"]}) + if TRAER["cancelar"]: + TRAER["estado"] = "cancelado" + elif TRAER["errores"]: + TRAER["estado"] = "error" + else: + if ordenar: + TRAER.update(estado="ordenando", actual="repartiendo por rodaje y cámara") + TRAER["ordenado"] = ordenar_entrada(raiz) + TRAER["estado"] = "listo" + except Exception as ex: # noqa + TRAER["errores"].append(str(ex)) + TRAER["estado"] = "error" + TRAER.update(fin=time.time(), actual="") + return dict(TRAER) + + +# -------------------------------------------------------------------------- +# Orden automático: todas las carpetas, lo seguro, sin que nadie pulse nada +# -------------------------------------------------------------------------- + +AUTOMATICO_PATH = os.path.join(DATOS, "automatico.json") +AUTO = {"activo": False, "raiz": "", "hechas": 0, "total": 0, "inicio": 0} +TAREA_WINDOWS = "OrganiLAB - orden automatico" + + +def automatico_una(raiz, limite): + """Pone al día una carpeta sin preguntar: la lee y hace solo lo que el plan da por seguro. Si no se + puede leer entera, o si el plan quiere mover demasiado para hacerlo sin que alguien lo mire, no toca + nada y lo dice.""" + raiz = _clave(raiz) + r = raiz_cfg(raiz) or {} + out = {"raiz": raiz, "cliente": r.get("cliente", ""), "ts": time.time(), "estado": "", "detalle": "", + "movimientos": 0, "lotes": [], "dudosos": 0, "avisos": [], "errores": []} + if not os.path.isdir(raiz): + out.update(estado="sin conexion", detalle="la carpeta no está conectada") + return out + escanear(raiz) + prog = PROGRESO.get(raiz) or {} + if prog.get("estado") != "listo" or prog.get("fallos"): + out.update(estado="sin leer", detalle=prog.get("error") or "no se pudo leer entera") + return out + plan = Plan(r).correr() + marcadas = [f for f in plan["filas"] if f["marcado"]] + n = sum(f["n"] or 0 for f in marcadas) + if n > limite: + out.update(estado="demasiado", dudosos=sum(1 for f in plan["filas"] if not f["marcado"]), avisos=plan["avisos"], + detalle=f"el plan mueve {_plural(n, 'archivo', 'archivos')}: es mucho para hacerlo sin que lo mires") + return out + dec = {p["clave"]: p["defecto"] for p in asistente.pasos(asistente.diagnostico(raiz), plan["filas"])} + dec.update(convertir="no", respaldo="no", carpetas="seguras") + res = asistente.ejecutar(raiz, dec, plan["filas"]) + out["movimientos"] = (res.get("movimientos") or 0) + (res.get("stock") or 0) + out["lotes"] = res.get("lotes") or [] + out["errores"] = (res.get("errores") or [])[:20] + if asistente.ESTADO.get("error"): + out["errores"].append(asistente.ESTADO["error"]) + despues = Plan(r).correr() + out["dudosos"] = sum(1 for f in despues["filas"] if not f["marcado"]) + out["avisos"] = despues["avisos"][:20] + out["estado"] = "con errores" if out["errores"] else "con dudas" if out["dudosos"] else "al dia" + PLANES.pop(raiz, None) + return out + + +def automatico(en_hilo=False, origen="a mano"): + """Todas las carpetas registradas, una tras otra. Deja el resumen en datos\\automatico.json.""" + ocupado = en_marcha() or (COLA["activa"] and "hay una conversión en marcha") or \ + (TRAER.get("estado") in ("copiando", "ordenando") and "se está trayendo material") + if AUTO["activo"] or ocupado: + return {"error": "ahora no: " + (ocupado or "el orden automático ya está en marcha")} + raices = [_clave(r["ruta"]) for r in CONFIG.get("raices", [])] + AUTO.update(activo=True, raiz="", hechas=0, total=len(raices), inicio=time.time()) + if en_hilo: + threading.Thread(target=_automatico, args=(raices, origen), daemon=True).start() + return {"lanzado": True, "carpetas": len(raices)} + return _automatico(raices, origen) + + +def _automatico(raices, origen): + limite = int((CONFIG.get("automatico") or {}).get("limite") or 3000) + resumen = {"ts": time.time(), "origen": origen, "version": VERSION, "carpetas": []} + try: + for raiz in raices: + AUTO["raiz"] = raiz + try: + resumen["carpetas"].append(automatico_una(raiz, limite)) + except Exception as ex: # noqa + resumen["carpetas"].append({"raiz": raiz, "cliente": (raiz_cfg(raiz) or {}).get("cliente", ""), "ts": time.time(), + "estado": "con errores", "detalle": repr(ex), "movimientos": 0, "lotes": [], + "dudosos": 0, "avisos": [], "errores": [repr(ex)]}) + AUTO["hechas"] += 1 + resumen["seg"] = round(time.time() - resumen["ts"], 1) + guardar_json(AUTOMATICO_PATH, resumen) + finally: + AUTO.update(activo=False, raiz="") + return resumen + + +def estado_automatico(): + c = CONFIG.get("automatico") or {} + return {"activo": bool(c.get("activo")), "hora": c.get("hora") or "02:00", "limite": int(c.get("limite") or 3000), + "en_marcha": dict(AUTO), "ultimo": cargar_json(AUTOMATICO_PATH, None)} + + +def _orden_de_la_tarea(): + """Lo que tiene que lanzar Windows: este mismo programa con la orden «automatico».""" + import instalar + exe, args = instalar.orden(BASE) + return f'"{exe}" {args} automatico' if args else f'"{exe}" automatico' + + +def programar_automatico(activo, hora): + """Da de alta (o de baja) en el Programador de tareas de Windows la pasada diaria. La tarea es del + usuario: corre con su sesión abierta, que es cuando están montados los discos del NAS.""" + hora = (hora or "").strip() + if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", hora): + return False, "la hora va como 02:00" + ensayo = os.environ.get("ORGANILAB_ENSAYO", "") + if ensayo: # las pruebas no tocan el Programador de tareas + guardar_json(os.path.join(ensayo, "tarea.json"), {"activo": bool(activo), "hora": hora, "orden": _orden_de_la_tarea()}) + return True, "" + if activo: + cmd = ["schtasks", "/Create", "/F", "/TN", TAREA_WINDOWS, "/SC", "DAILY", "/ST", hora, "/TR", _orden_de_la_tarea()] + else: + cmd = ["schtasks", "/Delete", "/F", "/TN", TAREA_WINDOWS] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SIN_VENTANA) + except (OSError, subprocess.SubprocessError) as ex: + return False, str(ex) + if r.returncode != 0 and activo: + return False, (r.stderr or r.stdout or "Windows no aceptó la tarea").strip() + return True, "" + + +def configurar_automatico(activo, hora): + ok, error = programar_automatico(activo, hora or (CONFIG.get("automatico") or {}).get("hora") or "02:00") + if not ok: + return {"error": "no se pudo programar: " + error} + CONFIG.setdefault("automatico", {}).update(activo=bool(activo), hora=(hora or "02:00").strip()) + guardar_json(CONFIG_PATH, CONFIG) + return {"ok": True, **estado_automatico()} + + +def automatico_por_comando(): + """Lo que lanza la tarea programada. Si el programa ya está abierto con estos datos, se lo pide a él + (dos procesos moviendo lo mismo se pisarían); si no, lo hace este mismo, sin ventana ni consola.""" + if _soy_la_unica(): + r = automatico(origen="programado") + return 1 if r.get("error") else 0 + puerto = cargar_json(SESION_PATH, {}).get("puerto") + if not puerto: + return 1 + import urllib.request + try: + rq = urllib.request.Request(f"http://127.0.0.1:{puerto}/api/automatico/ahora", b'{"origen": "programado"}', + {"Content-Type": "application/json"}) + urllib.request.urlopen(rq, timeout=30).read() + return 0 + except Exception: # noqa + return 1 + + def acciones(raiz): """Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra. @@ -4127,6 +4828,15 @@ def acciones(raiz): cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.", ir="escanear") + # --- la bandeja de entrada: material nuevo esperando a que lo repartan --- + ent = entrada(raiz) + if ent["n"]: + añadir(clave="entrada", titulo="Repartir lo que hay en la bandeja de entrada", boton="Repartir", + que=f"Material nuevo en «{R.ENTRADA}»: va a su mes, a su día de rodaje y a su cámara.", + cifra=f"{_plural(ent['n'], 'archivo', 'archivos')} · {_gb(ent['bytes'])}", + detalle="La app vuelve a leer la carpeta y mueve lo que da por seguro; lo dudoso se queda en la bandeja.", + ir="entrada") + # --- 1. basura y proxies que siguen fuera de la papelera --- fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? " "AND (es_basura = 1 OR es_proxy = 1)" + en_papelera, [raiz] + args_pap).fetchone() @@ -4223,16 +4933,40 @@ def acciones(raiz): ir="stock") # --- 6. material repetido --- + # lo que ya está apartado en una carpeta «Duplicados» no cuenta: si contara, esto no acabaría nunca + sin_dup = " AND instr(lower(ruta), ?) = 0" + arg_dup = [os.sep + R.DUPLICADOS.lower() + os.sep] rep_fila = con.execute( "SELECT COALESCE(SUM(n - 1), 0) n, COALESCE(SUM((n - 1) * bytes), 0) b FROM " "(SELECT nombre, bytes, COUNT(*) n FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 " - "AND bytes > 5000000" + en_papelera + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)", - [raiz] + args_pap).fetchone() - if rep_fila["n"]: + "AND bytes > 5000000" + en_papelera + sin_dup + " GROUP BY nombre, bytes HAVING COUNT(*) > 1)", + [raiz] + args_pap + arg_dup).fetchone() + # el mismo clip con otra calidad: se llama igual y pesa distinto. Cuenta lo que aún no se ha leído y + # lo que, ya leído, dura lo mismo; dos clips que se llaman igual y duran distinto son clips distintos. + por_nombre = defaultdict(list) + for f in con.execute( + "SELECT lower(nombre) nm, bytes, dur, analizado FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 " + "AND bytes > 5000000 AND rol IN ('camara_raw','camara_video','celular_video') AND ext IN (%s)" + % ",".join("?" * len(_EXT_VIDEO_DUP)) + en_papelera + sin_dup, + [raiz] + sorted(_EXT_VIDEO_DUP) + args_pap + arg_dup).fetchall(): + if f["analizado"] != 2: # 2 = no se pudo leer: no hay con qué comparar + por_nombre[f["nm"]].append(f) + por_comparar = 0 + for fs in por_nombre.values(): + if len(fs) < 2 or len({f["bytes"] for f in fs}) < 2: + continue + if any(f["analizado"] != 1 or not f["dur"] for f in fs): + por_comparar += len(fs) - 1 + else: + durs = sorted(f["dur"] for f in fs) + por_comparar += sum(1 for a, b in zip(durs, durs[1:]) if b - a <= max(0.5, 0.01 * a)) + if rep_fila["n"] or por_comparar: + partes = ([f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}"] if rep_fila["n"] else []) \ + + ([f"{_plural(por_comparar, 'clip', 'clips')} con otra calidad por comparar"] if por_comparar else []) añadir(clave="repetidos", titulo="Juntar material repetido", boton="Ver repetidos", - que="Mismo nombre y mismo peso en varias carpetas.", - cifra=f"{_plural(rep_fila['n'], 'copia de más', 'copias de más')} · {_gb(rep_fila['b'])}", - detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está.", + que="Mismo nombre y mismo peso en varias carpetas, o el mismo clip guardado con dos calidades.", + cifra=" · ".join(partes), + detalle="Se comprueba el contenido antes de tocar nada; la copia buena se queda donde está y la otra va a «Duplicados» a su lado.", ir="repetidos") # --- 7. convertir clips viejos --- @@ -4451,9 +5185,15 @@ class Manejador(BaseHTTPRequestHandler): lo["movimientos"] += 1 if len(lo["ejemplos"]) < 5: lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")}) + if e.get("op") == "mkdir": # una pieza nueva: cuentan las carpetas creadas + lo["movimientos"] += 1 if e.get("op") == "fin": lo["raiz"] = e.get("raiz", "") lo["errores"] = len(e.get("errores") or []) + if l.startswith("entrada-"): # lo traído de una tarjeta: copias, no movimientos + lo["movimientos"] = e.get("movimientos", 0) + if e.get("origen") and e.get("destino"): + lo["ejemplos"].append({"de": e["origen"], "a": e["destino"]}) if e.get("op") == "deshacer": lotes.setdefault(e.get("de_lote"), {"lote": e.get("de_lote"), "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})["deshecho"] = True return self._json({"lotes": sorted([v for k, v in lotes.items() if not k.endswith("-deshecho")], key=lambda x: -(x["ts"] or 0))}) @@ -4493,7 +5233,7 @@ class Manejador(BaseHTTPRequestHandler): tipo = r.get("tipo") if r else None return self._json(mapa_estructura(tipo or "contenido", os.path.normpath(raiz) if raiz else None)) if ruta == "/api/duplicados": - return self._json(duplicados(os.path.normpath(q["raiz"]))) + return self._json(duplicados(os.path.normpath(q["raiz"]), calidades=q.get("calidades") == "1")) if ruta == "/api/respaldo/plan": raiz = os.path.normpath(q["raiz"]) destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "") @@ -4508,6 +5248,12 @@ class Manejador(BaseHTTPRequestHandler): return self._json(RESPALDO) if ruta == "/api/buscar-carpeta": return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))}) + if ruta == "/api/entrada": + return self._json(entrada(q["raiz"])) + if ruta == "/api/automatico": + return self._json(estado_automatico()) + if ruta == "/api/pieza/previa": + return self._json(nueva_pieza(q["raiz"], q.get("titulo", ""), q.get("anio"), q.get("mes"), crear=False)) return self._json({"error": "no existe"}, 404) except Exception as ex: # noqa import traceback @@ -4525,7 +5271,8 @@ class Manejador(BaseHTTPRequestHandler): return self._json({"lanzado": lanzar_escaneo(d["raiz"])}) if ruta == "/api/elegir-carpeta": try: - return self._json({"ruta": elegir_carpeta()}) + que = "Elige la tarjeta o la carpeta con el material nuevo" if d.get("para") == "traer" else "Elige la carpeta del cliente" + return self._json({"ruta": elegir_carpeta(que)}) except ValueError as e: return self._json({"error": str(e)}, 409) if ruta == "/api/raiz/quitar": @@ -4695,6 +5442,31 @@ class Manejador(BaseHTTPRequestHandler): return self._json(apartar_duplicados(os.path.normpath(d["raiz"]))) if ruta == "/api/abrir": return self._json(abrir_carpeta(d.get("ruta", ""))) + if ruta == "/api/pieza": + r = nueva_pieza(d["raiz"], d.get("titulo", ""), d.get("anio"), d.get("mes")) + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/entrada/abrir": + r = abrir_entrada(d["raiz"]) + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/entrada/ordenar": + r = ordenar_entrada(d["raiz"]) + return self._json(r, 409 if r.get("error") else 200) + if ruta == "/api/entrada/plan": + r = plan_traer(d["raiz"], d.get("origen", ""), d.get("nombre", "")) + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/entrada/traer": + r = traer(d["raiz"], d.get("origen", ""), d.get("nombre", ""), d.get("ordenar", True) is not False, en_hilo=True) + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/entrada/cancelar": + if TRAER.get("estado") == "copiando": + TRAER["cancelar"] = True + return self._json({"ok": True}) + if ruta == "/api/automatico/config": + r = configurar_automatico(bool(d.get("activo")), d.get("hora")) + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/automatico/ahora": + r = automatico(en_hilo=True, origen=d.get("origen") or "a mano") + return self._json(r, 409 if r.get("error") else 200) if ruta == "/api/respaldo/copiar": _rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", ""))) if _rp and _rp.get("destino_base"): @@ -4741,6 +5513,8 @@ AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando OrganizadorLabstream.exe deshacer LOTE deshace un lote de la bitácora OrganizadorLabstream.exe actualizar [--si] busca versión nueva en Labstream OS y la instala OrganizadorLabstream.exe duplicados "ruta" [--si] lista los duplicados; con --si los junta en subcarpetas Duplicados + OrganizadorLabstream.exe automatico pone al día todas las carpetas registradas: lee cada una y hace solo lo seguro + (es lo que lanza Windows cada día si se enciende el orden automático) La app NUNCA borra: lo que sobra va a _Para borrar dentro de la misma carpeta (la borra una persona a mano) y todo queda en datos\\bitacora.jsonl. """ @@ -4979,6 +5753,10 @@ def en_marcha(): que = actualizador.tarea_en_marcha() if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]: que = "se están moviendo archivos" + if not que and TRAER.get("estado") in ("copiando", "ordenando"): + que = "se está trayendo material de una tarjeta" + if not que and AUTO.get("activo"): + que = "el orden automático está poniendo al día las carpetas" return que @@ -4997,6 +5775,10 @@ def avance(): return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1 if RESPALDO.get("estado") == "corriendo": return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1 + if TRAER.get("estado") == "copiando": + return TRAER["bytes"] / TRAER["bytes_total"] if TRAER.get("bytes_total") else -1 + if AUTO.get("activo"): + return AUTO["hechas"] / AUTO["total"] if AUTO.get("total") else -1 if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]: return APLICANDO["hechas"] / APLICANDO["total"] if asistente.ESTADO.get("activo"): @@ -5169,6 +5951,8 @@ def main(): ctypes.windll.user32.MessageBoxW(None, "Cierra OrganiLAB antes de instalarlo.", "OrganiLAB", ventana.MB_OK | ventana.MB_ICONINFORMATION) sys.exit(1) sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv)) + if args_tmp and args_tmp[0].lower() == "automatico" and "--puente" not in sys.argv: + sys.exit(automatico_por_comando()) # lo lanza la tarea programada: sin consola ni ventana if args_tmp and "--puente" not in sys.argv: _asegurar_consola() try: diff --git a/probar.py b/probar.py index e67364a..3bd222f 100644 --- a/probar.py +++ b/probar.py @@ -169,7 +169,15 @@ class Windows(unittest.TestCase): # quitarlo desde otra copia del programa no toca lo de esta; desde esta, sí self.assertEqual(instalar.desinstalar(tmp)["accesos"], []) self.assertTrue(instalar.instalado_aqui(base)) - self.assertEqual(instalar.desinstalar(base)["accesos"], [inicio]) + # desinstalar de verdad quita también la tarea del orden automático; contra la clave de ensayo + # no debe ni intentarlo: aquí se comprueba que no llama al Programador de tareas + llamadas, correr = [], instalar.subprocess.run + instalar.subprocess.run = lambda *a, **k: llamadas.append(a) + try: + self.assertEqual(instalar.desinstalar(base)["accesos"], [inicio]) + finally: + instalar.subprocess.run = correr + self.assertEqual(llamadas, []) self.assertIsNone(instalar.instalado()) finally: for clave in (instalar.CLAVE, r"Software\OrganiLAB-ensayo"): diff --git a/prueba_funcional.py b/prueba_funcional.py index 568cb06..6dc4d69 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -78,7 +78,9 @@ if FF: with socket.socket() as s_: s_.bind(('127.0.0.1', 0)) PUERTO = s_.getsockname()[1] -env = dict(os.environ, ORGANIZADOR_PUERTO=str(PUERTO), PYTHONIOENCODING='utf-8') +ENSAYO = os.path.join(TMP, 'equipo') +os.makedirs(ENSAYO) +env = dict(os.environ, ORGANIZADOR_PUERTO=str(PUERTO), PYTHONIOENCODING='utf-8', ORGANILAB_ENSAYO=ENSAYO) log = open(os.path.join(TMP, 'servidor.log'), 'w', encoding='utf-8') proc = subprocess.Popen([sys.executable, 'organizador.py', '--sin-navegador'], cwd=APP, env=env, stdout=log, stderr=subprocess.STDOUT) BASE = f'http://127.0.0.1:{PUERTO}' @@ -380,6 +382,84 @@ def p_asistente(): return 'pasos + «hacer las N de una vez» terminan sin error' +def p_pieza(): + hoy = time.localtime() + v = get('/api/pieza/previa' + q(raiz=RAIZ, titulo='Reel de prueba', anio=hoy.tm_year, mes=hoy.tm_mon)) + assert v['rel'].endswith('Reel de prueba') and not v['existe'], v + assert not os.path.exists(v['ruta']), 'la vista previa creó la carpeta' + c, r = post('/api/pieza', {'raiz': RAIZ, 'titulo': 'Reel de prueba', 'anio': hoy.tm_year, 'mes': hoy.tm_mon}) + assert c == 200 and r.get('ok'), r + assert sorted(os.listdir(r['ruta'])) == sorted(r['subcarpetas']) and len(r['subcarpetas']) >= 5, os.listdir(r['ruta']) + c2, r2 = post('/api/pieza', {'raiz': RAIZ, 'titulo': 'reel de prueba', 'anio': hoy.tm_year, 'mes': hoy.tm_mon}) + assert c2 == 400 and 'ya existe' in r2.get('error', ''), (c2, r2) + vac = [x['rel'] for x in get('/api/vacias' + q(raiz=RAIZ))['carpetas']] + assert not any('Reel de prueba' in x for x in vac), 'ofrece borrar una pieza recién creada: %s' % vac + c3, r3 = post('/api/deshacer', {'lote': r['lote']}) + assert c3 == 200 and not r3['errores'] and not os.path.exists(r['ruta']), r3 + return 'crea «%s» con %d subcarpetas, no repite, y se deshace' % (r['nombre'], len(r['subcarpetas'])) + + +def p_entrada(): + tarjeta = os.path.join(TMP, 'tarjeta') + clip = os.path.join(tarjeta, 'DCIM', '100MEDIA', 'DJI_0007.MP4') + os.makedirs(os.path.dirname(clip)) + with open(clip, 'wb') as fh: + fh.write(os.urandom(700_000)) + t = time.mktime(time.struct_time((2026, 8, 10, 12, 0, 0, 0, 0, -1))) + os.utime(clip, (t, t)) + c, p = post('/api/entrada/plan', {'raiz': RAIZ, 'origen': tarjeta}) + assert c == 200 and p['n'] == 1 and p['bytes'] == 700_000, p + c, mal = post('/api/entrada/plan', {'raiz': RAIZ, 'origen': os.path.join(RAIZ, '2026')}) + assert c == 400 and mal.get('error'), mal + c, r = post('/api/entrada/traer', {'raiz': RAIZ, 'origen': tarjeta}) + assert c == 200 and r.get('lanzado'), r + assert esperar(lambda: get('/api/estado')['traer'].get('estado') not in ('copiando', 'ordenando'), 120) + tr = get('/api/estado')['traer'] + assert tr['estado'] == 'listo' and tr['copiados'] == 1 and not tr['errores_n'], tr + final = os.path.join(RAIZ, '2026', '08- Agosto', '00_Grabacion', 'DJI', 'DJI_0007.MP4') + assert os.path.isfile(final), 'no llegó a su sitio: %s' % [x for x in arbol() if 'dji' in x] + assert os.path.getsize(final) == 700_000 and abs(os.path.getmtime(final) - t) < 2, 'la copia no conserva peso o fecha' + assert os.path.isfile(clip), 'tocó la tarjeta' + e = get('/api/entrada' + q(raiz=RAIZ)) + assert e['n'] == 0 and e['existe'], e + # y lo que se deja a mano en la bandeja sale en la lista y se reparte con su botón + suelto = os.path.join(RAIZ, '_Entrada', 'C0099.MP4') + with open(suelto, 'wb') as fh: + fh.write(os.urandom(4096)) + os.utime(suelto, (t, t)) + a = get('/api/carpeta/acciones' + q(raiz=RAIZ)) + assert any(x['clave'] == 'entrada' and x['estado'] == 'toca' for x in a['acciones']), [x['clave'] for x in a['acciones']] + c, o = post('/api/entrada/ordenar', {'raiz': RAIZ}) + assert c == 200 and o['movimientos'] >= 1 and not o['errores'] and o['quedan'] == 0, o + assert os.path.isfile(os.path.join(RAIZ, '2026', '08- Agosto', '00_Grabacion', 'SONY', 'C0099.MP4')), [x for x in arbol() if 'c0099' in x] + return 'trae de una tarjeta con copia comprobada y la reparte; lo dejado a mano en la bandeja también' + + +def p_automatico(): + c, r = post('/api/automatico/config', {'activo': True, 'hora': '03:15'}) + assert c == 200 and r['activo'] and r['hora'] == '03:15', r + tarea = json.load(open(os.path.join(ENSAYO, 'tarea.json'), encoding='utf-8')) + assert tarea['activo'] and tarea['orden'].endswith(' automatico'), tarea + c, mal = post('/api/automatico/config', {'activo': True, 'hora': 'a las tres'}) + assert c == 400, mal + c, r = post('/api/automatico/ahora', {}) + assert c == 200 and r.get('lanzado'), r + assert esperar(lambda: not get('/api/estado')['automatico'].get('activo'), 180) + u = get('/api/automatico')['ultimo'] + assert u and len(u['carpetas']) == 1 and u['carpetas'][0]['estado'] in ('al dia', 'con dudas'), u + assert not u['carpetas'][0]['errores'], u['carpetas'][0] + # lo que lanza la tarea de Windows cuando el programa ya está abierto: se lo pide a él y se va, para + # que no haya dos procesos moviendo lo mismo + r = subprocess.run([sys.executable, 'organizador.py', 'automatico'], cwd=APP, env=env, timeout=120) + assert r.returncode == 0, 'la orden «automatico» salió con %s' % r.returncode + assert esperar(lambda: not get('/api/estado')['automatico'].get('activo'), 180) + u2 = get('/api/automatico')['ultimo'] + assert u2['origen'] == 'programado' and u2['ts'] > u['ts'], u2 + c, r = post('/api/automatico/config', {'activo': False, 'hora': '03:15'}) + assert c == 200 and not r['activo'], r + return 'se enciende (tarea de Windows en ensayo), hace una pasada y deja el resumen (%s); con el programa abierto, la tarea se lo pide a él' % u['carpetas'][0]['estado'] + + def p_soltar_y_buscar(): s = get('/api/soltado' + q(nombre='nada')) assert s['ruta'] is None @@ -424,6 +504,7 @@ for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpet ('Motores de conversión', p_motores), ('Leer clips (ffprobe)', p_analizar), ('Convertir, verificar, reemplazar', p_convertir), ('Control de la cola', p_cola_control), ('Respaldar', p_respaldo), ('Asistente / hacer de una vez', p_asistente), + ('Pieza nueva', p_pieza), ('Bandeja de entrada y tarjeta', p_entrada), ('Orden automático', p_automatico), ('Arrastrar y buscar carpeta', p_soltar_y_buscar), ('Actualizaciones', p_actualizacion_offline), ('Clientes, reglas', p_clientes_config), ('Quitar de la lista', p_quitar)): prueba(nombre, fn) diff --git a/prueba_orden.py b/prueba_orden.py index 344ed3b..51b867e 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -382,6 +382,225 @@ class Motor(unittest.TestCase): self.assertTrue(any("versión anterior" in x for x in O.Plan(O.raiz_cfg(raiz)).correr()["avisos"])) +class Nuevo(unittest.TestCase): + """Lo que entra nuevo: una pieza, el material de una tarjeta, y la pasada automática.""" + + def test_pieza_nueva(self): + """Las piezas se creaban a mano: dos «01» en el mismo mes, «03 - X» junto a «03- X», «portasda».""" + raiz = cliente("Piezas") + mk(raiz, "2026/10- Octubre/01- Uno/a.mp4", 2026, 10, 1) + mk(raiz, "2026/10- Octubre/02- Dos/b.mp4", 2026, 10, 2) + plan(raiz) + previa = O.nueva_pieza(raiz, "Ozempic face", 2026, 10, crear=False) + self.assertEqual((previa["rel"], previa["existe"]), (rel("2026", "10- Octubre", "03- Ozempic face"), "")) + self.assertFalse(os.path.exists(previa["ruta"]), "la vista previa no debe crear nada") + r = O.nueva_pieza(raiz, "Ozempic face", 2026, 10) + self.assertTrue(r.get("ok"), r) + subs = sorted(os.listdir(r["ruta"])) + self.assertEqual(subs, sorted(O.CONFIG_DEFECTO["pieza"]["subcarpetas"])) + self.assertIn("ya existe", O.nueva_pieza(raiz, "ozempic FACE", 2026, 10).get("error", "")) # la misma, escrita distinto + self.assertEqual(O.nueva_pieza(raiz, "07- Otra cosa", 2026, 10, crear=False)["nombre"], "04- Otra cosa") # el número lo pone la app + for malo in ("", "a/b", "x" * 90): + self.assertTrue(O.nueva_pieza(raiz, malo, 2026, 10).get("error"), malo) + mes_nuevo = O.nueva_pieza(raiz, "Primera", 2026, 11) + self.assertEqual(mes_nuevo["rel"], rel("2026", "11- Noviembre", "01- Primera")) + # una pieza recién creada no es un hueco que borrar, ni el mes que se creó para ella + self.assertEqual(O.carpetas_vacias(raiz)["carpetas"], []) + self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"]], "el plan quiere mover una pieza recién creada") + # deshacer la quita si sigue vacía; si ya tiene algo dentro, la deja + mk(raiz, "2026/11- Noviembre/01- Primera/Guion/guion.docx", 2026, 11, 2) + d = O.deshacer(mes_nuevo["lote"]) + self.assertTrue(os.path.isfile(os.path.join(mes_nuevo["ruta"], "Guion", "guion.docx")) and d["errores"], d) + d = O.deshacer(r["lote"]) + self.assertFalse(os.path.exists(r["ruta"]) or d["errores"], d) + # donde las piezas no van por mes (campañas), sin número + anuncios = cliente("Piezas de pauta", "pauta") + self.assertEqual(O.nueva_pieza(anuncios, "Navidad", 2026, 12)["rel"], rel("Campañas", "2026_Navidad")) + + def test_bandeja_de_entrada(self): + """Lo que se deja en _Entrada se reparte solo por rodaje y cámara, y la bandeja queda vacía.""" + raiz = cliente("Bandeja") + mk(raiz, "2026/09- Septiembre/01- Pieza/a.png", 2026, 9, 1) + t = "_Entrada/Tarjeta A/PRIVATE/M4ROOT/" + mk(raiz, t + "CLIP/C0001.MP4", 2026, 9, 20) + mk(raiz, t + "CLIP/C0001M01.XML", 2026, 9, 20) + mk(raiz, t + "MEDIAPRO.XML", 2026, 9, 20) + mk(raiz, t + "THMBNL/C0001T01.JPG", 2026, 9, 20) + mk(raiz, t + "SUB/C0001S03.MP4", 2026, 9, 20) + mk(raiz, "_Entrada/A001_09211200_C001.braw", 2026, 9, 21) + mk(raiz, "_Entrada/Entrevista Dra/C0005.MP4", 2026, 9, 22) + antes = nombres(raiz) + plan(raiz) + self.assertEqual([a["clave"] for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"], ["entrada"]) + self.assertGreaterEqual(O.entrada(raiz)["n"], 4) + r = O.ordenar_entrada(raiz) + self.assertTrue(r.get("ok") and r["movimientos"] and not r["errores"], r) + self.assertEqual(nombres(raiz), antes) + g = os.path.join(raiz, "2026", "09- Septiembre", "00_Grabacion") + for esperado in (os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001.MP4"), + os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001M01.XML"), + os.path.join(g, R.nombre_dia("2026-09-21"), "URSA_BRAW", "A001_09211200_C001.braw"), + os.path.join(g, R.nombre_dia("2026-09-22"), "Entrevista Dra", "SONY", "C0005.MP4"), + os.path.join(raiz, "_Para borrar", "proxies", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "SUB", "C0001S03.MP4"), + os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "THMBNL", "C0001T01.JPG"), + os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "MEDIAPRO.XML")): + self.assertTrue(os.path.isfile(esperado), "falta " + os.path.relpath(esperado, raiz) + "\n" + "\n".join(arbol(raiz))) + # la bandeja se queda, vacía y sin el esqueleto de la tarjeta, y no sale como hueco que borrar + self.assertEqual(os.listdir(os.path.join(raiz, "_Entrada")), []) + self.assertEqual((r["quedan"], O.entrada(raiz)["n"]), (0, 0)) + self.assertFalse([a for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"]) + self.assertNotIn("_Entrada", [c["rel"] for c in O.carpetas_vacias(raiz)["carpetas"]]) + # y se deshace: cada cosa vuelve a su sitio dentro de la tarjeta + d = O.deshacer(r["lote"]) + self.assertFalse(d["errores"], d) + self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "CLIP", "C0001.MP4"))) + self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Entrevista Dra", "C0005.MP4"))) + + def test_traer_de_una_tarjeta(self): + """La copia desde la tarjeta se comprueba, conserva la fecha del rodaje y no toca el origen.""" + raiz = cliente("Traer") + mk(raiz, "2026/08- Agosto/01- Pieza/a.png", 2026, 8, 1) + tarjeta = os.path.join(TMP, "tarjeta-sd") + clip = mk(tarjeta, "DCIM/100MEDIA/DJI_0001.MP4", 2026, 8, 10, tam=300_000) + mk(tarjeta, "System Volume Information/indice.dat", 2026, 8, 10) + with open(clip, "rb") as fh: + contenido = fh.read() + self.assertTrue(O.plan_traer(raiz, os.path.join(raiz, "2026")).get("error")) # de dentro no se trae + self.assertTrue(O.plan_traer(raiz, os.path.join(TMP, "no-existe")).get("error")) + p = O.plan_traer(raiz, tarjeta) + self.assertEqual((p["n"], p["bytes"]), (1, 300_000), p) + self.assertTrue(p["carpeta"].startswith("Tarjeta ")) + r = O.traer(raiz, tarjeta) + self.assertEqual((r["estado"], r["copiados"], r["errores"]), ("listo", 1, []), r) + self.assertTrue(r["ordenado"]["ok"] and not r["ordenado"]["errores"], r["ordenado"]) + final = os.path.join(raiz, "2026", "08- Agosto", "00_Grabacion", "DJI", "DJI_0001.MP4") + self.assertTrue(os.path.isfile(final), "\n".join(arbol(raiz))) + with open(final, "rb") as fh: + self.assertEqual(fh.read(), contenido) + self.assertLess(abs(os.path.getmtime(final) - os.path.getmtime(clip)), 2, "no conservó la fecha del rodaje") + with open(clip, "rb") as fh: + self.assertEqual(fh.read(), contenido, "tocó el origen") + self.assertEqual(arbol(tarjeta), [rel("DCIM", "100MEDIA", "DJI_0001.MP4"), rel("System Volume Information", "indice.dat")]) + actas = os.listdir(os.path.join(raiz, ".organilab", "entradas")) + self.assertEqual(len(actas), 1) + with open(os.path.join(raiz, ".organilab", "entradas", actas[0]), encoding="utf-8-sig") as fh: + self.assertIn(rel("DCIM", "100MEDIA", "DJI_0001.MP4") + ";300000;", fh.read()) + # una copia que no queda igual que el original no se da por buena + malo = mk(tarjeta, "DCIM/100MEDIA/DJI_0002.MP4", 2026, 8, 10, tam=50_000) + real = O.hashlib.blake2b + vez = [] + + class Trucado: # la segunda lectura (la de comprobación) sale distinta + def __init__(self, **kw): + self.h = real(**kw) + vez.append(self) + self.malo = len(vez) % 2 == 0 + + def update(self, b): + self.h.update(b[::-1] if self.malo else b) + + def digest(self): + return self.h.digest() + + def hexdigest(self): + return self.h.hexdigest() + O.hashlib.blake2b = Trucado + try: + estado = {"bytes": 0} + destino = os.path.join(raiz, "_Entrada", "prueba", "DJI_0002.MP4") + with self.assertRaises(OSError): + O._copiar_comprobando(malo, destino, estado) + finally: + O.hashlib.blake2b = real + self.assertFalse(os.path.exists(destino) or os.path.exists(destino + ".copiando"), "dejó una copia sin comprobar") + self.assertEqual(estado["bytes"], 0) + + def test_versiones_del_mismo_clip(self): + """39 clips estaban dos veces con el mismo nombre y distinta calidad, y no cabían juntos en su mes.""" + raiz = cliente("Versiones") + oct_, may = "2025/10- Octubre/00_Grabacion/BLACKMAGIC_MP4/", "2026/05- Mayo/00_Grabacion/BLACKMAGIC_MP4/" + buena = mk(raiz, oct_ + "A002_03020727_C001.mp4", 2025, 12, 1, tam=9000) + peor = mk(raiz, may + "A002_03020727_C001.mp4", 2026, 7, 1, tam=3000) + mk(raiz, oct_ + "C0001.MP4", 2025, 10, 5, tam=5000) # se llaman igual… + mk(raiz, may + "C0001.MP4", 2026, 5, 5, tam=4000) # …pero son clips distintos: duran distinto + # dos exportados con el mismo nombre no son un duplicado: son dos entregas de la pieza (la vertical y + # la horizontal). No son material de cámara, así que ni se leen: `sonda` fallaría si se los pasaran. + mk(raiz, "2026/02- Febrero/02- Liftera/Exportados/Liftera VR3 Final.mp4", 2026, 2, 20, tam=7000) + mk(raiz, "2026/02- Febrero/01- Otra/Exportados/Liftera VR3 Final.mp4", 2026, 2, 21, tam=6000) + p = plan(raiz) + self.assertTrue([f for f in p["filas"] if "OJO" in f["motivo"]], "sin comparar, las dos versiones chocan en su mes") + medidas = {buena: (12.0, 3840, 2160), peor: (12.02, 1920, 1080), + os.path.join(raiz, oct_.replace("/", S), "C0001.MP4"): (10.0, 1920, 1080), + os.path.join(raiz, may.replace("/", S), "C0001.MP4"): (25.0, 1920, 1080)} + + def sonda(ruta): + d, an, al = medidas[ruta] + return {"codec": "h264", "perfil": "", "pix_fmt": "yuv420p", "ancho": an, "alto": al, "fps": 25.0, "dur": d, + "kbps": 1000, "alfa": 0, "acodec": "aac", "pistas_audio": 1} + viejos = (O.ffprobe, O.herramientas) + O.ffprobe, O.herramientas = sonda, lambda: {"ffprobe": {"ok": True}} + try: + self.assertEqual(O.duplicados(raiz, minimo=0)["por_comparar"], 2) + d = O.duplicados(raiz, minimo=0, calidades=True) + finally: + O.ffprobe, O.herramientas = viejos + self.assertEqual([(g["tipo"], g["queda"], g["mueven"]) for g in d["grupos"]], [("calidad", buena, [peor])], d["grupos"]) + r = O.apartar_duplicados(raiz, d["grupos"]) + self.assertEqual((r["movimientos"], r["errores"]), (1, []), r) + self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(buena), "Duplicados", "A002_03020727_C001.mp4"))) + self.assertTrue(os.path.isfile(os.path.join(raiz, may.replace("/", S), "C0001.MP4")), "apartó un clip que solo se llamaba igual") + # ya no chocan: las dos van a su mes, la peor dentro de Duplicados + p = O.Plan(O.raiz_cfg(raiz)).correr() + self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]]) + aplicar(raiz, p) + mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion", "BLACKMAGIC_MP4") + self.assertTrue(os.path.isfile(os.path.join(mar, "A002_03020727_C001.mp4")), "\n".join(arbol(raiz))) + self.assertTrue(os.path.isfile(os.path.join(mar, "Duplicados", "A002_03020727_C001.mp4")), "\n".join(arbol(raiz))) + + def test_orden_automatico(self): + """La pasada automática hace lo seguro en cada carpeta, y se frena sola cuando no debe seguir.""" + normal = cliente("Auto normal") + for n in ("C0001.MP4", "C0002.MP4"): + mk(normal, "2026/04- Abril/Grabacion/" + n, 2026, 4, 10) + mk(normal, "2026/04- Abril/01 - Botox/final.mp4", 2026, 4, 12) # un retoque de nombre: dudoso, no se toca + mucho = cliente("Auto demasiado") + for i in range(6): + mk(mucho, "2026/04- Abril/Grabacion/C%04d.MP4" % i, 2026, 4, 10) + todas, ajuste = O.CONFIG["raices"], dict(O.CONFIG["automatico"]) + desconectada = {"ruta": os.path.join(TMP, "no-conectada"), "cliente": "Sin disco", "tipo": "contenido"} + O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto normal"] + [desconectada] + ensayo = os.path.join(TMP, "ensayo-tarea") + os.makedirs(ensayo, exist_ok=True) + os.environ["ORGANILAB_ENSAYO"] = ensayo + try: + res = O.automatico(origen="prueba") + self.assertEqual([(c["cliente"], c["estado"]) for c in res["carpetas"]], [("Auto normal", "con dudas"), ("Sin disco", "sin conexion")]) + c = res["carpetas"][0] + self.assertEqual((c["movimientos"], c["dudosos"], c["errores"]), (2, 1, []), c) + self.assertTrue(os.path.isfile(os.path.join(normal, "2026", "04- Abril", "00_Grabacion", "SONY", "C0001.MP4"))) + self.assertTrue(os.path.isdir(os.path.join(normal, "2026", "04- Abril", "01 - Botox")), "tocó lo dudoso") + self.assertEqual(O.estado_automatico()["ultimo"]["origen"], "prueba") + self.assertFalse(O.AUTO["activo"]) + # con más movimientos que el límite, la carpeta no se toca y se dice por qué + O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto demasiado"] + O.CONFIG["automatico"]["limite"] = 5 + c = O.automatico(origen="prueba")["carpetas"][0] + self.assertEqual(c["estado"], "demasiado", c) + self.assertTrue(os.path.isfile(os.path.join(mucho, "2026", "04- Abril", "Grabacion", "C0000.MP4")), "movió pese al límite") + # encenderlo da de alta la tarea de Windows (aquí, en una carpeta de ensayo) con la orden «automatico» + self.assertIn("error", O.configurar_automatico(True, "25:00")) + r = O.configurar_automatico(True, "03:30") + self.assertTrue(r.get("ok") and r["activo"] and r["hora"] == "03:30", r) + with open(os.path.join(ensayo, "tarea.json"), encoding="utf-8") as fh: + tarea = json.load(fh) + self.assertTrue(tarea["activo"] and tarea["hora"] == "03:30" and tarea["orden"].endswith(" automatico"), tarea) + self.assertFalse(O.configurar_automatico(False, "03:30")["activo"]) + finally: + os.environ.pop("ORGANILAB_ENSAYO", None) + O.CONFIG["raices"] = todas + O.CONFIG["automatico"] = ajuste + + if __name__ == "__main__": try: unittest.main(verbosity=2, exit=False) diff --git a/reglas.py b/reglas.py index d59270a..2a0ca97 100644 --- a/reglas.py +++ b/reglas.py @@ -237,7 +237,9 @@ def app_proyecto(ext: str): return EXT_A_APP.get((ext or "").lower()) -BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"} +BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings", + # lo que la cámara deja en la tarjeta además de los clips: índices que solo le sirven a ella + "mediapro.xml", "sonycard.ind", "discmeta.xml", "status.bin"} # solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos # (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito. BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"} @@ -571,7 +573,7 @@ def cubeta_carpeta(nombre: str, diccionario: dict): FUENTE_PALABRAS = {"grabacion", "grabaciones", "grabado", "grabados", "ursa", "sony", "osmo", "dji", "blackmagic", "braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos", "mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular", - "fotos", "foto", "atomos"} + "fotos", "foto", "atomos", "tarjeta", "tarjetas", "card", "sd"} # carpetas que crea la propia tarjeta de la cámara: no son el nombre de nada _TARJETA = {"dcim", "private", "m4root", "clip", "sub", "thmbnl", "general", "avchd", "bdmv", "stream", @@ -757,6 +759,9 @@ COLOR_CUBETA = { # Todo lo de la app en la carpeta del cliente vive aquí: los iconos de color, y lo que venga. CARPETA_APP = ".organilab" +# La bandeja de entrada: lo que se deje aquí dentro, el plan lo abre siempre y lo reparte. +ENTRADA = "_Entrada" + # Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta. ROL_QUE = { "camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)", diff --git a/static/index.html b/static/index.html index b958ed1..8d31696 100644 --- a/static/index.html +++ b/static/index.html @@ -371,6 +371,7 @@
  o pega la ruta
+
1 · EscanearLee nombres, tamaños y fechas. Solo lectura.
2 · RevisarQué pesa: BRAW, cámara, stock, proxies, basura, repetidos.
@@ -380,7 +381,7 @@
-

…

+

…

@@ -406,7 +407,7 @@
Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente

Por fuente (de qué cámara viene)

Material de cámara por mes

-

Por tipo de archivo

Repetidos (mismo nombre y peso, o el mismo clip en MP4 y MOV)

+

Por tipo de archivo

Repetidos (mismo nombre y peso, el mismo clip en MP4 y MOV, o con dos calidades)

Dónde vive cada cliente (todos los discos conectados)

cargando…

Discos

cargando…
@@ -567,6 +568,9 @@
Escanear
Lee nombres, tamaños y fechas de todo lo que hay dentro. Cómo: recorre la carpeta sin abrir los archivos, guarda un inventario y clasifica cada cosa por su extensión, su nombre y la carpeta donde está: cámara, proyecto, exportado, stock, proxy, basura…
Lista de acciones
Todo lo que se puede hacer ahora en esa carpeta, en orden, cada cosa con su cifra y su botón; lo ya hecho sale tachado. Cómo: son consultas al inventario, por eso es instantánea.
Hacer las N de una vez
Ejecuta seguidas las acciones que no necesitan que elijas nada: apartar basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo y juntar repetidos. Cómo: solo entra lo que el plan da por seguro o probable; lo dudoso no entra nunca, tampoco si es material de cámara. Convertir, respaldar y borrar carpetas vacías tienen su propio botón.
+
Nueva pieza
Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas (Guion, Videos, Imagenes, Musica, Elements, IA, Portada, Exportado), para que todas se llamen igual. Cómo: mira qué números hay ya en ese mes y pone el siguiente; si la pieza ya existe, lo dice y no crea otra. Mientras siga vacía se deshace desde el Historial, y no sale entre las carpetas vacías por borrar.
+
Bandeja de entrada
Lo que se deje en la carpeta _Entrada del cliente (una tarjeta volcada, clips sueltos, una carpeta con el nombre de la sesión) aparece en la lista como «Repartir» y va solo a su mes, a su día de rodaje y a su cámara. Cómo: el plan abre siempre esa carpeta. Los índices y miniaturas que deja la cámara en la tarjeta van a _Para borrar; lo que no sabe dónde poner se queda en la bandeja.
+
Traer material
Copia una tarjeta, o cualquier carpeta de fuera, a la bandeja de entrada y la reparte. De la tarjeta no borra ni mueve nada. Cómo: vuelve a leer cada archivo copiado y lo compara con el original; conserva la fecha del archivo, que es la del rodaje, y deja la lista de lo copiado con su huella en .organilab\entradas. Si se corta, al repetirlo se retoma donde iba.
Quitar de la lista
La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.
@@ -580,7 +584,7 @@
Repaso de lo ya ordenado
El plan también mira lo que ya está en 00_Grabacion: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo. Cómo: solo con fechas escritas por la cámara; la fecha de modificación no cuenta, salvo para lo que se grabó ese mismo día.
Aplicar
Hace los movimientos marcados, siempre dentro de la carpeta del cliente. Cómo: no pisa nada; si en el destino ya hay un archivo con ese nombre, lo deja donde está y lo avisa. Cada movimiento queda apuntado para poder deshacerlo.
Stock viejo
Aparta a _Para borrar\stock las tomas de Envato de piezas de más de 1, 2, 3, 6 o 12 meses, que se vuelven a descargar con la suscripción. Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».
-
Repetidos
Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta Duplicados. Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca.
+
Repetidos
Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta Duplicados. También junta el mismo clip guardado con dos calidades: se queda el de más imagen. Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca. Dos clips de cámara que se llaman igual y pesan distinto se leen: si duran lo mismo son el mismo clip, y si no, son dos clips distintos y se dejan. Dos exportados con el mismo nombre no se tocan: son dos entregas de la pieza.
Carpetas vacías
Lista los huecos que deja ordenar y los borra. Es lo único que el programa borra, porque dentro no hay nada. Cómo: cada una se comprueba en el disco justo antes; se puede deshacer, y no entra en «hacer de una vez».
@@ -631,7 +635,8 @@

Sin abrir el programa

Arrastrar una carpeta
Sobre el .exe o sobre «Organizar carpeta (arrastra aqui).bat»: la escanea, da el diagnóstico y pregunta paso a paso en una consola.
-
Por comando
OrganizadorLabstream.exe ayuda lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, instalar y desinstalar.
+
Por comando
OrganizadorLabstream.exe ayuda lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, automatico, instalar y desinstalar.
+
Orden automático
En la portada. Encendido, el programa pone al día todas las carpetas cada día a la hora que digas: lee cada una y hace solo lo seguro. El resumen de la última pasada queda en la portada, carpeta por carpeta, y «Hacerlo ahora» lo lanza a mano. Viene apagado. Cómo: lo abre una tarea del Programador de tareas de Windows, con tu sesión iniciada, que es cuando están montados los discos. Si una carpeta no se puede leer entera, o si su plan mueve más de 3.000 archivos, no la toca y lo dice. Cada cambio se deshace desde el Historial.

Lo que nunca hace

@@ -787,12 +792,21 @@ function detectarCambios(){ if (antes.asis && !ahora.asis && vistaActual() === 'carpeta') pintarAcciones().catch(() => {}); fin('asis', 'Listo: se hizo todo lo seguro de esa carpeta.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')}); fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')}); + if (antes.traer && !ahora.traer) { + const t = ESTADO.traer || {}, o = t.ordenado; + if (t.estado === 'listo') aviso(`Material traído y comprobado: ${num(t.copiados)} archivo${t.copiados !== 1 ? 's' : ''}${t.saltados ? ` (${num(t.saltados)} ya estaban)` : ''}.` + (o && !o.error ? ` Repartido: ${o.movimientos} movimiento${o.movimientos !== 1 ? 's' : ''}${o.quedan ? `; quedan ${o.quedan} en la bandeja por mirar` : ''}.` : o && o.error ? ' No se pudo repartir: ' + o.error : ' Está en la bandeja de entrada.'), 'ok', o && o.lote ? {texto: 'Deshacer el reparto', ir: () => deshacer(o.lote)} : null); + else if (t.estado === 'cancelado') aviso('Se dejó de traer material. Lo ya copiado está en la bandeja de entrada; al volver a traerlo se retoma.', 'ok'); + else aviso(`Al traer el material, ${t.errores_n} archivo${t.errores_n !== 1 ? 's' : ''} no se pudieron copiar bien. Lo demás está en la bandeja de entrada, sin repartir.`, 'bad'); + cargarVista(); + } + if (antes.auto && !ahora.auto) { aviso('Orden automático terminado.', 'ok'); if (!raizActual()) pintarAuto().catch(() => {}); else cargarVista(); } if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola(); } function huella(E){ const h = {}; E.raices.forEach(r => { if (r.progreso && r.progreso.estado === 'corriendo') h['esc:' + r.clave] = 1; if (r.analisis && r.analisis.estado === 'corriendo') h['ana:' + r.clave] = 1; }); if (E.cola.activa) h.cola = 1; if (E.asistente.activo) h.asis = 1; if (E.respaldo.estado === 'corriendo') h.resp = 1; + if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) h.traer = 1; if (E.automatico && E.automatico.activo) h.auto = 1; return h; } let SONDEO = null; @@ -808,6 +822,81 @@ function limpiarTablas(){ } $('#btnEscanear').onclick = () => accion($('#btnEscanear'), async () => { await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); }); +// ---------------- pieza nueva ---------------- +const MESES_LARGOS = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; +$('#btnPieza').onclick = () => { + const raiz = raizActual(), hoy = new Date(), a0 = hoy.getFullYear(); + modal('Nueva pieza', `

La app crea la carpeta de la pieza en su mes, con el número que le toca y sus subcarpetas, para que todas se llamen igual.

+ +
+
Escribe el título para ver dónde va a quedar.
`, + () => accion($('#btnPieza'), async () => { + const r = await api('/api/pieza', {raiz, titulo: $('#pzTitulo').value, anio: +$('#pzAnio').value, mes: +$('#pzMes').value}); + MAPA = null; PLAN = null; + aviso(`Creada «${r.rel}» con ${r.subcarpetas.length} subcarpetas.`, 'ok', {texto: 'Abrir la carpeta', ir: () => api('/api/abrir', {ruta: r.ruta}).catch(e => aviso(e.message, 'bad'))}); + await cargarEstado(); cargarVista(); + })); + $('#modalSi').textContent = 'Crear'; $('#modalSi').disabled = true; + let turno = 0; + const previa = async () => { + const t = $('#pzTitulo').value.trim(), mio = ++turno; + if (!t) { $('#pzPrevia').textContent = 'Escribe el título para ver dónde va a quedar.'; $('#modalSi').disabled = true; return; } + try { + const r = await api(`/api/pieza/previa?raiz=${encodeURIComponent(raiz)}&titulo=${encodeURIComponent(t)}&anio=${$('#pzAnio').value}&mes=${$('#pzMes').value}`); + if (mio !== turno) return; + $('#pzPrevia').innerHTML = r.existe ? `Esa pieza ya existe: ${esc(r.existe)}` : `Va a quedar en ${esc(r.rel)}, con: ${r.subcarpetas.map(esc).join(' · ')}`; + $('#modalSi').disabled = !!r.existe; + } catch (e) { if (mio === turno) { $('#pzPrevia').innerHTML = `${esc(e.message)}`; $('#modalSi').disabled = true; } } + }; + $('#pzTitulo').oninput = previa; $('#pzMes').onchange = previa; $('#pzAnio').onchange = previa; + setTimeout(() => $('#pzTitulo').focus(), 50); +}; + +// ---------------- bandeja de entrada: traer material de una tarjeta ---------------- +$('#btnTraer').onclick = () => { + const raiz = raizActual(); + modal('Traer material nuevo', `

La app copia lo que haya en la tarjeta (o en cualquier carpeta de fuera) a la bandeja de entrada de este cliente, vuelve a leer cada archivo copiado para comprobar que quedó igual, y lo reparte por mes, día de rodaje y cámara. De la tarjeta no borra ni mueve nada.

+
+ + +
Elige de dónde traerlo.
+

También puedes dejar el material a mano en la bandeja: abrir la bandeja de entrada. Lo que dejes ahí sale en la lista de la carpeta como «Repartir».

`, + () => accion($('#btnTraer'), async () => { + const r = await api('/api/entrada/traer', {raiz, origen: $('#trOrigen').value.trim(), nombre: $('#trNombre').value.trim(), ordenar: $('#trOrdenar').checked}); + aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}). El avance está en la barra de abajo.`, 'ok'); + await cargarEstado(); + })); + $('#modalSi').textContent = 'Traer'; $('#modalSi').disabled = true; + const previa = async () => { + const o = $('#trOrigen').value.trim(); + if (!o) { $('#trPrevia').textContent = 'Elige de dónde traerlo.'; $('#modalSi').disabled = true; return; } + try { + const r = await api('/api/entrada/plan', {raiz, origen: o, nombre: $('#trNombre').value.trim()}); + $('#trPrevia').innerHTML = `Son ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} · ${gb(r.bytes)}. Se copian a _Entrada\\${esc(r.carpeta)}${r.libre != null ? ` (en el disco quedan ${gb(r.libre)} libres)` : ''}.`; + $('#modalSi').disabled = false; + } catch (e) { $('#trPrevia').innerHTML = `${esc(e.message)}`; $('#modalSi').disabled = true; } + }; + $('#trElegir').onclick = seguro(async () => { const r = await api('/api/elegir-carpeta', {para: 'traer'}); if (r.ruta) { $('#trOrigen').value = r.ruta; previa(); } }); + $('#trOrigen').onchange = previa; $('#trNombre').onchange = previa; + $('#trAbrir').onclick = e => { e.preventDefault(); api('/api/entrada/abrir', {raiz}).then(() => aviso('Bandeja de entrada abierta en el Explorador. Lo que dejes ahí saldrá en la lista de la carpeta.', 'ok')).catch(er => aviso(er.message, 'bad')); }; +}; + +// ---------------- orden automático (portada) ---------------- +const ESTADO_AUTO = {'al dia': ['ok', 'al día'], 'con dudas': ['', 'con dudas'], 'con errores': ['bad', 'con avisos'], 'demasiado': ['warn', 'sin tocar'], 'sin conexion': ['mut', 'sin conexión'], 'sin leer': ['warn', 'sin leer']}; +async function pintarAuto(){ + const caja = $('#portadaAuto'); if (!caja) return; + const a = await api('/api/automatico'); const u = a.ultimo, m = a.en_marcha || {}; + const filas = u && u.carpetas ? u.carpetas.map(c => { const e = ESTADO_AUTO[c.estado] || ['', c.estado]; return `${esc(c.cliente || c.raiz)}${esc(e[1])}${c.movimientos ? num(c.movimientos) + ' movimiento' + (c.movimientos !== 1 ? 's' : '') : '—'}${esc([c.dudosos ? c.dudosos + ' dudoso' + (c.dudosos !== 1 ? 's' : '') + ' por mirar' : '', c.detalle || '', (c.errores || []).length ? (c.errores || []).length + ' avisos' : ''].filter(Boolean).join(' · '))}`; }).join('') : ''; + caja.innerHTML = `

Orden automático

+ +

Windows abre el programa a esa hora, lee cada carpeta y hace solo lo seguro; lo dudoso lo deja para ti, y todo se deshace desde el Historial. Hace falta el equipo encendido y tu sesión abierta. Si en una carpeta el plan mueve más de ${num(a.limite)} archivos, no la toca y lo dice.

+
${u ? `Última pasada: ${fecha(u.ts)} (${u.origen === 'programado' ? 'programada' : 'a mano'})` : 'Todavía no ha corrido ninguna vez.'}
+ ${filas ? `${filas}
` : ''}
`; + const guardar = seguro(async () => { try { await api('/api/automatico/config', {activo: $('#autoActivo').checked, hora: $('#autoHora').value || '02:00'}); aviso($('#autoActivo').checked ? `Orden automático encendido: cada día a las ${$('#autoHora').value}.` : 'Orden automático apagado.', 'ok'); } finally { pintarAuto().catch(() => {}); } }); + $('#autoActivo').onchange = guardar; $('#autoHora').onchange = () => { if ($('#autoActivo').checked) guardar(); }; + $('#autoAhora').onclick = () => modal('Poner al día todas las carpetas', `

La app va a leer las ${(ESTADO.raices || []).length} carpetas registradas, una tras otra, y a hacer en cada una solo lo seguro. Lo dudoso se queda como está. Nada se borra, y cada cambio se deshace desde el Historial.

`, () => accion($('#autoAhora'), async () => { await api('/api/automatico/ahora', {}); aviso('En marcha. El avance está en la barra de abajo.', 'ok'); await cargarEstado(); pintarAuto().catch(() => {}); })); +} + // ---------------- barra de tareas ---------------- function tareasDe(E){ const T = []; const porNombre = k => (E.raices.find(r => r.clave === k) || {cliente: k}).cliente; @@ -819,6 +908,8 @@ function tareasDe(E){ if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); } else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)}); if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); } + if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) { const t = E.traer; const cop = t.estado === 'copiando'; T.push({raiz: t.raiz, q: cop ? 'Trayendo material' : 'Repartiendo lo traído', d: `${porNombre(t.raiz)} · ${num(t.hechos)} de ${num(t.total)} · ${gb(t.bytes)} de ${gb(t.bytes_total)}${t.actual ? ' · ' + t.actual.slice(-40) : ''}`, pct: cop && t.bytes_total ? Math.round(t.bytes / t.bytes_total * 100) : null, cancelar: cop ? () => api('/api/entrada/cancelar', {}) : null}); } + if (E.automatico && E.automatico.activo) { const a = E.automatico; T.push({raiz: a.raiz, q: 'Orden automático', d: `${a.raiz ? porNombre(a.raiz) + ' · ' : ''}${a.hechas} de ${a.total} carpetas`, pct: a.total ? Math.round(a.hechas * 100 / a.total) : null}); } const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct}); return T; } @@ -896,7 +987,8 @@ function pintarPortada(){ // las carpetas que el programa ya conoce, para entrar en una con un clic; con la última vez que se // escaneó cada una. Es el «historial» de la portada: nada se abre solo. const caja = $('#portadaCarpetas'); const rs = ESTADO.raices || []; - if (!rs.length) { caja.innerHTML = ''; return; } + if (!rs.length) { caja.innerHTML = ''; $('#portadaAuto').innerHTML = ''; return; } + pintarAuto().catch(e => console.warn(e)); caja.innerHTML = '

Tus carpetas

' + rs.map(r => { const e = r.escaneo; return `
${esc(r.cliente)}${esc(r.ruta)}${esc(ESTADO.tipos[r.tipo] || r.tipo)}${e ? `${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : 'sin escanear todavía'}
`; @@ -923,7 +1015,7 @@ async function cargarCarpeta(){ $('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `
${esc(f)}
`).join('') + (d.frases.length > 3 ? `
Más en Revisar y ordenar.
` : ''); } const lotes = CARPETA.lotes || []; - $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}${l.lote && l.lote.startsWith('optimizar') ? '' : ``}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; + $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-')) ? '' : ``}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; $$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l)); } // ---------------- lo que se puede hacer en esta carpeta ---------------- @@ -939,7 +1031,16 @@ const IR_ACCION = { convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); }, respaldar: () => irA('respaldar'), escanear: () => $('#btnEscanear').click(), + entrada: () => { modal('Repartir la bandeja de entrada', '

La app vuelve a leer la carpeta y lleva lo que hay en _Entrada a su mes, a su día de rodaje y a su cámara.

Solo mueve lo que da por seguro: lo dudoso se queda en la bandeja para que lo mires. Nada se borra y se deshace desde el Historial.

', repartirEntrada); $('#modalSi').textContent = 'Repartir'; }, }; +const repartirEntrada = seguro(async () => { + const raiz = raizActual(); + aviso('Leyendo la carpeta y repartiendo la bandeja…', 'ok'); + const r = await api('/api/entrada/ordenar', {raiz}); + PLAN = null; MAPA = null; + aviso(`${r.movimientos} movimiento${r.movimientos !== 1 ? 's' : ''} hecho${r.movimientos !== 1 ? 's' : ''}.` + (r.quedan ? ` Quedan ${num(r.quedan)} archivo${r.quedan !== 1 ? 's' : ''} en la bandeja: la app no supo dónde ponerlos, míralos en «Qué va a pasar».` : ' La bandeja quedó vacía.') + (r.errores.length ? ` ${r.errores.length} no se pudieron mover.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n')); + await cargarEstado(); cargarVista(); +}); async function pintarAcciones(){ const raiz = raizActual(); if (!raiz) return; const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz)); @@ -971,6 +1072,8 @@ function queSeHizo(lote){ if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo'; if (lote.startsWith('dup-')) return 'Juntó material repetido'; if (lote.startsWith('vacias-')) return 'Borró carpetas vacías'; + if (lote.startsWith('pieza-')) return 'Creó una pieza nueva'; + if (lote.startsWith('entrada-')) return 'Trajo material a la bandeja de entrada'; return 'Ordenó carpetas y archivos'; } function pasosDe(C, r){ @@ -1070,6 +1173,24 @@ async function cargarMapa(){ $('#exts').innerHTML = barras(m.por_ext, r => r.ext || '(sin extensión)'); $('#dups').innerHTML = m.duplicados.ejemplos.length ? m.duplicados.ejemplos.map(d => `
${esc(d.nombre)} ${d.tipo === 'version' ? 'dos formatos' : 'idénticos'} · ${d.copias} copias · ${gb(d.bytes)} de más
  • se queda: ${esc(d.queda)}
  • ${d.rutas.map(r => `
  • → Duplicados: ${esc(r)}
  • `).join('')}
`).join('') : 'ninguno relevante'; } +// repetidos: la lista entera, comparando también los clips que se llaman igual y pesan distinto +const TIPO_DUP = {identico: 'idénticos', version: 'dos formatos', calidad: 'dos calidades'}; +let DUPS = null; +$('#btnComparar').onclick = () => accion($('#btnComparar'), async () => { + const raiz = raizActual(); + DUPS = await api('/api/duplicados?calidades=1&raiz=' + encodeURIComponent(raiz)); DUPS.raiz = raiz; + const rel = p => p.toLowerCase().startsWith(raiz.toLowerCase()) ? p.slice(raiz.length).replace(/^[\\/]/, '') : p; + $('#dups').innerHTML = DUPS.grupos.length ? DUPS.grupos.slice(0, 80).map(d => `
${esc(d.nombre)} ${TIPO_DUP[d.tipo] || d.tipo} · ${gb(d.bytes)} de más${d.detalle ? ' · ' + esc(d.detalle) : ''}
  • se queda: ${esc(rel(d.queda))}
  • ${d.mueven.map(r => `
  • → Duplicados: ${esc(rel(r))}
  • `).join('')}
`).join('') + (DUPS.grupos.length > 80 ? `
… y ${DUPS.grupos.length - 80} grupos más
` : '') : 'Nada repetido: los clips que se llaman igual duran distinto, así que son clips distintos.'; + $('#dupsResumen').textContent = DUPS.grupos.length ? `${DUPS.n} de más · ${gb(DUPS.bytes)} · ${DUPS.identicos} idénticos, ${DUPS.versiones} en dos formatos, ${DUPS.calidades} con dos calidades` : ''; + $('#btnJuntar').disabled = !DUPS.grupos.length; +}); +$('#btnJuntar').onclick = () => { if (!DUPS || DUPS.raiz !== raizActual()) return; modal('Juntar material repetido', `

De cada grupo se queda un archivo donde está, y los demás (${DUPS.n}, ${gb(DUPS.bytes)}) van a una subcarpeta Duplicados a su lado, para mirarlos a mano.

No se borra nada. Las copias idénticas se comparan por contenido antes de moverlas, y de dos calidades del mismo clip se queda la de más imagen. Se deshace desde el Historial.

`, () => accion($('#btnJuntar'), async () => { + const r = await api('/api/duplicados/apartar', {raiz: raizActual()}); + DUPS = null; MAPA = null; PLAN = null; $('#btnJuntar').disabled = true; $('#dupsResumen').textContent = ''; + aviso(`${r.movimientos} archivo${r.movimientos !== 1 ? 's' : ''} a Duplicados.` + (r.errores.length ? ` ${r.errores.length} se dejaron donde estaban.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n')); + await cargarEstado(); cargarVista(); +})); $('#modalSi').textContent = 'Juntar'; }; + function treemap(el, items){ el.innerHTML = ''; const W = el.clientWidth || 600, H = el.clientHeight || 300; const total = items.reduce((s,i) => s + i.bytes, 0) || 1; @@ -1371,7 +1492,7 @@ async function cargarBit(){ // Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron. const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz); const avHtml = av.length ? `
Avisos recientes
` + av.slice(0, 20).map(a => `
${hora(a.ts)}${esc(a.texto)}${!solo && a.raiz ? ` · ${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}` : ''}${a.detalle ? `
detalle
${esc(a.detalle)}
` : ''}
`).join('') : ''; - $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); + $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho || (l.lote || '').startsWith('entrada-') ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); $$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l)); const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); }; } @@ -1762,7 +1883,7 @@ const asistente = seguro(async (raiz, escanearAntes) => { const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'}; // ---------------- modal ---------------- -function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); } +function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); } document.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal').classList.contains('on')) $('#modal').classList.remove('on'); }); $('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });