Files
labstream-organizador/organizador.py
Labstream 8b482af652 1.6.2: manifiesto firmado, tiempo estimado en tareas, pruebas automáticas
- firma.py: RSA-2048 PKCS#1 v1.5 SHA-256 sin dependencias; empaquetar.py firma
  el manifiesto y actualizador.py exige la firma (CLAVE_PUBLICA incrustada).
- Barra de tareas: tiempo restante estimado a partir del avance.
- Corregido: APLICANDO nunca llegaba al total y «Moviendo» quedaba fijo,
  bloqueando desconectar la carpeta.
- probar.py: reglas, firma y recorrido completo sobre una copia temporal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 14:48:47 -05:00

2736 líneas
131 KiB
Python
Original Blame Histórico

Este archivo contiene caracteres Unicode invisibles

Este archivo contiene caracteres Unicode invisibles que son indistinguibles para los humanos, pero que pueden ser procesados de forma diferente por un ordenador. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

# -*- coding: utf-8 -*-
"""
Organizador Labstream — inventario, mapa, orden y optimización del material.
Solo librería estándar de Python (3.10+). Arranca un servidor local y abre la
interfaz en el navegador:
py -3.12 organizador.py (o doble clic en Iniciar.bat)
Nunca borra nada: lo que sobra va a _Para borrar dentro de la misma raíz y cada
movimiento queda en datos/bitacora.jsonl para poder deshacerlo.
"""
import os
import sys
import re
import json
import time
import queue
import shutil
import hashlib
import sqlite3
import threading
import subprocess
import datetime
import webbrowser
from collections import Counter, defaultdict
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import reglas as R
import asistente
import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "1.6.2"
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
BASE = os.path.dirname(os.path.abspath(sys.executable if CONGELADO else __file__))
DATOS = os.path.join(BASE, "datos")
os.makedirs(DATOS, exist_ok=True)
DB_PATH = os.path.join(DATOS, "inventario.sqlite")
CONFIG_PATH = os.path.join(DATOS, "config.json")
DICC_PATH = os.path.join(DATOS, "diccionario.json")
BITACORA_PATH = os.path.join(DATOS, "bitacora.jsonl")
PLANTILLAS_PATH = os.path.join(DATOS, "plantillas.json")
PUERTO = int(os.environ.get("ORGANIZADOR_PUERTO", "8765"))
LOCK = threading.RLock()
# ==========================================================================
# Configuración y diccionario (lo que la app aprende)
# ==========================================================================
CONFIG_DEFECTO = {
"raices": [
{"ruta": r"Z:\Dermatologas\David Reyes", "cliente": "David Reyes", "tipo": "contenido"},
{"ruta": r"W:\Danney", "cliente": "Danney Gómez", "tipo": "contenido"},
{"ruta": "N:\\", "cliente": "Camilo Ortega", "tipo": "pauta"},
],
"clientes": {
"David Reyes": {"tipo": "contenido", "alias": ["DR. DAVID REYES", "David Reyes", "Davids", "Dr David Reyes"]},
"Danney Gómez": {"tipo": "contenido", "alias": ["Danney", "DRA. DANNEY GOMEZ", "Dra_Danney", "Dra Danney", "Skincenter"]},
"Camilo Ortega": {"tipo": "pauta", "alias": ["Camilo Ortega", "Camilo", "Camilo Ortega Anuncios", "Camilo Anuncio", "VSL Camilo Ortega", "Apoyos Camilo", "Anuncios Jonathan"]},
"Andrea Galeano": {"tipo": "contenido", "alias": ["DRA. ANDREA GALEANO", "Galeano", "Portadas Galeano"]},
"Carolina Solórzano": {"tipo": "contenido", "alias": ["DRA. CAROLINA SOLORZANO", "Solorzano"]},
"Diana Méndez": {"tipo": "contenido", "alias": ["DRA. DIANA MENDEZ", "Diana Mendez", "DRA. DIANA MENDEZ (1)"]},
"Luisa Báez": {"tipo": "contenido", "alias": ["DRA. LUISA BAEZ", "Luisa Baez"]},
"Malpraxis": {"tipo": "pauta", "alias": ["Malpraxis", "Malpraxiss", "Malpraxis 2026", "App Malpraxis - Hotjamin"]},
"Brigard & Urrutia": {"tipo": "proyecto", "alias": ["Brigard & Urrutia", "Brigard Urrutia", "Brigard", "Brigard 2025", "Brigard 2026"]},
"PepsiCo": {"tipo": "proyecto", "alias": ["PepsiCo", "pepsico"]},
"Kingspan / Synthesia": {"tipo": "proyecto", "alias": ["Kingspam", "Kingspan", "Sinthesia", "Syntesia", "SYNTHESIA - KINGSPAN"]},
"Filarmónica de Bogotá": {"tipo": "proyecto", "alias": ["FILARMONICA DE BOGOTA", "Filarmónica de Bogotá", "Filarmonica"]},
"Fundación Vascular": {"tipo": "proyecto", "alias": ["CLINICA FUNDACIÓN VASCULAR", "Fundación Vascular"]},
"Legal Core": {"tipo": "proyecto", "alias": ["LEGAL CORE", "Legal Core"]},
"Vía Sumapaz": {"tipo": "proyecto", "alias": ["Vía SUMAPAZ", "VÍA SUMAPAZ", "Sumapaz", "Páramo Sumapaz lucy"]},
"Latinología": {"tipo": "proyecto", "alias": ["Latinología"]},
"CICR": {"tipo": "proyecto", "alias": ["CICR"]},
"Andrés Arango": {"tipo": "proyecto", "alias": ["Andres Arango"]},
"Labstream (interno)": {"tipo": "interno", "alias": ["LABSTREAM", "LABSTREAM ACADEMY", "Labstream Medicina", "producción", "Recursos audiovisuales", "Recursos Audiovisuales", "Sonidos"]},
},
"discos": {
"Z:": {"nombre": "labstream · NAS Labstream", "nivel": "trabajo"},
"W:": {"nombre": "Dra_Danney · Cloud Store Mini (10G)", "nivel": "caliente"},
"N:": {"nombre": "Camilo Ortega · NAS Labstream", "nivel": "trabajo"},
"K:": {"nombre": "Entregas_LAB · LabTem (lo maneja Labstream OS)", "nivel": "entregas"},
"Q:": {"nombre": "Exportados · NAS Labstream (lo maneja Labstream OS)", "nivel": "entregas"},
"T:": {"nombre": "temple · LabTem", "nivel": "frio"},
"M:": {"nombre": "Malpraxis · NAS Labstream", "nivel": "trabajo"},
"X:": {"nombre": "Datos · NAS Labstream", "nivel": "frio"},
"Y:": {"nombre": "Nubes · NAS Labstream", "nivel": "frio"},
"V:": {"nombre": "Medicina1 · NAS Labstream", "nivel": "trabajo"},
"U:": {"nombre": "Reels · LabTem", "nivel": "trabajo"},
"L:": {"nombre": "Apoyos Camilo · LabTem", "nivel": "trabajo"},
"R:": {"nombre": "Recursos Audiovisuales · LabTem", "nivel": "recursos"},
"S:": {"nombre": "Sonidos · LabTem", "nivel": "recursos"},
"O:": {"nombre": "Operaciones_LAB · NAS Labstream", "nivel": "sistema"},
},
"optimizar": {
"meses_minimos": 3,
"min_mb": 300,
"perfil": "equilibrado",
"chroma": "420",
"color": "rec709",
"salida": "junto", # junto = al lado del original con sufijo _10bit ; espejo = _Archivo/<ruta>
"sufijo": "_10bit",
"ffmpeg": "ffmpeg",
"ffprobe": "ffprobe",
"python_resolve": "", # vacío = el mismo intérprete
},
}
PERFILES = {
"ahorro": {"bpp": 0.055, "cq": 27, "nombre": "Ahorro (pesa menos)"},
"equilibrado": {"bpp": 0.085, "cq": 24, "nombre": "Equilibrado (recomendado)"},
"maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"},
}
def cargar_json(ruta, defecto):
try:
with open(ruta, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return json.loads(json.dumps(defecto))
def guardar_json(ruta, datos):
tmp = ruta + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(datos, f, ensure_ascii=False, indent=2)
os.replace(tmp, ruta)
CONFIG = cargar_json(CONFIG_PATH, CONFIG_DEFECTO)
for k, v in CONFIG_DEFECTO.items(): # completar claves nuevas sin pisar lo del usuario
CONFIG.setdefault(k, v)
if isinstance(v, dict):
for kk, vv in v.items():
CONFIG[k].setdefault(kk, vv)
guardar_json(CONFIG_PATH, CONFIG)
def ruta_herramienta(h):
"""ffmpeg/ffprobe: primero el empaquetado en ffmpeg\\ junto al programa, luego lo que diga la config, luego el PATH.
No se guarda en la config para que la carpeta se pueda mover de sitio."""
local = os.path.join(BASE, "ffmpeg", h + ".exe")
if os.path.exists(local):
return local
return CONFIG["optimizar"].get(h) or h
def herramientas():
"""qué hay disponible: ffmpeg/ffprobe y Resolve"""
out = {}
for h in ("ffmpeg", "ffprobe"):
ruta = ruta_herramienta(h)
ok = os.path.exists(ruta) or shutil.which(ruta) is not None
out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())}
out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"}
out["congelado"] = CONGELADO
return out
DICC = cargar_json(DICC_PATH, {"carpetas": R.DICCIONARIO_SEMILLA, "reglas": [], "aprendido": []})
DICC.setdefault("carpetas", dict(R.DICCIONARIO_SEMILLA))
DICC.setdefault("reglas", [])
DICC.setdefault("aprendido", [])
for k, v in R.DICCIONARIO_SEMILLA.items(): # palabras nuevas de la semilla, sin pisar lo aprendido
DICC["carpetas"].setdefault(k, v)
guardar_json(DICC_PATH, DICC)
PLANTILLAS = cargar_json(PLANTILLAS_PATH, R.PLANTILLAS)
for t, d in R.PLANTILLAS.items():
PLANTILLAS.setdefault(t, d)
for b, p in d.items():
PLANTILLAS[t].setdefault(b, p)
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
def raiz_cfg(ruta):
ruta = os.path.normpath(ruta)
for r in CONFIG["raices"]:
if os.path.normpath(r["ruta"]).lower() == ruta.lower():
return r
return None
def dentro_de(raiz, ruta):
"""True si `ruta` está dentro de `raiz` (o es la propia raíz). Evita mover cosas fuera de la carpeta del cliente."""
try:
a = os.path.normcase(os.path.normpath(raiz))
b = os.path.normcase(os.path.normpath(ruta))
except (TypeError, ValueError):
return False
return b == a or b.startswith(a.rstrip(os.sep) + os.sep)
# ==========================================================================
# Base de datos
# ==========================================================================
def db():
con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=60)
con.row_factory = sqlite3.Row
return con
def init_db():
con = db()
con.executescript("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS archivos (
ruta TEXT PRIMARY KEY, raiz TEXT, dir TEXT, nombre TEXT, ext TEXT, bytes INTEGER, mtime REAL,
rol TEXT, fuente TEXT, contexto TEXT, anio_ruta INTEGER, mes_ruta INTEGER,
fecha TEXT, fecha_origen TEXT, es_basura INTEGER, es_proxy INTEGER,
codec TEXT, perfil TEXT, pix_fmt TEXT, ancho INTEGER, alto INTEGER, fps REAL, dur REAL,
kbps INTEGER, alfa INTEGER, acodec TEXT, analizado INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_arch_dir ON archivos(dir);
CREATE INDEX IF NOT EXISTS ix_arch_raiz ON archivos(raiz);
CREATE INDEX IF NOT EXISTS ix_arch_nb ON archivos(nombre, bytes);
CREATE TABLE IF NOT EXISTS carpetas (
ruta TEXT PRIMARY KEY, raiz TEXT, nombre TEXT, n INTEGER, bytes INTEGER,
anio_moda INTEGER, mes_moda INTEGER, frac_moda REAL, dias_rango INTEGER,
es_proxy INTEGER, es_basura INTEGER
);
CREATE INDEX IF NOT EXISTS ix_carp_raiz ON carpetas(raiz);
CREATE TABLE IF NOT EXISTS escaneos (raiz TEXT PRIMARY KEY, ts REAL, n INTEGER, bytes INTEGER, seg REAL);
CREATE TABLE IF NOT EXISTS trabajos (
id INTEGER PRIMARY KEY AUTOINCREMENT, ruta TEXT, salida TEXT, motor TEXT, perfil TEXT,
chroma TEXT, color TEXT, kbps INTEGER, estado TEXT, pct REAL DEFAULT 0, log TEXT DEFAULT '',
bytes_in INTEGER, bytes_out INTEGER, creado REAL, fin REAL, raiz TEXT
);
""")
con.commit()
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",):
try:
con.execute(alter)
con.commit()
except sqlite3.OperationalError:
pass
con.close()
init_db()
def con_sep(ruta):
r"""la ruta con un separador al final, sin duplicarlo (N: ya lo trae)"""
ruta = os.path.normpath(ruta)
return ruta if ruta.endswith(os.sep) else ruta + os.sep
def esc_like(s):
return s.replace("!", "!!").replace("%", "!%").replace("_", "!_")
def archivos_bajo(con, carpeta):
"""filas de archivos dentro de una carpeta (recursivo)"""
c = os.path.normpath(carpeta)
return con.execute(
"SELECT * FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
(c, esc_like(con_sep(c)) + "%")).fetchall()
def archivos_en(con, carpeta):
return con.execute("SELECT * FROM archivos WHERE dir = ?", (os.path.normpath(carpeta),)).fetchall()
# ==========================================================================
# Escaneo (inventario)
# ==========================================================================
PROGRESO = {} # raiz -> dict
def contexto_dir(nombre, ctx_padre, nivel_padre):
"""cubeta de contexto que hereda un archivo por la carpeta donde está"""
if R.detectar_anio(nombre):
return "ANIO", "anio"
if nivel_padre == "anio" and R.detectar_mes(nombre)[0]:
return "MES", "mes"
if nivel_padre == "mes" and R.detectar_pieza(nombre):
return "PIEZA", "pieza"
cub, _ = R.cubeta_carpeta(nombre, DICC["carpetas"])
if cub in (None, "CONTENEDOR"):
return ctx_padre, nivel_padre if cub == "CONTENEDOR" else nivel_padre
if cub == "PIEZA":
return "PIEZA", "pieza"
return cub, nivel_padre
def escanear(ruta):
raiz = os.path.normpath(ruta)
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
filas = []
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
props = ("n", "bytes")
def agrega_dir(d, size, fecha_am):
info = dirs.get(d)
if info is None:
info = dirs[d] = [0, 0, Counter(), 0, 0, os.path.basename(d)]
info[0] += 1
info[1] += size
info[2][fecha_am] += 1
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes):
try:
it = os.scandir(path)
except OSError:
return
prog["actual"] = path
with it:
for e in it:
try:
nombre = e.name
if e.is_dir(follow_symlinks=False):
if nombre.lower() in ("#recycle", "@eadir"):
continue
p = en_proxy or R.es_proxy_dir(nombre)
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) or R.norm(nombre) in ("elements", "elemnts")
c2, n2 = contexto_dir(nombre, ctx, nivel)
a2, m2 = anio, mes
if c2 == "ANIO":
a2, m2 = R.detectar_anio(nombre), None
elif c2 == "MES":
m2 = R.detectar_mes(nombre)[0]
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
dirs[e.path][3] = 1 if p else 0
dirs[e.path][4] = 1 if b else 0
walk(e.path, p, b, s, c2, n2, a2, m2)
else:
st = e.stat(follow_symlinks=False)
ext = os.path.splitext(nombre)[1].lower()
firma = R.firma_archivo(nombre)
rol = R.rol_archivo(nombre, ext, firma)
basura = 1 if (en_basura or rol == "basura") else 0
proxy = 1 if (en_proxy or rol == "proxy") else 0
stock = 1 if (en_stock or rol == "stock") else 0
fecha, origen = R.fecha_de_nombre(nombre, st.st_mtime)
fy, fm = int(fecha[:4]), int(fecha[5:7])
filas.append((e.path, raiz, path, nombre, ext, st.st_size, st.st_mtime, rol, firma or "",
ctx or "", anio, mes, fecha, origen, basura, proxy, stock))
prog["n"] += 1
prog["bytes"] += st.st_size
d = path
while True:
agrega_dir(d, st.st_size, (fy, fm))
if os.path.normpath(d).lower() == raiz.lower():
break
padre = os.path.dirname(d)
if padre == d:
break
d = padre
except OSError:
continue
try:
walk(raiz, False, False, False, "", "raiz", None, None)
con = db()
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()}
con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,))
con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,))
con.executemany(
"INSERT OR REPLACE INTO archivos (ruta, raiz, dir, nombre, ext, bytes, mtime, rol, fuente, contexto, "
"anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
filas)
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]))
cfilas = []
for d, info in dirs.items():
n, b, fechas, p, bs, nombre = info
if fechas:
(ay, am), cnt = fechas.most_common(1)[0]
frac = cnt / n
claves = sorted(fechas)
d0 = datetime.date(claves[0][0], claves[0][1], 1)
d1 = datetime.date(claves[-1][0], claves[-1][1], 1)
rango = (d1 - d0).days
else:
ay, am, frac, rango = None, None, 0, 0
cfilas.append((d, raiz, nombre, n, b, ay, am, frac, rango, p, bs))
con.executemany("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", cfilas)
con.execute("INSERT OR REPLACE INTO escaneos VALUES (?,?,?,?,?)",
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
con.close()
prog["estado"] = "listo"
PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
except Exception as ex: # noqa
prog["estado"] = "error"
prog["error"] = repr(ex)
prog["fin"] = time.time()
def lanzar_escaneo(ruta):
raiz = os.path.normpath(ruta)
p = PROGRESO.get(raiz)
if p and p["estado"] == "corriendo":
return False
threading.Thread(target=escanear, args=(raiz,), daemon=True).start()
return True
# ==========================================================================
# Utilidades de fechas / nombres para el plan
# ==========================================================================
def fecha_carpeta(con, ruta):
"""(anio, mes, confianza, motivo) para una carpeta según sus archivos"""
fila = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (os.path.normpath(ruta),)).fetchone()
if not fila or not fila["anio_moda"]:
return None, None, "baja", "sin archivos con fecha"
frac = fila["frac_moda"] or 0
if frac >= 0.8:
return fila["anio_moda"], fila["mes_moda"], "media", f"fecha de sus archivos ({int(frac * 100)}% del mismo mes)"
return fila["anio_moda"], fila["mes_moda"], "baja", f"fechas mezcladas ({fila['dias_rango']} días de rango)"
def limpiar_nombre(nombre):
n = re.sub(r"\s{2,}", " ", nombre).strip(" -_.")
return n or nombre
def destino_plantilla(tipo, cubeta, **kw):
plantilla = PLANTILLAS.get(tipo, PLANTILLAS["contenido"]).get(cubeta)
if not plantilla:
plantilla = PLANTILLAS["contenido"].get(cubeta, "_Revisar/{nombre}")
anio = kw.get("anio")
mes = kw.get("mes")
partes = {
"anio": str(anio) if anio else "",
"mes": R.nombre_mes(mes) if mes else "",
"nombre": kw.get("nombre", ""),
"sub": kw.get("sub", ""),
"rel": kw.get("rel", ""),
"fuente": kw.get("fuente", ""),
}
# un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion"
vacio = "\x00"
partes_o = {k: (v if v != "" else vacio) for k, v in partes.items()}
segmentos = []
for seg in plantilla.split("/"):
s = seg.format(**partes_o)
s = re.sub(r"[-_ ]*\x00", "", s)
if seg.startswith("{"):
s = s.lstrip("-_ ")
if seg.endswith("}"):
s = s.rstrip("-_ ")
if s:
segmentos.append(s)
if not segmentos:
segmentos = ["_Sin fecha"]
return os.sep.join(segmentos)
# ==========================================================================
# Planificador
# ==========================================================================
class Plan:
def __init__(self, rcfg):
self.raiz = os.path.normpath(rcfg["ruta"])
self.cliente = rcfg["cliente"]
self.tipo = rcfg.get("tipo", "contenido")
self.dicc = DICC["carpetas"]
self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)]
self.filas = []
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos
self.con = db()
self.avisos = []
# ---- helpers ----
def rel(self, ruta):
return os.path.relpath(ruta, self.raiz)
def abs_(self, rel):
return os.path.normpath(os.path.join(self.raiz, rel))
def regla_para(self, ruta):
rel = R.norm(self.rel(ruta))
for r in self.reglas:
if R.norm(r.get("origen", "")) == rel:
return r
return None
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None, marcado=None):
destino = os.path.normpath(destino)
if archivos is None and os.path.normpath(origen).lower() == destino.lower():
return
if n is None or bytes_ is None:
if archivos is not None:
n = len(archivos)
bytes_ = sum(a[1] for a in archivos)
else:
fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone()
n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0)
aviso_colision = ""
if 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):
aviso_colision = " · la carpeta destino ya existe: se fusionan"
self.filas.append({
"id": len(self.filas) + 1,
"origen": origen,
"origen_rel": self.rel(origen) if archivos is None else self.rel(origen),
"destino": destino,
"destino_rel": self.rel(destino),
"cubeta": cubeta,
"accion": accion,
"motivo": motivo + aviso_colision,
"confianza": confianza,
"n": n,
"bytes": bytes_,
"archivos": [a[0] for a in archivos] if archivos else None,
"marcado": (confianza in ("alta", "media")) if marcado is None else marcado,
"cosmetico": marcado is False and accion == "renombrar",
})
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
clave = (cubeta, destino_rel, motivo, confianza)
self.sueltos[clave].append((fila["ruta"], fila["bytes"]))
def volcar_sueltos(self):
for (cubeta, destino_rel, motivo, confianza), 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()
# ---- niveles ----
def correr(self):
self.plan_dir(self.raiz, "raiz", None, None)
self.volcar_sueltos()
self.plan_basura()
self.con.close()
# primero las filas de archivos sueltos (rutas exactas), luego carpetas de más profunda a menos,
# para que un renombrado de mes no deje rutas viejas en filas que aún no se aplicaron
self.filas.sort(key=lambda f: (0 if f["archivos"] else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
for i, f in enumerate(self.filas, 1):
f["id"] = i
return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos}
def entradas(self, ruta):
try:
with os.scandir(ruta) as it:
ents = [e for e in it if e.name.lower() not in ("#recycle", "@eadir")]
except OSError as ex:
self.avisos.append(f"No se pudo leer {ruta}: {ex}")
return [], []
dirs = sorted([e for e in ents if e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower())
files = sorted([e for e in ents if not e.is_dir(follow_symlinks=False)], key=lambda e: e.name.lower())
return dirs, files
def plan_dir(self, ruta, nivel, anio, mes):
dirs, files = self.entradas(ruta)
# numeración de piezas dentro de un mes
max_num = 0
if nivel == "mes":
for e in dirs:
p = R.detectar_pieza(e.name)
if p:
max_num = max(max_num, p[0])
for e in dirs:
self.plan_subdir(e, nivel, anio, mes, ruta, max_num)
if nivel == "mes" and not R.detectar_pieza(e.name):
cub, _ = R.cubeta_carpeta(e.name, self.dicc)
if cub is None:
max_num += 1
for e in files:
self.plan_archivo(e, nivel, anio, mes, ruta)
def plan_subdir(self, e, nivel, anio, mes, padre, max_num):
nombre = e.name
nn = R.norm(nombre)
regla = self.regla_para(e.path)
if regla:
if regla.get("destino") in ("", None, "IGNORAR"):
return
self.agregar(e.path, self.abs_(regla["destino"]), regla.get("cubeta", "REVISAR"), "mover carpeta",
"regla aprendida", "alta")
return
if nn in R.CARPETAS_PROPIAS or nombre.startswith("_"):
return # estructura ya canónica
if R.es_basura_dir(nombre):
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "BASURA", rel=self.rel(e.path))),
"BASURA", "papelera", "carpeta de caché / autoguardado", "alta")
return
if R.es_proxy_dir(nombre):
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "PROXY", rel=self.rel(e.path))),
"PROXY", "papelera", "proxies: se regeneran desde Resolve/Premiere", "alta")
return
a = R.detectar_anio(nombre)
if a and nivel in ("raiz", "contenedor", "grabacion"):
if nivel == "raiz":
self.plan_dir(e.path, "anio", a, None)
else:
# un año dentro de un contenedor: mirar adentro con el año fijado
self.plan_dir(e.path, "contenedor", a, None)
return
if nivel == "anio":
m, conflicto = R.detectar_mes(nombre)
if m:
canon = R.nombre_mes(m)
if nombre != canon:
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if conflicto else "alta")
self.plan_dir(e.path, "mes", anio, m)
return
if nivel == "raiz" or (nivel == "contenedor" and not anio):
# un mes suelto ("marzo", "Marzo 2026", "03- Marzo") fuera de su carpeta de año
sin_anio = re.sub(r"20\d\d", " ", nombre).strip(" -_")
m, conflicto = R.detectar_mes(sin_anio)
if m:
m_anio = re.search(r"(20\d\d)", nombre)
fa = int(m_anio.group(1)) if m_anio else fecha_carpeta(self.con, e.path)[0]
hoy = datetime.date.today()
if fa and (fa, m) > (hoy.year, hoy.month):
fa -= 1
if fa:
canon = self.abs_(os.path.join(str(fa), R.nombre_mes(m)))
self.agregar(e.path, canon, "MES", "mover carpeta",
f"mes suelto fuera de su año: entra en {fa}", "media")
self.plan_dir(e.path, "mes", fa, m)
return
if nivel == "contenedor" and anio:
m, conflicto = R.detectar_mes(nombre)
if m:
# un mes guardado dentro de "Nuevo 2026": sale al año que le toca
canon = self.abs_(os.path.join(str(anio), R.nombre_mes(m)))
self.agregar(e.path, canon, "MES", "mover carpeta", f"mes guardado dentro de «{os.path.basename(padre)}»", "media")
self.plan_dir(e.path, "mes", anio, m)
return
if nivel == "mes":
p = R.detectar_pieza(nombre)
if p:
cub_t, _ = R.cubeta_carpeta(p[1], self.dicc)
if cub_t == "GRABACION": # "03 - Grabacion Agosto": es la sesión del mes, no una pieza
self.plan_grabacion(e, nivel, anio, mes, "carpeta numerada que es una grabación")
return
canon = R.nombre_pieza(p[0], limpiar_nombre(p[1]))
if nombre != canon:
self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar",
"cosmético: nombre de pieza a «NN- Título» (sin marcar por defecto)", "media", marcado=False)
return # dentro de una pieza no se toca
cub, motivo = R.cubeta_carpeta(nombre, self.dicc)
if cub == "IGNORAR":
return
if cub == "CONTENEDOR":
m_anio = re.search(r"(20\d\d)", nombre)
self.plan_dir(e.path, "contenedor", int(m_anio.group(1)) if m_anio else anio, mes)
return
if cub == "REVISAR":
self.agregar(e.path, self.abs_(destino_plantilla(self.tipo, "REVISAR", nombre=nombre)), "REVISAR",
"mover carpeta", motivo, "baja")
return
if cub == "GRABACION":
self.plan_grabacion(e, nivel, anio, mes, motivo)
return
generico = nn in self.dicc
limpio = limpiar_nombre(nombre)
p = R.detectar_pieza(nombre)
if p and cub in ("CAMPANA", "PIEZA", None):
limpio = limpiar_nombre(p[1]) # "01- Dove" -> "Dove"
sub = "" if generico else limpio
fa, fm, fconf, fmot = fecha_carpeta(self.con, e.path)
con_fecha = cub in ("PIEZA", "CAMPANA", "EXPORTADOS", "PORTADAS", "VOZ", "IA", "GUION", "FOTOS", "CELULAR", None)
if anio:
fa = anio
if mes:
fm = mes
elif fconf == "baja" and cub not in ("PIEZA", None):
fm = None # fechas mezcladas: se queda a nivel de año ("2025/_Exportados")
fconf = "media" if fa else "baja"
hoy = datetime.date.today()
if fa and fm and (fa, fm) > (hoy.year, hoy.month):
fa -= 1 # un mes en el futuro no existe: era del año pasado
if cub is None:
filas_c = [f for f in archivos_bajo(self.con, e.path) if not f["es_basura"] and not f["es_proxy"]]
cam = [f for f in filas_c if f["fuente"] in R.FUENTE_CARPETA
or f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado")]
if filas_c and len(cam) >= 0.8 * len(filas_c):
self.plan_grabacion(e, nivel, anio, mes, "carpeta sin nombre reconocido pero llena de clips de cámara")
return
if nivel == "mes":
canon = R.nombre_pieza(max_num + 1, limpio)
self.agregar(e.path, os.path.join(padre, canon), "PIEZA", "renombrar",
"carpeta sin número dentro del mes: se numera como pieza", "media")
return
cub = "PIEZA"
motivo = "no reconocida: se propone como pieza por la fecha de sus archivos"
fconf = "baja"
sub = limpio
if con_fecha and not (anio and mes):
conf = fconf
motivo = f"{motivo}; {fmot}"
else:
conf = "alta" if generico else "media"
if cub == "MARCA":
sub = R.MARCA_SUB.get(nn, sub or limpio)
conf = "alta" if generico or nn in R.MARCA_SUB else "media"
if cub in ("PIEZA", "CAMPANA", "PROYECTOS"):
sub = sub or limpio
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))
self.agregar(e.path, self.abs_(destino), cub, "mover carpeta", motivo, conf)
def plan_grabacion(self, e, nivel, anio, mes, motivo):
"""Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
nombre = e.name
nn = R.norm(nombre)
generico = nn in self.dicc or nn in ("grabacion", "grabado", "grabaciones")
filas = archivos_bajo(self.con, e.path)
if not filas:
return
grupos = defaultdict(list)
for f in filas:
if f["es_basura"] or f["es_proxy"]:
continue
fuente = R.FUENTE_CARPETA.get(f["fuente"], None)
if fuente is None:
if f["rol"] in ("audio", "audio_grabado"):
fuente = "AUDIO"
elif f["rol"] in ("video", "camara_video"):
fuente = "OTROS"
elif f["rol"] in ("imagen", "foto_camara", "celular_foto"):
fuente = "FOTOS"
else:
fuente = "OTROS"
# año / mes que digan las carpetas intermedias (p. ej. "Blackmagic (BRAW)/2023/…")
anio_ruta, mes_ruta = None, None
for seg in os.path.relpath(f["dir"], e.path).split(os.sep):
if R.detectar_anio(seg):
anio_ruta = R.detectar_anio(seg)
elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0]
if anio and mes:
ay, am, fconf = anio, mes, "alta"
else:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime"
fconf = "media" if por_nombre else "baja"
# el año de una carpeta intermedia ("…/2023/…") manda sobre la fecha de modificación,
# pero no sobre una fecha escrita en el nombre del archivo
if anio_ruta and not por_nombre:
ay = anio_ruta
fconf = "media"
if anio and not por_nombre:
ay = anio
if mes_ruta:
am = mes_ruta
fconf = "media"
hoy = datetime.date.today()
if (ay, am) > (hoy.year, hoy.month):
ay -= 1
grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"]))
etiqueta = "" if generico else R.etiqueta_grabacion(nombre)
for (ay, am, fuente, fconf), archivos in grupos.items():
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente)
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, sub=sub, nombre=limpiar_nombre(nombre))
# si todos ya están en esa carpeta, no hay nada que hacer
if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
continue
mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else ""))
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot, fconf, archivos=archivos)
def plan_archivo(self, e, nivel, anio, mes, padre):
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
if not fila:
return
if fila["es_basura"] or fila["es_proxy"]:
return # los recoge plan_basura
if nivel == "pieza":
return
rol = fila["rol"]
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
conf = "media"
motivo = f"archivo suelto ({rol})"
fa, fm = int(fila["fecha"][:4]), int(fila["fecha"][5:7])
if anio and fila["fecha_origen"] == "mtime":
fa = anio
if mes:
fm = mes
elif fila["fecha_origen"] == "mtime":
conf = "baja"
motivo += " · mes por fecha de modificación"
hoy = datetime.date.today()
if (fa, fm) > (hoy.year, hoy.month):
fa -= 1
sub = ""
if cub == "GRABACION":
sub = R.FUENTE_CARPETA.get(fila["fuente"], "OTROS")
conf = "alta" if (anio and mes) else conf
if cub == "CELULAR":
if self.tipo == "contenido":
cub = "GRABACION"
sub = "CELULAR"
else:
sub = fila["fecha"][:7] if not (anio and mes) else ""
if cub == "PORTADAS" and nivel not in ("mes",):
cub = "REVISAR" if rol == "imagen" and nivel == "raiz" else cub
conf = "baja" if cub == "REVISAR" else conf
if cub == "EXPORTADOS":
conf = "media" if nivel == "mes" else "baja"
motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "")
if cub == "PROYECTOS":
sub = {"diseno": "Diseño", "proyecto": "Edicion"}.get(rol, "")
motivo = "proyecto de edición / diseño suelto"
conf = "alta"
if cub == "MUSICA" and rol == "audio":
conf = "baja"
if cub == "REVISAR":
destino = "_Revisar"
elif cub == "PROYECTOS":
destino = destino_plantilla(self.tipo, "PROYECTOS", nombre=sub)
else:
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="")
self.suelto(fila, cub, destino, motivo, conf)
def plan_basura(self):
con = self.con
# carpetas enteras de basura / proxy ya salieron en plan_subdir si estaban a la vista;
# aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura
carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
cubiertas = []
for c in sorted(carpetas, key=lambda c: len(c["ruta"])):
if any(c["ruta"].lower().startswith(p.lower() + os.sep) for p in cubiertas):
continue
if any(os.path.normpath(f["origen"]).lower() == c["ruta"].lower() for f in self.filas):
cubiertas.append(c["ruta"])
continue
if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue
cub = "PROXY" if c["es_proxy"] else "BASURA"
self.agregar(c["ruta"], self.abs_(destino_plantilla(self.tipo, cub, rel=self.rel(c["ruta"]))), cub, "papelera",
"proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta")
cubiertas.append(c["ruta"])
sueltos = con.execute("SELECT * FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
grupos = defaultdict(list)
for f in sueltos:
if any(f["dir"].lower() == p.lower() or f["dir"].lower().startswith(p.lower() + os.sep) for p in cubiertas):
continue
if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue
cub = "PROXY" if f["es_proxy"] else "BASURA"
grupos[cub].append(f)
# una sola fila por tipo: cada archivo conserva su ruta relativa dentro de _Para borrar
for cub, fs in grupos.items():
destinos = {}
for f in fs:
destinos[f["ruta"]] = self.abs_(os.path.join(destino_plantilla(self.tipo, cub, rel=self.rel(f["dir"])), f["nombre"]))
archivos = [(f["ruta"], f["bytes"]) for f in fs]
self.agregar(self.raiz, self.abs_(destino_plantilla(self.tipo, cub, rel="")), cub, "papelera",
f"basura de sistema en {len(set(f['dir'] for f in fs))} carpetas (.DS_Store, Thumbs.db, .tmp, ._*)" if cub == "BASURA" else "proxies sueltos",
"alta", archivos=archivos)
self.filas[-1]["destinos_archivo"] = destinos
self.filas[-1]["origen_rel"] = "(toda la raíz)"
# ==========================================================================
# Aplicar el plan (con bitácora y deshacer)
# ==========================================================================
def bitacora(entrada):
entrada["ts"] = time.time()
with open(BITACORA_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
def leer_bitacora(limite=2000):
if not os.path.exists(BITACORA_PATH):
return []
with open(BITACORA_PATH, "r", encoding="utf-8") as f:
lineas = f.readlines()
out = []
for l in lineas[-limite:]:
try:
out.append(json.loads(l))
except ValueError:
pass
return out
def nombre_libre(destino):
if not os.path.exists(destino):
return destino
base, ext = os.path.splitext(destino)
i = 2
while os.path.exists(f"{base} ({i}){ext}"):
i += 1
return f"{base} ({i}){ext}"
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.lower() == destino.lower():
return
if os.path.isdir(origen) and os.path.isdir(destino):
for nombre in os.listdir(origen):
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
try:
os.rmdir(origen)
bitacora({"lote": lote, "op": "rmdir", "de": origen})
except OSError:
pass
return
os.makedirs(os.path.dirname(destino), exist_ok=True)
if os.path.exists(destino):
# dentro de _Para borrar o Duplicados el nombre da igual: se numera. Fuera, NUNCA se renombra
# (Resolve/Premiere enlazan por nombre y los sidecar Sony van emparejados): se deja donde está y se avisa.
if _fuera_de_dup(destino):
raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo")
destino = nombre_libre(destino)
try:
os.rename(origen, destino)
except OSError:
# entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen
if os.path.isdir(origen):
shutil.move(origen, destino)
else:
shutil.copy2(origen, destino)
if os.path.getsize(destino) != os.path.getsize(origen):
os.remove(destino)
raise OSError(f"la copia de «{os.path.basename(origen)}» quedó incompleta; el original no se toca")
try:
os.remove(origen)
except OSError as ex:
os.remove(destino)
raise OSError(f"«{os.path.basename(origen)}» está en uso (¿abierto en Resolve o Premiere?); no se mueve") from ex
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino))
APLICANDO = {"hechas": 0, "total": 0, "actual": ""}
def aplicar(raiz, filas):
lote = time.strftime("%Y%m%d-%H%M%S")
movidos = []
errores = []
hechas = 0
filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
for f in filas:
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
try:
if f.get("archivos"):
especiales = f.get("destinos_archivo") or {}
if not especiales:
os.makedirs(f["destino"], exist_ok=True)
for a in f["archivos"]:
if os.path.exists(a):
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
else:
if os.path.exists(f["origen"]):
mover(f["origen"], f["destino"], lote, movidos)
hechas += 1
except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: {ex}")
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
for f in filas:
base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"])
if os.path.normpath(base).lower() != os.path.normpath(raiz).lower():
quitar_vacias(base, raiz)
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores})
return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores}
def actualizar_inventario(movidos):
"""refleja en la base de datos los movimientos hechos, sin re-escanear"""
con = db()
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
with con:
for de, a in movidos:
en_papelera = 1 if any(m in a for m in marcas) else 0
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, es_basura = CASE WHEN ? = 1 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(a, os.path.dirname(a), os.path.basename(a), en_papelera, de))
pref = de + os.sep
for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall():
nueva = a + r["ruta"][len(de):]
con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = CASE WHEN ? = 1 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(nueva, os.path.dirname(nueva), en_papelera, r["ruta"]))
con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%"))
con.close()
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)
tope = os.path.normpath(tope)
while ruta.lower() != tope.lower() and ruta.lower().startswith(tope.lower()):
try:
if os.path.isdir(ruta) and not os.listdir(ruta):
os.rmdir(ruta)
else:
break
except OSError:
break
ruta = os.path.dirname(ruta)
def deshacer(lote):
todas = leer_bitacora(100000)
entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir")]
fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {})
raiz = fin.get("raiz") or ""
deshechos, errores, movidos = 0, [], []
for e in reversed(entradas):
try:
if e["op"] == "rmdir":
os.makedirs(e["de"], exist_ok=True)
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 os.path.exists(e["de"]):
errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve")
else:
os.makedirs(os.path.dirname(e["de"]), exist_ok=True)
try:
os.rename(e["a"], e["de"])
except OSError:
shutil.move(e["a"], e["de"]) # el respaldo copió entre discos: se vuelve de la misma forma
movidos.append((e["a"], e["de"]))
deshechos += 1
if raiz:
quitar_vacias(os.path.dirname(e["a"]), raiz)
except OSError as ex:
errores.append(f"{e.get('a')}: {ex}")
actualizar_inventario(movidos)
bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores})
return {"deshechos": deshechos, "errores": errores}
# ==========================================================================
# Vocabulario (lo que la app "ve" que usamos como nombres)
# ==========================================================================
def vocabulario(raiz):
con = db()
filas = con.execute("SELECT * FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()
con.close()
tokens = {}
for c in filas:
nombre = c["nombre"]
if os.path.normpath(c["ruta"]).lower() == os.path.normpath(raiz).lower():
continue
if R.detectar_anio(nombre) or R.detectar_mes(nombre)[0]:
continue
rel = os.path.relpath(c["ruta"], raiz)
padre = os.path.basename(os.path.dirname(c["ruta"]))
if R.detectar_pieza(nombre) and R.detectar_mes(padre)[0]:
continue
if R.norm(nombre) in R.CARPETAS_PROPIAS or nombre.startswith("_"):
continue
tok = R.norm(nombre)
if not tok or len(tok) > 60:
continue
# ¿está dentro de una pieza (carpeta numerada bajo un mes)? esas no se mueven
partes = rel.split(os.sep)[:-1]
dentro_pieza = any(R.detectar_pieza(partes[i]) and i > 0 and R.detectar_mes(partes[i - 1])[0] for i in range(len(partes)))
t = tokens.get(tok)
if t is None:
cub, motivo = R.cubeta_carpeta(nombre, DICC["carpetas"])
t = tokens[tok] = {"token": tok, "ejemplo": nombre, "n": 0, "archivos": 0, "bytes": 0, "rutas": [], "dentro_pieza": dentro_pieza,
"cubeta": cub or "", "motivo": motivo, "aprendido": tok in DICC["carpetas"] and tok not in R.DICCIONARIO_SEMILLA}
t["dentro_pieza"] = t["dentro_pieza"] and dentro_pieza
t["n"] += 1
t["archivos"] += c["n"] or 0
t["bytes"] += c["bytes"] or 0
if len(t["rutas"]) < 4:
t["rutas"].append(rel)
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
return out
# ==========================================================================
# Mapa
# ==========================================================================
def mapa(raiz):
con = db()
raiz = os.path.normpath(raiz)
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
if not esc:
con.close()
return {"escaneado": False}
q = lambda sql, *a: [dict(r) for r in con.execute(sql, a).fetchall()]
total = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ?", raiz)[0]
por_ext = q("SELECT ext, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY ext ORDER BY bytes DESC LIMIT 14", raiz)
por_rol = q("SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY rol ORDER BY bytes DESC", raiz)
por_fuente = q("SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND fuente != '' GROUP BY fuente ORDER BY bytes DESC", raiz)
por_anio = q("SELECT substr(fecha,1,4) anio, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? GROUP BY anio ORDER BY anio", raiz)
por_mes = q("SELECT substr(fecha,1,7) mes, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado') GROUP BY mes ORDER BY mes", raiz)
basura = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_basura = 1", raiz)[0]
proxies = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", raiz)[0]
dd = duplicados(raiz)
dup_ej = [{"tipo": g["tipo"], "nombre": g["nombre"], "bytes": g["bytes"], "copias": len(g["mueven"]) + 1,
"queda": os.path.relpath(g["queda"], raiz), "rutas": [os.path.relpath(r, raiz) for r in g["mueven"]]} for g in dd["grupos"][:12]]
raw = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND rol = 'camara_raw'", raiz)[0]
grandes = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND bytes > 1000000000 AND rol IN ('video','camara_video') AND es_proxy = 0", raiz)[0]
sin_fecha = q("SELECT COUNT(*) n FROM archivos WHERE raiz = ? AND fecha_origen = 'mtime' AND rol IN ('camara_raw','camara_video')", raiz)[0]
# árbol de 2 niveles para el treemap
hijos = q("SELECT * FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC",
raiz, esc_like(con_sep(raiz)) + "%", esc_like(con_sep(raiz)) + "%" + esc_like(os.sep) + "%")
arbol = []
for h in hijos:
nietos = q("SELECT nombre, n, bytes FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC LIMIT 40",
raiz, esc_like(h["ruta"] + os.sep) + "%", esc_like(h["ruta"] + os.sep) + "%" + esc_like(os.sep) + "%")
cub, _ = R.cubeta_carpeta(h["nombre"], DICC["carpetas"])
if R.detectar_anio(h["nombre"]):
cub = "ANIO"
elif R.norm(h["nombre"]) in R.PAPELERAS_NORM or R.norm(h["nombre"]) == R.norm(R.DUPLICADOS):
cub = "APARTADO" # carpetas de la propia app: no son «sin reconocer»
arbol.append({"nombre": h["nombre"], "n": h["n"], "bytes": h["bytes"], "cubeta": cub or "", "hijos": nietos,
"anio": h["anio_moda"], "mes": h["mes_moda"]})
sueltos_raiz = q("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, raiz)[0]
con.close()
return {
"escaneado": True, "raiz": raiz, "escaneo": dict(esc), "total": total, "por_ext": por_ext, "por_rol": por_rol,
"por_fuente": por_fuente, "por_anio": por_anio, "por_mes": por_mes, "basura": basura, "proxies": proxies,
"duplicados": {"grupos": len(dd["grupos"]), "n": dd["n"], "bytes": dd["bytes"], "identicos": dd["identicos"], "versiones": dd["versiones"], "ejemplos": dup_ej}, "raw": raw, "grandes": grandes,
"sin_fecha": sin_fecha, "arbol": arbol, "sueltos_raiz": sueltos_raiz,
}
def buscar_carpeta(nombre, profundidad=3):
"""Busca una carpeta por nombre en todos los discos (hasta 3 niveles), para el arrastrar-y-soltar."""
objetivo = R.norm(nombre)
if not objetivo:
return []
saltar = {"#recycle", "@eadir", "windows", "program files", "program files (x86)", "programdata", "$recycle.bin", "appdata", "node_modules", "system volume information"}
hits = []
inicio = time.time()
def rec(path, nivel):
if time.time() - inicio > 25 or len(hits) >= 20:
return
try:
with os.scandir(path) as it:
ents = [e for e in it if e.is_dir(follow_symlinks=False) and not e.name.startswith((".", "$", "~"))]
except OSError:
return
for e in ents:
if R.norm(e.name) == objetivo:
hits.append(e.path)
if nivel < profundidad:
for e in ents:
if e.name.lower() not in saltar:
rec(e.path, nivel + 1)
for letra in "DEFGHIJKLMNOPQRSTUVWXYZC":
raiz = letra + ":" + os.sep
if os.path.exists(raiz):
rec(raiz, 1)
return hits
_DISCOS_CACHE = {"ts": 0, "datos": None}
def discos():
if time.time() - _DISCOS_CACHE["ts"] < 120 and _DISCOS_CACHE["datos"]:
return _DISCOS_CACHE["datos"]
unc = {}
try:
out = subprocess.run(["net", "use"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA).stdout
for l in out.splitlines():
m = re.search(r"\b([A-Z]):\s+(\\\\\S+)", l)
if m:
unc[m.group(1) + ":"] = m.group(2)
except Exception: # noqa
pass
alias = {}
for cli, info in CONFIG["clientes"].items():
for a in info.get("alias", []) + [cli]:
alias[R.norm(a)] = cli
res = []
for letra in "CDEFGHIJKLMNOPQRSTUVWXYZ":
d = letra + ":"
raiz = d + os.sep
if not os.path.exists(raiz):
continue
try:
u = shutil.disk_usage(raiz)
except OSError:
continue
carpetas = []
try:
with os.scandir(raiz) as it:
for e in it:
if e.is_dir(follow_symlinks=False) and not e.name.startswith(("#", ".", "$", "@")):
cli = alias.get(R.norm(e.name))
carpetas.append({"nombre": e.name, "cliente": cli})
except OSError:
pass
cfg = CONFIG["discos"].get(d, {})
res.append({"letra": d, "unc": unc.get(d, ""), "nombre": cfg.get("nombre", ""), "nivel": cfg.get("nivel", "" if unc.get(d) else "local"),
"total": u.total, "libre": u.free, "carpetas": sorted(carpetas, key=lambda c: c["nombre"].lower())})
# dónde vive cada cliente
por_cliente = defaultdict(list)
for disco in res:
for c in disco["carpetas"]:
if c["cliente"]:
por_cliente[c["cliente"]].append(f"{disco['letra']}\\{c['nombre']}")
for r in CONFIG["raices"]:
por_cliente[r["cliente"]].append(r["ruta"])
datos = {"discos": res, "clientes": {k: sorted(set(v)) for k, v in por_cliente.items()}}
_DISCOS_CACHE.update(ts=time.time(), datos=datos)
return datos
# ==========================================================================
# Optimización: análisis (ffprobe), candidatos, cola, motores
# ==========================================================================
ANALISIS = {} # raiz -> progreso
def ffprobe(ruta):
cmd = [ruta_herramienta("ffprobe"), "-v", "error", "-show_entries",
"stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,bit_rate,nb_frames",
"-show_entries", "format=duration,bit_rate", "-of", "json", ruta]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA).stdout
j = json.loads(out or "{}")
except Exception: # noqa
return None
v = next((s for s in j.get("streams", []) if s.get("codec_type") == "video"), None)
a = next((s for s in j.get("streams", []) if s.get("codec_type") == "audio"), None)
fmt = j.get("format", {})
if not v:
return None
fr = v.get("r_frame_rate", "0/1")
try:
num, den = fr.split("/")
fps = float(num) / float(den) if float(den) else 0
except ValueError:
fps = 0
dur = float(fmt.get("duration") or 0)
kbps = int(int(fmt.get("bit_rate") or v.get("bit_rate") or 0) / 1000)
pix = v.get("pix_fmt") or ""
return {
"codec": v.get("codec_name") or "", "perfil": v.get("profile") or "", "pix_fmt": pix,
"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 "",
}
def analizar(raiz, min_mb):
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()}
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()
prog["total"] = len(pend)
try:
for f in pend:
info = ffprobe(f["ruta"])
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"
except Exception as ex: # noqa
prog["estado"] = "error"
prog["error"] = str(ex)
finally:
con.close()
def kbps_objetivo(ancho, alto, fps, perfil):
bpp = PERFILES.get(perfil, PERFILES["equilibrado"])["bpp"]
fps = fps or 30
return max(1500, int(ancho * alto * fps * bpp / 1000))
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False):
con = db()
limite = time.time() - int(meses) * 30.4 * 86400
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC",
(raiz, int(min_mb) * 1024 * 1024)).fetchall()
en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").fetchall()}
con.close()
out = []
for f in filas:
motivo_no = ""
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
if f["es_stock"]:
motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)"
elif f["alfa"]:
motivo_no = "tiene canal alfa (HEVC lo perdería)"
elif f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * 1.3:
motivo_no = "ya está en HEVC/AV1 eficiente"
elif f["mtime"] > limite:
motivo_no = f"tiene menos de {meses} meses"
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
motivo_no = "es un exportado / master"
elif not f["ancho"] or not f["dur"]:
motivo_no = "no se pudo leer (ffprobe)"
obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil)
est = int(obj * 1000 / 8 * (f["dur"] or 0)) + int(256000 / 8 * (f["dur"] or 0))
out.append({
"ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"],
"pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"],
"kbps_objetivo": obj, "estimado": est, "motor": motor, "excluido": motivo_no, "fecha": f["fecha"], "rol": f["rol"],
"contexto": f["contexto"], "en_cola": en_cola.get(f["ruta"], ""),
})
return out
def salida_para(ruta, raiz):
o = CONFIG["optimizar"]
base, _ = os.path.splitext(ruta)
if o.get("salida") == "espejo":
rel = os.path.relpath(base, raiz)
return os.path.join(raiz, "_Archivo", rel + ".mp4")
return base + o.get("sufijo", "_10bit") + ".mp4"
def encolar(rutas, perfil, chroma, color, raiz):
con = db()
ids = []
with con:
for r in rutas:
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone()
if not f:
continue
ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r,)).fetchone()
if ya:
continue
salida = salida_para(r, raiz)
# el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola.
otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone()
if otro or os.path.exists(salida):
continue
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
kb = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil)
cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz))
ids.append(cur.lastrowid)
con.close()
return ids
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": ""}
def set_trabajo(id_, **kw):
con = db()
with con:
sets = ", ".join(f"{k} = ?" for k in kw)
con.execute(f"UPDATE trabajos SET {sets} WHERE id = ?", (*kw.values(), id_))
con.close()
def correr_ffmpeg(t):
o = CONFIG["optimizar"]
con = db()
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
con.close()
dur = (f["dur"] if f else 0) or 0
fps = (f["fps"] if f else 30) or 30
chroma = t["chroma"] or "420"
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"]
maxrate = int(t["kbps"] * 1.8)
salida = t["salida"]
os.makedirs(os.path.dirname(salida), exist_ok=True)
tmp = salida + ".parcial.mp4"
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?",
"-c:v", "hevc_nvenc",
"-pix_fmt", "p210le" if chroma == "422" else "p010le",
"-profile:v", "rext" if chroma == "422" else "main10",
"-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0",
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k",
"-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32",
"-bf", "3", "-b_ref_mode", "middle", "-g", str(int(round(fps * 2))),
"-tag:v", "hvc1", "-map_metadata", "0", "-movflags", "+faststart"]
if f and f["acodec"] == "aac":
cmd += ["-c:a", "copy"]
else:
cmd += ["-c:a", "aac", "-b:a", "320k"]
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
return
ultimo = 0
for linea in p.stdout:
if COLA["cancelar"]:
p.kill()
break
if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="):
try:
us = int(linea.split("=")[1])
if linea.startswith("out_time_ms="):
us = us # ffmpeg reporta microsegundos en ambas claves
pct = min(99.0, us / 1e6 / dur * 100) if dur else 0
if time.time() - ultimo > 1:
set_trabajo(t["id"], pct=pct)
ultimo = time.time()
except ValueError:
pass
err = p.stderr.read()
p.wait()
if COLA["cancelar"]:
set_trabajo(t["id"], estado="cancelado", pct=0)
try:
os.remove(tmp)
except OSError:
pass
return
if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
return
os.replace(tmp, salida)
try:
st = os.stat(t["ruta"])
os.utime(salida, (st.st_atime, st.st_mtime))
except OSError:
pass
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time())
def correr_resolve(trabajos):
"""Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte)."""
o = CONFIG["optimizar"]
con = db()
jobs = []
for t in trabajos:
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
jobs.append({"id": t["id"], "src": t["ruta"], "dst": t["salida"], "w": (f["ancho"] if f else 0) or 0,
"h": (f["alto"] if f else 0) or 0, "fps": (f["fps"] if f else 0) or 0, "kbps": t["kbps"],
"color": t["color"] or "rec709"})
set_trabajo(t["id"], estado="corriendo", pct=0)
con.close()
lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json")
with open(lote, "w", encoding="utf-8") as fh:
json.dump(jobs, fh, ensure_ascii=False)
if CONGELADO and not o.get("python_resolve"):
cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente
else:
py = o.get("python_resolve") or sys.executable
cmd = [py, os.path.join(BASE, "resolve_bridge.py"), lote]
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
for t in trabajos:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el puente de Resolve: {ex}")
return
log_general = []
for linea in p.stdout:
linea = linea.strip()
if not linea:
continue
if linea.startswith("{"):
try:
ev = json.loads(linea)
except ValueError:
log_general.append(linea)
continue
if "id" in ev:
kw = {}
if "pct" in ev:
kw["pct"] = ev["pct"]
if ev.get("estado"):
kw["estado"] = ev["estado"]
if ev.get("log"):
kw["log"] = ev["log"][-3000:]
if ev.get("estado") == "listo":
kw["fin"] = time.time()
try:
kw["bytes_out"] = os.path.getsize(ev.get("salida") or "")
except OSError:
pass
set_trabajo(ev["id"], **kw)
else:
COLA["mensaje"] = ev.get("msg", "")
log_general.append(ev.get("msg", ""))
else:
log_general.append(linea)
if COLA["cancelar"]:
p.kill()
break
p.wait()
con = db()
for t in trabajos:
r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
if r and r["estado"] == "corriendo":
set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error",
log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo")
con.close()
def _verificar_listos(ids):
"""al acabar cada conversión se verifica sola (códec, 10 bits, duración): el usuario solo decide si reemplaza"""
try:
con = db()
listos = [r["id"] for r in con.execute(f"SELECT id FROM trabajos WHERE estado = 'listo' AND id IN ({','.join('?' * len(ids))})", ids).fetchall()] if ids else []
con.close()
if listos:
verificar(listos)
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo verificar: {ex}"
def bucle_cola():
try:
while COLA["activa"]:
con = db()
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'ffmpeg' ORDER BY id LIMIT 1").fetchone()
lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()]
con.close()
if t:
COLA["actual"] = t["id"]
set_trabajo(t["id"], estado="corriendo", pct=0)
try:
correr_ffmpeg(dict(t))
except Exception as ex: # noqa
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
_verificar_listos([t["id"]])
elif lote_resolve:
COLA["actual"] = lote_resolve[0]["id"]
try:
correr_resolve(lote_resolve)
except Exception as ex: # noqa
for r in lote_resolve:
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
_verificar_listos([r["id"] for r in lote_resolve])
else:
break
COLA["actual"] = None
if COLA["cancelar"]:
COLA["cancelar"] = False
break
except Exception as ex: # noqa
COLA["mensaje"] = f"la cola se detuvo por un error: {ex}"
finally:
# pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa)
COLA["activa"] = False
COLA["actual"] = None
COLA["hilo"] = None
def iniciar_cola():
if COLA["hilo"] and COLA["hilo"].is_alive():
return False
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
def verificar(ids):
con = db()
res = []
for id_ in ids:
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
if not t or not os.path.exists(t["salida"]):
res.append({"id": id_, "ok": False, "motivo": "no existe la salida"})
continue
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
info = ffprobe(t["salida"])
if not info:
res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"})
set_trabajo(id_, estado="error", log="verificación: ffprobe no pudo leer la salida")
continue
dur_src = (f["dur"] if f else 0) or 0
tol = max(0.5, dur_src * 0.01)
problemas = []
if dur_src and abs(info["dur"] - dur_src) > tol:
problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s")
if info["codec"] != "hevc":
problemas.append(f"códec {info['codec']}")
if "10" not in info["pix_fmt"]:
problemas.append(f"no es 10 bits ({info['pix_fmt']})")
if os.path.getsize(t["salida"]) < 100000:
problemas.append("archivo demasiado pequeño")
if problemas:
set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas))
res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)})
else:
set_trabajo(id_, estado="verificado", bytes_out=os.path.getsize(t["salida"]))
res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"})
con.close()
return res
def reemplazar(ids):
"""El original se va a _Para borrar/originales (misma raíz). Nunca se borra."""
con = db()
res = []
lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia
movimientos, raiz_lote = 0, ""
for id_ in ids:
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
if not t or t["estado"] != "verificado":
res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"})
continue
if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024:
res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"})
set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar")
continue
raiz = t["raiz"]
raiz_lote = raiz_lote or raiz
rel = os.path.relpath(t["ruta"], raiz)
destino = os.path.join(raiz, R.PAPELERA, "originales", rel)
try:
os.makedirs(os.path.dirname(destino), exist_ok=True)
destino = nombre_libre(destino)
os.rename(t["ruta"], destino)
movimientos += 1
bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_})
# el sidecar .xml de Sony y el .mp4 proxy de Blackmagic se van con el original
base, ext = os.path.splitext(t["ruta"])
for extra in (base + "M01.xml", base + "M01.XML"):
if os.path.exists(extra):
d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra))
os.rename(extra, d2)
movimientos += 1
bitacora({"lote": lote, "op": "mover", "de": extra, "a": d2, "trabajo": id_})
set_trabajo(id_, estado="reemplazado")
with con:
con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = 1 WHERE ruta = ?", (destino, os.path.dirname(destino), t["ruta"]))
res.append({"id": id_, "ok": True, "motivo": f"original en {os.path.relpath(destino, raiz)}"})
except OSError as ex:
res.append({"id": id_, "ok": False, "motivo": str(ex)})
con.close()
if movimientos:
bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos,
"errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"]]})
return res
# ==========================================================================
# Stock de Envato en proyectos viejos, papelera y respaldo
# ==========================================================================
def tipo_medio(rol, ext):
e = (ext or "").lower()
if e in R.EXT["video"] or e in R.EXT["video_raw"]:
return "Video"
if e in R.EXT["audio"]:
return "Audio"
if e in R.EXT["imagen"] or e in R.EXT["foto_raw"]:
return "Imagenes"
if e in R.EXT["diseno"]:
return "Diseño"
if e in R.EXT["proyecto"]:
return "Proyectos"
if e in R.EXT["documento"]:
return "Documentos"
if e in R.EXT["fuente"] or e in R.EXT["lut"]:
return "Fuentes"
return "Otros"
def limite_meses(meses):
hoy = datetime.date.today()
m = hoy.year * 12 + hoy.month - 1 - int(meses)
return m // 12, m % 12 + 1 # (año, mes) tope: lo anterior es "viejo"
def es_viejo(f, tope):
"""un archivo es viejo si el mes de su pieza (o su fecha) es anterior al tope"""
if f["anio_ruta"] and f["mes_ruta"]:
return (f["anio_ruta"], f["mes_ruta"]) < tope
return (int(f["fecha"][:4]), int(f["fecha"][5:7])) < tope
def stock_viejo(raiz, meses, tipos):
con = db()
tope = limite_meses(meses)
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0", (raiz,)).fetchall()
con.close()
grupos = {}
for f in filas:
t = tipo_medio(f["rol"], f["ext"])
if t not in tipos and not (t == "Otros" and f["ext"] in (".zip", ".rar") and "Video" in tipos):
continue
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["n"] += 1
g["bytes"] += f["bytes"]
g["rutas"].append(f["ruta"])
if len(g["ejemplos"]) < 3:
g["ejemplos"].append(f["nombre"])
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}"}
def stock_a_papelera(raiz, rutas):
lote = "stock-" + time.strftime("%Y%m%d-%H%M%S")
movidos, errores = [], []
for r in rutas:
try:
if not os.path.exists(r):
continue
if not dentro_de(raiz, r):
errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca")
continue
rel = os.path.relpath(r, raiz)
mover(r, os.path.join(raiz, R.PAPELERA, "stock", rel), lote, movidos)
quitar_vacias(os.path.dirname(r), raiz)
except Exception as ex: # noqa
errores.append(f"{r}: {ex}")
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(rutas), "movimientos": len(movidos), "errores": errores})
return {"lote": lote, "movimientos": len(movidos), "errores": errores}
def papelera(raiz):
"""Qué hay en _Para borrar (y en la _Papelera vieja si existe). Solo lee: borrar es cosa de una persona."""
out = []
for carpeta in R.PAPELERAS:
base = os.path.join(raiz, carpeta)
if not os.path.isdir(base):
continue
for nombre in sorted(os.listdir(base)):
p = os.path.join(base, nombre)
if not os.path.isdir(p):
continue
n, b = 0, 0
for d, _, fs in os.walk(p):
for f in fs:
try:
b += os.path.getsize(os.path.join(d, f))
n += 1
except OSError:
pass
out.append({"carpeta": carpeta, "nombre": nombre, "ruta": p, "n": n, "bytes": b})
return {"subcarpetas": out, "bytes": sum(x["bytes"] for x in out), "carpeta": os.path.join(raiz, R.PAPELERA)}
def abrir_carpeta(ruta):
"""Abre una carpeta en el Explorador (solo dentro de alguna raíz configurada)."""
ruta = os.path.normpath(ruta)
permitido = any(ruta.lower().startswith(os.path.normpath(r["ruta"]).lower()) for r in CONFIG.get("raices", []))
if not permitido or not os.path.isdir(ruta):
return {"ok": False, "motivo": "esa carpeta no está dentro de una raíz"}
os.startfile(ruta)
return {"ok": True}
# ==========================================================================
# Duplicados: se juntan en una subcarpeta «Duplicados» junto al que se queda. Nunca se borran.
# ==========================================================================
_EXT_VIDEO_DUP = {".mp4", ".mov", ".mxf", ".braw", ".avi", ".mkv", ".m4v", ".mts", ".r3d"}
def _fuera_de_dup(ruta):
partes = {R.norm(p) for p in os.path.normpath(ruta).split(os.sep)}
return not (partes & (R.PAPELERAS_NORM | {R.norm(R.DUPLICADOS)}))
def _mejor(rutas):
"""Cuál se queda: el que ya esté en una carpeta de grabación, luego el de ruta más corta."""
def clave(r):
en_grab = 0 if "grabacion" in R.norm(r.replace(os.sep, " ")) else 1
return (en_grab, len(r.split(os.sep)), len(r), r.lower())
return sorted(rutas, key=clave)[0]
def duplicados(raiz, minimo=5_000_000):
"""Grupos de archivos repetidos. Dos clases:
identico = mismo nombre y mismo tamaño en sitios distintos.
version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede
cambiar el color o el peso, así que se deja para revisar a mano."""
con = db()
filas = [dict(r) for r in con.execute(
"SELECT ruta, dir, nombre, ext, bytes FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0",
(os.path.normpath(raiz), minimo)).fetchall()]
con.close()
filas = [f for f in filas if _fuera_de_dup(f["ruta"]) and os.path.exists(f["ruta"])]
grupos, usados = [], set()
por_nombre = {}
for f in filas:
por_nombre.setdefault((f["nombre"].lower(), f["bytes"]), []).append(f)
for (nombre, b), fs in por_nombre.items():
if len(fs) < 2:
continue
rutas = [f["ruta"] for f in fs]
queda = _mejor(rutas)
mueven = [r for r in rutas if r != queda]
usados.update(rutas)
grupos.append({"tipo": "identico", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven,
"bytes": b * len(mueven), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)})
por_raiz = {}
for f in filas:
if f["ruta"] in usados or (f["ext"] or "").lower() not in _EXT_VIDEO_DUP:
continue
stem = os.path.splitext(f["nombre"])[0].lower()
if len(stem) < 6:
continue
por_raiz.setdefault(stem, []).append(f)
for stem, fs in por_raiz.items():
if len({(f["ext"] or "").lower() for f in fs}) < 2:
continue
fs = sorted(fs, key=lambda f: -(f["bytes"] or 0))
queda = fs[0]["ruta"] # el más pesado suele ser el original
mueven = [f["ruta"] for f in fs[1:]]
grupos.append({"tipo": "version", "nombre": fs[0]["nombre"], "queda": queda, "mueven": mueven,
"bytes": sum(f["bytes"] or 0 for f in fs[1:]), "destino": os.path.join(os.path.dirname(queda), R.DUPLICADOS)})
grupos.sort(key=lambda g: -g["bytes"])
return {"grupos": grupos, "n": sum(len(g["mueven"]) for g in grupos), "bytes": sum(g["bytes"] for g in grupos),
"identicos": sum(1 for g in grupos if g["tipo"] == "identico"), "versiones": sum(1 for g in grupos if g["tipo"] == "version")}
def huella_rapida(ruta, trozo=1 << 20):
"""sha256 del primer MB, uno del centro y el último (y del tamaño): suficiente para distinguir dos archivos que solo
coinciden en nombre y peso, sin leer gigas por la red."""
h = hashlib.sha256()
tam = os.path.getsize(ruta)
h.update(str(tam).encode())
with open(ruta, "rb") as fh:
h.update(fh.read(trozo))
if tam > trozo * 3:
fh.seek(tam // 2)
h.update(fh.read(trozo))
if tam > trozo * 2:
fh.seek(tam - trozo)
h.update(fh.read(trozo))
return h.hexdigest()
def apartar_duplicados(raiz, grupos=None):
"""Mueve cada copia a <carpeta del que se queda>/Duplicados. Reversible por lote; nada se borra.
Las copias «idénticas» se comprueban por contenido antes de moverlas: si difieren, se dejan y se avisa."""
raiz = os.path.normpath(raiz)
if grupos is None:
grupos = duplicados(raiz)["grupos"]
lote = "dup-" + time.strftime("%Y%m%d-%H%M%S")
movidos, errores, distintos = [], [], 0
for g in grupos:
ref = None
if g.get("tipo") == "identico":
try:
ref = huella_rapida(g["queda"])
except OSError as ex:
errores.append(f"{g['queda']}: no se pudo leer para comparar ({ex}); el grupo se deja")
continue
for r in g["mueven"]:
try:
if not os.path.exists(r):
continue
if ref is not None and huella_rapida(r) != ref:
distintos += 1
errores.append(f"{os.path.relpath(r, raiz)}: mismo nombre y peso pero contenido distinto; se deja donde está")
continue
mover(r, os.path.join(g["destino"], os.path.basename(r)), lote, movidos)
quitar_vacias(os.path.dirname(r), raiz)
except Exception as ex: # noqa
errores.append(f"{r}: {ex}")
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(grupos), "movimientos": len(movidos), "errores": errores})
return {"lote": lote, "grupos": len(grupos), "movimientos": len(movidos), "errores": errores, "distintos": distintos}
RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0}
def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True):
con = db()
rc = raiz_cfg(raiz)
cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz"
base = os.path.join(destino, re.sub(r'[<>:"/\\|?*]', "_", cliente))
conv = {}
for t in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('verificado','reemplazado')").fetchall():
if os.path.exists(t["salida"]):
conv[os.path.normpath(t["ruta"]).lower()] = t["salida"]
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0", (raiz,)).fetchall()
con.close()
marcas_p = [os.sep + p + os.sep for p in R.PAPELERAS]
marca_a = os.sep + "_Archivo" + os.sep
items, vistos = [], set()
omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0
def agregar(ruta, rol, ext, bytes_, mtime):
k = os.path.normpath(ruta).lower()
if k in vistos:
return
vistos.add(k)
rel_dir = os.path.relpath(os.path.dirname(ruta), raiz)
rel_dir = "" if rel_dir == "." else rel_dir
tipo = tipo_medio(rol, ext)
items.append({"origen": ruta, "rel_dir": rel_dir, "nombre": os.path.basename(ruta), "bytes": bytes_, "tipo": tipo, "mtime": mtime, "rel": os.path.relpath(ruta, raiz)})
for f in filas:
if any(m in f["ruta"] for m in marcas_p) or marca_a in f["ruta"]:
continue
if sin_stock and f["es_stock"]:
omit_stock_b += f["bytes"]
omit_stock_n += 1
continue
k = os.path.normpath(f["ruta"]).lower()
if k in conv:
sal = conv[k]
try:
st = os.stat(sal)
except OSError:
continue
convertidos += 1
vistos.add(k)
agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime)
continue
if f["rol"] == "camara_raw" and not incluir_raw:
omit_raw.append({"rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"]})
continue
agregar(f["ruta"], f["rol"], f["ext"], f["bytes"], f["mtime"])
for k, sal in conv.items():
if os.path.normpath(sal).lower().startswith(os.path.normpath(raiz).lower() + os.sep) and not any(m in sal for m in marcas_p):
try:
st = os.stat(sal)
agregar(sal, "video", os.path.splitext(sal)[1], st.st_size, st.st_mtime)
except OSError:
pass
# la subcarpeta por tipo solo se añade donde una carpeta mezcla tipos de medio
# ("Audio/" ya es solo audio: no hace falta "Audio/Audio/")
tipos_por_dir = defaultdict(set)
for it in items:
tipos_por_dir[it["rel_dir"]].add(it["tipo"])
for it in items:
mezcla = por_tipo and len(tipos_por_dir[it["rel_dir"]]) > 1
partes = [base, it["rel_dir"], it["tipo"] if mezcla else "", it["nombre"]]
it["destino"] = os.path.normpath(os.path.join(*[p for p in partes if p]))
por_tipo_res = defaultdict(lambda: {"n": 0, "bytes": 0})
for it in items:
por_tipo_res[it["tipo"]]["n"] += 1
por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
omit_raw.sort(key=lambda x: -x["bytes"])
return {
"cliente": cliente, "base": base, "n": len(items), "bytes": sum(i["bytes"] for i in items),
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
"muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]],
"items": items,
}
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 = []
for it in plan["items"]:
if RESPALDO["cancelar"]:
RESPALDO["estado"] = "cancelado"
break
d = it["destino"]
RESPALDO["actual"] = it["rel"]
try:
os.makedirs(os.path.dirname(d), exist_ok=True)
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
if ya:
RESPALDO["saltados"] += 1
else:
tmp = d + ".parcial"
shutil.copy2(it["origen"], tmp)
if os.path.getsize(tmp) != it["bytes"]:
raise OSError("tamaño distinto tras copiar")
os.replace(tmp, d)
RESPALDO["copiados"] += 1
indice.append((os.path.relpath(d, plan["base"]), it["rel"], it["tipo"], it["bytes"], datetime.datetime.fromtimestamp(it["mtime"]).strftime("%Y-%m-%d")))
except Exception as ex: # noqa
RESPALDO["errores"].append(f"{it['rel']}: {ex}")
RESPALDO["hechos"] += 1
RESPALDO["bytes"] += it["bytes"]
try:
os.makedirs(plan["base"], exist_ok=True)
import csv
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)
resumen = defaultdict(lambda: defaultdict(lambda: [0, 0]))
for rel_d, rel_o, tipo, b, _ in indice:
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
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")
fh.write("Los clips de cámara están en MP4 H.265 10 bits (Main10). Los BRAW/originales pesados no se copian salvo que se pida.\n\n")
fh.write("| Carpeta | Tipo | Archivos | GB |\n|---|---|---:|---:|\n")
for carpeta in sorted(resumen):
for tipo, (n, b) in sorted(resumen[carpeta].items()):
fh.write(f"| {carpeta or '(raíz)'} | {tipo} | {n} | {b/1e9:.2f} |\n")
with open(os.path.join(plan["base"], "manifest.json"), "w", encoding="utf-8") as fh:
json.dump({"cliente": plan["cliente"], "origen": raiz, "fecha": time.time(), "archivos": len(indice), "bytes": sum(i[3] for i in indice),
"errores": RESPALDO["errores"]}, fh, ensure_ascii=False, indent=1)
except Exception as ex: # noqa
RESPALDO["errores"].append(f"índice: {ex}")
if RESPALDO["estado"] == "corriendo":
RESPALDO["estado"] = "listo"
bitacora({"lote": "respaldo", "op": "respaldo", "raiz": raiz, "a": plan["base"], "archivos": RESPALDO["hechos"], "copiados": RESPALDO["copiados"],
"saltados": RESPALDO["saltados"], "bytes": RESPALDO["bytes"], "errores": len(RESPALDO["errores"])})
# ==========================================================================
# Servidor HTTP
# ==========================================================================
PLANES = {} # raiz -> plan
PLANES_RESPALDO = {}
def _clave(ruta):
return os.path.normpath(ruta)
def quitar_raiz(ruta, borrar_datos=False):
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear)."""
k = _clave(ruta)
r = raiz_cfg(ruta)
if not r:
raise ValueError("esa carpeta no está registrada")
if (PROGRESO.get(k) or {}).get("estado") == "corriendo" or (ANALISIS.get(k) or {}).get("estado") == "corriendo":
raise ValueError("la carpeta se está escaneando o analizando; espera a que termine")
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"] and (APLICANDO.get("raiz") or "").lower() == k.lower():
raise ValueError("se está aplicando el plan de esa carpeta; espera a que termine")
con = db()
activos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo')", (k.lower(),)).fetchone()["n"]
if activos:
con.close()
raise ValueError(f"hay {activos} conversiones pendientes o en curso de esa carpeta; detén la cola o límpiala primero")
CONFIG["raices"] = [x for x in CONFIG["raices"] if _clave(x["ruta"]).lower() != k.lower()]
guardar_json(CONFIG_PATH, CONFIG)
PLANES.pop(k, None); PROGRESO.pop(k, None); ANALISIS.pop(k, None)
if borrar_datos:
for t in ("archivos", "carpetas", "escaneos"):
con.execute(f"DELETE FROM {t} WHERE lower(raiz) = ?", (k.lower(),))
con.commit()
con.close()
bitacora({"op": "raiz_quitada", "raiz": k, "datos_borrados": bool(borrar_datos)})
return {"ok": True, "raices": len(CONFIG["raices"])}
def estado_general():
"""Todo lo que la interfaz sondea cada pocos segundos, en una sola respuesta."""
con = db()
esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()}
cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()}
con.close()
esc_ci = {k.lower(): v for k, v in esc.items()}
raices = []
for r in CONFIG["raices"]:
k = _clave(r["ruta"])
raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)})
return {"config": CONFIG, "raices": raices, "herramientas": herramientas(),
"version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO,
"cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "n": cola_n},
"aplicando": APLICANDO, "asistente": asistente.ESTADO,
"respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])},
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "plantillas": PLANTILLAS}
def estado_carpeta(ruta):
"""El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea."""
raiz = _clave(ruta)
r = raiz_cfg(raiz)
if not r:
return {"error": "carpeta no registrada"}
con = db()
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
trabajos = {x["estado"]: {"n": x["n"], "bytes_in": x["bi"] or 0, "bytes_out": x["bo"] or 0}
for x in con.execute("SELECT estado, COUNT(*) n, SUM(bytes_in) bi, SUM(bytes_out) bo FROM trabajos WHERE raiz = ? GROUP BY estado", (raiz,)).fetchall()}
analizados = con.execute("SELECT SUM(analizado = 1) ok, SUM(analizado = 0) sin FROM archivos WHERE raiz = ? AND rol IN ('camara_raw','camara_video','celular_video','video')", (raiz,)).fetchone()
con.close()
prog = PROGRESO.get(raiz)
diag = asistente.diagnostico(raiz) if esc else None
g = {x["clave"]: x for x in (diag["grupos"] if diag else [])}
# último lote aplicado sobre esta carpeta
lotes = []
for e in leer_bitacora(3000):
if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower():
lotes.append({"lote": e.get("lote"), "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])})
lotes.sort(key=lambda x: -(x["ts"] or 0))
plan = PLANES.get(raiz)
plan_res = None
if plan:
filas = plan["filas"]
plan_res = {"filas": len(filas), "marcadas": sum(1 for f in filas if f.get("marcado")), "bytes": sum(f["bytes"] for f in filas),
"alta": sum(1 for f in filas if f["confianza"] == "alta"), "baja": sum(1 for f in filas if f["confianza"] == "baja"),
"avisos": plan.get("avisos") or []}
sin_nombre = [t for t in vocabulario(raiz) if not t.get("cubeta") and not t.get("dentro_pieza")] if esc else []
cam_bytes = (g.get("camara_raw", {}).get("bytes", 0) + g.get("camara_video", {}).get("bytes", 0))
resp_plan = PLANES_RESPALDO.get(raiz)
return {
"raiz": raiz, "cliente": r["cliente"], "tipo": r["tipo"],
"escaneo": dict(esc) if esc else None, "progreso": prog,
"diagnostico": diag,
"sin_nombre": [{"token": t["token"], "ejemplo": t["ejemplo"], "n": t["n"], "bytes": t["bytes"]} for t in sin_nombre[:12]],
"sin_nombre_n": len(sin_nombre),
"plan": plan_res, "lotes": lotes[:6],
"convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos,
"analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0,
"cola_activa": COLA["activa"]},
"respaldo": {"destino": CONFIG.get("respaldo", {}).get("destino", ""), "plan": ({"n": resp_plan["n"], "bytes": resp_plan["bytes"], "base": resp_plan.get("base")} if resp_plan else None),
"estado": RESPALDO.get("estado"), "raw_pendientes": (resp_plan or {}).get("raw_omitidos", {}).get("n", 0)},
"asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None,
}
class Manejador(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
pass
def _json(self, datos, codigo=200):
cuerpo = json.dumps(datos, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(codigo)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(cuerpo)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(cuerpo)
def _cuerpo(self):
n = int(self.headers.get("Content-Length") or 0)
if not n:
return {}
try:
return json.loads(self.rfile.read(n).decode("utf-8"))
except ValueError:
return {}
def _es_local(self):
"""Solo acepta peticiones que vienen de la propia página (evita que otra web abierta en el navegador dé órdenes)."""
host = (self.headers.get("Host") or "").lower()
if host not in (f"127.0.0.1:{PUERTO}", f"localhost:{PUERTO}"):
return False
origen = self.headers.get("Origin")
if origen and origen.lower() not in (f"http://127.0.0.1:{PUERTO}", f"http://localhost:{PUERTO}"):
return False
return True
def do_GET(self):
u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()}
ruta = u.path
try:
if ruta in ("/", "/index.html"):
with open(os.path.join(BASE, "static", "index.html"), "rb") as f:
cuerpo = f.read()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(cuerpo)))
self.end_headers()
self.wfile.write(cuerpo)
return
if ruta.startswith("/static/") and re.fullmatch(r"/static/[A-Za-z0-9_.-]+", ruta):
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"}
with open(p, "rb") as f:
cuerpo = f.read()
self.send_response(200)
self.send_header("Content-Type", tipos.get(os.path.splitext(p)[1].lower(), "application/octet-stream"))
self.send_header("Content-Length", str(len(cuerpo)))
self.send_header("Cache-Control", "max-age=86400")
self.end_headers()
self.wfile.write(cuerpo)
return
if ruta == "/api/estado":
return self._json(estado_general())
if ruta == "/api/carpeta/estado":
return self._json(estado_carpeta(q["raiz"]))
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)
if ruta == "/api/asistente/pasos":
raiz = os.path.normpath(q["raiz"])
r = raiz_cfg(raiz)
if not r:
return self._json({"error": "raíz no registrada"}, 400)
d = asistente.diagnostico(raiz)
filas = Plan(r).correr()["filas"]
asistente.PLAN_CACHE[raiz] = filas
return self._json({"diagnostico": d, "pasos": asistente.pasos(d, filas), "movimientos": len(filas)})
if ruta == "/api/discos":
return self._json(discos())
if ruta == "/api/vocabulario":
return self._json({"tokens": vocabulario(os.path.normpath(q["raiz"])), "diccionario": DICC})
if ruta == "/api/diccionario":
return self._json(DICC)
if ruta == "/api/plan":
r = raiz_cfg(q["raiz"])
if not r:
return self._json({"error": "raíz no configurada"}, 400)
plan = Plan(r).correr()
PLANES[plan["raiz"]] = plan
return self._json(plan)
if ruta == "/api/bitacora":
ent = leer_bitacora(3000)
lotes = {}
for e in ent:
l = e.get("lote")
if not l:
continue
lo = lotes.setdefault(l, {"lote": l, "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})
if e.get("op") == "mover":
lo["movimientos"] += 1
if len(lo["ejemplos"]) < 5:
lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")})
if e.get("op") == "fin":
lo["raiz"] = e.get("raiz", "")
lo["errores"] = len(e.get("errores") or [])
if e.get("op") == "deshacer":
lotes.setdefault(e.get("de_lote"), {"lote": e.get("de_lote"), "movimientos": 0, "ts": e.get("ts"), "raiz": "", "errores": 0, "deshecho": False, "ejemplos": []})["deshecho"] = True
return self._json({"lotes": sorted([v for k, v in lotes.items() if not k.endswith("-deshecho")], key=lambda x: -(x["ts"] or 0))})
if ruta == "/api/optimizar/candidatos":
o = CONFIG["optimizar"]
return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]),
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1")})
if ruta == "/api/optimizar/cola":
con = db()
filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()]
con.close()
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}})
if ruta == "/api/stock":
tipos = [t for t in (q.get("tipos") or "Video").split(",") if t]
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
if ruta == "/api/papelera":
return self._json(papelera(os.path.normpath(q["raiz"])))
if ruta == "/api/duplicados":
return self._json(duplicados(os.path.normpath(q["raiz"])))
if ruta == "/api/respaldo/plan":
raiz = os.path.normpath(q["raiz"])
destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "")
if not destino:
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1")
PLANES_RESPALDO[raiz] = plan
CONFIG.setdefault("respaldo", {})["destino"] = destino
guardar_json(CONFIG_PATH, CONFIG)
return self._json({k: v for k, v in plan.items() if k != "items"})
if ruta == "/api/respaldo/estado":
return self._json(RESPALDO)
if ruta == "/api/buscar-carpeta":
return self._json({"coincidencias": buscar_carpeta(q.get("nombre", ""))})
return self._json({"error": "no existe"}, 404)
except Exception as ex: # noqa
import traceback
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
def do_POST(self):
u = urlparse(self.path)
ruta = u.path
if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"):
return self._json({"error": "petición no permitida"}, 403)
d = self._cuerpo()
try:
if ruta == "/api/escanear":
return self._json({"lanzado": lanzar_escaneo(d["raiz"])})
if ruta == "/api/raiz/quitar":
try:
return self._json(quitar_raiz(d["raiz"], bool(d.get("datos"))))
except ValueError as e:
return self._json({"error": str(e)}, 409)
if ruta == "/api/raices":
CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
if ruta == "/api/clientes":
CONFIG["clientes"] = d["clientes"]
guardar_json(CONFIG_PATH, CONFIG)
_DISCOS_CACHE["ts"] = 0
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", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}}
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]})
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True, "config": CONFIG})
if ruta == "/api/diccionario":
tok = R.norm(d.get("token", ""))
cub = d.get("cubeta")
if not tok:
return self._json({"error": "token vacío"}, 400)
with LOCK:
if cub in (None, ""):
DICC["carpetas"].pop(tok, None)
else:
DICC["carpetas"][tok] = cub
DICC["aprendido"].append({"ts": time.time(), "token": tok, "cubeta": cub})
guardar_json(DICC_PATH, DICC)
return self._json({"ok": True})
if ruta == "/api/regla":
with LOCK:
DICC["reglas"] = [r for r in DICC["reglas"] if not (R.norm(r.get("origen", "")) == R.norm(d.get("origen", "")) and r.get("cliente") == d.get("cliente"))]
if d.get("destino") is not None:
DICC["reglas"].append({"cliente": d.get("cliente"), "origen": d.get("origen"), "destino": d.get("destino"),
"cubeta": d.get("cubeta", "REVISAR"), "ts": time.time()})
guardar_json(DICC_PATH, DICC)
return self._json({"ok": True, "reglas": DICC["reglas"]})
if ruta == "/api/plantillas":
PLANTILLAS.update(d.get("plantillas", {}))
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
return self._json({"ok": True})
if ruta == "/api/aplicar":
raiz = os.path.normpath(d["raiz"])
plan = PLANES.get(raiz)
if not plan:
return self._json({"error": "primero genera el plan"}, 400)
ids = set(d.get("ids", []))
filas = [f for f in plan["filas"] if f["id"] in ids]
# respetar destinos editados en la interfaz
for f in filas:
ed = (d.get("destinos") or {}).get(str(f["id"]))
if ed:
nuevo = os.path.normpath(os.path.join(raiz, ed))
if not dentro_de(raiz, nuevo):
return self._json({"error": f"el destino «{ed}» queda fuera de la carpeta del cliente"}, 400)
f["destino"] = nuevo
res = aplicar(raiz, filas)
PLANES.pop(raiz, None)
return self._json(res)
if ruta == "/api/deshacer":
return self._json(deshacer(d["lote"]))
if ruta == "/api/actualizacion/buscar":
actualizador.comprobar()
return self._json({"version": VERSION, **actualizador.ESTADO})
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/asistente":
raiz = os.path.normpath(d["raiz"])
if asistente.ESTADO["activo"]:
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":
raiz = os.path.normpath(d["raiz"])
p = ANALISIS.get(raiz)
if p and p["estado"] == "corriendo":
return self._json({"lanzado": False})
threading.Thread(target=analizar, args=(raiz, d.get("min_mb", CONFIG["optimizar"]["min_mb"])), daemon=True).start()
return self._json({"lanzado": True})
if ruta == "/api/optimizar/encolar":
o = CONFIG["optimizar"]
ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]))
if d.get("iniciar"):
iniciar_cola()
return self._json({"ids": ids})
if ruta == "/api/optimizar/control":
acc = d.get("accion")
if acc == "iniciar":
return self._json({"ok": iniciar_cola()})
if acc == "detener":
COLA["cancelar"] = True
COLA["activa"] = False
return self._json({"ok": True})
if acc == "quitar":
con = db()
with con:
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
con.close()
return self._json({"ok": True})
if acc == "reintentar":
con = db()
with con:
con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
con.close()
return self._json({"ok": True})
if ruta == "/api/optimizar/verificar":
return self._json({"resultados": verificar(d.get("ids", []))})
if ruta == "/api/optimizar/reemplazar":
return self._json({"resultados": reemplazar(d.get("ids", []))})
if ruta == "/api/stock/papelera":
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", [])))
if ruta == "/api/duplicados/apartar":
return self._json(apartar_duplicados(os.path.normpath(d["raiz"])))
if ruta == "/api/abrir":
return self._json(abrir_carpeta(d.get("ruta", "")))
if ruta == "/api/respaldo/copiar":
raiz = os.path.normpath(d["raiz"])
plan = PLANES_RESPALDO.get(raiz)
if not plan:
return self._json({"error": "primero calcula el respaldo"}, 400)
if RESPALDO["estado"] == "corriendo":
return self._json({"error": "ya hay una copia en curso"}, 400)
threading.Thread(target=copiar_respaldo, args=(raiz, plan), daemon=True).start()
return self._json({"ok": True})
if ruta == "/api/respaldo/detener":
RESPALDO["cancelar"] = True
return self._json({"ok": True})
return self._json({"error": "no existe"}, 404)
except Exception as ex: # noqa
import traceback
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
# ==========================================================================
# Línea de comandos: organizar una carpeta con un solo comando
# ==========================================================================
AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando
OrganizadorLabstream.exe abre la interfaz en el navegador
OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra
de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer
OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar)
OrganizadorLabstream.exe organizar "ruta" [opciones]
--cliente "Nombre" nombre del cliente (por defecto, el de la carpeta)
--tipo contenido|pauta|proyecto|interno (por defecto contenido)
--solo-alta aplica solo los movimientos de confianza alta
--todo aplica también los de confianza baja y los cosméticos
--simular solo muestra el plan, no mueve nada
--si no pregunta: aplica directamente
OrganizadorLabstream.exe escanear "ruta" solo inventaría (para ver el mapa en la interfaz)
OrganizadorLabstream.exe stock "ruta" [--meses 3] [--todo-tipo] [--si] aparta el stock de Envato viejo a _Para borrar/stock
OrganizadorLabstream.exe respaldo "ruta" "destino" [--con-raw] [--con-stock] copia compacta a otro disco
OrganizadorLabstream.exe deshacer LOTE deshace un lote de la bitácora
OrganizadorLabstream.exe actualizar [--si] busca versión nueva en Labstream OS y la instala
OrganizadorLabstream.exe duplicados "ruta" [--si] lista los duplicados; con --si los junta en subcarpetas Duplicados
La app NUNCA borra: lo que sobra va a _Para borrar dentro de la misma carpeta (la borra una persona a mano) y todo queda en datos\\bitacora.jsonl.
"""
def _gb(b):
return f"{(b or 0) / 1e9:.1f} GB" if (b or 0) >= 1e8 else f"{(b or 0) / 1e6:.0f} MB"
def _opcion(args, nombre, defecto=None):
if nombre in args:
i = args.index(nombre)
return args[i + 1] if i + 1 < len(args) else defecto
return defecto
def _confirmar(pregunta, auto):
if auto:
return True
try:
return input(pregunta + " [S/n] ").strip().lower() in ("", "s", "si", "sí", "y")
except EOFError:
return False
def cli_escanear(ruta):
ruta = os.path.normpath(ruta)
print(f"Escaneando {ruta} …", flush=True)
escanear(ruta)
p = PROGRESO[ruta]
if p["estado"] != "listo":
print("ERROR al escanear:", p.get("error"))
return False
print(f" {p['n']:,} archivos · {_gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s")
return True
def cli_raiz(ruta, args):
"""asegura que la carpeta esté registrada como raíz (la crea si es nueva)"""
ruta = os.path.normpath(ruta)
if not os.path.isdir(ruta):
print("No existe la carpeta:", ruta)
return None
r = raiz_cfg(ruta)
if not r:
nombre = os.path.basename(ruta.rstrip("\\/")) or ruta
cliente = _opcion(args, "--cliente") or next((c for c, i in CONFIG["clientes"].items()
if any(R.norm(a) == R.norm(nombre) for a in i.get("alias", []) + [c])), nombre)
tipo = _opcion(args, "--tipo") or CONFIG["clientes"].get(cliente, {}).get("tipo", "contenido")
r = {"ruta": ruta, "cliente": cliente, "tipo": tipo}
CONFIG["raices"].append(r)
CONFIG["clientes"].setdefault(cliente, {"tipo": tipo, "alias": [nombre]})
guardar_json(CONFIG_PATH, CONFIG)
print(f"Carpeta nueva registrada como raíz: cliente «{cliente}», tipo {tipo}")
return r
def cli_organizar(ruta, args):
r = cli_raiz(ruta, args)
if not r or not cli_escanear(r["ruta"]):
return 1
plan = Plan(r).correr()
filas = plan["filas"]
if not filas:
print("Nada que mover: la carpeta ya está en orden.")
return 0
if "--todo" in args:
elegidas = filas
elif "--solo-alta" in args:
elegidas = [f for f in filas if f["confianza"] == "alta"]
else:
elegidas = [f for f in filas if f["marcado"]]
print(f"\nPLAN: {len(filas)} movimientos propuestos; se aplicarían {len(elegidas)} "
f"({_gb(sum(f['bytes'] for f in elegidas))}, {sum(f['n'] for f in elegidas):,} archivos)\n")
for f in sorted(elegidas, key=lambda f: -f["bytes"])[:60]:
n = f" [{len(f['archivos'])} archivos]" if f["archivos"] else ""
print(f" {f['confianza']:5} {f['cubeta']:10} {_gb(f['bytes']):>8} {f['origen_rel']}{n}\n{'':28}-> {f['destino_rel']} ({f['motivo'][:70]})")
if len(elegidas) > 60:
print(f" … y {len(elegidas) - 60} más")
omitidas = [f for f in filas if f not in elegidas]
if omitidas:
print(f"\n Sin aplicar (confianza baja o cosméticos): {len(omitidas)}. Con --todo se aplican; con la interfaz se revisan uno a uno.")
for a in plan["avisos"]:
print(" AVISO:", a)
if "--simular" in args:
print("\nSimulación: no se movió nada.")
return 0
if not elegidas:
return 0
if not _confirmar("\n¿Aplicar estos movimientos? (los archivos no se renombran; lo sobrante va a _Para borrar)", "--si" in args):
print("Cancelado. No se movió nada.")
return 0
res = aplicar(r["ruta"], elegidas)
print(f"\nHecho: lote {res['lote']} · {res['movimientos']} movimientos · {len(res['errores'])} errores")
for e in res["errores"][:20]:
print(" ERROR:", e)
print(f"Para deshacer: OrganizadorLabstream.exe deshacer {res['lote']}")
return 0 if not res["errores"] else 2
def cli_stock(ruta, args):
r = cli_raiz(ruta, args)
if not r or not cli_escanear(r["ruta"]):
return 1
meses = int(_opcion(args, "--meses", CONFIG["optimizar"]["meses_minimos"]))
tipos = ["Video", "Audio", "Imagenes"] if "--todo-tipo" in args else ["Video"]
s = stock_viejo(r["ruta"], meses, tipos)
print(f"\nStock de Envato en proyectos anteriores a {s['tope']}: {s['n']} archivos · {_gb(s['bytes'])}")
for g in s["grupos"][:40]:
print(f" {_gb(g['bytes']):>8} {g['carpeta']} ({g['n']}: {', '.join(g['ejemplos'])[:80]})")
if not s["n"] or "--simular" in args:
return 0
if not _confirmar("¿Apartar todo a _Para borrar/stock? (reversible)", "--si" in args):
return 0
res = stock_a_papelera(r["ruta"], [x for g in s["grupos"] for x in g["rutas"]])
print(f"Hecho: lote {res['lote']} · {res['movimientos']} archivos apartados. Están en _Para borrar\\stock: revísalos y bórralos a mano.")
return 0
def cli_respaldo(ruta, destino, args):
r = cli_raiz(ruta, args)
if not r or not cli_escanear(r["ruta"]):
return 1
plan = plan_respaldo(r["ruta"], destino, True, "--con-raw" in args, "--con-stock" not in args)
print(f"\nRespaldo de «{plan['cliente']}» → {plan['base']}: {plan['n']:,} archivos · {_gb(plan['bytes'])}")
print(" por tipo:", ", ".join(f"{t['tipo']} {t['n']} ({_gb(t['bytes'])})" for t in plan["por_tipo"]))
if plan["raw_omitidos"]["n"]:
print(f" NO se copian {plan['raw_omitidos']['n']} originales de cámara sin convertir ({_gb(plan['raw_omitidos']['bytes'])}); conviértelos antes o usa --con-raw")
if "--simular" in args or not _confirmar("¿Copiar?", "--si" in args):
return 0
hilo = threading.Thread(target=copiar_respaldo, args=(r["ruta"], plan), daemon=True)
hilo.start()
ultimo = -1
while hilo.is_alive():
time.sleep(2)
pct = int(RESPALDO["bytes"] / RESPALDO["bytes_total"] * 100) if RESPALDO["bytes_total"] else 0
if pct != ultimo:
print(f" {pct:3d}% {RESPALDO['hechos']}/{RESPALDO['total']} {RESPALDO['actual'][-60:]}", flush=True)
ultimo = pct
print(f"\n{RESPALDO['estado']}: {RESPALDO['copiados']} copiados, {RESPALDO['saltados']} ya estaban, {len(RESPALDO['errores'])} errores. Índice en {plan['base']}\\INDICE.csv")
return 0
def cli(args):
cmd = args[0].lower() if args else ""
resto = args[1:]
if cmd in ("-h", "--help", "ayuda", "/?"):
print(AYUDA)
return 0
if os.path.isdir(args[0]) and cmd not in ("organizar", "escanear", "stock", "respaldo", "deshacer", "duplicados", "asistente"):
return asistente.cli(args[0], resto) # arrastraron una carpeta encima del .exe
if cmd == "asistente" and resto:
return asistente.cli(resto[0], resto[1:])
if cmd in ("actualizar", "actualizacion"):
return actualizador.cli(resto)
if cmd == "organizar" and resto:
return cli_organizar(resto[0], resto[1:])
if cmd == "escanear" and resto:
r = cli_raiz(resto[0], resto[1:])
return 0 if r and cli_escanear(r["ruta"]) else 1
if cmd == "stock" and resto:
return cli_stock(resto[0], resto[1:])
if cmd == "respaldo" and len(resto) >= 2:
return cli_respaldo(resto[0], resto[1], resto[2:])
if cmd == "deshacer" and resto:
res = deshacer(resto[0])
print(f"Deshechos {res['deshechos']} movimientos · {len(res['errores'])} errores")
return 0
if cmd == "duplicados" and resto:
d = duplicados(os.path.normpath(resto[0]))
print(f"{len(d['grupos'])} grupos · {d['n']} copias · {d['bytes'] / 1e9:.1f} GB ({d['identicos']} idénticos, {d['versiones']} versiones MP4/MOV)")
for g in d["grupos"][:40]:
print(f" [{g['tipo']}] {g['nombre']} queda: {g['queda']} → {len(g['mueven'])} a {g['destino']}")
if "--si" in resto and d["grupos"]:
print(apartar_duplicados(os.path.normpath(resto[0]), d["grupos"]))
return 0
print(AYUDA)
return 1
def _asegurar_consola():
"""El .exe es de ventana (sin consola). Para los comandos y el arrastrar-carpeta hace falta una:
se engancha a la del padre (cmd/PowerShell) o se abre una nueva."""
if not CONGELADO or os.name != "nt":
return
try:
import ctypes
k = ctypes.windll.kernel32
if k.GetConsoleWindow(): # ya hay consola
return
h = k.GetStdHandle(-11)
if h and h != -1 and k.GetFileType(h) in (1, 3): # salida a archivo o tubería: se respeta
return
if not k.AttachConsole(-1):
k.AllocConsole()
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", buffering=1)
sys.stderr = sys.stdout
sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace")
k.SetConsoleTitleW("OrganiLAB")
except Exception: # noqa
pass
def _ventana(url):
"""Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador."""
try:
import webview
except Exception: # noqa
webbrowser.open(url)
return False
try:
v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff")
webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana"))
return True
except Exception as ex: # noqa
print("Sin WebView2; abro el navegador:", ex)
webbrowser.open(url)
return False
def main():
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")]
if args_tmp and "--puente" not in sys.argv:
_asegurar_consola()
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa
pass
if "--puente" in sys.argv: # modo puente con Resolve (lo usa el propio .exe)
import resolve_bridge
sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1]))
args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")]
if args:
if CONGELADO and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?"):
actualizador.comprobar_en_segundo_plano()
codigo = cli(args)
d = actualizador.ESTADO.get("disponible")
if d:
print(f"\nHay una versión nueva del Organizador ({d['version']}). Para instalarla: OrganizadorLabstream.exe actualizar")
if CONGELADO and "--si" not in args and sys.stdin and sys.stdin.isatty():
try:
input("\nPulsa Enter para cerrar.")
except EOFError:
pass
sys.exit(codigo)
h = herramientas()
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)")
try:
srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador)
except OSError:
print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.")
webbrowser.open(f"http://127.0.0.1:{PUERTO}/")
return
url = f"http://127.0.0.1:{PUERTO}/"
if sys.stdout:
print(f"OrganiLAB {VERSION} en {url} (Ctrl+C para cerrar)")
if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto", True):
actualizador.comprobar_en_segundo_plano()
if "--sin-navegador" in sys.argv: # solo el servidor (desarrollo / vista previa)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return
hilo = threading.Thread(target=srv.serve_forever, daemon=True)
hilo.start()
if "--navegador" in sys.argv:
webbrowser.open(url)
try:
hilo.join()
except KeyboardInterrupt:
pass
return
# ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha)
_ventana(url)
while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"):
time.sleep(1)
srv.shutdown()
if __name__ == "__main__":
main()