3.1.0: se instala solo en su sitio, manual dentro y el puerto para una sola copia

- instalar.py: instalar un paquete lo copia a la carpeta de programas del
  usuario (%LOCALAPPDATA%\Programs\OrganiLAB) con sus datos y su ffmpeg, lo da
  de alta desde ahí y lo vuelve a abrir desde su sitio. Del origen no se borra
  nada: queda instalado-en.txt, que además hace que abrir esa copia abra la
  instalada. Un destino ocupado no se pisa, y si ya hay un OrganiLAB instalado
  lo dice en vez de poner otro encima.
- static/index.html: vista «Manual», con cada función del programa, qué hace
  y cómo lo hace (41 entradas, un apartado por pestaña).
- organizador.py: ServidorLocal pide el puerto en exclusiva. Con «reutilizar
  dirección», en Windows dos copias escuchaban a la vez en el mismo puerto y
  se contestaban la una por la otra sin que nada fallara a la vista.
- Pruebas: probar.py 19, prueba_funcional.py 26 de 26, prueba_ventana.py 18
  desde el código y 17 con el paquete, incluida la instalación entera en un
  equipo de ensayo (ORGANILAB_ENSAYO) que no toca el Windows de verdad.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-04 12:28:03 -05:00
padre 6c21145b1c
commit 50562aeaab
Se han modificado 8 ficheros con 562 adiciones y 43 borrados

Ver fichero

@@ -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 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) 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 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 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 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 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 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 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 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 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) 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 `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. 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=<carpeta>` 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 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -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: with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh:
fh.write(f"OrganiLAB · Organizador Labstream {VERSION}\n\n" 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" "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" "La primera vez pregunta si lo instalas: se copia a tu carpeta de programas, con sus datos, y queda en el menú\n"
"(y se quita en Configuración › Aplicaciones). También: OrganizadorLabstream.exe instalar / desinstalar.\n\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" "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" "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" "y en la barra de tareas sale con su icono.\n\n"

Ver fichero

@@ -1,11 +1,16 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
r""" r"""
OrganiLAB como un programa más de Windows: su acceso directo en el menú Inicio y en el Escritorio, y su OrganiLAB como un programa más de Windows: en su sitio, con su acceso directo en el menú Inicio y en el
línea en Configuración › Aplicaciones para quitarlo. 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 Instalar el paquete lo lleva a su sitio definitivo, la carpeta de programas del usuario
usuario y sin pedir permisos de administrador. Quitarlo borra los accesos directos y la línea de (%LOCALAPPDATA%\Programs\OrganiLAB): de Descargas, que es donde se suele descomprimir, se limpia por
Aplicaciones; la carpeta del programa y los datos no se tocan. 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 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. 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 os
import sys import sys
import time
import ctypes import ctypes
import shutil
import winreg import winreg
import sqlite3
from ctypes import wintypes as W from ctypes import wintypes as W
import ventana as V import ventana as V
NOMBRE = "OrganiLAB" NOMBRE = "OrganiLAB"
DESCRIPCION = "OrganiLAB · ordena y aligera el material de video" 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=<carpeta>, 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_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) 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}" CLSID_ACCESO, IID_ACCESO = "{00021401-0000-0000-C000-000000000046}", "{000214F9-0000-0000-C000-000000000046}"
@@ -132,9 +148,106 @@ def leer_acceso(ruta):
def _accesos(): def _accesos():
"""Las dos rutas donde van los accesos directos: (menú Inicio, Escritorio).""" """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") 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): def _peso_kb(base):
total = 0 total = 0
for raiz, dirs, files in os.walk(base): 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()} 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): def al_dia(base, version):
"""Tras una actualización: si este programa está dado de alta, Windows tiene que enseñar la versión """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.""" nueva y los accesos directos tienen que apuntar a lo que ahora lo abre."""
@@ -222,7 +347,11 @@ def principal(que, base, version, silencioso=False):
return V.IDOK if silencioso else u.MessageBoxW(None, texto, NOMBRE, botones) return V.IDOK if silencioso else u.MessageBoxW(None, texto, NOMBRE, botones)
try: try:
if que == "instalar": if que == "instalar":
instalar(base, version) 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" decir("OrganiLAB ya está en el menú Inicio y en el Escritorio.\n\n"
"Para quitarlo: Configuración › Aplicaciones › Aplicaciones instaladas.") "Para quitarlo: Configuración › Aplicaciones › Aplicaciones instaladas.")
return 0 return 0
@@ -233,6 +362,11 @@ def principal(que, base, version, silencioso=False):
desinstalar(base) desinstalar(base)
decir(f"Quitado. La carpeta del programa sigue en:\n\n{base}\n\nSi ya no la quieres, bórrala a mano.") decir(f"Quitado. La carpeta del programa sigue en:\n\n{base}\n\nSi ya no la quieres, bórrala a mano.")
return 0 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 except Exception as ex: # noqa
decir(f"No se pudo: {ex}", V.MB_OK | V.MB_ICONWARNING) decir(f"No se pudo: {ex}", V.MB_OK | V.MB_ICONWARNING)
return 1 return 1

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "3.0.0", "version": "3.1.0",
"builtAt": "2026-10-04T14:50:25.000Z", "builtAt": "2026-10-04T17:23:53.000Z",
"archivo": "OrganizadorLabstream-portatil-3.0.0.zip", "archivo": "OrganizadorLabstream-portatil-3.1.0.zip",
"size": 195378394, "size": 195387012,
"sha256": "98c43f1f06865f233e98d4b4b8f2cae82e1386def7b38673ce5a4cd01bc850dd", "sha256": "1c80a369bcdc8f07c5a9db1dec1bd95dcd390a8e65714ec60251388544ee6523",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-3.0.0.zip", "archivo": "OrganizadorLabstream-app-3.1.0.zip",
"size": 11565815, "size": 11574433,
"sha256": "09d9024a41370bdd07dbbd3fe1110822c0f51ce4987f62cf9dd185ad9aa3e82e", "sha256": "cedd68ade3f61ccd2958e3578bf46be8b92e20d73aade3b7e455585e53898d3e",
"url": "/api/herramientas/OrganizadorLabstream-app-3.0.0.zip" "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": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "6456976cf21259a5ae91b5ba5cc8074b552cfec0a389f8b9c89c7af962841cdb523f8f39b7143281d7d965079ee0471fc8b7f14c79636d2cc443315131a3fc53ec6a22ff87b1c49d806e4a795c2365b83f8236a523a1349730b90300eff68cc84ad32f58e663798e77301024f870e094c1fa112b994d55f719c1cdf8c22b0375f6eb24276bfa9837d755ef07c71e9b701df073fd4c94cf3ddccc6e5a2203e48458398beb4f38d2d68ebab2810a0ac56add4b9294321cb4eb3083f1e2d717bd2b2a6b638cadb8140e48f4d46e3e0a13686b462477bb8e9a3923f7a178c5e326f65a8ae90ff68949ac6a5bd2cf5758a53dd79779c79b4f67414f0251aae3f9566e" "valor": "1ca46a8c6af640d62da3b8a97500661ae2d1e5442b66fb9ba9ee5268e3cfd85c1c1b6c2949484742adbdc758788bb5df323654b6a8533dd1ce3a93e9b974b0870bfa7c2f74fec4f6ef922cf1228c2d290a09a54dd62cabe846cdb40ed79276cc36940283e39db57ccc5f392daaf074672546f38cc3342cbc569d76723d365c2807755d95e8f129fb5e309952da27f645ef6130af7abb3f1356f895a92745ae890ad0c5b9d2a2139bc85ea5b66bcd3b2385d74c2d4b2bc438acf474e8dde041951d0c8f9949b39bf214d38ce3f0a80451f9c1681a9e3e53c2d28abf3e570201cc624b248213392ada7c892743a26c2be40d5170149d72d2dd3cd8bd92e3bff5d7"
} }
} }

Ver fichero

@@ -22,6 +22,7 @@ import ctypes
import time import time
import queue import queue
import shutil import shutil
import socket
import stat as stat_mod import stat as stat_mod
import hashlib import hashlib
import sqlite3 import sqlite3
@@ -39,7 +40,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.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__)) 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 # 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): class Manejador(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
@@ -4132,7 +4146,7 @@ class Manejador(BaseHTTPRequestHandler):
AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando
OrganizadorLabstream.exe abre OrganiLAB en su ventana 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 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 de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer
OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar) OrganizadorLabstream.exe 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 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 _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 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): def _al_soltar(rutas):
@@ -4471,7 +4486,8 @@ def _vigilar(v, fin):
def _recien_abierta(v): 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 """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: try:
import ventana import ventana
import instalar import instalar
@@ -4480,14 +4496,35 @@ def _recien_abierta(v):
oferta = CONFIG.get("instalacion") or {} oferta = CONFIG.get("instalacion") or {}
if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE): if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE):
return 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 CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez
guardar_json(CONFIG_PATH, CONFIG) guardar_json(CONFIG_PATH, CONFIG)
time.sleep(1.5) time.sleep(1.5)
if not instalar.hay_que_mudar(BASE): # ya está en su sitio: solo falta darlo de alta
if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n" 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.", "Se quita desde Configuración › Aplicaciones.", ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES:
ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES:
instalar.instalar(BASE, VERSION) instalar.instalar(BASE, VERSION)
v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.") 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(
'<p><span class="spin"></span> Copiando el programa a su sitio definitivo. Tarda unos segundos; al terminar se abre solo desde allí.</p>') + ", 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 except Exception as ex: # noqa
print("no se pudo dar de alta en Windows:", ex) print("no se pudo dar de alta en Windows:", ex)
@@ -4539,8 +4576,20 @@ def _atender_navegador():
def main(): def main():
global PUERTO global PUERTO
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] 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: 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 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)) sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv))
if args_tmp and "--puente" not in sys.argv: if args_tmp and "--puente" not in sys.argv:
_asegurar_consola() _asegurar_consola()
@@ -4583,13 +4632,14 @@ def main():
if huerfanas: if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
try: try:
srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador)
except OSError: except OSError:
if os.environ.get("ORGANIZADOR_PUERTO"): if os.environ.get("ORGANIZADOR_PUERTO"):
print(f"El puerto {PUERTO} está ocupado por otro programa.") print(f"El puerto {PUERTO} está ocupado por otro programa.")
sys.exit(1) sys.exit(1)
# el puerto de siempre lo tiene otro programa: se toma uno libre (a la ventana le da igual cuál sea) # el puerto de siempre lo tiene otro programa (u otro OrganiLAB con otros datos): se toma uno libre,
srv = ThreadingHTTPServer(("127.0.0.1", 0), Manejador) # que a la ventana le da igual cuál sea
srv = ServidorLocal(("127.0.0.1", 0), Manejador)
PUERTO = srv.server_address[1] PUERTO = srv.server_address[1]
url = f"http://127.0.0.1:{PUERTO}/" url = f"http://127.0.0.1:{PUERTO}/"
guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": 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 while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir
time.sleep(1) time.sleep(1)
srv.shutdown() 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: finally:
try: try:
os.remove(SESION_PATH) os.remove(SESION_PATH)

Ver fichero

@@ -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 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.""" 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-") tmp = tempfile.mkdtemp(prefix="organilab-imprevistos-")
tapon = socket.socket() # quien ocupa el puerto es un servidor como el de otro OrganiLAB: pide «reutilizar dirección», y en
tapon.bind(("127.0.0.1", 0)) # Windows eso dejaba que un segundo programa escuchara en el mismo puerto sin que nada fallara
tapon.listen(1) import http.server
ocupado = tapon.getsockname()[1] tapon = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.BaseHTTPRequestHandler)
ocupado = tapon.server_address[1]
try: try:
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"): 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) 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.assertTrue(p.stdout.strip().splitlines()[-1].startswith("FIN "))
self.assertFalse(os.path.exists(os.path.join(tmp, "datos", "sesion.json"))) self.assertFalse(os.path.exists(os.path.join(tmp, "datos", "sesion.json")))
finally: 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) shutil.rmtree(tmp, ignore_errors=True)
def test_orden_de_arranque(self): def test_orden_de_arranque(self):
@@ -215,6 +281,24 @@ class Windows(unittest.TestCase):
self.assertEqual(p.stdout.strip(), "sigue") 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'<a data-t="([a-z]+)"[^>]*>([^<]+)</a>', html[:html.index("</nav>")])
manual = html[html.index('<section id="t-manual">'):html.index("<!-- ============ AJUSTES")]
apartados = re.findall(r"<h3>([^<]+)</h3>", manual)
self.assertGreaterEqual(len(vistas), 8)
for clave, etiqueta in vistas:
if clave != "manual":
self.assertIn(etiqueta.replace("⚙", "").strip(), apartados)
self.assertGreater(manual.count("<dt>"), 35)
self.assertEqual(manual.count("<dt>"), manual.count("<dd>"))
class Firma(unittest.TestCase): class Firma(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

Ver fichero

@@ -5,7 +5,7 @@ sin abrirlo; esto abre OrganiLAB sobre una copia temporal (programa y datos apar
comprueba desde otro hilo lo que solo se puede comprobar con la ventana delante: comprueba desde otro hilo lo que solo se puede comprobar con la ventana delante:
py -3.12 prueba_ventana.py el programa desde el código py -3.12 prueba_ventana.py el programa desde el código
py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream el paquete, arrancado por su .exe py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream el paquete, arrancado por su .exe, y su instalación
… --captura ruta.png deja una imagen de la ventana entera … --captura ruta.png deja una imagen de la ventana entera
Durante medio minuto se ve una ventana que se abre, se mueve sola y se cierra. No hay que tocar nada. Durante medio minuto se ve una ventana que se abre, se mueve sola y se cierra. No hay que tocar nada.
@@ -138,6 +138,21 @@ def marca_de(datos):
return (zlib.crc32(os.path.normcase(os.path.abspath(datos)).encode("utf-8")) & 0x7FFFFFFF) or 1 return (zlib.crc32(os.path.normcase(os.path.abspath(datos)).encode("utf-8")) & 0x7FFFFFFF) or 1
def cerrar_las_de(V, *marcas):
"""Cierra las copias de ensayo que sigan abiertas (con sus cuadros de diálogo), pase lo que pase en la
prueba: una copia olvidada se queda en la pantalla y, peor, atendiendo."""
for _ in range(3):
abiertas = [h for m in marcas for h in V._ventanas(m)]
if not abiertas:
return
for h in abiertas:
d = dialogo_de(h)
if d:
U.PostMessageW(d, WM_CLOSE, 0, 0)
U.PostMessageW(h, WM_CLOSE, 0, 0)
time.sleep(3)
def captura_pedida(): def captura_pedida():
return sys.argv[sys.argv.index("--captura") + 1] if "--captura" in sys.argv else "" return sys.argv[sys.argv.index("--captura") + 1] if "--captura" in sys.argv else ""
@@ -206,6 +221,10 @@ def desde_el_codigo(tmp):
return r return r
aj = v.en_ui(ajustes) aj = v.en_ui(ajustes)
anota("lo que es de navegador está apagado", not any(aj.values()), ", ".join(k for k, x in aj.items() if x) or "todo apagado") anota("lo que es de navegador está apagado", not any(aj.values()), ", ".join(k for k, x in aj.items() if x) or "todo apagado")
v.evaluar("irA('manual')")
entradas = v.evaluar("document.querySelector('#t-manual').classList.contains('on') ? document.querySelectorAll('#t-manual dt').length : 0")
anota("el manual está dentro del programa", (entradas or 0) > 35, f"{entradas} entradas")
v.evaluar("irA('carpeta')")
anota("la interfaz sabe que está en la ventana", v.evaluar("document.documentElement.classList.contains('nativo')") is True) anota("la interfaz sabe que está en la ventana", v.evaluar("document.documentElement.classList.contains('nativo')") is True)
# el avance en la barra de tareas # el avance en la barra de tareas
@@ -348,10 +367,89 @@ def el_paquete(tmp, origen):
proc.kill() proc.kill()
anota("al cerrar la ventana se cierra el programa", proc.returncode == 0, f"código {proc.returncode}") anota("al cerrar la ventana se cierra el programa", proc.returncode == 0, f"código {proc.returncode}")
anota("y deja de atender", estado() is None) anota("y deja de atender", estado() is None)
cerrar_las_de(V, marca)
p = subprocess.run([exe, "ayuda"], capture_output=True, timeout=60, stdin=subprocess.DEVNULL, env=entorno) p = subprocess.run([exe, "ayuda"], capture_output=True, timeout=60, stdin=subprocess.DEVNULL, env=entorno)
anota("por consola responde a sus comandos", p.returncode == 0 and b"asistente" in p.stdout) anota("por consola responde a sus comandos", p.returncode == 0 and b"asistente" in p.stdout)
def instalar_el_paquete(tmp, origen):
"""La instalación de punta a punta, sin tocar el Windows de verdad: con ORGANILAB_ENSAYO el «equipo» es una
carpeta temporal. El paquete se abre desde unas «Descargas», ofrece instalarse, se copia a su sitio, se da
de alta, se cierra y vuelve a abrirse desde allí; y abrir después la copia de Descargas abre la instalada."""
import winreg
equipo = os.path.join(tmp, "equipo")
os.environ["ORGANILAB_ENSAYO"] = equipo
os.environ.pop("ORGANIZADOR_PUERTO", None)
sys.path.insert(0, AQUI)
import ventana as V
import instalar
descarga = os.path.join(tmp, "Descargas", "OrganizadorLabstream")
shutil.copytree(origen, descarga, ignore=shutil.ignore_patterns("ffmpeg", "actualizacion", "__pycache__"))
os.makedirs(os.path.join(descarga, "ffmpeg"))
with open(os.path.join(descarga, "ffmpeg", "LICENSE.txt"), "w") as fh:
fh.write("en el paquete completo aquí va ffmpeg")
destino = instalar.sitio_definitivo()
exe_d, exe_i = os.path.join(descarga, "OrganizadorLabstream.exe"), os.path.join(destino, "OrganizadorLabstream.exe")
marca_d, marca_i = marca_de(os.path.join(descarga, "datos")), marca_de(os.path.join(destino, "datos"))
try:
proc = subprocess.Popen([exe_d], cwd=tmp)
hs = espera(lambda: V._ventanas(marca_d), 40)
if not hs:
anota("el paquete abre desde Descargas", False)
proc.kill()
return
h = hs[0]
d = espera(lambda: dialogo_de(h), 25)
pregunta = " ".join(texto(x) for x in hijos(d)) if d else ""
anota("la primera vez ofrece instalarlo en el equipo", "Instalar OrganiLAB en este equipo" in pregunta, pregunta[8:70])
if d:
U.PostMessageW(d, WM_COMMAND, 6, 0) # «Sí»
def aviso_final():
x = dialogo_de(h)
return x if x and "quedó instalado" in " ".join(texto(y) for y in hijos(x)) else None
d = espera(aviso_final, 90)
anota("se copia a su sitio y lo dice", bool(d))
if d:
U.PostMessageW(d, WM_CLOSE, 0, 0) # solo tiene «Aceptar»
hi = espera(lambda: V._ventanas(marca_i), 60)
anota("se cierra y vuelve a abrirse solo desde su sitio", bool(hi) and espera(lambda: not V._ventanas(marca_d), 30) is True)
try:
proc.wait(30)
except subprocess.TimeoutExpired:
proc.kill()
anota("en su sitio están el programa, sus datos y su ffmpeg",
all(os.path.exists(os.path.join(destino, x)) for x in ("OrganizadorLabstream.exe", "runtime", "static", "datos\\config.json", "ffmpeg\\LICENSE.txt")))
a = instalar.leer_acceso(instalar._accesos()[0]) if os.path.exists(instalar._accesos()[0]) else None
anota("queda dado de alta desde su sitio (Inicio, Escritorio y Aplicaciones)",
instalar.instalado_aqui(destino) and bool(a) and instalar._misma(a["exe"], exe_i) and os.path.exists(instalar._accesos()[1]),
a["exe"] if a else "sin acceso directo")
anota("del origen no se borra nada, y queda la nota de la mudanza", os.path.exists(exe_d) and bool(instalar.mudado(descarga)))
if hi:
try:
with open(os.path.join(destino, "datos", "sesion.json"), encoding="utf-8") as fh:
puerto = json.load(fh)["puerto"]
e = json.loads(urllib.request.urlopen(f"http://127.0.0.1:{puerto}/api/estado", timeout=5).read())
except Exception: # noqa
e = {}
anota("el instalado atiende con sus datos", e.get("congelado") is True and (e.get("config") or {}).get("instalacion", {}).get("ofrecida") is True,
f"versión {e.get('version')}")
subprocess.run([exe_d], cwd=tmp, timeout=40)
time.sleep(2.5)
anota("abrir la copia de Descargas abre la instalada", not V._ventanas(marca_d) and len(V._ventanas(marca_i)) == 1)
U.PostMessageW(hi[0], WM_CLOSE, 0, 0)
anota("y se cierra como siempre", espera(lambda: not V._ventanas(marca_i), 25) is True)
time.sleep(2)
finally:
cerrar_las_de(V, marca_d, marca_i)
for clave in (instalar.CLAVE, "Software\\OrganiLAB-ensayo"):
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, clave)
except OSError:
pass
os.environ.pop("ORGANILAB_ENSAYO", None)
def main(): def main():
guardia = threading.Timer(300, lambda: os._exit(3)) # pase lo que pase, no se queda colgada guardia = threading.Timer(300, lambda: os._exit(3)) # pase lo que pase, no se queda colgada
guardia.daemon = True guardia.daemon = True
@@ -360,6 +458,7 @@ def main():
try: try:
if "--paquete" in sys.argv: if "--paquete" in sys.argv:
el_paquete(tmp, os.path.abspath(sys.argv[sys.argv.index("--paquete") + 1])) el_paquete(tmp, os.path.abspath(sys.argv[sys.argv.index("--paquete") + 1]))
instalar_el_paquete(tmp, os.path.abspath(sys.argv[sys.argv.index("--paquete") + 1]))
else: else:
desde_el_codigo(tmp) desde_el_codigo(tmp)
finally: finally:

Ver fichero

@@ -68,6 +68,15 @@
nav a{padding:13px 11px;color:var(--mut);text-decoration:none;border-bottom:2px solid transparent;cursor:pointer;font-weight:600} nav a{padding:13px 11px;color:var(--mut);text-decoration:none;border-bottom:2px solid transparent;cursor:pointer;font-weight:600}
nav a.on{color:var(--tx);border-color:var(--tx)} nav a.on{color:var(--tx);border-color:var(--tx)}
nav a.eng{margin-left:auto;font-size:12.5px;font-weight:500} nav a.eng{margin-left:auto;font-size:12.5px;font-weight:500}
nav a.eng2{font-size:12.5px;font-weight:500}
/* ── manual ── */
.manual{max-width:1120px}
.manual h3{margin:24px 0 8px;font-size:14px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.manual dl{display:grid;grid-template-columns:230px 1fr;gap:10px 22px;margin:0}
.manual dt{font-weight:600}
.manual dd{margin:0}
.manual dd i{font-style:normal;color:var(--mut)}
@media (max-width:900px){.manual dl{grid-template-columns:1fr;gap:2px 0}.manual dd{margin-bottom:10px}}
main{padding:22px 28px 40px;max-width:1720px;overflow-x:hidden} main{padding:22px 28px 40px;max-width:1720px;overflow-x:hidden}
section{display:none}section.on{display:block} section{display:none}section.on{display:block}
h2.v{margin:0 0 2px;font-size:18px;font-weight:700;letter-spacing:-.01em} h2.v{margin:0 0 2px;font-size:18px;font-weight:700;letter-spacing:-.01em}
@@ -347,6 +356,7 @@
<a data-t="respaldar" tabindex="0">Respaldar</a> <a data-t="respaldar" tabindex="0">Respaldar</a>
<a data-t="historial" tabindex="0">Historial</a> <a data-t="historial" tabindex="0">Historial</a>
<a data-t="ajustes" class="eng" tabindex="0" title="Clientes, sus carpetas y plantillas">⚙ Ajustes</a> <a data-t="ajustes" class="eng" tabindex="0" title="Clientes, sus carpetas y plantillas">⚙ Ajustes</a>
<a data-t="manual" class="eng2" tabindex="0" title="Qué hace cada cosa y cómo lo hace">Manual</a>
</nav> </nav>
<main> <main>
@@ -537,6 +547,104 @@
<div id="historial"></div> <div id="historial"></div>
</section> </section>
<!-- ============ MANUAL ============ -->
<section id="t-manual">
<div class="cab"><h2 class="v">Manual</h2></div>
<p class="ayuda">Cada función del programa: qué hace y, en gris, cómo lo hace. El orden es el de las pestañas de arriba.</p>
<div class="manual">
<h3>Para empezar</h3>
<dl>
<dt>Instalarlo</dt><dd>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. <i>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.</i></dd>
<dt>Añadir la carpeta de un cliente</dt><dd>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. <i>Cómo: al confirmar solo la lee; no mueve nada hasta que tú lo apliques.</i></dd>
<dt>Clase de trabajo</dt><dd>Piezas mes a mes, anuncios y campañas, un trabajo puntual o material de Labstream. <i>Cómo: cada clase tiene su plantilla de carpetas, y es lo único que decide dónde se guarda cada cosa.</i></dd>
<dt>Tus carpetas</dt><dd>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.</dd>
</dl>
<h3>Carpeta</h3>
<dl>
<dt>Escanear</dt><dd>Lee nombres, tamaños y fechas de todo lo que hay dentro. <i>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…</i></dd>
<dt>Lista de acciones</dt><dd>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. <i>Cómo: son consultas al inventario, por eso es instantánea.</i></dd>
<dt>Hacer las N de una vez</dt><dd>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. <i>Cómo: lo dudoso no entra nunca; convertir, respaldar y borrar carpetas vacías tienen su propio botón.</i></dd>
<dt>Quitar de la lista</dt><dd>La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.</dd>
</dl>
<h3>Revisar y ordenar</h3>
<dl>
<dt>Qué hay</dt><dd>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.</dd>
<dt>Por nombrar</dt><dd>Las carpetas cuyo nombre no reconoce. Propone qué son y tú lo confirmas. <i>Cómo: mira qué tienen dentro; lo confirmado se guarda en un diccionario y vale para todas las carpetas y discos.</i></dd>
<dt>Qué va a pasar</dt><dd>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…). <i>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.</i></dd>
<dt>Aplicar</dt><dd>Hace los movimientos marcados, siempre dentro de la carpeta del cliente. <i>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.</i></dd>
<dt>Stock viejo</dt><dd>Aparta a <span class="path">_Para borrar\stock</span> 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. <i>Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».</i></dd>
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca.</i></dd>
<dt>Carpetas vacías</dt><dd>Lista los huecos que deja ordenar y los borra. Es lo único que el programa borra, porque dentro no hay nada. <i>Cómo: cada una se comprueba en el disco justo antes; se puede deshacer, y no entra en «hacer de una vez».</i></dd>
</dl>
<h3>Mapa</h3>
<dl>
<dt>El árbol</dt><dd>Cómo quedan las carpetas con cada clase de trabajo y qué va en cada una, con el color de lo que guarda. <i>Cómo: se dibuja con las plantillas de verdad, así que enseña exactamente lo que el programa va a hacer.</i></dd>
<dt>Tocar una carpeta</dt><dd>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.</dd>
<dt>Cambiar un nombre</dt><dd>Renombra una carpeta en todas las plantillas que pasan por ella y, si quieres, también las que ya existen en el disco. <i>Cómo: es un movimiento normal, queda en el historial y se deshace.</i></dd>
<dt>¿Dónde va a parar este archivo?</dt><dd>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. <i>Cómo: con el mismo clasificador que usa el escaneo.</i></dd>
<dt>Colores en Windows</dt><dd>Pinta las carpetas en el Explorador con el color de lo que guardan, y los quita con un clic. <i>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.</i></dd>
</dl>
<h3>Convertidor</h3>
<dl>
<dt>Buscar clips</dt><dd>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. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero y los exportados.</i></dd>
<dt>Formato y calidad</dt><dd>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.</dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. <i>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.</i></dd>
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
<dt>Reemplazar</dt><dd>El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a <span class="path">_Para borrar\originales</span>.</dd>
</dl>
<h3>Respaldar</h3>
<dl>
<dt>Calcular</dt><dd>Dice cuánto ocuparía la copia, por tipo de medio, y qué originales de cámara quedarían fuera por no estar convertidos.</dd>
<dt>Copiar</dt><dd>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. <i>Cómo: es incremental; lo que ya está igual se salta, así que se puede detener y retomar. Deja <span class="path">INDICE.csv</span> y <span class="path">_MAPA.md</span> para encontrar cosas sin montar el disco.</i></dd>
</dl>
<h3>Historial</h3>
<dl>
<dt>Deshacer</dt><dd>Cada vez que el programa mueve algo queda aquí como un lote, y «Deshacer» devuelve cada archivo a donde estaba. <i>Cómo: en orden inverso; si alguien movió un archivo después, ese se salta y se avisa.</i></dd>
<dt>_Para borrar</dt><dd>Enseña lo que hay apartado. El programa no lo borra nunca: lo revisa y lo borra una persona desde el Explorador.</dd>
</dl>
<h3>Ajustes</h3>
<dl>
<dt>Clientes</dt><dd>Nombre, clase de trabajo, sus carpetas y los otros nombres con que aparece en los discos.</dd>
<dt>Actualizaciones</dt><dd>«Buscar actualizaciones» mira si hay versión nueva; el interruptor de «consultar al arrancar» viene apagado. <i>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.</i></dd>
<dt>Plantillas</dt><dd>En «Avanzado»: la ruta exacta de cada tipo de material en cada clase de trabajo. Es lo mismo que se cambia desde el Mapa.</dd>
</dl>
<h3>La ventana</h3>
<dl>
<dt>En curso</dt><dd>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.</dd>
<dt>Cerrar con trabajo en marcha</dt><dd>Avisa de lo que está haciendo; si cierras, sigue hasta terminar y se cierra solo. Abrirlo otra vez devuelve la misma ventana.</dd>
<dt>Una sola copia</dt><dd>Abrirlo dos veces trae al frente la ventana que ya había. <i>Cómo: dos copias sobre los mismos datos se pisarían el inventario.</i></dd>
</dl>
<h3>Sin abrir el programa</h3>
<dl>
<dt>Arrastrar una carpeta</dt><dd>Sobre el .exe o sobre «Organizar carpeta (arrastra aqui).bat»: la escanea, da el diagnóstico y pregunta paso a paso en una consola.</dd>
<dt>Por comando</dt><dd><span class="path">OrganizadorLabstream.exe ayuda</span> lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, instalar y desinstalar.</dd>
</dl>
<h3>Lo que nunca hace</h3>
<dl>
<dt>Borrar archivos</dt><dd>Lo que sobra va a <span class="path">_Para borrar</span>, dentro de la misma carpeta del cliente, y lo repetido a <span class="path">Duplicados</span>.</dd>
<dt>Pisar o sacar de su carpeta</dt><dd>No sobrescribe nada y no mueve nada fuera de la carpeta del cliente.</dd>
<dt>Salir a la red</dt><dd>Todo ocurre en este equipo. Solo se conecta cuando le pides buscar una versión nueva.</dd>
</dl>
<h3>Dónde guarda lo suyo</h3>
<dl>
<dt>La carpeta datos</dt><dd>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.</dd>
</dl>
</div>
</section>
<!-- ============ AJUSTES ============ --> <!-- ============ AJUSTES ============ -->
<section id="t-ajustes"> <section id="t-ajustes">
<div class="cab"><h2 class="v">Clientes</h2><div class="der"><input type="text" id="filtroCli" placeholder="buscar cliente…" style="min-width:200px"><button id="btnAddCliente">+ Añadir cliente</button></div></div> <div class="cab"><h2 class="v">Clientes</h2><div class="der"><input type="text" id="filtroCli" placeholder="buscar cliente…" style="min-width:200px"><button id="btnAddCliente">+ Añadir cliente</button></div></div>
@@ -628,7 +736,7 @@ $$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
const cargarVista = seguro(async t => { const cargarVista = seguro(async t => {
t = t || vistaActual(); t = t || vistaActual();
const r = cfgRaiz(); 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 === '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 === '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(); if (t === 'mapa') await cargarMapaEst();