Files
labstream-organizador/asistente.py
Labstream c419d5d943 3.2.0: el orden mira la forma de las carpetas, no solo su nombre
Medido sobre una carpeta de cliente real (8.207 archivos, 920 GB) antes de tocar nada:

- «Por nombrar» solo pregunta por lo que el plan llega a ver y no sabe resolver; ya no por las
  carpetas de la app ni por lo de dentro de la papelera. Lo aprendido vale por el nombre entero.
- Una carpeta es una grabación por lo que tiene dentro: una pieza montada no se desarma, y lo
  que no es rodaje no acaba en 00_Grabacion\OTROS. Se conserva el nombre de cada sesión.
- Una carpeta que guarda meses o años dentro se abre y cada cosa va a su mes; si la pieza ya
  existe allí, se junta con ella.
- Repaso de lo ya ordenado: lo que está en un mes que no es el de su rodaje vuelve al suyo.
- El año de un clip de Blackmagic sale de los demás clips de su tarjeta y día; un rodaje que
  pasa de medianoche cuenta como uno.
- Un clip convertido sigue siendo de su cámara, y el convertidor no repite lo que ya convirtió.
- «Exportados» junto a «_Exportados» y «_Papelera» junto a «_Para borrar» se juntan.
- «Hacer de una vez» solo mueve lo marcado; lo dudoso que queda no cuenta como pendiente.
- El inventario lleva la versión de las reglas: uno anterior pide escanear otra vez.

Pruebas: prueba_orden.py (nueva, 19 casos), probar.py 19, prueba_funcional.py 26,
prueba_ventana.py 18 desde el código y 17 con el paquete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 16:33:45 -05:00

421 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"]},
"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("resolve") or {}).get("ok"):
aviso = " Los BRAW necesitan DaVinci Resolve Studio abierto en un proyecto."
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"):
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)")
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