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:
47
README.md
47
README.md
@@ -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
|
||||
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/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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"nombre": "OrganiLAB",
|
||||
"version": "2.5.0",
|
||||
"builtAt": "2026-09-17T04:03:44.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-2.5.0.zip",
|
||||
"size": 198736121,
|
||||
"sha256": "ef8054268fafbf35dc4e7336160c59195b382fe1f7e1cf9b6b1db6b852791319",
|
||||
"version": "2.6.0",
|
||||
"builtAt": "2026-09-25T01:54:23.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-2.6.0.zip",
|
||||
"size": 198765260,
|
||||
"sha256": "67efa6f70d9c13a6dc98251243af5802d65c912e456b45184adb967983604e44",
|
||||
"app": {
|
||||
"archivo": "OrganizadorLabstream-app-2.5.0.zip",
|
||||
"size": 14916362,
|
||||
"sha256": "2d0bf45795699476bcf94ccb6f0633d726320e562c9594e0be62d85ae1648941",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-2.5.0.zip"
|
||||
"archivo": "OrganizadorLabstream-app-2.6.0.zip",
|
||||
"size": 14945501,
|
||||
"sha256": "aca84360a8b7918926c0cc47253407013ac3e142f93ba744f66d58173eb4df32",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-2.6.0.zip"
|
||||
},
|
||||
"notes": "",
|
||||
"firma": {
|
||||
"alg": "rsa2048-pkcs1v15-sha256",
|
||||
"valor": "4b0649e34b9be12b93f46ca15dfd1ef1e0a87365c45fdebf80a3ebe94b6bc1714f438f6c141d3456f0a41b6ce000444229bc1d4c1f2a2ce3b19dc1f21a36de1c7b2657f342906f2d6cc61eaafdfc7c05f2353778abc09dd74c4b4134974aafcf162b2ceac48e1b14536db8ec58990f6aadfaed7baa4d9a30f39e2ca90ebe8c2755b8026c4ff217c8ebc5b61933a657dec61ba1598089f595639b36e4dc7083597002e70317463714a19c889bae2f926ab9dfd6c1f36c45180608c968886ea2881dfb9d29f42b621e58eb583355e634670ab9424f3446092ae636716d86ebdb42d69e10d4fd24db2580acd4e2a01b14b6f4ba401a26d14d779310527c2d7d087f"
|
||||
"valor": "3b71582c420ab85f2650a008caa6e5977613bc836e45ec0240bd44228a06f111f8ff4d410ad903e65fbb2b654343ddf0260d7066d25655e49ef436e9077a392173978acd00617fbb675a317b9e055986777b5c4fd222fe4841df15b2dcef0aca409f39dc4416f59e747c7119577ea742a1cbd369206af33cd0e477875aafce48fff1ec1216d32d4547db50987bfb7ecb0f290c7154c1dbc5361a791cc21d6f9b65f5b05df3fcf8e8ba41f62e8efe0e584570ed3a6773e08363e460b46f46a138f20cdfb3ad21f13c2fad09bee5f18066390e8129f308ff477b82868b8b8d83e2c08a5a901a4a9fd355a5a8ba5526d24c4071222bf86b1dbfcd33ccc63adc6c7c"
|
||||
}
|
||||
}
|
||||
111
organizador.py
111
organizador.py
@@ -37,7 +37,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "2.5.0"
|
||||
VERSION = "2.6.0"
|
||||
|
||||
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
|
||||
@@ -713,7 +713,12 @@ class Plan:
|
||||
marcado=None, kw_destinos=None):
|
||||
destino = os.path.normpath(destino)
|
||||
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
|
||||
solo_mayusculas = os.path.normpath(origen).lower() == destino.lower()
|
||||
if n is None or bytes_ is None:
|
||||
if archivos is not None:
|
||||
n = len(archivos)
|
||||
@@ -737,7 +742,7 @@ class Plan:
|
||||
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á"
|
||||
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"
|
||||
self.filas.append({
|
||||
"id": len(self.filas) + 1,
|
||||
@@ -761,11 +766,11 @@ class Plan:
|
||||
})
|
||||
|
||||
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"]))
|
||||
|
||||
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])
|
||||
self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos)
|
||||
self.sueltos.clear()
|
||||
@@ -1238,16 +1243,35 @@ def mover(origen, destino, lote, movidos):
|
||||
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
|
||||
origen = os.path.normpath(origen)
|
||||
destino = os.path.normpath(destino)
|
||||
if origen == destino:
|
||||
return
|
||||
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
|
||||
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):
|
||||
try:
|
||||
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
|
||||
except OSError as ex:
|
||||
fallos.append(f"{nombre}: {ex}")
|
||||
try:
|
||||
os.rmdir(origen)
|
||||
bitacora({"lote": lote, "op": "rmdir", "de": origen})
|
||||
except OSError:
|
||||
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
|
||||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||||
if os.path.exists(destino):
|
||||
@@ -1371,9 +1395,57 @@ def actualizar_inventario(movidos):
|
||||
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
|
||||
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
|
||||
fila["es_proxy"], fila["es_basura"]))
|
||||
_recontar_carpetas(con, movidos)
|
||||
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):
|
||||
"""borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)"""
|
||||
ruta = os.path.normpath(ruta)
|
||||
@@ -1497,6 +1569,10 @@ def deshacer(lote):
|
||||
else:
|
||||
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")
|
||||
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"]):
|
||||
errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve")
|
||||
else:
|
||||
@@ -1918,7 +1994,7 @@ def estimar_bytes(f, perfil, formato):
|
||||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
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
|
||||
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()
|
||||
@@ -1940,7 +2016,7 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
motivo_no = "ya está en HEVC/AV1 eficiente"
|
||||
elif motor == "resolve" and formato != "h265_10bit":
|
||||
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"
|
||||
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
|
||||
motivo_no = "es un exportado / master"
|
||||
@@ -2432,12 +2508,15 @@ def stock_viejo(raiz, meses, tipos):
|
||||
if not es_viejo(f, tope):
|
||||
continue
|
||||
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["bytes"] += f["bytes"]
|
||||
g["rutas"].append(f["ruta"])
|
||||
if len(g["ejemplos"]) < 3:
|
||||
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"])
|
||||
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/"):])
|
||||
if not os.path.isfile(p):
|
||||
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:
|
||||
cuerpo = f.read()
|
||||
self.send_response(200)
|
||||
@@ -3724,11 +3804,7 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
if ruta == "/api/mapa":
|
||||
return self._json(mapa(q["raiz"]))
|
||||
if ruta == "/api/actualizacion":
|
||||
return self._json({"version": VERSION, **actualizador.ESTADO})
|
||||
if ruta == "/api/diagnostico":
|
||||
return self._json(asistente.diagnostico(q["raiz"]))
|
||||
if ruta == "/api/asistente/estado":
|
||||
return self._json(asistente.ESTADO)
|
||||
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
|
||||
if ruta == "/api/asistente/pasos":
|
||||
raiz = os.path.normpath(q["raiz"])
|
||||
r = raiz_cfg(raiz)
|
||||
@@ -3861,7 +3937,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"ok": True})
|
||||
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
|
||||
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():
|
||||
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]})
|
||||
@@ -3916,14 +3993,10 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json(deshacer(d["lote"]))
|
||||
if ruta == "/api/actualizacion/buscar":
|
||||
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":
|
||||
ok = actualizador.descargar_y_aplicar(en_hilo=True)
|
||||
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":
|
||||
# «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:
|
||||
|
||||
45
probar.py
45
probar.py
@@ -221,6 +221,51 @@ class Servidor(unittest.TestCase):
|
||||
c, res = self.post("/api/deshacer", {"lote": res["lote"]})
|
||||
self.assertEqual(c, 200)
|
||||
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
|
||||
est = self.get("/api/estructura" + q)
|
||||
proy = next(c for c in est["cubetas"] if c["clave"] == "PROYECTOS")
|
||||
|
||||
442
prueba_funcional.py
Archivo normal
442
prueba_funcional.py
Archivo normal
@@ -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)
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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="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>
|
||||
: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}
|
||||
@@ -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>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -1243,6 +1246,9 @@ $('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'
|
||||
// ---------------- ajustes ----------------
|
||||
let AJ = null, CLI_ABIERTO = null;
|
||||
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) {
|
||||
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)}));
|
||||
|
||||
BIN
static/manrope.woff2
Archivo normal
BIN
static/manrope.woff2
Archivo normal
Archivo binario no mostrado.
Referencia en una nueva incidencia
Block a user