# -*- 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"): if dec.get("material", "ordenar") == "ordenar": 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) plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK"] 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