diff --git a/README.md b/README.md index d57eef8..19383c5 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ menú Inicio y todo ocurre en este equipo. ``` organizador.py ← inventario + plan + cola de conversión, y el servidor local con que habla la interfaz ventana.py ← la ventana de Windows del programa (marco Win32 + WebView2, solo ctypes) -instalar.py ← accesos directos en Inicio y Escritorio, y la línea en Configuración › Aplicaciones +instalar.py ← lo instala en su sitio (carpeta de programas del usuario), con accesos en Inicio y Escritorio y su línea en Aplicaciones arranque.py ← cómo arranca el paquete: su .exe es el Python firmado, y esto le dice qué hacer reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura resolve_bridge.py ← convierte BRAW a H.265 10 bits usando DaVinci Resolve Studio actualizador.py ← busca versión nueva en Labstream OS (solo si se le pide) y la instala; firma.py la verifica -static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Mapa · Convertidor · Respaldar · Historial · Ajustes) +static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Mapa · Convertidor · Respaldar · Historial · Ajustes · Manual) static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de este equipo WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips -probar.py ← 17 pruebas sin abrir nada · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad +probar.py ← 19 pruebas sin abrir nada · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad Iniciar.bat ← desde el código, con consola (para ver lo que imprime) ``` @@ -696,6 +696,45 @@ una sola copia, puerto ocupado, Windows sin WebView2, las tablas de la ventana, `prueba_funcional.py` sigue en 26 de 26; y `prueba_ventana.py`, nueva, abre la ventana de verdad y comprueba 17 cosas desde el código y 8 más arrancando el paquete por su .exe. +3.1.0, instalar es instalar de verdad, y el manual va dentro. + +**El programa se instala solo en su sitio.** En la 3.0 «instalar» daba de alta el programa donde estuviera la +carpeta, y la carpeta suele estar en Descargas, que es de donde se limpia: en el equipo de Munka hubo que +mudarla a mano. Ahora, la primera vez que se abre un paquete, la pregunta es «¿Instalar OrganiLAB en este +equipo?», y decir que sí lo copia a `%LOCALAPPDATA%\Programs\OrganiLAB` con sus datos y su ffmpeg, lo da de +alta desde ahí (Inicio, Escritorio, Aplicaciones), lo cierra y lo vuelve a abrir desde su sitio +(`instalar.poner`, `instalar.mudar`). Lo mismo hace `OrganizadorLabstream.exe instalar`. Detalles: + +- Del origen no se borra ni se mueve nada. Queda `instalado-en.txt`, que dice adónde se fue y además hace + que abrir esa copia abra la instalada (`instalar.mudado`): no hay dos programas con dos juegos de datos. +- No viaja lo que es de la sesión abierta (`sesion.json`) ni lo que se rehace solo (el perfil del motor de la + ventana, `actualizacion\`, `__pycache__`). El inventario se copia con la copia de SQLite, que sale entera + y coherente aunque el programa esté abierto. +- Un destino que ya existe y no está vacío no se pisa. Si ya hay un OrganiLAB instalado en el equipo, lo + dice y no instala otro encima. Si falta sitio en el disco o la copia falla a medias, quita lo copiado y + el programa sigue funcionando desde donde estaba. +- Si el paquete ya está en su sitio, o se corre desde el código, instalar sigue siendo solo darlo de alta. +- Para probarlo sin tocar el Windows de verdad, `ORGANILAB_ENSAYO=` hace que el «equipo» sea esa + carpeta (programas, Inicio, Escritorio y una clave aparte del registro). `prueba_ventana.py --paquete` lo + usa para recorrer la instalación entera: abre desde unas «Descargas», acepta la oferta, espera a que se + reabra desde su sitio y comprueba que abrir la copia de origen abre la instalada. + +**El puerto, para una sola copia.** Probando la instalación salió un fallo que venía de siempre: +`ThreadingHTTPServer` pide «reutilizar dirección», y en Windows eso deja que dos programas escuchen a la vez +en el mismo puerto; las peticiones van a cualquiera de los dos. Dos OrganiLAB con datos distintos (el +instalado y una copia suelta) se contestaban el uno por el otro sin que nada fallara a la vista, y la +detección de «ya hay uno abierto» por puerto ocupado no saltaba nunca. `ServidorLocal` pide el puerto en +exclusiva (`SO_EXCLUSIVEADDRUSE`); si está ocupado, la copia toma otro. La prueba del puerto ocupado usa +ahora un servidor como el de otro OrganiLAB, que es justo el caso que no fallaba cuando debía. Y las pruebas +con ventana cierran siempre las copias que abren, también cuando una comprobación falla. + +**Manual.** Vista nueva en la interfaz, «Manual», junto a Ajustes: cada función del programa, qué hace y +cómo lo hace, en el orden de las pestañas (41 entradas en 12 apartados). Vive en `static/index.html`, así +que viaja con cada versión; una prueba comprueba que cada pestaña tiene su apartado. + +Pruebas: `probar.py` 19 de 19, `prueba_funcional.py` 26 de 26, `prueba_ventana.py` 18 desde el código y, +con el paquete, 8 del arranque y 9 de la instalación. + 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/empaquetar.py b/empaquetar.py index 3eb7742..9139f06 100644 --- a/empaquetar.py +++ b/empaquetar.py @@ -161,8 +161,10 @@ with open(os.path.join(dest, "Apartar stock Envato viejo (arrastra aqui).bat"), with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh: fh.write(f"OrganiLAB · Organizador Labstream {VERSION}\n\n" "ABRIRLO: doble clic en OrganizadorLabstream.exe. Se abre en su propia ventana, como cualquier programa de Windows.\n" - "La primera vez pregunta si lo quieres en el menú Inicio y en el Escritorio; desde entonces se abre desde ahí\n" - "(y se quita en Configuración › Aplicaciones). También: OrganizadorLabstream.exe instalar / desinstalar.\n\n" + "La primera vez pregunta si lo instalas: se copia a tu carpeta de programas, con sus datos, y queda en el menú\n" + "Inicio y en el Escritorio. Desde entonces se abre desde ahí, y esta carpeta ya no hace falta: se puede borrar.\n" + "Se quita en Configuración › Aplicaciones. También: OrganizadorLabstream.exe instalar / desinstalar.\n\n" + "MANUAL: dentro del programa, arriba a la derecha, «Manual»: cada función, qué hace y cómo lo hace.\n\n" "En el Explorador el .exe sale con el icono de Python: es el motor que lo mueve, firmado por la Python Software\n" "Foundation, y por eso Windows lo deja abrir también con Smart App Control encendido. En Inicio, en el Escritorio\n" "y en la barra de tareas sale con su icono.\n\n" diff --git a/instalar.py b/instalar.py index 4be9a56..422051c 100644 --- a/instalar.py +++ b/instalar.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- r""" -OrganiLAB como un programa más de Windows: su acceso directo en el menú Inicio y en el Escritorio, y su -línea en Configuración › Aplicaciones para quitarlo. +OrganiLAB como un programa más de Windows: en su sitio, con su acceso directo en el menú Inicio y en el +Escritorio, y con su línea en Configuración › Aplicaciones para quitarlo. -No copia nada: el programa se queda en la carpeta donde está y esto solo lo da de alta, todo dentro del -usuario y sin pedir permisos de administrador. Quitarlo borra los accesos directos y la línea de -Aplicaciones; la carpeta del programa y los datos no se tocan. +Instalar el paquete lo lleva a su sitio definitivo, la carpeta de programas del usuario +(%LOCALAPPDATA%\Programs\OrganiLAB): de Descargas, que es donde se suele descomprimir, se limpia por +descuido. Se copia el programa con sus datos y su ffmpeg; del origen no se borra ni se mueve nada, y allí +queda una nota que, además, hace que abrir esa copia abra la instalada. Todo dentro del usuario, sin +permisos de administrador. Desde el código (sin .exe) solo se da de alta donde está. + +Quitarlo borra los accesos directos y la línea de Aplicaciones; la carpeta del programa y los datos no se +tocan. Los accesos directos llevan la misma identidad que la ventana (ventana.IDENTIDAD). Así el acceso anclado a la barra de tareas y la ventana abierta son, para Windows, la misma cosa: un solo botón, con su icono. @@ -15,15 +20,26 @@ a la barra de tareas y la ventana abierta son, para Windows, la misma cosa: un s """ import os import sys +import time import ctypes +import shutil import winreg +import sqlite3 from ctypes import wintypes as W import ventana as V NOMBRE = "OrganiLAB" DESCRIPCION = "OrganiLAB · ordena y aligera el material de video" -CLAVE = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB" +# Para probar la instalación entera sin tocar el Windows de verdad: con ORGANILAB_ENSAYO=, el +# «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" +MARCA_MUDANZA = "instalado-en.txt" # en la carpeta de origen: adónde se instaló lo que había aquí +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 EN_INICIO = "{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}" # la carpeta Programas del menú Inicio EN_ESCRITORIO = "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}" # el Escritorio (puede estar en OneDrive) CLSID_ACCESO, IID_ACCESO = "{00021401-0000-0000-C000-000000000046}", "{000214F9-0000-0000-C000-000000000046}" @@ -132,9 +148,106 @@ def leer_acceso(ruta): def _accesos(): """Las dos rutas donde van los accesos directos: (menú Inicio, Escritorio).""" + if ENSAYO: + carpetas = os.path.join(ENSAYO, "Inicio"), os.path.join(ENSAYO, "Escritorio") + for c in carpetas: + os.makedirs(c, exist_ok=True) + return tuple(os.path.join(c, NOMBRE + ".lnk") for c in carpetas) return os.path.join(carpeta_de(EN_INICIO), NOMBRE + ".lnk"), os.path.join(carpeta_de(EN_ESCRITORIO), NOMBRE + ".lnk") +def _misma(a, b): + return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b)) + + +def sitio_definitivo(): + """Donde Windows pone los programas de cada usuario. De ahí no se limpia nada por descuido.""" + programas = os.path.join(ENSAYO, "Programs") if ENSAYO else os.path.join( + os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local"), "Programs") + return os.path.join(programas, NOMBRE) + + +def hay_que_mudar(base): + """True si instalar significa llevar el programa a su sitio: es un paquete (trae su .exe) y no está ya allí.""" + return os.path.isfile(os.path.join(base, "OrganizadorLabstream.exe")) and not _misma(base, sitio_definitivo()) + + +def mudado(base): + """Si desde esta carpeta ya se instaló el programa en otra, el .exe de esa otra; si no, "".""" + try: + with open(os.path.join(base, MARCA_MUDANZA), encoding="utf-8") as fh: + destino = fh.readline().strip() + except OSError: + return "" + exe = os.path.join(destino, "OrganizadorLabstream.exe") + return exe if destino and not _misma(destino, base) and os.path.isfile(exe) else "" + + +def _lo_que_viaja(base): + """(ruta relativa, bytes) de cada archivo que se lleva la mudanza. No viaja lo que es de la sesión abierta + ni lo que se rehace solo; el inventario va aparte, con la copia segura de SQLite.""" + out = [] + for raiz, dirs, files in os.walk(base): + rel = os.path.relpath(raiz, base) + if rel == ".": + dirs[:] = [d for d in dirs if d not in NO_VIAJA] + files = [f for f in files if f not in NO_VIAJA] + elif rel == "datos": + dirs[:] = [d for d in dirs if d not in NO_VIAJA_DATOS] + files = [f for f in files if f not in NO_VIAJA_DATOS and not f.startswith(INVENTARIO) and not f.endswith(".tmp")] + for f in files: + p = os.path.join(raiz, f) + out.append((os.path.relpath(p, base), os.path.getsize(p))) + return out + + +def mudar(base, destino=None, avance=None): + """Copia el programa de `base`, con sus datos y su ffmpeg, a su sitio definitivo. Del origen no borra ni + mueve nada: deja allí la nota de la mudanza. Si falla a medias, quita lo copiado y el origen sigue como + estaba. `avance(0..1)` va diciendo cuánto lleva. Devuelve el destino.""" + destino = destino or sitio_definitivo() + if os.path.isdir(destino) and os.listdir(destino): + raise FileExistsError(f"la carpeta {destino} ya existe y no está vacía") + cosas = _lo_que_viaja(base) + inventario = os.path.join(base, "datos", INVENTARIO) + total = sum(t for _, t in cosas) + (os.path.getsize(inventario) if os.path.exists(inventario) else 0) or 1 + os.makedirs(os.path.dirname(destino), exist_ok=True) + if shutil.disk_usage(os.path.dirname(destino)).free < total * 1.05 + (50 << 20): + raise OSError(f"no hay sitio en el disco: hacen falta {total / 1e6:.0f} MB") + hechos = 0 + try: + for rel, tam in cosas: + a = os.path.join(destino, rel) + os.makedirs(os.path.dirname(a), exist_ok=True) + shutil.copy2(os.path.join(base, rel), a) + hechos += tam + if avance: + avance(hechos / total) + if os.path.exists(inventario): # puede estar en uso: SQLite sabe copiarse entero y coherente + os.makedirs(os.path.join(destino, "datos"), exist_ok=True) + origen, copia = sqlite3.connect(inventario), sqlite3.connect(os.path.join(destino, "datos", INVENTARIO)) + try: + origen.backup(copia) + finally: + copia.close() + origen.close() + except Exception: + shutil.rmtree(destino, ignore_errors=True) + raise + with open(os.path.join(base, MARCA_MUDANZA), "w", encoding="utf-8") as fh: + fh.write(destino + "\n\nOrganiLAB se instaló en la carpeta de arriba el " + time.strftime("%d/%m/%Y") + + ", con sus datos.\nEsta copia ya no se usa: si la abres, se abre la instalada.\n" + "Puedes borrar esta carpeta entera cuando quieras.\n") + return destino + + +class YaInstalado(Exception): + """Ya hay un OrganiLAB instalado en este equipo, en otra carpeta.""" + def __init__(self, otro): + super().__init__(f"ya hay un OrganiLAB instalado en {otro['base']} (versión {otro['version']})") + self.otro = otro + + def _peso_kb(base): total = 0 for raiz, dirs, files in os.walk(base): @@ -181,6 +294,18 @@ def instalar(base, version, solo_los_que_hay=False): return {"base": base, "accesos": hechos, "orden": f'"{exe}" {args}'.strip()} +def poner(base, version, avance=None): + """Instalar de verdad: si el programa es un paquete que no está en su sitio definitivo, se copia allí; y + se da de alta en Windows desde donde quede. El resultado dice dónde quedó y si hubo mudanza.""" + if not hay_que_mudar(base): + return {**instalar(base, version), "mudado": False} + otro = instalado() + if otro and not _misma(otro["base"], base) and os.path.isfile(os.path.join(otro["base"], "OrganizadorLabstream.exe")): + raise YaInstalado(otro) + destino = mudar(base, avance=avance) + return {**instalar(destino, version), "mudado": True} + + def al_dia(base, version): """Tras una actualización: si este programa está dado de alta, Windows tiene que enseñar la versión nueva y los accesos directos tienen que apuntar a lo que ahora lo abre.""" @@ -222,9 +347,13 @@ def principal(que, base, version, silencioso=False): return V.IDOK if silencioso else u.MessageBoxW(None, texto, NOMBRE, botones) try: if que == "instalar": - instalar(base, version) - decir("OrganiLAB ya está en el menú Inicio y en el Escritorio.\n\n" - "Para quitarlo: Configuración › Aplicaciones › Aplicaciones instaladas.") + r = poner(base, version) + if r["mudado"]: + decir(f"OrganiLAB quedó instalado en:\n\n{r['base']}\n\nÁbrelo desde el menú Inicio o el Escritorio. " + "La carpeta desde la que lo instalaste ya no hace falta: puedes borrarla.") + else: + decir("OrganiLAB ya está en el menú Inicio y en el Escritorio.\n\n" + "Para quitarlo: Configuración › Aplicaciones › Aplicaciones instaladas.") return 0 if not silencioso and decir("¿Quitar OrganiLAB del menú Inicio, del Escritorio y de la lista de aplicaciones?\n\n" "La carpeta del programa y tus datos (inventario, historial y ajustes) no se borran.", @@ -233,6 +362,11 @@ def principal(que, base, version, silencioso=False): desinstalar(base) decir(f"Quitado. La carpeta del programa sigue en:\n\n{base}\n\nSi ya no la quieres, bórrala a mano.") return 0 + except YaInstalado as ex: + decir(f"Ya hay un OrganiLAB instalado en este equipo (versión {ex.otro['version']}), en:\n\n{ex.otro['base']}\n\n" + "Ábrelo desde el menú Inicio. Para ponerlo al día, usa «Buscar actualizaciones» dentro del programa.", + V.MB_OK | V.MB_ICONINFORMATION) + return 1 except Exception as ex: # noqa decir(f"No se pudo: {ex}", V.MB_OK | V.MB_ICONWARNING) return 1 diff --git a/manifiesto.json b/manifiesto.json index 77ed66e..30f533e 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "3.0.0", - "builtAt": "2026-10-04T14:50:25.000Z", - "archivo": "OrganizadorLabstream-portatil-3.0.0.zip", - "size": 195378394, - "sha256": "98c43f1f06865f233e98d4b4b8f2cae82e1386def7b38673ce5a4cd01bc850dd", + "version": "3.1.0", + "builtAt": "2026-10-04T17:23:53.000Z", + "archivo": "OrganizadorLabstream-portatil-3.1.0.zip", + "size": 195387012, + "sha256": "1c80a369bcdc8f07c5a9db1dec1bd95dcd390a8e65714ec60251388544ee6523", "app": { - "archivo": "OrganizadorLabstream-app-3.0.0.zip", - "size": 11565815, - "sha256": "09d9024a41370bdd07dbbd3fe1110822c0f51ce4987f62cf9dd185ad9aa3e82e", - "url": "/api/herramientas/OrganizadorLabstream-app-3.0.0.zip" + "archivo": "OrganizadorLabstream-app-3.1.0.zip", + "size": 11574433, + "sha256": "cedd68ade3f61ccd2958e3578bf46be8b92e20d73aade3b7e455585e53898d3e", + "url": "/api/herramientas/OrganizadorLabstream-app-3.1.0.zip" }, - "notes": "OrganiLAB pasa a ser un programa de Windows: ventana propia, se instala en el menú Inicio y el Escritorio, y el .exe vuelve a abrir con Smart App Control encendido.", + "notes": "El programa se instala solo en su sitio definitivo (tu carpeta de programas) y trae un manual dentro, en «Manual». Además, dos copias ya no pueden compartir puerto.", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "6456976cf21259a5ae91b5ba5cc8074b552cfec0a389f8b9c89c7af962841cdb523f8f39b7143281d7d965079ee0471fc8b7f14c79636d2cc443315131a3fc53ec6a22ff87b1c49d806e4a795c2365b83f8236a523a1349730b90300eff68cc84ad32f58e663798e77301024f870e094c1fa112b994d55f719c1cdf8c22b0375f6eb24276bfa9837d755ef07c71e9b701df073fd4c94cf3ddccc6e5a2203e48458398beb4f38d2d68ebab2810a0ac56add4b9294321cb4eb3083f1e2d717bd2b2a6b638cadb8140e48f4d46e3e0a13686b462477bb8e9a3923f7a178c5e326f65a8ae90ff68949ac6a5bd2cf5758a53dd79779c79b4f67414f0251aae3f9566e" + "valor": "1ca46a8c6af640d62da3b8a97500661ae2d1e5442b66fb9ba9ee5268e3cfd85c1c1b6c2949484742adbdc758788bb5df323654b6a8533dd1ce3a93e9b974b0870bfa7c2f74fec4f6ef922cf1228c2d290a09a54dd62cabe846cdb40ed79276cc36940283e39db57ccc5f392daaf074672546f38cc3342cbc569d76723d365c2807755d95e8f129fb5e309952da27f645ef6130af7abb3f1356f895a92745ae890ad0c5b9d2a2139bc85ea5b66bcd3b2385d74c2d4b2bc438acf474e8dde041951d0c8f9949b39bf214d38ce3f0a80451f9c1681a9e3e53c2d28abf3e570201cc624b248213392ada7c892743a26c2be40d5170149d72d2dd3cd8bd92e3bff5d7" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index bc1bf3e..c993381 100644 --- a/organizador.py +++ b/organizador.py @@ -22,6 +22,7 @@ import ctypes import time import queue import shutil +import socket import stat as stat_mod import hashlib import sqlite3 @@ -39,7 +40,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "3.0.0" +VERSION = "3.1.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 @@ -3751,6 +3752,19 @@ def estado_carpeta(ruta): } +class ServidorLocal(ThreadingHTTPServer): + """El servidor de la interfaz, con el puerto para él solo. ThreadingHTTPServer pide «reutilizar dirección», + y en Windows eso significa que dos programas pueden escuchar a la vez en el mismo puerto y las peticiones + van a cualquiera de los dos: dos OrganiLAB con datos distintos (el instalado y una copia suelta, o el de + ensayo y el de verdad) se contestaban el uno por el otro sin que nada fallara a la vista.""" + allow_reuse_address = False + + def server_bind(self): + if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + super().server_bind() + + class Manejador(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -4132,7 +4146,7 @@ class Manejador(BaseHTTPRequestHandler): AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando OrganizadorLabstream.exe abre OrganiLAB en su ventana - OrganizadorLabstream.exe instalar lo pone en el menú Inicio y en el Escritorio (desinstalar lo quita) + OrganizadorLabstream.exe instalar lo instala en su sitio: carpeta de programas, menú Inicio y Escritorio (desinstalar lo quita) OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar) @@ -4359,6 +4373,7 @@ SOLTADOS = {"ts": 0, "rutas": []} SESION_PATH = os.path.join(DATOS, "sesion.json") # qué copia tiene abiertos estos datos, y en qué puerto atiende _CANDADO = [] # el de «una sola copia abierta»; vive lo que el proceso SILENCIO_NAVEGADOR = 180 # segundos sin que la página pregunte para dar el navegador por cerrado +RELEVO = [] # el .exe que toma el relevo al salir (tras instalarse en su sitio) def _al_soltar(rutas): @@ -4471,7 +4486,8 @@ def _vigilar(v, fin): def _recien_abierta(v): """Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y - sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece darlo de alta.""" + sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece instalarlo: copiarlo a + su sitio definitivo, darlo de alta y seguir desde allí.""" try: import ventana import instalar @@ -4480,14 +4496,35 @@ def _recien_abierta(v): oferta = CONFIG.get("instalacion") or {} if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE): return + if instalar.instalado() and not instalar.instalado_aqui(BASE): + return # ya hay otro instalado en este equipo: esta copia va por libre CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez guardar_json(CONFIG_PATH, CONFIG) time.sleep(1.5) - if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n" - "No copia nada: el programa se queda en esta carpeta. Se quita desde Configuración › Aplicaciones.", - ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES: - instalar.instalar(BASE, VERSION) - v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.") + if not instalar.hay_que_mudar(BASE): # ya está en su sitio: solo falta darlo de alta + if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n" + "Se quita desde Configuración › Aplicaciones.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES: + instalar.instalar(BASE, VERSION) + v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.") + return + if v.mensaje("¿Instalar OrganiLAB en este equipo?\n\nSe copia a tu carpeta de programas, queda en el menú Inicio y en el " + "Escritorio, y se quita desde Configuración › Aplicaciones. La carpeta desde la que lo has abierto ya no hará falta.", + ventana.MB_YESNO | ventana.MB_ICONQUESTION) != ventana.IDYES: + return + v.evaluar("modal(" + json.dumps("Instalando OrganiLAB…") + ", " + json.dumps( + '

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

') + ", null)") + try: + r = instalar.poner(BASE, VERSION, avance=v.progreso) + except Exception as ex: # noqa + v.progreso(None) + v.evaluar("document.querySelector('#modal').classList.remove('on')") + v.mensaje(f"No se pudo instalar: {ex}\n\nEl programa sigue funcionando desde esta carpeta.", ventana.MB_OK | ventana.MB_ICONWARNING) + return + v.progreso(None) + v.mensaje("OrganiLAB quedó instalado.\n\nAhora se cierra y se abre desde su sitio. La carpeta desde la que lo abriste " + "ya no hace falta: puedes borrarla.") + RELEVO.append(os.path.join(r["base"], "OrganizadorLabstream.exe")) + v.cerrar() except Exception as ex: # noqa print("no se pudo dar de alta en Windows:", ex) @@ -4539,8 +4576,20 @@ def _atender_navegador(): def main(): global PUERTO args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] + if CONGELADO and "--puente" not in sys.argv: + import instalar + instalada = instalar.mudado(BASE) + if instalada: # desde esta carpeta ya se instaló el programa: se abre el instalado + if sys.argv[1:]: + sys.exit(subprocess.call([instalada] + sys.argv[1:])) + subprocess.Popen([instalada], cwd=os.path.dirname(instalada), close_fds=True) + return if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv: import instalar # hablan con cuadros de mensaje: no hace falta consola + if args_tmp[0].lower() == "instalar" and instalar.hay_que_mudar(BASE) and not _soy_la_unica(): + import ventana # la mudanza se lleva los datos: con el programa abierto se quedarían a medias + ctypes.windll.user32.MessageBoxW(None, "Cierra OrganiLAB antes de instalarlo.", "OrganiLAB", ventana.MB_OK | ventana.MB_ICONINFORMATION) + sys.exit(1) sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv)) if args_tmp and "--puente" not in sys.argv: _asegurar_consola() @@ -4583,13 +4632,14 @@ def main(): if huerfanas: print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: - srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) + srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador) except OSError: if os.environ.get("ORGANIZADOR_PUERTO"): print(f"El puerto {PUERTO} está ocupado por otro programa.") sys.exit(1) - # el puerto de siempre lo tiene otro programa: se toma uno libre (a la ventana le da igual cuál sea) - srv = ThreadingHTTPServer(("127.0.0.1", 0), Manejador) + # el puerto de siempre lo tiene otro programa (u otro OrganiLAB con otros datos): se toma uno libre, + # que a la ventana le da igual cuál sea + srv = ServidorLocal(("127.0.0.1", 0), Manejador) PUERTO = srv.server_address[1] url = f"http://127.0.0.1:{PUERTO}/" guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO}) @@ -4620,6 +4670,9 @@ def main(): while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir time.sleep(1) srv.shutdown() + srv.server_close() + if RELEVO: # se acaba de instalar en su sitio: sigue desde allí + subprocess.Popen([RELEVO[0]], cwd=os.path.dirname(RELEVO[0]), close_fds=True) finally: try: os.remove(SESION_PATH) diff --git a/probar.py b/probar.py index 397a031..c01bc77 100644 --- a/probar.py +++ b/probar.py @@ -169,10 +169,11 @@ class Windows(unittest.TestCase): abrir la página de ese otro. Y este Windows no puede abrir la ventana (aquí, porque falta el cargador de WebView2): la interfaz va al navegador y el programa se va solo cuando esa página deja de preguntar.""" tmp = tempfile.mkdtemp(prefix="organilab-imprevistos-") - tapon = socket.socket() - tapon.bind(("127.0.0.1", 0)) - tapon.listen(1) - ocupado = tapon.getsockname()[1] + # quien ocupa el puerto es un servidor como el de otro OrganiLAB: pide «reutilizar dirección», y en + # Windows eso dejaba que un segundo programa escuchara en el mismo puerto sin que nada fallara + import http.server + tapon = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.BaseHTTPRequestHandler) + ocupado = tapon.server_address[1] try: for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"): shutil.copy(os.path.join(AQUI, f), tmp) @@ -192,7 +193,72 @@ class Windows(unittest.TestCase): self.assertTrue(p.stdout.strip().splitlines()[-1].startswith("FIN ")) self.assertFalse(os.path.exists(os.path.join(tmp, "datos", "sesion.json"))) finally: - tapon.close() + tapon.server_close() + shutil.rmtree(tmp, ignore_errors=True) + + def test_instalarse_en_su_sitio(self): + """Instalar un paquete lo lleva a su sitio definitivo con sus datos, sin borrar nada del origen. No viaja + lo que es de la sesión abierta ni lo que se rehace solo, y la copia de origen queda sabiendo adónde se fue.""" + import sqlite3 + import winreg + import instalar + tmp = tempfile.mkdtemp(prefix="organilab-mudanza-") + base, destino = os.path.join(tmp, "Descargas", "OrganizadorLabstream"), os.path.join(tmp, "Programs", "OrganiLAB") + for rel in ("OrganizadorLabstream.exe", "organizador.py", "static/index.html", "static/organilab.ico", "ffmpeg/ffmpeg.exe", + "datos/config.json", "datos/bitacora.jsonl", "datos/sesion.json", "datos/ventana/EBWebView/cache.bin", + "actualizacion/nuevo.zip", "__pycache__/organizador.cpython-312.pyc"): + p = os.path.join(base, rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as fh: + fh.write(rel.encode() * 50) + con = sqlite3.connect(os.path.join(base, "datos", "inventario.sqlite")) + con.execute("CREATE TABLE archivos (ruta TEXT)") + con.execute("INSERT INTO archivos VALUES ('V:/Cliente/C0001.MP4')") + con.commit() + con.close() + inicio, escritorio = os.path.join(tmp, "inicio", "OrganiLAB.lnk"), os.path.join(tmp, "escritorio", "OrganiLAB.lnk") + os.makedirs(os.path.dirname(inicio)) + os.makedirs(os.path.dirname(escritorio)) + viejos = instalar._accesos, instalar.CLAVE, instalar.sitio_definitivo + instalar._accesos, instalar.CLAVE, instalar.sitio_definitivo = (lambda: (inicio, escritorio)), r"Software\OrganiLAB-ensayo\Mudanza", (lambda: destino) + try: + self.assertTrue(instalar.hay_que_mudar(base)) + self.assertEqual(instalar.mudado(base), "") + pasos = [] + r = instalar.poner(base, "3.1.0", avance=pasos.append) + self.assertTrue(r["mudado"]) + self.assertTrue(instalar._misma(r["base"], destino)) + self.assertTrue(pasos and 0 < pasos[-1] <= 1) + for rel in ("OrganizadorLabstream.exe", "organizador.py", "static/index.html", "ffmpeg/ffmpeg.exe", "datos/config.json", "datos/bitacora.jsonl"): + self.assertTrue(os.path.exists(os.path.join(destino, rel)), rel) + for rel in ("datos/sesion.json", "datos/ventana", "actualizacion", "__pycache__", instalar.MARCA_MUDANZA): + self.assertFalse(os.path.exists(os.path.join(destino, rel)), rel) + con = sqlite3.connect(os.path.join(destino, "datos", "inventario.sqlite")) + self.assertEqual(con.execute("SELECT ruta FROM archivos").fetchall(), [("V:/Cliente/C0001.MP4",)]) + con.close() + # del origen no se ha ido nada, y ya sabe adónde se instaló + for rel in ("OrganizadorLabstream.exe", "ffmpeg/ffmpeg.exe", "datos/sesion.json", "datos/inventario.sqlite"): + self.assertTrue(os.path.exists(os.path.join(base, rel)), rel) + self.assertTrue(instalar._misma(os.path.dirname(instalar.mudado(base)), destino)) + # dado de alta desde su sitio, no desde Descargas + self.assertTrue(instalar.instalado_aqui(destino)) + self.assertTrue(instalar._misma(instalar.leer_acceso(inicio)["exe"], os.path.join(destino, "OrganizadorLabstream.exe"))) + # instalar otra vez desde el origen avisa de que ya hay uno; desde su sitio, es solo darlo de alta + with self.assertRaises(instalar.YaInstalado): + instalar.poner(base, "3.1.0") + self.assertFalse(instalar.poner(destino, "3.1.0")["mudado"]) + # y un destino ocupado no se pisa nunca + instalar.desinstalar(destino) + with self.assertRaises(FileExistsError): + instalar.mudar(base) + self.assertTrue(os.path.exists(os.path.join(destino, "organizador.py"))) + finally: + for clave in (instalar.CLAVE, r"Software\OrganiLAB-ensayo"): + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, clave) + except OSError: + pass + instalar._accesos, instalar.CLAVE, instalar.sitio_definitivo = viejos shutil.rmtree(tmp, ignore_errors=True) def test_orden_de_arranque(self): @@ -215,6 +281,24 @@ class Windows(unittest.TestCase): self.assertEqual(p.stdout.strip(), "sigue") +class Manual(unittest.TestCase): + def test_manual_al_dia(self): + """El manual vive dentro de la interfaz, con un apartado por cada pestaña del programa: si se añade una + vista y no se documenta, esto falla.""" + import re + with open(os.path.join(AQUI, "static", "index.html"), encoding="utf-8") as fh: + html = fh.read() + vistas = re.findall(r']*>([^<]+)', html[:html.index("")]) + manual = html[html.index('
'):html.index(" +
+

Manual

+

Cada función del programa: qué hace y, en gris, cómo lo hace. El orden es el de las pestañas de arriba.

+
+ +

Para empezar

+
+
Instalarlo
La primera vez que se abre pregunta si lo instala en el equipo: queda en el menú Inicio, en el Escritorio y en Configuración › Aplicaciones. Cómo: se copia con sus datos a tu carpeta de programas y se da de alta desde ahí; no pide permisos de administrador y de la carpeta original no borra nada.
+
Añadir la carpeta de un cliente
Suéltala en la ventana, pulsa «Elegir carpeta…» o pega su ruta. Pregunta el nombre del cliente y qué clase de trabajo le haces, y te enseña cómo va a quedar ordenada. Cómo: al confirmar solo la lee; no mueve nada hasta que tú lo apliques.
+
Clase de trabajo
Piezas mes a mes, anuncios y campañas, un trabajo puntual o material de Labstream. Cómo: cada clase tiene su plantilla de carpetas, y es lo único que decide dónde se guarda cada cosa.
+
Tus carpetas
Al abrir no se entra en ninguna: la portada lista las que ya conoce, cada una con su último escaneo, para entrar con un clic.
+
+ +

Carpeta

+
+
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: lo dudoso no entra nunca; convertir, respaldar y borrar carpetas vacías tienen su propio botón.
+
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.
+
+ +

Revisar y ordenar

+
+
Qué hay
Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.
+
Por nombrar
Las carpetas cuyo nombre no reconoce. Propone qué son y tú lo confirmas. Cómo: mira qué tienen dentro; lo confirmado se guarda en un diccionario y vale para todas las carpetas y discos.
+
Qué va a pasar
El plan: a dónde iría cada cosa, sin mover nada. Va en tres pasos que se aplican por separado: apartar basura y proxies, ordenar por año y mes (y por día de rodaje si ese mes hubo más de uno), y ordenar lo demás (proyectos por programa, exportados, música…). Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada fila va marcada como segura, por revisar o dudosa.
+
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.
+
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».
+
+ +

Mapa

+
+
El árbol
Cómo quedan las carpetas con cada clase de trabajo y qué va en cada una, con el color de lo que guarda. Cómo: se dibuja con las plantillas de verdad, así que enseña exactamente lo que el programa va a hacer.
+
Tocar una carpeta
Dice qué guarda y cuánto material de eso hay hoy, y deja cambiar ahí mismo a dónde va. «Sin carpeta asignada» lista lo que todavía no tiene sitio.
+
Cambiar un nombre
Renombra una carpeta en todas las plantillas que pasan por ella y, si quieres, también las que ya existen en el disco. Cómo: es un movimiento normal, queda en el historial y se deshace.
+
¿Dónde va a parar este archivo?
Escribes el nombre de un archivo, exista o no, y dice qué es, de qué cámara o programa viene y en qué carpeta lo dejaría. Cómo: con el mismo clasificador que usa el escaneo.
+
Colores en Windows
Pinta las carpetas en el Explorador con el color de lo que guardan, y los quita con un clic. Cómo: Windows no tiene color de carpeta, así que les pone un icono de color; hay discos que no lo permiten, y entonces lo dice y no deja nada escrito.
+
+ +

Convertidor

+
+
Buscar clips
Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero y los exportados.
+
Formato y calidad
H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.
+
Convertir
Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW pasan por DaVinci Resolve Studio, que tiene que estar abierto en un proyecto.
+
Verificar
Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.
+
Reemplazar
El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a _Para borrar\originales.
+
+ +

Respaldar

+
+
Calcular
Dice cuánto ocuparía la copia, por tipo de medio, y qué originales de cámara quedarían fuera por no estar convertidos.
+
Copiar
Copia a otro disco una versión ligera: misma estructura, los clips en su versión convertida, sin stock, proxies ni basura. Todo, o solo lo de más de 3, 6 o 12 meses. Cómo: es incremental; lo que ya está igual se salta, así que se puede detener y retomar. Deja INDICE.csv y _MAPA.md para encontrar cosas sin montar el disco.
+
+ +

Historial

+
+
Deshacer
Cada vez que el programa mueve algo queda aquí como un lote, y «Deshacer» devuelve cada archivo a donde estaba. Cómo: en orden inverso; si alguien movió un archivo después, ese se salta y se avisa.
+
_Para borrar
Enseña lo que hay apartado. El programa no lo borra nunca: lo revisa y lo borra una persona desde el Explorador.
+
+ +

Ajustes

+
+
Clientes
Nombre, clase de trabajo, sus carpetas y los otros nombres con que aparece en los discos.
+
Actualizaciones
«Buscar actualizaciones» mira si hay versión nueva; el interruptor de «consultar al arrancar» viene apagado. Cómo: descarga el paquete de Labstream OS, comprueba su firma y su huella, se cierra, se sustituye sin tocar tus datos y vuelve a abrirse.
+
Plantillas
En «Avanzado»: la ruta exacta de cada tipo de material en cada clase de trabajo. Es lo mismo que se cambia desde el Mapa.
+
+ +

La ventana

+
+
En curso
La barra de abajo enseña el avance de lo que esté corriendo (escaneo, lectura de clips, conversión, copia) con su botón de detener; el mismo avance sale en el botón de la barra de tareas de Windows, que parpadea al terminar algo largo.
+
Cerrar con trabajo en marcha
Avisa de lo que está haciendo; si cierras, sigue hasta terminar y se cierra solo. Abrirlo otra vez devuelve la misma ventana.
+
Una sola copia
Abrirlo dos veces trae al frente la ventana que ya había. Cómo: dos copias sobre los mismos datos se pisarían el inventario.
+
+ +

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.
+
+ +

Lo que nunca hace

+
+
Borrar archivos
Lo que sobra va a _Para borrar, dentro de la misma carpeta del cliente, y lo repetido a Duplicados.
+
Pisar o sacar de su carpeta
No sobrescribe nada y no mueve nada fuera de la carpeta del cliente.
+
Salir a la red
Todo ocurre en este equipo. Solo se conecta cuando le pides buscar una versión nueva.
+
+ +

Dónde guarda lo suyo

+
+
La carpeta datos
Dentro de la carpeta del programa: el inventario, el historial, los ajustes y lo que ha aprendido. Si cambias de equipo, llévate esa carpeta.
+
+ +
+
+

Clientes

@@ -628,7 +736,7 @@ $$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s)); const cargarVista = seguro(async t => { t = t || vistaActual(); const r = cfgRaiz(); - if (t !== 'carpeta' && t !== 'ajustes' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } } + if (t !== 'carpeta' && t !== 'ajustes' && t !== 'manual' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } } if (t === 'carpeta') await cargarCarpeta(); if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } if (VACIAS && VACIAS.raiz !== raizActual()) { VACIAS = null; pintarVacias(); } } if (t === 'mapa') await cargarMapaEst();