2.6.0: auditoría, 100 % local y 26 funciones verificadas

Sale a la red solo lo que se pide: la tipografía viene con el programa
(static/manrope.woff2) y el comprobador de versiones tiene interruptor en
Ajustes. Nueva prueba funcional (prueba_funcional.py) que levanta una copia
sobre un cliente de ensayo y ejercita las 26 funciones por su API, con una
conversión real: 26 de 26.

Fallos que sacó y quedan arreglados:
- Un renombrado solo de mayúsculas («10- octubre» → «10- Octubre») se
  descartaba en Plan.agregar y en mover; deshacer decía «sitio ocupado».
- Las carpetas que crea el plan no entraban en el inventario hasta el
  siguiente escaneo (_recontar_carpetas las da de alta y recuenta).
- Una fusión de carpetas cortaba en el primer archivo que tropezaba.
- «Viejo» era distinto en convertidor, stock y respaldo: ahora es_viejo.
- /api/actualizacion informaba versión vacía hasta la primera consulta.
- Filas de sueltos por carpeta de origen; mes del stock el más repetido;
  fuera tres rutas sin uso.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-24 20:57:41 -05:00
padre 9d4d1e9cc2
commit 1151320bf2
Se han modificado 7 ficheros con 645 adiciones y 32 borrados

Ver fichero

@@ -9,6 +9,7 @@ organizador.py ← servidor + inventario + plan + cola de conversión
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
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)
static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de este equipo
datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl
``` ```
@@ -521,6 +522,52 @@ Probado sobre un árbol de ensayo con un marzo de tres rodajes y un mayo de uno
en `03- Martes`, `11- Miércoles` y `19- Jueves`, mayo se queda sin carpeta de día, y aplicar y deshacer en `03- Martes`, `11- Miércoles` y `19- Jueves`, mayo se queda sin carpeta de día, y aplicar y deshacer
dejan el árbol exacto. dejan el árbol exacto.
2.6.0, auditoría: 100 % local y las 26 funciones verificadas una a una.
**Qué sale a la red.** Se buscó toda llamada externa en el código: había dos. La tipografía Manrope se
pedía a Google en cada arranque (y sin red la página esperaba a que fallara); ahora viene con el
programa (`static/manrope.woff2`, 25 KB, servida como `font/woff2`). Lo otro es el comprobador de
versiones, que consulta Labstream OS al arrancar: sigue igual pero con un interruptor en Ajustes
(«al arrancar, consultar si hay versión nueva»); apagado, el programa no habla con nada fuera de este
equipo. El botón «Buscar actualizaciones» funciona a mano en cualquier caso. Todo lo demás —inventario,
plan, conversión, respaldo, colores, mapa— es local.
**Verificación función por función.** `smoke.py` (en la carpeta de trabajo de la sesión) levanta una
copia del programa sobre un cliente de ensayo con de todo (cámara de dos días, exportados, proyectos,
música, stock de Envato viejo, basura, proxies, dos repetidos, un hueco, un mes en minúsculas y un clip
de video de verdad) y ejercita las 26 funciones por su API en el orden en que las usaría una persona,
incluida una conversión real con ffmpeg (H.265 10 bits por GPU), verificación, reemplazo y una copia de
respaldo con su índice. Resultado: 26 de 26. Por el camino salieron cinco fallos de verdad:
- **Un mes en minúsculas nunca se normalizaba.** «10- octubre» → «10- Octubre» es un renombrado que
solo cambia mayúsculas; en Windows los dos nombres son el mismo, y tanto `Plan.agregar` como `mover`
lo tomaban por «ya está en su sitio» y lo descartaban sin decir nada. El plan prometía «meses con
nombre completo» que nunca ocurrían. Ahora se propone, se aplica (`os.rename` sí sabe) y `deshacer` lo
devuelve (antes decía «su sitio original ya está ocupado», porque `exists()` también miente ahí).
- **Las carpetas que crea el plan no entraban en el inventario** hasta el siguiente escaneo
(`00_Grabacion`, `_Para borrar\basura`…): «existen», las carpetas vacías y los colores no las veían.
`_recontar_carpetas` las da de alta tras cada movimiento y recuenta las tocadas, con totales de
subárbol como los del escaneo; las que se quedaron sin nada y desaparecieron se dan de baja.
- **Una fusión de carpetas se quedaba a medias.** Si al fusionar «Grabacion mañana» sobre
`00_Grabacion/SONY` un archivo tropezaba (ya existía, estaba abierto), el bucle cortaba ahí, los
demás no se intentaban y el aviso hablaba de uno solo. Ahora cada hijo va por su cuenta y el aviso
cuenta cuántos se quedaron.
- **«Viejo» significaba tres cosas.** El convertidor miraba la fecha de modificación con meses de 30,4
días; el stock y el respaldo, el mes de la pieza (o la fecha del archivo) por meses de calendario.
Un clip de un rodaje de 2024 re-exportado ayer no era «viejo» para convertir. Ahora la regla es una
(`es_viejo` + `limite_meses`); con 0 meses no se filtra por edad.
- **`/api/actualizacion` decía versión vacía** hasta la primera consulta: `ESTADO` del actualizador trae
`version: ""` y pisaba la real al mezclar los diccionarios.
Y tres menores: cada fila de archivos sueltos era de una sola carpeta de origen (antes juntaba varias y
ponía la del primero), el mes de un grupo de stock es el más repetido (no el del primer archivo), y se
quitan tres rutas que nadie llamaba (`/api/diagnostico`, `/api/asistente/estado`, `/api/tour-visto`).
Lo que se miró y está bien: los 52 endpoints que usa la interfaz existen, todos los botones tienen su
manejador, la barra de tareas refleja escaneo, lectura de clips, cola, asistente, respaldo y descarga
de versión con su tiempo estimado, y la estimación de ProRes (por tasa nominal) y la copia de audio
(`-c:a copy` solo con AAC de una pista) resisten los casos del ensayo.
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

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "2.5.0", "version": "2.6.0",
"builtAt": "2026-09-17T04:03:44.000Z", "builtAt": "2026-09-25T01:54:23.000Z",
"archivo": "OrganizadorLabstream-portatil-2.5.0.zip", "archivo": "OrganizadorLabstream-portatil-2.6.0.zip",
"size": 198736121, "size": 198765260,
"sha256": "ef8054268fafbf35dc4e7336160c59195b382fe1f7e1cf9b6b1db6b852791319", "sha256": "67efa6f70d9c13a6dc98251243af5802d65c912e456b45184adb967983604e44",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-2.5.0.zip", "archivo": "OrganizadorLabstream-app-2.6.0.zip",
"size": 14916362, "size": 14945501,
"sha256": "2d0bf45795699476bcf94ccb6f0633d726320e562c9594e0be62d85ae1648941", "sha256": "aca84360a8b7918926c0cc47253407013ac3e142f93ba744f66d58173eb4df32",
"url": "/api/herramientas/OrganizadorLabstream-app-2.5.0.zip" "url": "/api/herramientas/OrganizadorLabstream-app-2.6.0.zip"
}, },
"notes": "", "notes": "",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "4b0649e34b9be12b93f46ca15dfd1ef1e0a87365c45fdebf80a3ebe94b6bc1714f438f6c141d3456f0a41b6ce000444229bc1d4c1f2a2ce3b19dc1f21a36de1c7b2657f342906f2d6cc61eaafdfc7c05f2353778abc09dd74c4b4134974aafcf162b2ceac48e1b14536db8ec58990f6aadfaed7baa4d9a30f39e2ca90ebe8c2755b8026c4ff217c8ebc5b61933a657dec61ba1598089f595639b36e4dc7083597002e70317463714a19c889bae2f926ab9dfd6c1f36c45180608c968886ea2881dfb9d29f42b621e58eb583355e634670ab9424f3446092ae636716d86ebdb42d69e10d4fd24db2580acd4e2a01b14b6f4ba401a26d14d779310527c2d7d087f" "valor": "3b71582c420ab85f2650a008caa6e5977613bc836e45ec0240bd44228a06f111f8ff4d410ad903e65fbb2b654343ddf0260d7066d25655e49ef436e9077a392173978acd00617fbb675a317b9e055986777b5c4fd222fe4841df15b2dcef0aca409f39dc4416f59e747c7119577ea742a1cbd369206af33cd0e477875aafce48fff1ec1216d32d4547db50987bfb7ecb0f290c7154c1dbc5361a791cc21d6f9b65f5b05df3fcf8e8ba41f62e8efe0e584570ed3a6773e08363e460b46f46a138f20cdfb3ad21f13c2fad09bee5f18066390e8129f308ff477b82868b8b8d83e2c08a5a901a4a9fd355a5a8ba5526d24c4071222bf86b1dbfcd33ccc63adc6c7c"
} }
} }

Ver fichero

@@ -37,7 +37,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "2.5.0" VERSION = "2.6.0"
CONGELADO = getattr(sys, "frozen", False) CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
@@ -713,7 +713,12 @@ class Plan:
marcado=None, kw_destinos=None): marcado=None, kw_destinos=None):
destino = os.path.normpath(destino) destino = os.path.normpath(destino)
if archivos is None and os.path.normpath(origen).lower() == destino.lower(): if archivos is None and os.path.normpath(origen).lower() == destino.lower():
# el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» ->
# «10- Octubre»); antes se descartaba también ese, y el plan prometía meses «con nombre
# completo» que nunca se normalizaban
if accion != "renombrar" or os.path.normpath(origen) == destino:
return return
solo_mayusculas = os.path.normpath(origen).lower() == destino.lower()
if n is None or bytes_ is None: if n is None or bytes_ is None:
if archivos is not None: if archivos is not None:
n = len(archivos) n = len(archivos)
@@ -737,7 +742,7 @@ class Plan:
elif archivos is None and accion != "renombrar" and os.path.isfile(destino): elif archivos is None and accion != "renombrar" and os.path.isfile(destino):
aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá" aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá"
confianza, marcado = "baja", False confianza, marcado = "baja", False
elif archivos is None and os.path.isdir(destino) and os.path.isdir(origen): elif archivos is None and not solo_mayusculas and os.path.isdir(destino) and os.path.isdir(origen):
aviso_colision = " · la carpeta destino ya existe: se fusionan" aviso_colision = " · la carpeta destino ya existe: se fusionan"
self.filas.append({ self.filas.append({
"id": len(self.filas) + 1, "id": len(self.filas) + 1,
@@ -761,11 +766,11 @@ class Plan:
}) })
def suelto(self, fila, cubeta, destino_rel, motivo, confianza): def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
clave = (cubeta, destino_rel, motivo, confianza) clave = (cubeta, destino_rel, motivo, confianza, os.path.normcase(os.path.dirname(fila["ruta"])))
self.sueltos[clave].append((fila["ruta"], fila["bytes"])) self.sueltos[clave].append((fila["ruta"], fila["bytes"]))
def volcar_sueltos(self): def volcar_sueltos(self):
for (cubeta, destino_rel, motivo, confianza), archivos in self.sueltos.items(): for (cubeta, destino_rel, motivo, confianza, _dir), archivos in self.sueltos.items():
origen = os.path.dirname(archivos[0][0]) origen = os.path.dirname(archivos[0][0])
self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos) self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos)
self.sueltos.clear() self.sueltos.clear()
@@ -1238,16 +1243,35 @@ def mover(origen, destino, lote, movidos):
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona""" """mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
origen = os.path.normpath(origen) origen = os.path.normpath(origen)
destino = os.path.normpath(destino) destino = os.path.normpath(destino)
if origen == destino:
return
if origen.lower() == destino.lower(): if origen.lower() == destino.lower():
# solo cambian mayúsculas («10- octubre» -> «10- Octubre»). En Windows es el mismo nombre, así
# que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una
# carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que
# nunca ocurría: os.rename sí sabe hacerlo en NTFS.
os.rename(origen, destino)
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino))
return return
if os.path.isdir(origen) and os.path.isdir(destino): if os.path.isdir(origen) and os.path.isdir(destino):
# fusión: cada hijo va por su cuenta. Si uno tropieza (ya existe con ese nombre, está abierto),
# los demás siguen; antes el primero que fallaba cortaba el bucle y la carpeta quedaba medio
# vaciada, con el aviso hablando de un solo archivo.
fallos = []
for nombre in os.listdir(origen): for nombre in os.listdir(origen):
try:
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos) mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
except OSError as ex:
fallos.append(f"{nombre}: {ex}")
try: try:
os.rmdir(origen) os.rmdir(origen)
bitacora({"lote": lote, "op": "rmdir", "de": origen}) bitacora({"lote": lote, "op": "rmdir", "de": origen})
except OSError: except OSError:
pass pass
if fallos:
raise OSError(f"{len(fallos)} de «{os.path.basename(origen)}» se quedaron: " + "; ".join(fallos[:3])
+ (f"; y {len(fallos) - 3} más" if len(fallos) > 3 else ""))
return return
os.makedirs(os.path.dirname(destino), exist_ok=True) os.makedirs(os.path.dirname(destino), exist_ok=True)
if os.path.exists(destino): if os.path.exists(destino):
@@ -1371,9 +1395,57 @@ def actualizar_inventario(movidos):
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"], (fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"], fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
fila["es_proxy"], fila["es_basura"])) fila["es_proxy"], fila["es_basura"]))
_recontar_carpetas(con, movidos)
con.close() con.close()
def _recontar_carpetas(con, movidos):
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del
escaneo."""
tocadas = set()
for de, a in movidos:
for ruta in (os.path.dirname(de), os.path.dirname(a), a if os.path.isdir(a) else None):
if not ruta:
continue
raiz = _raiz_de(ruta)
if not raiz:
continue
d = os.path.normpath(ruta)
while d.lower().startswith(raiz.lower()) and d.lower() != raiz.lower():
tocadas.add((d, raiz))
padre = os.path.dirname(d)
if padre == d:
break
d = padre
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
for d, raiz in tocadas:
if not os.path.isdir(d):
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
continue
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
(d, esc_like(d + os.sep) + "%")).fetchone()
previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone()
en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(d, raiz, os.path.basename(d), fila["n"], fila["b"],
previa["anio_moda"] if previa else None, previa["mes_moda"] if previa else None,
previa["frac_moda"] if previa else 0, previa["dias_rango"] if previa else 0,
previa["es_proxy"] if previa else (1 if R.es_proxy_dir(os.path.basename(d)) else 0),
1 if en_papelera else (previa["es_basura"] if previa else 0)))
def _raiz_de(ruta):
"""La carpeta de cliente registrada a la que pertenece una ruta, o None."""
r = os.path.normpath(ruta).lower()
for c in CONFIG.get("raices", []):
k = os.path.normpath(c["ruta"]).lower()
if r == k or r.startswith(k + os.sep):
return os.path.normpath(c["ruta"])
return None
def quitar_vacias(ruta, tope): def quitar_vacias(ruta, tope):
"""borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)""" """borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)"""
ruta = os.path.normpath(ruta) ruta = os.path.normpath(ruta)
@@ -1497,6 +1569,10 @@ def deshacer(lote):
else: else:
if not os.path.exists(e["a"]): if not os.path.exists(e["a"]):
errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve") errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve")
elif e["a"].lower() == e["de"].lower():
os.rename(e["a"], e["de"]) # solo cambiaban las mayúsculas: exists() diría que está ocupado
movidos.append((e["a"], e["de"]))
deshechos += 1
elif os.path.exists(e["de"]): elif os.path.exists(e["de"]):
errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve") errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve")
else: else:
@@ -1918,7 +1994,7 @@ def estimar_bytes(f, perfil, formato):
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"): def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
con = db() con = db()
limite = time.time() - int(meses) * 30.4 * 86400 tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 " filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
"AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall() "AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
@@ -1940,7 +2016,7 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
motivo_no = "ya está en HEVC/AV1 eficiente" motivo_no = "ya está en HEVC/AV1 eficiente"
elif motor == "resolve" and formato != "h265_10bit": elif motor == "resolve" and formato != "h265_10bit":
motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits" motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits"
elif f["mtime"] > limite: elif tope and not es_viejo(f, tope):
motivo_no = f"tiene menos de {meses} meses" motivo_no = f"tiene menos de {meses} meses"
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados: elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
motivo_no = "es un exportado / master" motivo_no = "es un exportado / master"
@@ -2432,12 +2508,15 @@ def stock_viejo(raiz, meses, tipos):
if not es_viejo(f, tope): if not es_viejo(f, tope):
continue continue
rel = os.path.relpath(f["dir"], raiz) rel = os.path.relpath(f["dir"], raiz)
g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "mes": f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]}) g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "meses": Counter()})
g["meses"][f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]] += 1
g["n"] += 1 g["n"] += 1
g["bytes"] += f["bytes"] g["bytes"] += f["bytes"]
g["rutas"].append(f["ruta"]) g["rutas"].append(f["ruta"])
if len(g["ejemplos"]) < 3: if len(g["ejemplos"]) < 3:
g["ejemplos"].append(f["nombre"]) g["ejemplos"].append(f["nombre"])
for g in grupos.values():
g["mes"] = g.pop("meses").most_common(1)[0][0]
out = sorted(grupos.values(), key=lambda g: -g["bytes"]) out = sorted(grupos.values(), key=lambda g: -g["bytes"])
return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"} return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"}
@@ -3702,7 +3781,8 @@ class Manejador(BaseHTTPRequestHandler):
p = os.path.join(BASE, "static", ruta[len("/static/"):]) p = os.path.join(BASE, "static", ruta[len("/static/"):])
if not os.path.isfile(p): if not os.path.isfile(p):
return self._json({"error": "no existe"}, 404) return self._json({"error": "no existe"}, 404)
tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css", ".js": "text/javascript"} tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css",
".js": "text/javascript", ".woff2": "font/woff2"}
with open(p, "rb") as f: with open(p, "rb") as f:
cuerpo = f.read() cuerpo = f.read()
self.send_response(200) self.send_response(200)
@@ -3724,11 +3804,7 @@ class Manejador(BaseHTTPRequestHandler):
if ruta == "/api/mapa": if ruta == "/api/mapa":
return self._json(mapa(q["raiz"])) return self._json(mapa(q["raiz"]))
if ruta == "/api/actualizacion": if ruta == "/api/actualizacion":
return self._json({"version": VERSION, **actualizador.ESTADO}) return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
if ruta == "/api/diagnostico":
return self._json(asistente.diagnostico(q["raiz"]))
if ruta == "/api/asistente/estado":
return self._json(asistente.ESTADO)
if ruta == "/api/asistente/pasos": if ruta == "/api/asistente/pasos":
raiz = os.path.normpath(q["raiz"]) raiz = os.path.normpath(q["raiz"])
r = raiz_cfg(raiz) r = raiz_cfg(raiz)
@@ -3861,7 +3937,8 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"ok": True}) return self._json({"ok": True})
if ruta == "/api/config": if ruta == "/api/config":
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json # solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}} permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"},
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
for k, v in d.items(): for k, v in d.items():
if k in permitidas and isinstance(v, dict): if k in permitidas and isinstance(v, dict):
CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]}) CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})
@@ -3916,14 +3993,10 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(deshacer(d["lote"])) return self._json(deshacer(d["lote"]))
if ruta == "/api/actualizacion/buscar": if ruta == "/api/actualizacion/buscar":
actualizador.comprobar() actualizador.comprobar()
return self._json({"version": VERSION, **actualizador.ESTADO}) return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
if ruta == "/api/actualizacion/instalar": if ruta == "/api/actualizacion/instalar":
ok = actualizador.descargar_y_aplicar(en_hilo=True) ok = actualizador.descargar_y_aplicar(en_hilo=True)
return self._json({"lanzado": bool(ok), **actualizador.ESTADO}) return self._json({"lanzado": bool(ok), **actualizador.ESTADO})
if ruta == "/api/tour-visto":
CONFIG["tour_visto"] = True
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
if ruta == "/api/acciones/hacer": if ruta == "/api/acciones/hacer":
# «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la # «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la
# lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático: # lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático:

Ver fichero

@@ -221,6 +221,51 @@ class Servidor(unittest.TestCase):
c, res = self.post("/api/deshacer", {"lote": res["lote"]}) c, res = self.post("/api/deshacer", {"lote": res["lote"]})
self.assertEqual(c, 200) self.assertEqual(c, 200)
self.assertEqual(antes, self._arbol()) self.assertEqual(antes, self._arbol())
# 4a. un mes escrito en minúsculas se normaliza de verdad (renombrado solo de mayúsculas), la
# carpeta nueva entra en el inventario sin volver a escanear, y deshacer lo devuelve
os.makedirs(os.path.join(self.raiz, "2026", "10- octubre"), exist_ok=True)
with open(os.path.join(self.raiz, "2026", "10- octubre", "Final.mp4"), "wb") as fh:
fh.write(os.urandom(4096))
self.post("/api/escanear", {"raiz": self.raiz})
self.esperar_escaneo()
pl = self.get("/api/plan" + q)
ren = [f for f in pl["filas"] if f["accion"] == "renombrar" and f["destino"].endswith("10- Octubre")]
self.assertTrue(ren, "el plan no propone normalizar «10- octubre»")
c, res = self.post("/api/aplicar", {"raiz": self.raiz, "ids": [f["id"] for f in ren]})
self.assertEqual(c, 200)
self.assertEqual(res["errores"], [])
self.assertIn("10- Octubre", os.listdir(os.path.join(self.raiz, "2026")))
ex = self.get("/api/estructura/existen?raiz=" + urllib.parse.quote(self.raiz)
+ "&ruta=" + urllib.parse.quote(json.dumps(["{anio}", "{mes}"])))
self.assertTrue(any(x.endswith("10- Octubre") for x in ex["carpetas"]), "la carpeta renombrada no está en el inventario")
c, _ = self.post("/api/deshacer", {"lote": res["lote"]})
self.assertIn("10- octubre", os.listdir(os.path.join(self.raiz, "2026")))
shutil.rmtree(os.path.join(self.raiz, "2026"), ignore_errors=True)
# 4a-bis. dos copias idénticas se ven como repetidas y una se aparta; dos con el mismo nombre y
# peso pero distinto contenido NO se tocan
os.makedirs(os.path.join(self.raiz, "Dup A"), exist_ok=True)
os.makedirs(os.path.join(self.raiz, "Dup B"), exist_ok=True)
cuerpo = os.urandom(4096) * 1500 # 6 MB: por encima del umbral
for d in ("Dup A", "Dup B"):
with open(os.path.join(self.raiz, d, "igual.mp4"), "wb") as fh:
fh.write(cuerpo)
with open(os.path.join(self.raiz, "Dup A", "parecido.mp4"), "wb") as fh:
fh.write(os.urandom(4096) * 1500)
with open(os.path.join(self.raiz, "Dup B", "parecido.mp4"), "wb") as fh:
fh.write(os.urandom(4096) * 1500)
self.post("/api/escanear", {"raiz": self.raiz})
self.esperar_escaneo()
dup = self.get("/api/duplicados" + q)
self.assertEqual(len(dup["grupos"]), 2)
c, ap = self.post("/api/duplicados/apartar", {"raiz": self.raiz})
self.assertEqual(ap["movimientos"], 1) # solo la idéntica
self.assertEqual(ap["distintos"], 1) # la parecida se deja, y se avisa
shutil.rmtree(os.path.join(self.raiz, "Dup A"), ignore_errors=True)
shutil.rmtree(os.path.join(self.raiz, "Dup B"), ignore_errors=True)
self.post("/api/escanear", {"raiz": self.raiz})
self.esperar_escaneo()
# 4b. el mapa dice dónde va a acabar cada cosa, con las plantillas de verdad # 4b. el mapa dice dónde va a acabar cada cosa, con las plantillas de verdad
est = self.get("/api/estructura" + q) est = self.get("/api/estructura" + q)
proy = next(c for c in est["cubetas"] if c["clave"] == "PROYECTOS") proy = next(c for c in est["cubetas"] if c["clave"] == "PROYECTOS")

442
prueba_funcional.py Archivo normal
Ver fichero

@@ -0,0 +1,442 @@
# -*- coding: utf-8 -*-
"""Prueba funcional completa: levanta una copia del programa sobre un cliente de ensayo con de todo
(cámara, exportados, proyectos, música, stock de Envato, basura, proxies, repetidos, huecos, un mes mal
escrito y un clip de video de verdad) y ejercita TODAS las funciones por su API, en el mismo orden en
que las usaría una persona. Imprime una tabla: qué se probó, qué salió y en cuánto tiempo."""
import os
import sys
import json
import time
import shutil
import socket
import tempfile
import subprocess
import urllib.parse
import urllib.error
import urllib.request
AQUI = os.path.dirname(os.path.abspath(__file__))
TMP = tempfile.mkdtemp(prefix='organilab-smoke-')
APP = os.path.join(TMP, 'app')
os.makedirs(APP)
for f in ('organizador.py', 'reglas.py', 'asistente.py', 'actualizador.py', 'resolve_bridge.py', 'firma.py'):
shutil.copy(os.path.join(AQUI, f), APP)
shutil.copytree(os.path.join(AQUI, 'static'), os.path.join(APP, 'static'))
RAIZ = os.path.join(TMP, 'Cliente Smoke')
RESP = os.path.join(TMP, 'Respaldo')
FILAS = []
def prueba(nombre, fn):
t = time.time()
try:
det = fn()
FILAS.append((nombre, 'OK', '%.2f s' % (time.time() - t), det or ''))
except Exception as ex: # noqa
FILAS.append((nombre, 'FALLA', '%.2f s' % (time.time() - t), str(ex)[:160]))
def mk(rel, y, m, d, tam=4096):
p = os.path.join(RAIZ, rel.replace('/', os.sep))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, 'wb') as fh:
fh.write(os.urandom(4096) * max(1, tam // 4096))
t = time.mktime(time.struct_time((y, m, d, 12, 0, 0, 0, 0, -1)))
os.utime(p, (t, t))
return p
# ---------------------------------------------------------------- el cliente de ensayo
mk('2026/03- Marzo/Grabacion/A001_03051200_C001.braw', 2026, 3, 5, 3_000_000)
mk('2026/03- Marzo/Grabacion/A001_03051200_C002.braw', 2026, 3, 5, 3_000_000)
mk('2026/03- Marzo/Grabacion/C0010.MP4', 2026, 3, 12, 2_000_000) # sony, otro día
mk('2026/03- Marzo/Grabacion/C0010M01.XML', 2026, 3, 12, 500)
mk('2026/03- Marzo/Grabacion/Proxy/C0010.MOV', 2026, 3, 12, 100_000) # proxy
mk('2026/03- Marzo/Grabacion/Thumbs.db', 2026, 3, 12, 100) # basura
mk('2026/03- Marzo/01- Botox/Reel botox.mp4', 2026, 3, 20, 6_000_000)
mk('2026/03- Marzo/01- Botox/Reel botox.prproj', 2026, 3, 20, 50_000)
mk('2026/03- Marzo/01- Botox/Visual/Elements/clinic-2025-01-22-22-34-00-utc.mp4', 2026, 3, 20, 900_000) # stock viejo
mk('2026/10- octubre/Exportados/Final.mp4', 2026, 10, 2, 700_000) # mes en minúsculas
mk('Musica/tema.wav', 2026, 1, 1, 300_000)
mk('Sueltos/Portada.jpg', 2026, 2, 2, 60_000)
mk('Sueltos/Pieza.aep', 2026, 2, 2, 40_000)
shutil.copy2(os.path.join(RAIZ, '2026', '03- Marzo', '01- Botox', 'Reel botox.mp4'), os.path.join(RAIZ, 'Sueltos', 'Reel botox.mp4')) # repetido de verdad: mismo contenido
mk('Raro/algo.xyz', 2026, 2, 2, 10)
os.makedirs(os.path.join(RAIZ, 'Vacia', 'dentro'), exist_ok=True)
# un video de verdad para el convertidor (6 s, 1280x720, H.264)
FF = shutil.which('ffmpeg')
CLIP = os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'C0020.MP4')
if FF:
subprocess.run([FF, '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=duration=6:size=1280x720:rate=25',
'-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18',
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', CLIP], check=True)
t = time.mktime(time.struct_time((2025, 6, 10, 12, 0, 0, 0, 0, -1)))
os.utime(CLIP, (t, t))
# ---------------------------------------------------------------- el servidor
with socket.socket() as s_:
s_.bind(('127.0.0.1', 0))
PUERTO = s_.getsockname()[1]
env = dict(os.environ, ORGANIZADOR_PUERTO=str(PUERTO), PYTHONIOENCODING='utf-8')
log = open(os.path.join(TMP, 'servidor.log'), 'w', encoding='utf-8')
proc = subprocess.Popen([sys.executable, 'organizador.py', '--sin-navegador'], cwd=APP, env=env, stdout=log, stderr=subprocess.STDOUT)
BASE = f'http://127.0.0.1:{PUERTO}'
def get(p):
return json.loads(urllib.request.urlopen(BASE + p, timeout=60).read())
def post(p, d):
rq = urllib.request.Request(BASE + p, json.dumps(d).encode(), {'Content-Type': 'application/json', 'Origin': BASE})
try:
r = urllib.request.urlopen(rq, timeout=120)
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def q(**kw):
return '?' + urllib.parse.urlencode(kw)
for _ in range(80):
try:
urllib.request.urlopen(BASE + '/api/estado', timeout=1)
break
except Exception: # noqa
time.sleep(0.25)
else:
raise SystemExit('el servidor no arrancó')
def esperar(cond, seg=90):
fin = time.time() + seg
while time.time() < fin:
if cond():
return True
time.sleep(0.3)
return False
def raiz_estado():
return next((x for x in get('/api/estado')['raices'] if x['ruta'].lower() == RAIZ.lower()), None)
def arbol():
return sorted(os.path.relpath(os.path.join(b, f), RAIZ).lower() for b, _, fs in os.walk(RAIZ) for f in fs
if '.organilab' not in b.lower())
# ---------------------------------------------------------------- las pruebas, en orden de uso
def p_estatico():
html = urllib.request.urlopen(BASE + '/', timeout=10).read().decode('utf-8')
assert 'googleapis' not in html, 'la interfaz sigue pidiendo la fuente a Google'
r = urllib.request.urlopen(BASE + '/static/manrope.woff2', timeout=10)
assert r.headers.get('Content-Type') == 'font/woff2', r.headers.get('Content-Type')
assert r.read(4) == b'wOF2'
return 'sin nada externo; fuente local servida como font/woff2'
def p_registrar():
c, r = post('/api/raices', {'raices': [{'ruta': RAIZ, 'cliente': 'Cliente Smoke', 'tipo': 'contenido'}]})
assert c == 200, r
# anidada: se rechaza
c2, r2 = post('/api/raices', {'raices': [{'ruta': RAIZ, 'cliente': 'A', 'tipo': 'contenido'},
{'ruta': os.path.join(RAIZ, 'Sueltos'), 'cliente': 'B', 'tipo': 'contenido'}]})
assert c2 != 200 or r2.get('error'), 'aceptó una carpeta dentro de otra'
return 'registra, y rechaza una carpeta anidada'
def p_escanear():
c, r = post('/api/escanear', {'raiz': RAIZ})
assert c == 200 and r.get('lanzado'), r
assert esperar(lambda: (raiz_estado() or {}).get('escaneo') and not ((raiz_estado() or {}).get('progreso') or {}).get('estado') == 'corriendo')
e = raiz_estado()['escaneo']
esperados = len(arbol())
assert e['n'] == esperados, 'inventarió %d de %d' % (e['n'], esperados)
return '%d archivos inventariados, los mismos que hay en el disco' % e['n']
def p_carpeta():
e = get('/api/carpeta/estado' + q(raiz=RAIZ))
assert e.get('raiz') and 'escaneo' in e, e
a = get('/api/carpeta/acciones' + q(raiz=RAIZ))
claves = [x['clave'] for x in a['acciones']]
for k in ('limpiar', 'nombrar', 'fechas', 'resto', 'vacias', 'stock', 'repetidos', 'respaldar'):
assert k in claves, 'falta la acción ' + k
toca = [x['clave'] for x in a['acciones'] if x['estado'] == 'toca']
assert 'limpiar' in toca and 'vacias' in toca and 'repetidos' in toca and 'stock' in toca, toca
return 'lista de acciones completa; pendientes: ' + ', '.join(toca)
def p_que_hay():
m = get('/api/mapa' + q(raiz=RAIZ))
assert m['basura']['n'] >= 1 and m['proxies']['n'] >= 1, (m['basura'], m['proxies'])
assert m.get('arbol'), 'sin árbol de peso'
d = get('/api/discos')
assert isinstance(d, (list, dict))
return 'basura %d · proxies %d · %d ramas' % (m['basura']['n'], m['proxies']['n'], len(m['arbol']))
def p_nombrar():
v = get('/api/vocabulario' + q(raiz=RAIZ))
toks = {t['token']: t for t in v['tokens']}
assert 'raro' in toks and not toks['raro'].get('cubeta'), 'debería salir «Raro» como no reconocida'
c, r = post('/api/diccionario', {'token': 'raro', 'cubeta': 'DOCUMENTOS'})
assert c == 200, r
v2 = get('/api/vocabulario' + q(raiz=RAIZ))
assert {t['token']: t for t in v2['tokens']}['raro'].get('cubeta') == 'DOCUMENTOS'
c, r = post('/api/diccionario', {'token': 'raro', 'cubeta': None}) # y se olvida
return 'reconoce, aprende y olvida un nombre de carpeta'
PLAN = {}
def p_plan():
pl = get('/api/plan' + q(raiz=RAIZ))
PLAN.update(pl)
filas = pl['filas']
assert filas, 'plan vacío'
cub = {f['cubeta'] for f in filas}
for c in ('GRABACION', 'BASURA', 'PROXY'):
assert c in cub, 'el plan no propone nada de ' + c
mes = [f for f in filas if f['accion'] == 'renombrar' and '10- Octubre' in f['destino']]
assert mes, 'no propone normalizar «10- octubre»'
dias = {f['destino_rel'].split(os.sep)[3] for f in filas if f['cubeta'] == 'GRABACION' and f['destino_rel'].count(os.sep) >= 3}
assert any(d.startswith(('05-', '12-')) for d in dias), 'marzo tuvo dos rodajes y no sale carpeta de día: %s' % dias
return '%d filas · %d marcadas · reparte marzo por día de rodaje' % (len(filas), sum(1 for f in filas if f['marcado']))
LOTES = {}
def p_aplicar():
antes = arbol()
c, r = post('/api/aplicar', {'raiz': RAIZ, 'ids': [f['id'] for f in PLAN['filas'] if f['marcado']]})
assert c == 200, r
assert not r['errores'], r['errores']
assert r['movimientos'] > 0, 'no movió nada'
LOTES['orden'] = r['lote']
despues = arbol()
assert sorted(os.path.basename(x) for x in antes) == sorted(os.path.basename(x) for x in despues), 'se perdió algún archivo'
assert '10- Octubre' in os.listdir(os.path.join(RAIZ, '2026')), 'el mes sigue en minúsculas: %s' % os.listdir(os.path.join(RAIZ, '2026'))
assert not any('thumbs.db' in x and '_para borrar' not in x for x in despues), 'la basura sigue fuera'
return '%d movimientos, sin errores, «10- octubre» pasó a «10- Octubre»' % r['movimientos']
def p_deshacer():
c, r = post('/api/deshacer', {'lote': LOTES['orden']})
assert c == 200 and not r['errores'], r
assert '10- octubre' in os.listdir(os.path.join(RAIZ, '2026')), 'deshacer no devolvió las minúsculas: %s' % os.listdir(os.path.join(RAIZ, '2026'))
assert os.path.isfile(os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'Thumbs.db')), 'la basura no volvió'
return '%d movimientos deshechos' % r['deshechos']
def p_reaplicar():
pl = get('/api/plan' + q(raiz=RAIZ))
c, r = post('/api/aplicar', {'raiz': RAIZ, 'ids': [f['id'] for f in pl['filas'] if f['marcado']]})
assert c == 200 and not r['errores'], r
LOTES['orden2'] = r['lote']
return 'vuelve a aplicar sin errores'
def p_historial():
b = get('/api/bitacora' + q(raiz=RAIZ))
lotes = b.get('lotes') or b
assert any(LOTES['orden2'] in json.dumps(l) for l in lotes), 'el lote aplicado no sale en el historial'
p = get('/api/papelera' + q(raiz=RAIZ))
assert any(x['nombre'] == 'basura' and x['n'] >= 1 for x in p['subcarpetas']), p
return 'historial y _Para borrar reflejan lo hecho'
def p_repetidos():
d = get('/api/duplicados' + q(raiz=RAIZ))
assert d['grupos'], 'no ve el Reel botox repetido'
c, r = post('/api/duplicados/apartar', {'raiz': RAIZ})
assert c == 200 and r['movimientos'] >= 1, r
return '%d grupo(s), %d copia(s) apartada(s) a Duplicados' % (len(d['grupos']), r['movimientos'])
def p_stock():
s = get('/api/stock' + q(raiz=RAIZ, meses=3, tipos='Video'))
assert s['grupos'], 'no ve el stock de Envato viejo'
rutas = [x for g in s['grupos'] for x in g['rutas']]
c, r = post('/api/stock/papelera', {'raiz': RAIZ, 'rutas': rutas})
assert c == 200 and r['movimientos'] == len(rutas), r
return '%d archivo(s) de stock apartados' % r['movimientos']
def p_vacias():
v = get('/api/vacias' + q(raiz=RAIZ))
assert any(x['rel'] == 'Vacia' for x in v['carpetas']), v
c, r = post('/api/vacias/borrar', {'raiz': RAIZ, 'rutas': [x['ruta'] for x in v['carpetas']]})
assert c == 200 and r['borradas'] >= 2, r
c, d = post('/api/deshacer', {'lote': r['lote']})
assert os.path.isdir(os.path.join(RAIZ, 'Vacia', 'dentro')), 'deshacer no recreó la carpeta'
post('/api/vacias/borrar', {'raiz': RAIZ, 'rutas': [x['ruta'] for x in v['carpetas']]})
return '%d huecos borrados, deshechos y vueltos a borrar' % r['borradas']
def p_mapa():
e = get('/api/estructura' + q(raiz=RAIZ))
assert e['cubetas'] and e['arbol']['hijos'], 'mapa vacío'
a = get('/api/estructura/archivo' + q(nombre='Proyecto.aep', tipo='contenido'))
assert a['destino'].endswith('After Effects'), a
ex = get('/api/estructura/existen' + q(raiz=RAIZ, ruta=json.dumps(['{anio}', '{mes}', '00_Grabacion'])))
assert ex['n'] >= 1, ex
c, r = post('/api/estructura/renombrar', {'tipo': 'contenido', 'ruta': ['{anio}', '{mes}', '00_Grabacion'], 'nombre': 'Grabacion', 'raiz': RAIZ, 'en_disco': True})
assert c == 200 and r.get('ok') and r['movidas'] >= 1, r
c, r2 = post('/api/estructura/renombrar', {'tipo': 'contenido', 'ruta': ['{anio}', '{mes}', 'Grabacion'], 'nombre': '00_Grabacion', 'raiz': RAIZ, 'en_disco': True})
assert r2.get('ok'), r2
c, r3 = post('/api/plantillas', {'plantillas': {'contenido': {'MUSICA': '_Recursos/Musica/{sub}'}}})
assert c == 200
return 'árbol, dónde iría, existen, renombrar ida y vuelta, guardar plantilla'
def p_colores():
c, r = post('/api/colores/pintar', {'raiz': RAIZ})
assert c == 200, r
if r.get('error'):
return 'este disco no lo permite (' + r['error'][:50] + ')'
assert r['pintadas'] >= 3, r
c, qq = post('/api/colores/quitar', {'raiz': RAIZ})
assert qq['quitadas'] == r['pintadas'], (r, qq)
return '%d carpetas pintadas y despintadas' % r['pintadas']
IDS = {}
def p_motores():
m = get('/api/optimizar/motores')
assert m.get('ffmpeg'), 'sin ffmpeg'
disp = [k for k, v in m['formatos'].items() if v]
assert 'h265_10bit' in disp or 'h264' in disp, disp
return 'formatos disponibles: ' + ', '.join(disp) + ' · GPU: ' + (m['gpu'].get('nombre') or 'no')
def p_analizar():
post('/api/config', {'optimizar': {'meses_minimos': 0, 'min_mb': 0}})
c, r = post('/api/optimizar/analizar', {'raiz': RAIZ, 'min_mb': 0})
assert c == 200, r
assert esperar(lambda: ((raiz_estado() or {}).get('analisis') or {}).get('estado') not in (None, 'corriendo'), 120)
cand = get('/api/optimizar/candidatos' + q(raiz=RAIZ, meses=0, min_mb=0, formato='h265_10bit'))['candidatos']
clip = [x for x in cand if x['ruta'].lower().endswith('c0020.mp4')]
assert clip and not clip[0]['excluido'], (clip or cand)[:1]
IDS['ruta'] = clip[0]['ruta']
return 'leyó %d clip(s) · C0020.MP4 %s %s' % (len(cand), clip[0]['codec'], clip[0]['res'])
def p_convertir():
m = get('/api/optimizar/motores')
fmt = 'h265_10bit' if m['formatos'].get('h265_10bit') else 'h264'
c, r = post('/api/optimizar/encolar', {'raiz': RAIZ, 'rutas': [IDS['ruta']], 'perfil': 'equilibrado', 'formato': fmt, 'chroma': '420', 'color': 'rec709', 'iniciar': True})
assert c == 200 and r['ids'], r
IDS['trabajo'] = r['ids'][0]
assert esperar(lambda: not get('/api/estado')['cola']['activa'], 240), 'la cola no terminó'
cola = get('/api/optimizar/cola' + q(raiz=RAIZ))
t = next(x for x in cola['trabajos'] if x['id'] == IDS['trabajo'])
assert t['estado'] in ('listo', 'verificado'), (t['estado'], t.get('log', '')[-200:])
c, v = post('/api/optimizar/verificar', {'ids': [IDS['trabajo']]})
assert v['resultados'][0]['ok'], v
c, rr = post('/api/optimizar/reemplazar', {'ids': [IDS['trabajo']]})
assert rr['resultados'][0]['ok'], rr
assert os.path.isfile(t['salida']) and not os.path.isfile(IDS['ruta'])
return '%s: convertido (%s), verificado y reemplazado; original apartado' % (fmt, t['motor'])
def p_cola_control():
c, r = post('/api/optimizar/control', {'accion': 'quitar', 'ids': [IDS['trabajo']]})
assert c == 200
return 'control de cola responde'
def p_respaldo():
pl = get('/api/respaldo/plan' + q(raiz=RAIZ, destino=RESP, por_tipo=1, sin_stock=1, incluir_raw=0, meses=0))
assert pl.get('n', 0) > 0, pl
c, r = post('/api/respaldo/copiar', {'raiz': RAIZ})
assert c == 200, r
assert esperar(lambda: get('/api/respaldo/estado')['estado'] != 'corriendo', 120)
e = get('/api/respaldo/estado')
assert e['hechos'] == pl['n'], (e['hechos'], pl['n'])
assert os.path.isfile(os.path.join(RESP, 'Cliente Smoke', 'INDICE.csv')), 'falta INDICE.csv'
return '%d archivos copiados a %s · INDICE.csv' % (e['hechos'], os.path.basename(RESP))
def p_asistente():
pz = get('/api/asistente/pasos' + q(raiz=RAIZ))
assert 'pasos' in pz and 'diagnostico' in pz, list(pz)
c, r = post('/api/acciones/hacer', {'raiz': RAIZ})
assert c == 200, r
assert esperar(lambda: not get('/api/estado')['asistente'].get('activo'), 120)
a = get('/api/estado')['asistente']
assert not a.get('error'), a.get('error')
return 'pasos + «hacer las N de una vez» terminan sin error'
def p_soltar_y_buscar():
s = get('/api/soltado' + q(nombre='nada'))
assert s['ruta'] is None
b = get('/api/buscar-carpeta' + q(nombre='Cliente Smoke'))
assert isinstance(b['coincidencias'], list)
return 'puente de arrastre responde; búsqueda de carpeta responde'
def p_actualizacion_offline():
# con una URL imposible el programa no puede quedarse colgado ni romper nada
a = get('/api/actualizacion')
assert a['version']
c, r = post('/api/config', {'actualizaciones': {'auto': False}})
assert c == 200 and r['config']['actualizaciones']['auto'] is False, r
return 'estado legible; el interruptor de arranque se guarda'
def p_clientes_config():
e = get('/api/estado')
cl = dict(e['config']['clientes'])
cl['Cliente Smoke'] = {'tipo': 'contenido', 'alias': ['smoke']}
c, r = post('/api/clientes', {'clientes': cl})
assert c == 200
c, r = post('/api/regla', {'cliente': 'Cliente Smoke', 'origen': 'Sueltos', 'destino': '_Revisar/Sueltos', 'cubeta': 'REVISAR'})
assert c == 200
return 'clientes y reglas se guardan'
def p_quitar():
c, r = post('/api/raiz/quitar', {'raiz': RAIZ, 'datos': True})
assert c == 200, r
assert get('/api/estado')['raices'] == []
return 'desconectada con sus datos'
for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpeta', p_registrar), ('Escanear', p_escanear),
('Estado y lista de acciones', p_carpeta), ('Qué hay (mapa de peso, discos)', p_que_hay),
('Por nombrar (vocabulario, diccionario)', p_nombrar), ('Calcular el plan', p_plan),
('Aplicar el plan', p_aplicar), ('Deshacer', p_deshacer), ('Volver a aplicar', p_reaplicar),
('Historial y papelera', p_historial), ('Repetidos', p_repetidos), ('Stock viejo', p_stock),
('Carpetas vacías', p_vacias), ('Mapa interactivo', p_mapa), ('Colores en Windows', p_colores),
('Motores de conversión', p_motores), ('Leer clips (ffprobe)', p_analizar),
('Convertir, verificar, reemplazar', p_convertir), ('Control de la cola', p_cola_control),
('Respaldar', p_respaldo), ('Asistente / hacer de una vez', p_asistente),
('Arrastrar y buscar carpeta', p_soltar_y_buscar), ('Actualizaciones', p_actualizacion_offline),
('Clientes, reglas', p_clientes_config), ('Quitar de la lista', p_quitar)):
prueba(nombre, fn)
proc.kill()
proc.wait()
log.close()
print('%-40s %-6s %-8s %s' % ('FUNCIÓN', 'ESTADO', 'TIEMPO', 'DETALLE'))
print('-' * 120)
for n, e, t, d in FILAS:
print('%-40s %-6s %-8s %s' % (n, e, t, d))
malas = [f for f in FILAS if f[1] != 'OK']
print('-' * 120)
print('%d de %d funciones OK' % (len(FILAS) - len(malas), len(FILAS)))
if malas:
print()
print('== últimas líneas del servidor ==')
print(open(os.path.join(TMP, 'servidor.log'), encoding='utf-8', errors='replace').read()[-1500:])
shutil.rmtree(TMP, ignore_errors=True)

Ver fichero

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>OrganiLAB</title> <title>OrganiLAB</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f57d6'/%3E%3Crect x='7' y='8' width='18' height='4' rx='2' fill='%23fff'/%3E%3Crect x='7' y='14' width='13' height='4' rx='2' fill='%23fff' opacity='.85'/%3E%3Crect x='7' y='20' width='8' height='4' rx='2' fill='%23fff' opacity='.65'/%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f57d6'/%3E%3Crect x='7' y='8' width='18' height='4' rx='2' fill='%23fff'/%3E%3Crect x='7' y='14' width='13' height='4' rx='2' fill='%23fff' opacity='.85'/%3E%3Crect x='7' y='20' width='8' height='4' rx='2' fill='%23fff' opacity='.65'/%3E%3C/svg%3E">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"> <style>@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/static/manrope.woff2) format('woff2')}</style>
<style> <style>
:root{--bg:#ffffff;--panel:#ffffff;--panel2:#f3f4f5;--line:#e6e7e8;--line2:#f0f1f2;--tx:#17191c;--mut:#6d7076;--acc:#1f57d6;--acc2:#e8eefc;--ok:#2f8f5b;--ok2:#e6f2ea;--warn:#9a6d12;--warn2:#fbf1d9;--bad:#b3473e;--bad2:#f7e6e4} :root{--bg:#ffffff;--panel:#ffffff;--panel2:#f3f4f5;--line:#e6e7e8;--line2:#f0f1f2;--tx:#17191c;--mut:#6d7076;--acc:#1f57d6;--acc2:#e8eefc;--ok:#2f8f5b;--ok2:#e6f2ea;--warn:#9a6d12;--warn2:#fbf1d9;--bad:#b3473e;--bad2:#f7e6e4}
*{box-sizing:border-box} *{box-sizing:border-box}
@@ -534,6 +534,9 @@
<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>
<p class="ayuda">Un cliente puede tener material en varias carpetas de varios discos; aquí se dice cuáles son y con qué otros nombres aparece. <span class="q">?</span><span class="mas">Las carpetas registradas son las que la app escanea y ordena. Los otros nombres (alias) sirven para reconocer al cliente en carpetas de otros discos. Casi nunca hace falta tocar nada: al soltar una carpeta en la barra lateral se registra sola.</span></p> <p class="ayuda">Un cliente puede tener material en varias carpetas de varios discos; aquí se dice cuáles son y con qué otros nombres aparece. <span class="q">?</span><span class="mas">Las carpetas registradas son las que la app escanea y ordena. Los otros nombres (alias) sirven para reconocer al cliente en carpetas de otros discos. Casi nunca hace falta tocar nada: al soltar una carpeta en la barra lateral se registra sola.</span></p>
<div id="clientes"></div> <div id="clientes"></div>
<div class="box" style="margin-top:18px"><h3>Actualizaciones</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label>
</div>
<details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary> <details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary>
<div class="box" style="margin-top:10px"> <div class="box" style="margin-top:10px">
<div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div> <div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div>
@@ -1243,6 +1246,9 @@ $('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'
// ---------------- ajustes ---------------- // ---------------- ajustes ----------------
let AJ = null, CLI_ABIERTO = null; let AJ = null, CLI_ABIERTO = null;
function pintarAjustes(){ function pintarAjustes(){
const ac = $('#actAuto');
ac.checked = (ESTADO.config.actualizaciones || {}).auto !== false;
ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad'));
if (!AJ) { if (!AJ) {
const c = ESTADO.config; AJ = {clientes: []}; const c = ESTADO.config; AJ = {clientes: []};
Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)})); Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)}));

BIN
static/manrope.woff2 Archivo normal

Archivo binario no mostrado.