OrganiLAB 1.6.1: organizador, actualizador, interfaz 1.6 y publicación
Primer commit del proyecto. Incluye el rediseño 1.6 (riel de pasos en la barra lateral), el actualizador con reintentos y respaldo, publicar.sh y la nueva opción de desconectar carpetas (1.6.1). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
303
actualizador.py
Archivo normal
303
actualizador.py
Archivo normal
@@ -0,0 +1,303 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
Actualización automática desde Labstream OS.
|
||||
|
||||
El OS publica public/herramientas/organizador.json (versión, archivo del paquete «app», tamaño,
|
||||
sha256). Este módulo lo consulta, descarga el zip nuevo a BASE\actualizacion\, comprueba el
|
||||
sha256, lo descomprime y deja un actualizar.bat que, cuando el .exe se cierra, guarda una copia
|
||||
de la versión actual, copia la nueva encima (sin tocar datos\ ni ffmpeg\), vuelve atrás si algo
|
||||
falla, y reabre el programa.
|
||||
|
||||
Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y se explica por qué):
|
||||
- versión con forma 1.2.3
|
||||
- nombre de archivo simple (sin rutas) terminado en .zip
|
||||
- sha256 de 64 hex, obligatorio
|
||||
- URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor)
|
||||
|
||||
`O` es el módulo organizador (lo fija él al arrancar).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import hashlib
|
||||
import zipfile
|
||||
import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
O = None
|
||||
URL_DEFECTO = "https://os.labstreamsas.com/herramientas/organizador.json"
|
||||
ESTADO = {"version": "", "comprobado": 0, "disponible": None, "estado": "", "pct": 0, "bytes": 0, "total": 0, "error": "",
|
||||
"aplicando": False}
|
||||
_LOCK = threading.Lock()
|
||||
RE_VERSION = re.compile(r"^\d{1,4}(\.\d{1,5}){0,3}$")
|
||||
RE_SHA = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
def _cfg():
|
||||
c = O.CONFIG.setdefault("actualizaciones", {})
|
||||
c.setdefault("url", URL_DEFECTO)
|
||||
c.setdefault("auto", True)
|
||||
return c
|
||||
|
||||
|
||||
def _tupla(v):
|
||||
out = []
|
||||
for p in str(v or "0").split("."):
|
||||
num = "".join(ch for ch in p if ch.isdigit())
|
||||
out.append(int(num) if num else 0)
|
||||
return tuple(out + [0] * (3 - len(out)))
|
||||
|
||||
|
||||
def _validar(m, url_manifiesto):
|
||||
"""Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados."""
|
||||
app = m.get("app") or {}
|
||||
version = str(m.get("version") or "")
|
||||
if not RE_VERSION.match(version):
|
||||
return None, f"versión con formato raro en el manifiesto: {version!r}"
|
||||
archivo = str(app.get("archivo") or "")
|
||||
if not archivo or archivo != os.path.basename(archivo) or not archivo.lower().endswith(".zip") or "/" in archivo or "\\" in archivo:
|
||||
return None, f"nombre de archivo no válido en el manifiesto: {archivo!r}"
|
||||
sha = str(app.get("sha256") or "")
|
||||
if not RE_SHA.match(sha):
|
||||
return None, "el manifiesto no trae el sha256 del paquete; no se instala nada sin él"
|
||||
url = urljoin(url_manifiesto, app.get("url") or f"/api/herramientas/{archivo}")
|
||||
a, b = urlparse(url_manifiesto), urlparse(url)
|
||||
if (a.scheme, a.netloc.lower()) != (b.scheme, b.netloc.lower()):
|
||||
return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}"
|
||||
if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"):
|
||||
return None, "el manifiesto debe servirse por https"
|
||||
return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo,
|
||||
"size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, ""
|
||||
|
||||
|
||||
def comprobar(timeout=8):
|
||||
"""Lee el manifiesto del OS. Devuelve el dict de la versión disponible o None."""
|
||||
ESTADO["version"] = O.VERSION
|
||||
url = _cfg()["url"]
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}", "Cache-Control": "no-cache"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
m = json.loads(r.read(1 << 20).decode("utf-8"))
|
||||
ESTADO["error"] = ""
|
||||
except Exception as ex: # noqa
|
||||
ESTADO["error"] = f"no se pudo consultar {url}: {ex}"
|
||||
ESTADO["comprobado"] = time.time()
|
||||
return None
|
||||
ESTADO["comprobado"] = time.time()
|
||||
d, motivo = _validar(m, url)
|
||||
if d and _tupla(d["version"]) > _tupla(O.VERSION):
|
||||
ESTADO["disponible"] = d
|
||||
else:
|
||||
ESTADO["disponible"] = None
|
||||
if not d and _tupla(m.get("version")) > _tupla(O.VERSION):
|
||||
ESTADO["error"] = "hay una versión publicada pero el manifiesto no pasa las comprobaciones: " + motivo
|
||||
return ESTADO["disponible"]
|
||||
|
||||
|
||||
def comprobar_en_segundo_plano():
|
||||
threading.Thread(target=comprobar, daemon=True).start()
|
||||
|
||||
|
||||
def _sha256(ruta):
|
||||
h = hashlib.sha256()
|
||||
with open(ruta, "rb") as fh:
|
||||
for b in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(b)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def tarea_en_marcha():
|
||||
"""Texto de la tarea que impide actualizar ahora, o '' si se puede."""
|
||||
if O.COLA.get("activa"):
|
||||
return "hay una conversión en marcha"
|
||||
if O.RESPALDO.get("estado") == "corriendo":
|
||||
return "hay una copia de respaldo en marcha"
|
||||
if O.asistente.ESTADO.get("activo"):
|
||||
return "el asistente está trabajando"
|
||||
if any((p or {}).get("estado") == "corriendo" for p in O.PROGRESO.values()):
|
||||
return "hay un escaneo en marcha"
|
||||
if any((a or {}).get("estado") == "corriendo" for a in O.ANALISIS.values()):
|
||||
return "se están leyendo clips"
|
||||
return ""
|
||||
|
||||
|
||||
def _bat(s):
|
||||
"""escapa lo que cmd interpretaría dentro del .bat"""
|
||||
return str(s).replace("%", "%%")
|
||||
|
||||
|
||||
def descargar_y_aplicar(en_hilo=False, relanzar_args=()):
|
||||
"""Descarga el paquete, lo verifica, lo descomprime y deja el .bat listo; luego cierra el programa."""
|
||||
if en_hilo:
|
||||
if _LOCK.locked():
|
||||
ESTADO["error"] = "ya hay una actualización en curso"
|
||||
return False
|
||||
threading.Thread(target=descargar_y_aplicar, args=(False, relanzar_args), daemon=True).start()
|
||||
return True
|
||||
if not _LOCK.acquire(blocking=False):
|
||||
ESTADO["error"] = "ya hay una actualización en curso"
|
||||
return False
|
||||
try:
|
||||
return _descargar_y_aplicar(relanzar_args)
|
||||
finally:
|
||||
if ESTADO["estado"] != "reiniciando":
|
||||
_LOCK.release()
|
||||
|
||||
|
||||
def _descargar_y_aplicar(relanzar_args):
|
||||
d = ESTADO.get("disponible") or comprobar()
|
||||
if not d:
|
||||
ESTADO["estado"] = "al día"
|
||||
return False
|
||||
if not O.CONGELADO:
|
||||
ESTADO["estado"] = "error"
|
||||
ESTADO["error"] = "la actualización automática solo funciona con el .exe empaquetado"
|
||||
return False
|
||||
ocupado = tarea_en_marcha()
|
||||
if ocupado:
|
||||
ESTADO["estado"] = "error"
|
||||
ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine."
|
||||
return False
|
||||
carpeta = os.path.join(O.BASE, "actualizacion")
|
||||
try:
|
||||
shutil.rmtree(carpeta, ignore_errors=True)
|
||||
os.makedirs(carpeta, exist_ok=True)
|
||||
destino = os.path.join(carpeta, os.path.basename(d["archivo"]))
|
||||
ESTADO.update(estado="descargando", pct=0, bytes=0, total=d.get("size", 0), error="")
|
||||
req = urllib.request.Request(d["url"], headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}"})
|
||||
tope = max(int(d.get("size") or 0) * 2, 64 << 20) # nunca más del doble de lo anunciado
|
||||
r = None
|
||||
for intento in range(1, 7):
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=30)
|
||||
break
|
||||
except urllib.error.HTTPError as ex:
|
||||
# 502/503/504: el OS se está redesplegando (pasa unos minutos cada vez que se publica algo)
|
||||
if ex.code not in (502, 503, 504) or intento == 6:
|
||||
raise RuntimeError("el servidor de Labstream OS está reiniciándose; prueba de nuevo en un par de minutos" if ex.code in (502, 503, 504) else str(ex))
|
||||
ESTADO["error"] = f"el servidor está ocupado (HTTP {ex.code}); reintento {intento} de 6 en 20 s…"
|
||||
time.sleep(20)
|
||||
except urllib.error.URLError as ex:
|
||||
if intento == 6:
|
||||
raise RuntimeError(f"no se pudo conectar con Labstream OS: {ex.reason}")
|
||||
ESTADO["error"] = f"sin conexión con el servidor; reintento {intento} de 6 en 20 s…"
|
||||
time.sleep(20)
|
||||
ESTADO["error"] = ""
|
||||
with r, open(destino, "wb") as fh:
|
||||
total = int(r.headers.get("Content-Length") or d.get("size") or 0)
|
||||
ESTADO["total"] = total
|
||||
leidos = 0
|
||||
while True:
|
||||
b = r.read(1 << 18)
|
||||
if not b:
|
||||
break
|
||||
fh.write(b)
|
||||
leidos += len(b)
|
||||
if leidos > tope:
|
||||
raise RuntimeError("la descarga es mucho más grande de lo anunciado; se cancela")
|
||||
ESTADO["bytes"] = leidos
|
||||
ESTADO["pct"] = int(leidos * 100 / total) if total else 0
|
||||
ESTADO["estado"] = "verificando"
|
||||
if _sha256(destino).lower() != d["sha256"]:
|
||||
raise RuntimeError("el archivo descargado no coincide con el sha256 publicado; no se instala")
|
||||
nueva = os.path.join(carpeta, "nueva")
|
||||
os.makedirs(nueva)
|
||||
with zipfile.ZipFile(destino) as z:
|
||||
nombres = z.namelist()
|
||||
# el zip lleva la carpeta OrganizadorLabstream/ como raíz: se quita ese primer nivel
|
||||
raiz = nombres[0].split("/")[0] + "/" if nombres and "/" in nombres[0] and all(n.startswith(nombres[0].split("/")[0] + "/") for n in nombres) else ""
|
||||
for n in nombres:
|
||||
rel = n[len(raiz):]
|
||||
if not rel or rel.endswith("/"):
|
||||
continue
|
||||
dest = os.path.normpath(os.path.join(nueva, rel))
|
||||
if not dest.startswith(os.path.normpath(nueva) + os.sep):
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with z.open(n) as src, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(src, out)
|
||||
if not os.path.exists(os.path.join(nueva, "OrganizadorLabstream.exe")):
|
||||
raise RuntimeError("el paquete no trae OrganizadorLabstream.exe")
|
||||
exe = os.path.join(O.BASE, "OrganizadorLabstream.exe")
|
||||
previa = os.path.join(carpeta, "previa")
|
||||
# relanzar_args None = no volver a abrir (modo consola); tupla = abrir con esos argumentos
|
||||
args = " ".join(f'"{_bat(a)}"' for a in (relanzar_args or ()))
|
||||
linea_start = f'start "" "{_bat(exe)}" {args}\r\n' if relanzar_args is not None else f"echo Listo: version {d['version']} instalada.\r\n"
|
||||
# el .bat vive FUERA de la carpeta que va a borrar; UTF-8 con chcp 65001 para rutas con acentos
|
||||
bat = os.path.join(tempfile.gettempdir(), "organizador_actualizar.bat")
|
||||
excl = "/XD datos ffmpeg actualizacion"
|
||||
with open(bat, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write("@echo off\r\n"
|
||||
"chcp 65001 >nul\r\n"
|
||||
f"title Actualizando OrganiLAB a {d['version']}\r\n"
|
||||
"echo Esperando a que se cierre OrganiLAB...\r\n"
|
||||
":espera\r\n"
|
||||
f"tasklist /FI \"PID eq {os.getpid()}\" 2>nul | find \"{os.getpid()}\" >nul && (timeout /t 1 /nobreak >nul & goto espera)\r\n"
|
||||
":espera2\r\n"
|
||||
"tasklist /FI \"IMAGENAME eq OrganizadorLabstream.exe\" 2>nul | find /I \"OrganizadorLabstream.exe\" >nul && (echo Cierra las otras ventanas de OrganiLAB para seguir... & timeout /t 2 /nobreak >nul & goto espera2)\r\n"
|
||||
"echo Guardando copia de la version actual...\r\n"
|
||||
f"robocopy \"{_bat(O.BASE)}\" \"{_bat(previa)}\" /E /R:2 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n"
|
||||
"if %ERRORLEVEL% GEQ 8 (echo ERROR: no se pudo guardar la copia de seguridad. No se cambia nada. & pause & exit /b 1)\r\n"
|
||||
f"echo Instalando la version {d['version']}...\r\n"
|
||||
f"robocopy \"{_bat(nueva)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n"
|
||||
"if %ERRORLEVEL% GEQ 8 (\r\n"
|
||||
" echo ERROR al copiar la version nueva. Volviendo a la anterior...\r\n"
|
||||
f" robocopy \"{_bat(previa)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n"
|
||||
" echo Restaurada la version anterior. Avisa a Labstream.\r\n"
|
||||
" pause & exit /b 1\r\n"
|
||||
")\r\n"
|
||||
+ linea_start +
|
||||
f"rmdir /s /q \"{_bat(carpeta)}\" >nul 2>&1\r\n"
|
||||
"timeout /t 2 /nobreak >nul\r\n"
|
||||
"(goto) 2>nul & del \"%~f0\"\r\n")
|
||||
ESTADO["estado"] = "reiniciando"
|
||||
ESTADO["aplicando"] = True
|
||||
# con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre
|
||||
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
|
||||
subprocess.Popen(["cmd", "/c", bat], creationflags=flags, close_fds=True, cwd=tempfile.gettempdir())
|
||||
threading.Timer(1.5, lambda: os._exit(0)).start()
|
||||
return True
|
||||
except Exception as ex: # noqa
|
||||
ESTADO["estado"] = "error"
|
||||
ESTADO["error"] = str(ex)
|
||||
ESTADO["aplicando"] = False
|
||||
return False
|
||||
|
||||
|
||||
def cli(args):
|
||||
print(f"Versión instalada: {O.VERSION}. Consultando {_cfg()['url']} …")
|
||||
d = comprobar()
|
||||
if ESTADO["error"]:
|
||||
print("No se pudo comprobar:", ESTADO["error"])
|
||||
return 1
|
||||
if not d:
|
||||
print("Ya tienes la última versión.")
|
||||
return 0
|
||||
print(f"Hay versión nueva: {d['version']} ({(d.get('size') or 0) / 1e6:.0f} MB)")
|
||||
if d.get("notes"):
|
||||
print(" " + d["notes"])
|
||||
if "--si" not in args and not O._confirmar("¿Descargar e instalar ahora? El programa se cerrará y volverá a abrirse", False):
|
||||
return 0
|
||||
hilo = threading.Thread(target=descargar_y_aplicar, args=(False, None), daemon=True)
|
||||
hilo.start()
|
||||
ultimo = -1
|
||||
while hilo.is_alive():
|
||||
if ESTADO["estado"] == "descargando" and ESTADO["pct"] != ultimo:
|
||||
ultimo = ESTADO["pct"]
|
||||
sys.stdout.write(f"\r descargando {ultimo:3d}% {ESTADO['bytes'] / 1e6:.0f}/{ESTADO['total'] / 1e6:.0f} MB")
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
if ESTADO["estado"] == "error":
|
||||
print("ERROR:", ESTADO["error"])
|
||||
return 1
|
||||
print("Instalando… se abre una ventana «Actualizando» que termina sola.", flush=True)
|
||||
time.sleep(3)
|
||||
return 0
|
||||
Referencia en una nueva incidencia
Block a user