Files
labstream-organizador/actualizador.py
Labstream 477857db45 3.3.0: lo que entra nuevo ya entra ordenado
Las cuatro funciones que eligió Munka tras la 3.2.0:

- Nueva pieza: crea «NN- Título» en su mes con el número que toca y sus subcarpetas; si la
  pieza ya existe, lo dice. Se deshace mientras siga vacía.
- Bandeja de entrada: lo que se deje en _Entrada se reparte por mes, día de rodaje y cámara.
  «Traer material» copia una tarjeta comprobando cada archivo (se vuelve a leer y se compara
  la huella), conserva la fecha del rodaje y no toca el origen.
- Versiones del mismo clip: dos clips de cámara que se llaman igual, duran lo mismo y tienen
  la misma forma son el mismo clip; se queda el de más imagen y el otro va a Duplicados.
- Orden automático, apagado de fábrica: una tarea del Programador de tareas de Windows lanza
  «automatico», que lee todas las carpetas y hace solo lo seguro. No toca una carpeta que no
  se pueda leer entera ni una cuyo plan mueva más de 3.000 archivos, y si el programa ya está
  abierto se lo pide a él. Desinstalar quita la tarea.

De paso: una ruta que empieza por guion bajo ya no pierde el guion al ir a la papelera.

Pruebas: prueba_orden.py 24, probar.py 19, prueba_funcional.py 29, prueba_ventana.py 18
desde el código y 17 con el paquete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 17:05:57 -05:00

317 líneas
16 KiB
Python

# -*- 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)
- firma RSA (firma.py) de versión+archivo+sha256+tamaño que cuadre con CLAVE_PUBLICA; sin ella no se instala
`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 firma
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", False) # apagado salvo que alguien lo encienda en Ajustes
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)))
# clave pública de firma.py (la privada está en ~/.labstream/organizador-firma.json, solo la usa empaquetar.py)
CLAVE_PUBLICA = {"e": 65537, "n": 24937087096150856258549531047458881410156319770314378325709810834727102787144078753904837107336360100277476122470417827694103702029600632424402930901393600571590411504459673905624547394933513534401412673838815719634257887193733377640612491708305947101493071063533297310409777772800911393696346540036769491297235656643827279260544972034028016485933509783117840560074417501599224256143123720254264555615803987537224119534643367425486525306755766385726396897033934795259582227391903282650010470084464660818450797096588538639818043343189296266160938215570507865913766129394056803324311835125960516203968300455854004216307}
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"
f = m.get("firma") or {}
if not isinstance(f, dict) or not firma.verificar(firma.texto_manifiesto(m), str(f.get("valor") or ""), CLAVE_PUBLICA):
return None, "el manifiesto no está firmado con la clave de Labstream (o se modificó); no se instala nada"
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"
if getattr(O, "TRAER", {}).get("estado") in ("copiando", "ordenando"):
return "se está trayendo material de una tarjeta"
if getattr(O, "AUTO", {}).get("activo"):
return "el orden automático está en marcha"
return ""
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 en el paquete (OrganizadorLabstream.exe); desde el código se actualiza con git"
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