Files
labstream-organizador/asistente.py
Labstream 0819f2da89 3.4.0: los BRAW se convierten sin Resolve, y lo que no compensa no se reconvierte
Munka no podía reconvertir archivos que antes sí. Auditado sobre la carpeta de Andrea Galeano:

- Los BRAW nunca habían salido: 87 clips (496 GB), tres tandas por el guion de Resolve y ni un
  clip convertido. Ahora los lee el decodificador de Blackmagic que instala Resolve (braw.py,
  en un proceso aparte), sin abrir Resolve, y ffmpeg los codifica como cualquier otro clip.
  Probado con clips del NAS: 15,6 GB → 394 MB con sus 7.004 cuadros, VMAF 96,0 contra el BRAW
  revelado sin pérdida. Las conversiones de BRAW que fallaron por Resolve pasan solas al lector.
- Lo que «antes podía» eran reconversiones de lo ya convertido: ahorraban un 5 % con pérdida.
  Siguen sin ofrecerse, y lo que ya viene comprimido de cámara solo se ofrece si compensa.
- La tarjeta codifica con «uhq»: entre un 19 y un 55 % menos de peso con la misma calidad
  medida, siempre H.265 de 10 bits.
- Una conversión que no aligera (más del 80 % del clip, o reconversión de una conversión) no
  reemplaza al original: queda «no compensa» y lo que salió va a _Para borrar\descartados.

De paso: los MP4 guardan las etiquetas de la cámara; un intento que no pasó la verificación se
puede reintentar; un H.264 de más de 4.096 px de lado va por CPU; el motivo de un fallo se lee
entero; el guion de Resolve llega con tildes y borra su ficha.

Pruebas: prueba_orden.py 33 (una con un BRAW de verdad), probar.py 19, prueba_funcional.py 30,
prueba_ventana.py 18 desde el código y 17 con el paquete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:24:34 -05:00

424 líneas
22 KiB
Python

# -*- coding: utf-8 -*-
"""
Asistente: al añadir una carpeta la escanea, da un diagnóstico corto por tipo de archivo y
pregunta, paso a paso, qué hacer con cada tipo. Sirve tanto a la consola (arrastrar la carpeta
sobre el .exe) como a la interfaz web (/api/diagnostico, /api/asistente).
`O` es el módulo organizador (lo fija él mismo al arrancar) para compartir CONFIG, base de datos,
Plan, aplicar, cola de conversión, etc. sin importarlo dos veces.
"""
import os
import sys
import time
import threading
O = None
# ---------------------------------------------------------------- diagnóstico
GRUPOS = [
# clave, título corto, qué es (una frase)
("camara_raw", "Clips BRAW (cámara URSA / Blackmagic)", "material original de cámara en 12 bits; lo que más pesa"),
("camara_video", "Clips de cámara MP4/MOV (Sony, Osmo, DJI, Atomos)", "material original de cámara, normalmente 10 bits"),
("audio", "Audio grabado (DJI Mic, Tascam) y música", "pistas de voz, ambiente y música"),
("celular", "Celular y WhatsApp", "videos y fotos del teléfono"),
("fotos", "Fotos de cámara", "JPG/RAW de cámara fotográfica"),
("stock_viejo", "Stock de Envato en proyectos viejos", "tomas descargadas de Envato de piezas ya entregadas"),
("stock_reciente", "Stock de Envato reciente", "tomas de Envato de piezas en curso"),
("proxies", "Proxies", "copias ligeras que Resolve/Premiere regeneran solas"),
("basura", "Basura de sistema y autoguardados", ".DS_Store, Thumbs.db, cachés, autosaves de After Effects"),
("exportados", "Exportados / masters", "videos finales renderizados"),
("video_otro", "Otros videos", "videos que no son de cámara ni exportados (descargas, referencias, IA)"),
("proyectos", "Proyectos de edición y diseño", ".drp, .prproj, .aep, .psd, .ai…"),
("imagenes", "Imágenes, diseño e IA", "PNG/JPG sueltos, capturas, imágenes generadas"),
("documentos", "Documentos", "guiones, PDF, hojas de cálculo"),
("otros", "Otros archivos", "todo lo demás"),
]
TITULO = {k: t for k, t, _ in GRUPOS}
QUE_ES = {k: q for k, _, q in GRUPOS}
def _grupo(f, limite_stock):
if f["es_basura"] or f["rol"] == "basura":
return "basura"
if f["es_proxy"] or f["rol"] == "proxy":
return "proxies"
if f["es_stock"] or f["rol"] == "stock":
return "stock_viejo" if f["mtime"] < limite_stock else "stock_reciente"
rol = f["rol"]
if rol == "camara_raw":
return "camara_raw"
if rol in ("camara_video", "camara_sidecar"):
return "camara_video"
if rol in ("audio", "audio_grabado"):
return "audio"
if rol in ("celular_video", "celular_foto"):
return "celular"
if rol == "foto_camara":
return "fotos"
if rol == "video":
return "exportados" if (f["contexto"] or "") == "EXPORTADOS" else "video_otro"
if rol in ("proyecto", "diseno", "fuente"):
return "proyectos"
if rol in ("imagen", "ia", "captura"):
return "imagenes"
if rol == "documento":
return "documentos"
return "otros"
def diagnostico(raiz):
"""Resumen corto y sencillo de lo que hay en la carpeta, por tipo de archivo."""
raiz = os.path.normpath(raiz)
con = O.db()
filas = con.execute("SELECT rol, bytes, mtime, es_basura, es_proxy, es_stock, contexto, fecha FROM archivos WHERE raiz = ?",
(raiz,)).fetchall()
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
con.close()
meses = int(O.CONFIG["optimizar"].get("meses_minimos", 3))
limite = time.time() - meses * 30.4 * 86400
acc = {k: {"clave": k, "titulo": TITULO[k], "que_es": QUE_ES[k], "n": 0, "bytes": 0, "desde": None, "hasta": None} for k, _, _ in GRUPOS}
total_b = 0
for f in filas:
g = acc[_grupo(f, limite)]
g["n"] += 1
g["bytes"] += f["bytes"] or 0
total_b += f["bytes"] or 0
fe = (f["fecha"] or "")[:7]
if fe:
g["desde"] = fe if not g["desde"] or fe < g["desde"] else g["desde"]
g["hasta"] = fe if not g["hasta"] or fe > g["hasta"] else g["hasta"]
grupos = [g for g in acc.values() if g["n"]]
grupos.sort(key=lambda g: -g["bytes"])
for g in grupos:
g["pct"] = round(g["bytes"] * 100 / total_b, 1) if total_b else 0
# frases de diagnóstico (lo importante, en una línea cada una)
frases = []
b = lambda k: acc[k]["bytes"] # noqa
if b("camara_raw") + b("camara_video"):
frases.append(f"El material de cámara ocupa {O._gb(b('camara_raw') + b('camara_video'))}"
f" ({round((b('camara_raw') + b('camara_video')) * 100 / total_b) if total_b else 0} % del total).")
if b("stock_viejo"):
frases.append(f"Hay {O._gb(b('stock_viejo'))} de stock de Envato en proyectos de hace más de {meses} meses: se puede borrar sin perder nada.")
if b("proxies"):
frases.append(f"Los proxies suman {O._gb(b('proxies'))}: se regeneran solos, no hace falta guardarlos.")
if acc["basura"]["n"]:
frases.append(f"{acc['basura']['n']:,} archivos de basura de sistema o autoguardado ({O._gb(b('basura'))}).")
dup = O.duplicados(raiz)
if dup["grupos"]:
frases.append(f"Hay {dup['n']} archivos repetidos ({O._gb(dup['bytes'])}) en {len(dup['grupos'])} grupos: se pueden juntar en subcarpetas Duplicados para revisarlos a mano.")
if not frases:
frases.append("Nada llamativo: la carpeta no tiene grandes bolsas de peso que recortar.")
return {"raiz": raiz, "n": len(filas), "bytes": total_b, "grupos": grupos, "frases": frases, "meses_stock": meses,
"duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"],
"por_comparar": dup.get("por_comparar", 0)},
"escaneo": dict(esc) if esc else None}
# ---------------------------------------------------------------- pasos del asistente
def pasos(diag, plan_filas):
"""Los pasos que se le preguntan al usuario, solo los que aplican a esta carpeta."""
g = {x["clave"]: x for x in diag["grupos"]}
por_cubeta = {}
for f in plan_filas:
c = por_cubeta.setdefault(f["cubeta"], {"n": 0, "bytes": 0, "filas": 0})
c["filas"] += 1
c["n"] += f["n"]
c["bytes"] += f["bytes"]
out = []
def paso(clave, titulo, resumen, opciones, defecto):
out.append({"clave": clave, "titulo": titulo, "resumen": resumen, "opciones": opciones, "defecto": defecto})
if "basura" in g or "proxies" in g:
partes = [f"{g[k]['n']:,} {TITULO[k].split(' ')[0].lower()} ({O._gb(g[k]['bytes'])})" for k in ("basura", "proxies") if k in g]
paso("limpieza", "Basura y proxies", " y ".join(partes),
[("papelera", "Apartarlos a _Para borrar (recomendado; nada se borra, se pueden recuperar)"), ("dejar", "Dejarlos donde están")], "papelera")
cam = [k for k in ("camara_raw", "camara_video", "audio", "celular", "fotos") if k in g]
if cam:
resumen = "; ".join(f"{TITULO[k]}: {g[k]['n']:,} archivos, {O._gb(g[k]['bytes'])}" for k in cam)
paso("material", "Material de cámara, audio, celular y fotos", resumen,
[("ordenar", "Ordenarlo por mes y por fuente en 00_Grabacion (recomendado)"), ("dejar", "No tocarlo")], "ordenar")
if "camara_raw" in g or "camara_video" in g:
h = O.herramientas()
aviso = ""
if "camara_raw" in g and not (h.get("braw") or {}).get("ok"):
aviso = " Para los BRAW hace falta el decodificador de Blackmagic, que se instala con DaVinci Resolve (vale el gratuito)."
paso("convertir", "Clips de cámara de proyectos ya entregados",
f"Convertir a MP4 H.265 de 10 bits los clips con más de {diag['meses_stock']} meses: pesan ~10 veces menos y conservan margen de color.{aviso}",
[("no", "Por ahora no (se puede hacer luego desde la pestaña Optimizar)"),
("si", "Sí, convertir y reemplazar los originales (los originales quedan en _Para borrar/originales)")], "no")
dup = diag.get("duplicados") or {}
if dup.get("grupos") or dup.get("por_comparar"):
det = []
if dup.get("identicos"):
det.append(f"{dup['identicos']} idénticos (mismo nombre y peso)")
if dup.get("versiones"):
det.append(f"{dup['versiones']} en dos formatos (MP4 y MOV del mismo clip)")
if dup.get("por_comparar"):
det.append(f"{dup['por_comparar']} clips que se llaman igual y pesan distinto (se comparan: si duran lo mismo, se queda el de más calidad)")
paso("duplicados", "Material repetido",
f"{dup['n']} copias en {dup['grupos']} grupos ({O._gb(dup['bytes'])}): {' y '.join(det)}. No se borra nada: cada copia se junta en una subcarpeta Duplicados al lado del que se queda, para revisarla a mano.",
[("apartar", "Juntarlas en Duplicados junto al original (recomendado)"), ("dejar", "Dejarlas donde están")], "apartar")
if "stock_viejo" in g:
paso("stock", "Stock de Envato viejo",
f"{g['stock_viejo']['n']:,} tomas de Envato ({O._gb(g['stock_viejo']['bytes'])}) en proyectos de hace más de {diag['meses_stock']} meses",
[("apartar", "Apartarlas a _Para borrar/stock (recomendado; luego una persona las borra a mano)"), ("dejar", "Dejarlas")], "apartar")
carpetas = {c: v for c, v in por_cubeta.items() if c not in ("BASURA", "PROXY", "GRABACION", "CELULAR", "FOTOS")}
if carpetas:
resumen = ", ".join(f"{c.lower()} {v['filas']}" for c, v in sorted(carpetas.items(), key=lambda x: -x[1]["bytes"]))
paso("carpetas", "Carpetas: meses, piezas, marca, exportados, proyectos",
f"{sum(v['filas'] for v in carpetas.values())} movimientos propuestos ({resumen})",
[("seguras", "Aplicar las de confianza alta y media (recomendado)"), ("alta", "Solo las de confianza alta"),
("todas", "Todas, incluidas las dudosas y los renombrados cosméticos"), ("dejar", "Ninguna")], "seguras")
paso("respaldo", "Respaldo compacto en otro disco",
"Copia organizada por carpeta y tipo, con los clips ya convertidos en vez de los originales",
[("no", "Ahora no"), ("si", f"Sí, a {O.CONFIG.get('respaldo', {}).get('destino', 'T:\\Archivo Labstream')}")], "no")
return out
def filas_elegidas(plan_filas, dec):
"""Traduce las decisiones a las filas del plan que se aplican."""
out = []
for f in plan_filas:
c = f["cubeta"]
if c in ("BASURA", "PROXY"):
if dec.get("limpieza", "papelera") == "papelera":
out.append(f)
elif c in ("GRABACION", "CELULAR", "FOTOS"):
# solo lo marcado: antes entraba todo el material aunque el plan lo diera por dudoso (el mes
# sacado de la fecha de modificación de un archivo que no es de cámara), y «hacer lo seguro»
# movía justo lo que el plan decía que había que mirar
if dec.get("material", "ordenar") == "ordenar" and f["marcado"]:
out.append(f)
else:
m = dec.get("carpetas", "seguras")
if m == "todas" or (m == "seguras" and f["marcado"]) or (m == "alta" and f["confianza"] == "alta"):
out.append(f)
return out
# ---------------------------------------------------------------- ejecución con progreso
PLAN_CACHE = {}
ESTADO = {"activo": False, "raiz": "", "paso": "", "pct": 0, "texto": "", "hechas": 0, "total": 0, "resultado": None, "error": ""}
def _prog(paso, hechas, total, texto=""):
ESTADO.update(paso=paso, hechas=hechas, total=total, pct=int(hechas * 100 / total) if total else 0, texto=texto)
def ejecutar(raiz, dec, plan_filas=None, en_hilo=False):
"""Aplica las decisiones. Actualiza ESTADO para las barras de progreso."""
if en_hilo:
threading.Thread(target=ejecutar, args=(raiz, dec, plan_filas, False), daemon=True).start()
return
raiz = os.path.normpath(raiz)
r = O.raiz_cfg(raiz)
ESTADO.update(activo=True, raiz=raiz, resultado=None, error="")
res = {"lotes": [], "movimientos": 0, "errores": [], "stock": 0, "convertidos": 0, "respaldo": None}
try:
if plan_filas is None:
_prog("plan", 0, 0, "pensando el plan…")
plan_filas = O.Plan(r).correr()["filas"]
if dec.get("stock") == "apartar":
# primero el stock viejo, para que en _Para borrar/stock quede con su ruta original
_prog("stock", 0, 0, "apartando stock de Envato viejo")
s = O.stock_viejo(raiz, O.CONFIG["optimizar"]["meses_minimos"], ["Video", "Audio", "Imagenes"])
rutas = [x for g in s["grupos"] for x in g["rutas"]]
if rutas:
st = O.stock_a_papelera(raiz, rutas)
res["lotes"].append(st.get("lote"))
res["stock"] = st.get("movimientos", 0)
# las filas de stock cuyo material acaba de irse entero ya no tienen qué mover; las demás
# siguen valiendo (antes se quitaban todas, y una carpeta «Recursos» que no era de Envato
# se quedaba sin ordenar hasta la siguiente pasada)
plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK" or os.path.exists(f["origen"])]
elegidas = filas_elegidas(plan_filas, dec)
if elegidas:
_prog("ordenar", 0, len(elegidas), "moviendo carpetas y archivos")
hilo = threading.Thread(target=lambda: res.update(_ap=O.aplicar(raiz, elegidas)), daemon=True)
hilo.start()
while hilo.is_alive():
a = O.APLICANDO
_prog("ordenar", a["hechas"], a["total"] or len(elegidas), a["actual"])
time.sleep(0.3)
ap = res.pop("_ap", None) or {}
res["lotes"].append(ap.get("lote"))
res["movimientos"] += ap.get("movimientos", 0)
res["errores"] += ap.get("errores", [])
if dec.get("duplicados") == "apartar":
_prog("duplicados", 0, 0, "juntando los duplicados en subcarpetas Duplicados")
dd = O.apartar_duplicados(raiz)
res["lotes"].append(dd.get("lote"))
res["duplicados"] = dd.get("movimientos", 0)
res["movimientos"] += dd.get("movimientos", 0)
res["errores"] += dd.get("errores", [])
if dec.get("convertir") == "si":
o = O.CONFIG["optimizar"]
_prog("analizar", 0, 0, "leyendo los clips con ffprobe")
hilo = threading.Thread(target=O.analizar, args=(raiz, o["min_mb"]), daemon=True)
hilo.start()
while hilo.is_alive():
a = O.ANALISIS.get(raiz) or {}
_prog("analizar", a.get("hechos", 0), a.get("total", 0), "leyendo los clips con ffprobe")
time.sleep(0.5)
cands = [c for c in O.candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"]) if not c["excluido"] and not c["en_cola"]]
ids = O.encolar([c["ruta"] for c in cands], o["perfil"], o["chroma"], o["color"], raiz)
if ids:
O.iniciar_cola()
total = len(ids)
while O.COLA["activa"]:
con = O.db()
hechos = con.execute("SELECT COUNT(*) FROM trabajos WHERE id IN (%s) AND estado NOT IN ('pendiente','corriendo')" % ",".join(map(str, ids))).fetchone()[0]
act = con.execute("SELECT ruta, pct FROM trabajos WHERE estado = 'corriendo' LIMIT 1").fetchone()
con.close()
_prog("convertir", hechos, total, f"{os.path.basename(act['ruta'])} {act['pct'] or 0}%" if act else O.COLA["mensaje"])
time.sleep(1)
vf = O.verificar(ids)
ok = [v["id"] for v in vf if v.get("ok")]
if ok:
O.reemplazar(ok)
res["convertidos"] = len(ok)
res["conversion_fallida"] = len(ids) - len(ok)
if dec.get("respaldo") == "si":
destino = O.CONFIG.get("respaldo", {}).get("destino") or "T:\\Archivo Labstream"
plan = O.plan_respaldo(raiz, destino, True, False, True)
_prog("respaldo", 0, plan["n"], f"copiando a {plan['base']}")
hilo = threading.Thread(target=O.copiar_respaldo, args=(raiz, plan), daemon=True)
hilo.start()
while hilo.is_alive():
rb = O.RESPALDO
_prog("respaldo", rb.get("hechos", 0), rb.get("total", plan["n"]), rb.get("actual", ""))
time.sleep(0.5)
res["respaldo"] = {"base": plan["base"], "copiados": O.RESPALDO.get("copiados", 0), "saltados": O.RESPALDO.get("saltados", 0)}
res["lotes"] = [x for x in res["lotes"] if x]
ESTADO["resultado"] = res
_prog("fin", 1, 1, "listo")
except Exception as ex: # noqa
ESTADO["error"] = str(ex)
ESTADO["resultado"] = res
finally:
ESTADO["activo"] = False
return res
# ---------------------------------------------------------------- consola
ANCHO = 34
def barra(hechas, total, texto="", final=False):
if total:
pct = min(100, int(hechas * 100 / total))
llenos = int(ANCHO * pct / 100)
b = "█" * llenos + "░" * (ANCHO - llenos)
linea = f"\r [{b}] {pct:3d}% {hechas:,}/{total:,} {texto[-40:]:<40}"
else:
giro = "|/-\\"[int(time.time() * 4) % 4]
linea = f"\r [{giro}] {hechas:,} {texto[-52:]:<52}"
sys.stdout.write(linea[:120])
if final:
sys.stdout.write("\n")
sys.stdout.flush()
def escanear_con_barra(raiz):
raiz = os.path.normpath(raiz)
con = O.db()
prev = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
con.close()
estimado = prev["n"] if prev else 0
print(f"\nEscaneando {raiz}")
hilo = threading.Thread(target=O.escanear, args=(raiz,), daemon=True)
hilo.start()
while hilo.is_alive():
p = O.PROGRESO.get(raiz) or {}
barra(p.get("n", 0), estimado if estimado and p.get("n", 0) <= estimado else 0, os.path.basename(p.get("actual") or ""))
time.sleep(0.25)
p = O.PROGRESO[raiz]
if p["estado"] != "listo":
print("\nERROR al escanear:", p.get("error"))
return False
barra(p["n"], p["n"], f"{O._gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s", final=True)
return True
def _preguntar(paso, auto):
print(f"\n{paso['titulo']}\n {paso['resumen']}")
for i, (clave, texto) in enumerate(paso["opciones"], 1):
marca = " (Enter)" if clave == paso["defecto"] else ""
print(f" {i}. {texto}{marca}")
if auto:
return paso["defecto"]
while True:
try:
t = input(" Elige: ").strip()
except EOFError:
return paso["defecto"]
if not t:
return paso["defecto"]
if t.isdigit() and 1 <= int(t) <= len(paso["opciones"]):
return paso["opciones"][int(t) - 1][0]
for clave, _ in paso["opciones"]:
if t.lower() == clave:
return clave
print(" Escribe el número de la opción.")
def cli(ruta, args):
r = O.cli_raiz(ruta, args)
if not r:
return 1
raiz = r["ruta"]
if not escanear_con_barra(raiz):
return 1
d = diagnostico(raiz)
print(f"\nDIAGNÓSTICO · {r['cliente']} · {d['n']:,} archivos · {O._gb(d['bytes'])}")
for g in d["grupos"]:
rango = f"{g['desde']} → {g['hasta']}" if g["desde"] and g["desde"] != g["hasta"] else (g["desde"] or "")
print(f" {O._gb(g['bytes']):>9} {g['pct']:5.1f} % {g['n']:>7,} {g['titulo']} {rango}")
for fr in d["frases"]:
print(" •", fr)
print("\nPensando el plan…", end="", flush=True)
plan_filas = O.Plan(r).correr()["filas"]
print(f" {len(plan_filas)} movimientos posibles.")
auto = "--si" in args
dec = {}
for paso in pasos(d, plan_filas):
dec[paso["clave"]] = _preguntar(paso, auto)
elegidas = filas_elegidas(plan_filas, dec)
print(f"\nRESUMEN: se moverán {len(elegidas)} carpetas/grupos ({sum(f['n'] for f in elegidas):,} archivos, {O._gb(sum(f['bytes'] for f in elegidas))})"
+ (", se apartará el stock viejo" if dec.get("stock") == "apartar" else "")
+ (", se convertirán los clips viejos a 10 bits" if dec.get("convertir") == "si" else "")
+ (", y se hará el respaldo" if dec.get("respaldo") == "si" else "") + ".")
if "--simular" in args:
print("Simulación: no se movió nada.")
return 0
if not O._confirmar("¿Empezamos? (nada se borra; lo sobrante va a _Para borrar y todo se puede deshacer)", auto):
print("Cancelado. No se movió nada.")
return 0
hilo = threading.Thread(target=ejecutar, args=(raiz, dec, plan_filas), daemon=True)
hilo.start()
paso_ant = None
while hilo.is_alive():
e = ESTADO
if e["paso"] != paso_ant:
if paso_ant:
barra(e["total"] or 1, e["total"] or 1, "", final=True)
paso_ant = e["paso"]
print({"plan": "Plan", "ordenar": "Ordenando", "stock": "Stock viejo", "analizar": "Analizando clips", "convertir": "Convirtiendo a 10 bits",
"respaldo": "Respaldo", "fin": "Fin"}.get(e["paso"], e["paso"]))
barra(e["hechas"], e["total"], e["texto"])
time.sleep(0.3)
barra(1, 1, "", final=True)
res = ESTADO["resultado"] or {}
if ESTADO["error"]:
print("ERROR:", ESTADO["error"])
print(f"\nHecho: {res.get('movimientos', 0)} movimientos"
+ (f", {res['stock']} tomas de stock apartadas" if res.get("stock") else "")
+ (f", {res['convertidos']} clips convertidos" if res.get("convertidos") else "")
+ (f", respaldo en {res['respaldo']['base']} ({res['respaldo']['copiados']} copiados)" if res.get("respaldo") else "") + ".")
for e in res.get("errores", [])[:20]:
print(" ERROR:", e)
for lote in res.get("lotes", []):
print(f"Para deshacer: OrganizadorLabstream.exe deshacer {lote}")
return 0 if not res.get("errores") else 2