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

@@ -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():
return
# 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):
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
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: