1.9.0: convertidor, análisis en paralelo y respaldo
- analizar(): 8 hilos, se puede detener, informa de los ilegibles. ffprobe es sobre todo espera de red (0,23 s por clip en el NAS). - Los formatos pesan de verdad distinto (cq_mas por formato): archivo 14,3 MB, ligero 8,2 MB, H.264 18,3 MB sobre el mismo clip. - ProRes sin -qscale: el perfil vuelve a gobernar la tasa. - motores(): se prueba el croma 4:2:2 de verdad; si la GPU no puede, va por CPU. - Audio multipista: -c:a copy solo con una pista AAC (nueva columna pistas_audio). - Raíces anidadas rechazadas al registrarlas. - Respaldo: incremental contra el archivo de origen, e índice acumulativo. - Interfaz: las casillas del plan no repintan la lista ni pierden el foco; el buscador de Ajustes conserva lo editado; la lista de stock no salta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
185
organizador.py
185
organizador.py
@@ -34,7 +34,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "1.8.1"
|
||||
VERSION = "1.9.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
|
||||
@@ -126,9 +126,11 @@ FORMATOS = {
|
||||
"h265_10bit": {"nombre": "MP4 H.265 10 bits · archivo (recomendado)", "ext": ".mp4", "codec": "hevc", "bits": 10, "sufijo": "_10bit",
|
||||
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 1.0, "que": "unas 10 veces más ligero que el original, sin perder calidad visible; el que usa el respaldo"},
|
||||
"h265_ligero": {"nombre": "MP4 H.265 8 bits · ligero para compartir", "ext": ".mp4", "codec": "hevc", "bits": 8, "sufijo": "_ligero",
|
||||
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "que": "para enviar por WhatsApp o Drive; pierde latitud de color"},
|
||||
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "cq_mas": 5,
|
||||
"que": "para enviar por WhatsApp o Drive; pierde latitud de color"},
|
||||
"h264": {"nombre": "MP4 H.264 8 bits · compatible con todo", "ext": ".mp4", "codec": "h264", "bits": 8, "sufijo": "_h264",
|
||||
"gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "que": "se abre en cualquier equipo o televisor; pesa más que H.265"},
|
||||
"gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "cq_mas": -2,
|
||||
"que": "se abre en cualquier equipo o televisor; pesa más que H.265"},
|
||||
"prores_proxy": {"nombre": "MOV ProRes Proxy · edición ligera", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_proxy",
|
||||
"cpu": "prores_ks", "prores": 0, "mbps_1080p30": 45, "que": "para editar suave en Resolve o Premiere; luego se reenlaza al original"},
|
||||
"prores_lt": {"nombre": "MOV ProRes 422 LT · edición", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_LT",
|
||||
@@ -145,7 +147,9 @@ def motores():
|
||||
if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600:
|
||||
return _MOTORES["datos"]
|
||||
ff = ruta_herramienta("ffmpeg")
|
||||
out = {"gpu": {"nombre": "", "hevc": False, "h264": False}, "cpu": {"libx265": False, "libx264": False, "prores_ks": False}, "formatos": {}, "ffmpeg": bool(ff)}
|
||||
out = {"gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False},
|
||||
"cpu": {"libx265": False, "libx264": False, "prores_ks": False},
|
||||
"formatos": {}, "chroma422": False, "ffmpeg": bool(ff)}
|
||||
disponibles = set()
|
||||
if ff:
|
||||
try:
|
||||
@@ -166,6 +170,16 @@ def motores():
|
||||
out["gpu"][k] = r.returncode == 0
|
||||
except Exception: # noqa
|
||||
pass
|
||||
if out["gpu"]["hevc"]:
|
||||
try:
|
||||
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
||||
"-i", "testsrc=size=256x144:rate=30", "-frames:v", "3",
|
||||
"-c:v", "hevc_nvenc", "-pix_fmt", "p210le", "-profile:v", "rext",
|
||||
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
|
||||
out["gpu"]["chroma422"] = r.returncode == 0
|
||||
except Exception: # noqa
|
||||
out["gpu"]["chroma422"] = False
|
||||
out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265"))
|
||||
try:
|
||||
r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA)
|
||||
out["gpu"]["nombre"] = (r.stdout or "").strip().splitlines()[0] if r.returncode == 0 and r.stdout.strip() else ""
|
||||
@@ -303,7 +317,9 @@ def init_db():
|
||||
);
|
||||
""")
|
||||
con.commit()
|
||||
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'"):
|
||||
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",
|
||||
"ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'",
|
||||
"ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1"):
|
||||
try:
|
||||
con.execute(alter)
|
||||
con.commit()
|
||||
@@ -487,8 +503,8 @@ def escanear(ruta):
|
||||
with con:
|
||||
# conservar el análisis de ffprobe de los archivos que no cambiaron
|
||||
previo = {r["ruta"]: r for r in con.execute(
|
||||
"SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, analizado "
|
||||
"FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
|
||||
"SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, "
|
||||
"pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
|
||||
con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,))
|
||||
con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,))
|
||||
con.executemany(
|
||||
@@ -498,8 +514,8 @@ def escanear(ruta):
|
||||
for f in filas:
|
||||
pv = previo.get(f[0])
|
||||
if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2:
|
||||
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?",
|
||||
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], f[0]))
|
||||
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
|
||||
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0]))
|
||||
cfilas = []
|
||||
for d, info in dirs.items():
|
||||
n, b, fechas, p, bs, nombre = info
|
||||
@@ -1616,28 +1632,65 @@ def ffprobe(ruta):
|
||||
"ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur,
|
||||
"kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0,
|
||||
"acodec": (a or {}).get("codec_name") or "",
|
||||
"pistas_audio": sum(1 for x in j.get("streams", []) if x.get("codec_type") == "audio"),
|
||||
}
|
||||
|
||||
|
||||
def analizar(raiz, min_mb):
|
||||
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()}
|
||||
HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red
|
||||
|
||||
|
||||
def analizar(raiz, min_mb, reintentar=False):
|
||||
"""Lee con ffprobe los clips que faltan (códec, duración, resolución). Va en paralelo porque cada
|
||||
lectura es sobre todo espera de red: medido, 0,23 s por clip; 15.423 clips en serie pasan de la hora
|
||||
y con ocho a la vez bajan a unos ocho minutos.
|
||||
|
||||
Con `reintentar` también vuelve sobre los que no se pudieron leer (analizado = 2), por si el problema
|
||||
era un ffprobe viejo o una unidad que estaba desconectada."""
|
||||
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time(),
|
||||
"cancelar": False, "ilegibles": 0}
|
||||
con = db()
|
||||
pend = con.execute("SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado = 0 AND es_proxy = 0 AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock')",
|
||||
(raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
estados = "(0, 2)" if reintentar else "(0)"
|
||||
pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado IN {estados} AND es_proxy = 0 "
|
||||
"AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') "
|
||||
"ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
prog["total"] = len(pend)
|
||||
try:
|
||||
for f in pend:
|
||||
info = ffprobe(f["ruta"])
|
||||
import concurrent.futures as cf
|
||||
lote = []
|
||||
|
||||
def guardar(lote):
|
||||
with con:
|
||||
if info:
|
||||
if f["ext"] == ".braw":
|
||||
info["codec"] = "braw"
|
||||
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?",
|
||||
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], f["ruta"]))
|
||||
else:
|
||||
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (f["ruta"],))
|
||||
prog["hechos"] += 1
|
||||
prog["estado"] = "listo"
|
||||
for ruta, info in lote:
|
||||
if info:
|
||||
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, "
|
||||
"kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
|
||||
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"],
|
||||
info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"],
|
||||
info.get("pistas_audio", 1), ruta))
|
||||
else:
|
||||
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (ruta,))
|
||||
|
||||
def leer(f):
|
||||
info = ffprobe(f["ruta"])
|
||||
if info and f["ext"] == ".braw":
|
||||
info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec
|
||||
return f["ruta"], info
|
||||
|
||||
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
|
||||
for ruta, info in pool.map(leer, pend):
|
||||
if prog["cancelar"]:
|
||||
break
|
||||
if info is None:
|
||||
prog["ilegibles"] += 1
|
||||
lote.append((ruta, info))
|
||||
prog["hechos"] += 1
|
||||
if len(lote) >= 50: # se guarda por tandas: una transacción por clip es mucho más lento
|
||||
guardar(lote)
|
||||
lote = []
|
||||
guardar(lote)
|
||||
prog["estado"] = "cancelado" if prog["cancelar"] else "listo"
|
||||
if prog["ilegibles"]:
|
||||
prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)"
|
||||
except Exception as ex: # noqa
|
||||
prog["estado"] = "error"
|
||||
prog["error"] = str(ex)
|
||||
@@ -1757,6 +1810,15 @@ def set_trabajo(id_, **kw):
|
||||
con.close()
|
||||
|
||||
|
||||
def formato_de(t):
|
||||
"""el formato de un trabajo, tolerando filas viejas sin columna"""
|
||||
try:
|
||||
f = t["formato"] if "formato" in t.keys() else None
|
||||
except AttributeError:
|
||||
f = t.get("formato")
|
||||
return f or "h265_10bit"
|
||||
|
||||
|
||||
def correr_ffmpeg(t):
|
||||
o = CONFIG["optimizar"]
|
||||
con = db()
|
||||
@@ -1766,11 +1828,21 @@ def correr_ffmpeg(t):
|
||||
fps = (f["fps"] if f else 30) or 30
|
||||
chroma = t["chroma"] or "420"
|
||||
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"]
|
||||
# el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir»
|
||||
# salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca)
|
||||
cq = max(14, min(38, round(cq + FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]).get("cq_mas", 0))))
|
||||
maxrate = int(t["kbps"] * 1.8)
|
||||
salida = t["salida"]
|
||||
formato = (t["formato"] if "formato" in t.keys() else None) or "h265_10bit"
|
||||
formato = formato_de(t)
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
motor = motores()["formatos"].get(formato)
|
||||
_m = motores()
|
||||
motor = _m["formatos"].get(formato)
|
||||
if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"):
|
||||
# la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU
|
||||
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
|
||||
if motor is None:
|
||||
set_trabajo(t["id"], estado="error", log="esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0")
|
||||
return
|
||||
if not motor:
|
||||
set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)")
|
||||
return
|
||||
@@ -1779,7 +1851,9 @@ def correr_ffmpeg(t):
|
||||
gop = str(int(round(fps * 2)))
|
||||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
|
||||
if fm["codec"] == "prores":
|
||||
cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0", "-qscale:v", "9"]
|
||||
# sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no
|
||||
# salía a la tasa de un HQ (que es justo lo que promete el formato)
|
||||
cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"]
|
||||
elif motor == "gpu":
|
||||
diez = fm["bits"] == 10
|
||||
cmd += ["-c:v", fm["gpu"],
|
||||
@@ -1806,9 +1880,10 @@ def correr_ffmpeg(t):
|
||||
cmd += ["-movflags", "+faststart"]
|
||||
if fm["codec"] == "prores":
|
||||
cmd += ["-c:a", "pcm_s16le"]
|
||||
elif f and f["acodec"] == "aac":
|
||||
elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1:
|
||||
cmd += ["-c:a", "copy"]
|
||||
else:
|
||||
# con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4
|
||||
cmd += ["-c:a", "aac", "-b:a", "320k"]
|
||||
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
|
||||
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
|
||||
@@ -2427,6 +2502,18 @@ def copiar_respaldo(raiz, plan):
|
||||
RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[],
|
||||
destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0)
|
||||
indice = []
|
||||
# un respaldo por lotes (solo lo de más de 6 meses, luego el resto) reescribía el índice con lo de la
|
||||
# última tanda y dejaba fuera todo lo anterior: se arranca de lo que ya había.
|
||||
previo = {}
|
||||
try:
|
||||
import csv as _csv
|
||||
with open(os.path.join(plan["base"], "INDICE.csv"), newline="", encoding="utf-8-sig") as fh:
|
||||
filas = list(_csv.reader(fh, delimiter=";"))
|
||||
for fila in filas[1:]:
|
||||
if len(fila) >= 5:
|
||||
previo[fila[0]] = tuple(fila)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
for it in plan["items"]:
|
||||
if RESPALDO["cancelar"]:
|
||||
RESPALDO["estado"] = "cancelado"
|
||||
@@ -2438,13 +2525,20 @@ def copiar_respaldo(raiz, plan):
|
||||
ya = False
|
||||
if os.path.exists(d):
|
||||
st = os.stat(d)
|
||||
ya = st.st_size == it["bytes"] and abs(st.st_mtime - it["mtime"]) < 2
|
||||
# contra el archivo de ORIGEN tal como está ahora, no contra lo que decía el último escaneo:
|
||||
# si se editó después de escanear, la copia vieja «coincidía» y no se volvía a copiar nunca
|
||||
try:
|
||||
so = os.stat(it["origen"])
|
||||
tam_o, mt_o = so.st_size, so.st_mtime
|
||||
except OSError:
|
||||
tam_o, mt_o = it["bytes"], it["mtime"]
|
||||
ya = st.st_size == tam_o and abs(st.st_mtime - mt_o) < 2
|
||||
if ya:
|
||||
RESPALDO["saltados"] += 1
|
||||
else:
|
||||
tmp = d + ".parcial"
|
||||
shutil.copy2(it["origen"], tmp)
|
||||
if os.path.getsize(tmp) != it["bytes"]:
|
||||
if os.path.getsize(tmp) != os.path.getsize(it["origen"]):
|
||||
raise OSError("tamaño distinto tras copiar")
|
||||
os.replace(tmp, d)
|
||||
RESPALDO["copiados"] += 1
|
||||
@@ -2456,15 +2550,19 @@ def copiar_respaldo(raiz, plan):
|
||||
try:
|
||||
os.makedirs(plan["base"], exist_ok=True)
|
||||
import csv
|
||||
# lo de esta tanda manda sobre lo que ya había; lo anterior se conserva
|
||||
for fila in indice:
|
||||
previo[fila[0]] = fila
|
||||
todo = sorted(previo.values(), key=lambda x: str(x[0]).lower())
|
||||
with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh:
|
||||
w = csv.writer(fh, delimiter=";")
|
||||
w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"])
|
||||
w.writerows(indice)
|
||||
w.writerows(todo)
|
||||
resumen = defaultdict(lambda: defaultdict(lambda: [0, 0]))
|
||||
for rel_d, rel_o, tipo, b, _ in indice:
|
||||
for rel_d, rel_o, tipo, b, _ in todo:
|
||||
carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else ""
|
||||
resumen[carpeta][tipo][0] += 1
|
||||
resumen[carpeta][tipo][1] += b
|
||||
resumen[carpeta][tipo][1] += int(b or 0)
|
||||
with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh:
|
||||
fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n"
|
||||
f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n")
|
||||
@@ -2517,6 +2615,18 @@ def elegir_carpeta():
|
||||
_ELIGIENDO.release()
|
||||
|
||||
|
||||
def _anidadas(raices):
|
||||
"""(externa, interna) si alguna carpeta de la lista está dentro de otra. El inventario va por raíz y
|
||||
`archivos.ruta` es única: al escanear la externa, las filas de la interna cambian de dueño y la interna
|
||||
se queda vacía sin decir nada (su respaldo copiaría cero archivos y terminaría «bien»)."""
|
||||
rutas = [os.path.normpath(r["ruta"]) for r in raices if r.get("ruta")]
|
||||
for a in rutas:
|
||||
for b in rutas:
|
||||
if a is not b and a.lower() != b.lower() and dentro_de(a, b):
|
||||
return a, b
|
||||
return None
|
||||
|
||||
|
||||
def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False):
|
||||
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).
|
||||
Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están
|
||||
@@ -2814,6 +2924,10 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
except ValueError as e:
|
||||
return self._json({"error": str(e)}, 409)
|
||||
if ruta == "/api/raices":
|
||||
choque = _anidadas(d.get("raices") or [])
|
||||
if choque:
|
||||
return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. "
|
||||
"Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400)
|
||||
CONFIG["raices"] = d["raices"]
|
||||
guardar_json(CONFIG_PATH, CONFIG)
|
||||
return self._json({"ok": True})
|
||||
@@ -2891,6 +3005,11 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"})
|
||||
asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True)
|
||||
return self._json({"lanzado": True})
|
||||
if ruta == "/api/optimizar/analizar/detener":
|
||||
a = ANALISIS.get(os.path.normpath(d["raiz"]))
|
||||
if a:
|
||||
a["cancelar"] = True
|
||||
return self._json({"ok": bool(a)})
|
||||
if ruta == "/api/optimizar/analizar":
|
||||
raiz = os.path.normpath(d["raiz"])
|
||||
p = ANALISIS.get(raiz)
|
||||
|
||||
Referencia en una nueva incidencia
Block a user