3.7.0: auditoría de Vía Sumapaz: «Calcular» ya no se queda gris, banco de material por temas, proxies sin original, y «Qué pesa» por dentro

- El botón «Calcular» (y «Reanudar» la cola, y «Volver a pintar») quedaba deshabilitado para siempre tras
  la primera vez: al cambiar de carpeta «Qué va a pasar» no calculaba nada.
- «material» suelto ya no ignora una carpeta; «No tocar» es un banco de material: los temas se quedan y,
  dentro, las grabaciones van por fecha y cámara, todo sin marcar.
- «Hacer de una vez» y la pasada de la noche ya no mueven basura ni proxies sin marcar; un proxy sin su
  original va sin marcar.
- «Qué pesa» entra nivel a nivel con el detalle de cada carpeta; repetidos, plan e Inicio mucho más rápidos;
  aviso cuando el tipo de trabajo de la carpeta no es el del cliente.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 06:55:38 -05:00
padre 0b15a6d788
commit cea36ea0c5
Se han modificado 7 ficheros con 694 adiciones y 44 borrados

Ver fichero

@@ -42,7 +42,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "3.6.0"
VERSION = "3.7.0"
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
@@ -1000,6 +1000,40 @@ def _fecha_de_camara(f):
_RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I)
_RX_FECHA_DIA = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# en un banco de material, lo que se reordena por fecha y cámara: el material grabado, no lo montado
_ROLES_BANCO = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video")
# carpetas que solo dicen con qué se grabó (además de las de R.FUENTE_PALABRAS)
_CAMARAS_BANCO = {"fuji", "gopro", "go pro", "mavic", "osmos", "insta360", "iphone", "ronin", "fx3", "fx6", "a7", "a7s"}
_NOMBRES_FUENTE = {v.upper() for v in R.FUENTE_CARPETA.values()} | {"AUDIO", "OTROS", "FOTOS"}
def _solo_fuente(seg):
"""True si la carpeta solo dice la cámara, la tarjeta, el día o que son repetidos: no es un tema."""
s = (seg or "").strip()
n = R.norm(s)
return (n in R.FUENTE_PALABRAS or n in _CAMARAS_BANCO or R.es_carpeta_de_tarjeta(s) or bool(_RX_DIA.match(s))
or bool(_RX_FECHA_DIA.match(s)) or s.upper() in _NOMBRES_FUENTE or n == R.norm(R.DUPLICADOS))
def _bajo_alguna(ruta, conjunto):
"""True si `ruta` o alguna carpeta por encima de ella está en `conjunto` (rutas en minúsculas)."""
d = os.path.normpath(ruta).lower()
while True:
if d in conjunto:
return True
p = os.path.dirname(d)
if p == d:
return False
d = p
def _tallo(nombre):
"""«C0001_Proxy.mov», «C0001S03.MP4» (el proxy que graba la Sony en M4ROOT/SUB) y «C0001.MP4» -> «c0001»:
para emparejar un proxy con su original."""
t = re.sub(r"[ _.-]?proxy$", "", os.path.splitext(nombre or "")[0].lower())
m = re.match(r"^(c\d{4})s\d{2}$", t)
return m.group(1) if m else t
# ==========================================================================
@@ -1014,6 +1048,9 @@ class Plan:
self.dicc = DICC["carpetas"]
self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)]
self.filas = []
self.bancos = [] # (ruta normcase + sep, nombre, protegido): los bancos de material
self._carpeta_existe = {} # destino -> si ya existe en el disco (para no preguntar dos veces)
self._bytes = None # ruta -> bytes, para partir filas de archivos
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos
self.con = db()
self.avisos = []
@@ -1076,8 +1113,14 @@ class Plan:
return r
return None
def _hay_carpeta(self, d):
d = os.path.normcase(os.path.normpath(d))
if d not in self._carpeta_existe:
self._carpeta_existe[d] = os.path.isdir(d)
return self._carpeta_existe[d]
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None,
marcado=None, kw_destinos=None):
marcado=None, kw_destinos=None, paso=None):
destino = os.path.normpath(destino)
if archivos is None and os.path.normpath(origen).lower() == destino.lower():
# el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» ->
@@ -1101,7 +1144,8 @@ class Plan:
# solo los primeros: cada comprobación es una consulta al disco de red, y para avisar
# basta con encontrar uno
choques = [os.path.basename(a[0]) for a in archivos[:40]
if os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))]
if self._hay_carpeta(os.path.dirname(especiales.get(a[0]) or os.path.join(destino, "x")))
and os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))]
if choques:
aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre "
f"(«{choques[0]}»): esos no se moverían")
@@ -1128,8 +1172,8 @@ class Plan:
"cosmetico": marcado is False and accion == "renombrar",
# en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones,
# piezas), 3 el resto (exportados, proyectos, marca, música, dudosos)
"paso": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto",
"paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"),
})
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
@@ -1185,6 +1229,8 @@ class Plan:
self.plan_auditoria()
self.volcar_sueltos()
self.plan_basura()
self.revisar_proxies()
self.proteger_bancos()
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
@@ -1383,6 +1429,9 @@ class Plan:
cub, motivo = R.cubeta_carpeta(nombre, self.dicc)
if cub == "IGNORAR":
return
if cub == "BANCO":
self.plan_banco(e)
return
if cub == "CONTENEDOR":
m_anio = re.search(r"(20\d\d)", nombre)
if self.est.fechas_dentro(e.path):
@@ -1488,6 +1537,153 @@ class Plan:
return None, ""
return (a, m), origenes.most_common(1)[0][0]
def plan_banco(self, e):
"""Un banco de material por temas («Material Vía (No tocar)\\Lugares\\Peajes»): el cliente lo consulta
por tema, así que los temas se quedan donde están. Lo que se ordena son las grabaciones de dentro de
cada tema: por fecha de rodaje y por cámara, como en cualquier 00_Grabacion. «Lugares\\Peajes\\Drone»
pasa a «Lugares\\Peajes\\2024-03-12\\DJI». Las carpetas que solo dicen la cámara, la tarjeta o el día
(«Drone», «URSA», «Pocket», «100MEDIA», «Grabado») no son un tema: se funden en el tema de encima.
Medido el 14-oct-2026 en Vía Sumapaz: el banco eran 3,6 TB, 50.076 archivos, con temas como Lugares,
Social, Ambiental o Unidades Funcionales; tratado como piezas sueltas, el plan quería llevar cada tema
entero a un mes inventado. Si el nombre dice «No tocar», todo lo de dentro va sin marcar."""
protegido = R.es_no_tocar(e.name)
self.bancos.append((os.path.normcase(os.path.normpath(e.path)) + os.sep, e.name, protegido))
grupos = defaultdict(list)
for f in archivos_bajo(self.con, e.path):
if f["es_basura"] or f["es_proxy"] or f["rol"] not in _ROLES_BANCO or not f["fecha"]:
continue
rel = os.path.relpath(f["dir"], e.path)
segs = [] if rel == "." else rel.split(os.sep)
repetido = False
while segs and _solo_fuente(segs[-1]):
repetido = repetido or R.norm(segs[-1]) == R.norm(R.DUPLICADOS)
segs.pop()
fuente = R.FUENTE_CARPETA.get(f["fuente"]) or ("AUDIO" if f["rol"] == "audio_grabado" else "OTROS")
fiable = f["fecha_origen"] != "mtime" or _fecha_de_camara(f)
# una cámara con el reloj sin poner fecha en 2008 o 2009: medido en Vía Sumapaz, 250 clips de la
# Blackmagic «del 28 de julio de 2008» y 100 del dron «del 19 de enero de 2009»
reloj = f["fecha_origen"] != "nombre" and int(f["fecha"][:4]) < 2014
destino = os.path.join(e.path, *segs, f["fecha"][:10], fuente, *([R.DUPLICADOS] if repetido else []))
if os.path.normcase(os.path.normpath(destino)) == os.path.normcase(os.path.normpath(f["dir"])):
continue
tema = segs[-1] if segs else e.name
por_que = "reloj" if reloj else "" if fiable else "mtime"
grupos[(f["dir"], destino, tema, "media" if fiable and not reloj else "baja", por_que)].append((f["ruta"], f["bytes"]))
for (origen, destino, tema, conf, por_que), archivos in sorted(grupos.items()):
self.agregar(origen, destino, "GRABACION", "mover archivos",
f"banco de material «{e.name}»: el tema «{tema}» se queda; dentro, por fecha de rodaje y cámara"
+ {"": "", "mtime": " (la fecha sale de la fecha de modificación: puede fallar)",
"reloj": f" (la fecha, {next((x for x in destino.split(os.sep) if _RX_FECHA_DIA.match(x)), '')}, parece de una cámara con el reloj sin poner: no es de fiar)"}[por_que],
conf, archivos=archivos, marcado=False if protegido else None, paso="banco")
self.filas[-1]["no_tocar"] = protegido
def _partir(self, f, aparte, motivo, confianza=None, no_tocar=False):
"""Saca de una fila de archivos los que cumplen `aparte` a una fila propia, sin marcar. Si son todos,
la fila entera queda sin marcar. Devuelve la fila nueva, si hace falta una."""
sep = [a for a in f["archivos"] if aparte(a)]
if not sep:
return None
if len(sep) == len(f["archivos"]):
f["marcado"] = False
f["no_tocar"] = f.get("no_tocar") or no_tocar
f["confianza"] = confianza or f["confianza"]
if motivo not in f["motivo"]:
f["motivo"] += motivo
return None
if self._bytes is None:
self._bytes = {r["ruta"]: r["bytes"] or 0 for r in self.con.execute("SELECT ruta, bytes FROM archivos WHERE raiz = ?", (self.raiz,))}
quedan = [a for a in f["archivos"] if not aparte(a)]
nueva = dict(f)
destinos = dict(f.get("destinos_archivo") or {})
for fila, lista in ((f, quedan), (nueva, sep)):
fila["archivos"] = lista
fila["n"] = len(lista)
fila["bytes"] = sum(self._bytes.get(a, 0) for a in lista)
if destinos:
fila["destinos_archivo"] = {a: destinos[a] for a in lista if a in destinos}
nueva.update(marcado=False, confianza=confianza or f["confianza"], motivo=f["motivo"] + motivo, cosmetico=False,
no_tocar=f.get("no_tocar") or no_tocar)
return nueva
def revisar_proxies(self):
"""Un proxy sin su original puede ser la única copia que queda de ese clip. Medido el 14-oct-2026 en Vía
Sumapaz: de 12.645 proxies (449 GB), 126 (4,4 GB) no tenían el original en ningún sitio de la carpeta
(clips Fuji de una carpeta «Proxy», el proxy de un reel cuyo master ya no está, .LRF de un dron sin su
MP4). Esos van aparte y sin marcar; los demás siguen yendo a _Para borrar como siempre."""
filas = [f for f in self.filas if f["cubeta"] == "PROXY"]
if not filas:
return
originales = {_tallo(r["nombre"]) for r in self.con.execute(
"SELECT nombre FROM archivos WHERE raiz = ? AND es_proxy = 0 AND es_basura = 0", (self.raiz,))}
def huerfano(ruta):
n = os.path.basename(ruta)
ext = os.path.splitext(n)[1].lower()
return (ext in R.EXT["video"] or ext == ".lrf") and _tallo(n) not in originales
bajo = defaultdict(list) # carpeta (minúsculas) -> los proxies que tiene debajo
tope = self.raiz.lower()
for r in self.con.execute("SELECT ruta, nombre, bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", (self.raiz,)).fetchall():
d = os.path.dirname(r["ruta"]).lower()
while True:
bajo[d].append(r)
p = os.path.dirname(d)
if d == tope or p == d:
break
d = p
nuevas, n_sin, b_sin = [], 0, 0
for f in filas:
if f["archivos"]:
sin = [a for a in f["archivos"] if huerfano(a)]
if not sin:
continue
nueva = self._partir(f, huerfano, f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la "
f"carpeta (p. ej. «{os.path.basename(sin[0])}»): puede ser la única copia; míralos antes", "baja")
if nueva:
nuevas.append(nueva)
n_sin += len(sin)
else:
sin = [r for r in bajo.get(os.path.normpath(f["origen"]).lower(), []) if huerfano(r["ruta"])]
if not sin:
continue
f["motivo"] += (f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la carpeta "
f"(p. ej. «{sin[0]['nombre']}»): puede ser la única copia; míralos antes")
f["confianza"], f["marcado"] = "baja", False
n_sin += len(sin)
b_sin += sum(r["bytes"] or 0 for r in sin)
self.filas += nuevas
if n_sin:
self.avisos.append(f"{_plural(n_sin, 'proxy no tiene', 'proxies no tienen')} su original en ningún sitio de esta carpeta. "
f"Pueden ser la única copia de esos clips: van sin marcar, para que los mires antes de apartarlos.")
def proteger_bancos(self):
"""Todo lo que el plan propone dentro de una carpeta «No tocar» (sus proxies, su basura) va sin marcar."""
prot = [(p, nombre) for p, nombre, protegido in self.bancos if protegido]
if not prot:
return
def de_cual(ruta):
rc = os.path.normcase(os.path.normpath(ruta))
return next((nombre for p, nombre in prot if rc.startswith(p)), None)
nuevas = []
for f in list(self.filas):
if f.get("paso") == "banco":
continue
if f["archivos"]:
dentro = [a for a in f["archivos"] if de_cual(a)]
if dentro:
nueva = self._partir(f, lambda a: de_cual(a) is not None,
f" · dentro de «{de_cual(dentro[0])}», que dice «No tocar»: márcalo tú si quieres", no_tocar=True)
if nueva:
nuevas.append(nueva)
else:
nombre = de_cual(f["origen"])
if nombre:
f["marcado"] = False
f["no_tocar"] = True
f["motivo"] += f" · dentro de «{nombre}», que dice «No tocar»: márcalo tú si quieres"
self.filas += nuevas
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
@@ -1763,11 +1959,16 @@ class Plan:
# 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 = []
# se mira subiendo por la ruta de cada uno, no recorriendo la lista entera: en Vía Sumapaz (13.899
# archivos de proxy o basura, 540 carpetas cubiertas) eran 5 millones de comparaciones y 13 s
cub_set = set()
origenes = {os.path.normpath(f["origen"]).lower() for f in self.filas}
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):
if _bajo_alguna(os.path.dirname(c["ruta"]), cub_set):
continue
if any(os.path.normpath(f["origen"]).lower() == c["ruta"].lower() for f in self.filas):
if c["ruta"].lower() in origenes:
cubiertas.append(c["ruta"])
cub_set.add(c["ruta"].lower())
continue
if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue
@@ -1775,10 +1976,11 @@ class Plan:
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"])
cub_set.add(c["ruta"].lower())
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):
if _bajo_alguna(f["dir"], cub_set):
continue
if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue
@@ -2329,7 +2531,7 @@ def vocabulario(raiz):
apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas")
continue # una pieza: no se toca por dentro
cub, mot = R.cubeta_carpeta(nombre, dicc)
if cub == "IGNORAR":
if cub in ("IGNORAR", "BANCO"):
apunta(h, nombre, cub, mot)
continue
if cub == "CONTENEDOR":
@@ -2456,7 +2658,7 @@ def _sugerir(est, resumen, raiz, t):
# c) por el nombre: se parece a una palabra del diccionario
dicc = DICC["carpetas"]
cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82)
if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION"):
if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION", "BANCO"):
sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»"
# d) por el contenido (manda si es contundente)
if total[0] >= 3 and orden:
@@ -4176,7 +4378,56 @@ def _mismas_tomas(candidatos, tope=400):
return grupos
_DUP_CACHE = {}
def duplicados(raiz, minimo=5_000_000, calidades=False):
"""Los repetidos de una carpeta, guardados hasta que algo se mueva en ella (o pasen 10 minutos): Inicio,
«Qué hay» y el diagnóstico los piden cada vez, y en Vía Sumapaz (76.559 archivos) costaban 22 s cada vez."""
k = (os.path.normcase(os.path.normpath(raiz)), minimo, bool(calidades))
marca = (sum(v for kk, v in PLAN_GEN.items() if kk.lower() == k[0]), _ts_escaneo(raiz))
c = _DUP_CACHE.get(k)
if c and c[0] == marca and time.time() - c[1] < 600:
return c[2]
r = _duplicados(raiz, minimo, calidades)
_DUP_CACHE[k] = (marca, time.time(), r)
return r
def _ts_escaneo(raiz):
con = db()
f = con.execute("SELECT ts FROM escaneos WHERE raiz = ?", (os.path.normpath(raiz),)).fetchone()
con.close()
return f["ts"] if f else 0
def _solo_existentes(filas):
"""Quita los que ya no están en el disco, pero solo pregunta por los que pueden formar grupo (mismo nombre
y peso, el mismo clip en dos formatos, o con dos pesos), y en paralelo. Medido el 14-oct-2026 en Vía
Sumapaz: comprobar uno a uno los 30.963 archivos de más de 5 MB por la red tardaba 20 de 22 segundos."""
por_nb = Counter((f["nombre"].lower(), f["bytes"]) for f in filas)
exts, pesos = defaultdict(set), defaultdict(set)
for f in filas:
e = (f["ext"] or "").lower()
if e in _EXT_VIDEO_DUP:
exts[os.path.splitext(f["nombre"])[0].lower()].add(e)
pesos[f["nombre"].lower()].add(f["bytes"])
def puede(f):
if por_nb[(f["nombre"].lower(), f["bytes"])] > 1:
return True
return (f["ext"] or "").lower() in _EXT_VIDEO_DUP and (
len(exts[os.path.splitext(f["nombre"])[0].lower()]) > 1 or len(pesos[f["nombre"].lower()]) > 1)
dudosos = [f["ruta"] for f in filas if puede(f)]
if not dudosos:
return filas
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=24) as ex:
existe = dict(zip(dudosos, ex.map(os.path.exists, dudosos)))
return [f for f in filas if existe.get(f["ruta"], True)]
def _duplicados(raiz, minimo=5_000_000, calidades=False):
"""Grupos de archivos repetidos. Tres 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
@@ -4189,7 +4440,7 @@ def duplicados(raiz, minimo=5_000_000, calidades=False):
"SELECT ruta, dir, nombre, ext, bytes, rol 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"])]
filas = _solo_existentes([f for f in filas if _fuera_de_dup(f["ruta"])])
grupos, usados = [], set()
por_nombre = {}
for f in filas:
@@ -4709,7 +4960,7 @@ def mapa_estructura(tipo, raiz=None):
plantilla = pl.get(cub, "")
n, b = hay.get(cub, [0, 0])
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
"asignable": cub not in ("CONTENEDOR", "IGNORAR"),
"asignable": cub not in ("CONTENEDOR", "IGNORAR", "BANCO"),
"color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
"ejemplos": ejemplos.get(cub, []),
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
@@ -5810,6 +6061,18 @@ def acciones(raiz):
cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.",
ir="escanear")
# --- el tipo de trabajo de la carpeta no es el del cliente ---
# Vía Sumapaz estaba guardado como «proyecto» y su carpeta como «contenido»: el plan, el mapa y «Organizar
# carpetas» usan el de la carpeta, y nada decía que no cuadraban.
tc = ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo")
tr = r.get("tipo", "contenido")
if tc and tc != tr and tc in PLANTILLAS:
nom = lambda t: (R.TIPOS_CLIENTE.get(t) or t).split(" · ")[0] # noqa
añadir(clave="tipo", titulo="Elegir cómo se ordena esta carpeta", boton="Elegir",
que=f"{r['cliente']} está guardado como «{nom(tc)}», pero esta carpeta se ordena como «{nom(tr)}».",
cifra="", detalle="El plan, el mapa y «Organizar carpetas» usan el de la carpeta. Elige uno y quedan los dos iguales.",
ir="tipo", tipos=[tr, tc])
# --- la bandeja de entrada: material nuevo esperando a que lo repartan ---
ent = entrada(raiz)
if ent["n"]:
@@ -5827,7 +6090,7 @@ def acciones(raiz):
añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar",
que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.",
cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}",
detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada.",
detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada." + _no_tocar_dentro(con, raiz),
ir="plan", paso="limpiar")
elif vieja:
añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar",
@@ -5898,6 +6161,33 @@ def acciones(raiz):
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
# --- el banco de material: los temas se quedan; dentro, por fecha y cámara ---
bancos = _bancos_de(con, raiz)
if bancos:
b0 = bancos[0]
nombre_b = b0["nombre"]
prot = any(b["protegido"] for b in bancos)
que_b = "Los temas se quedan como están; dentro de cada uno, las grabaciones por fecha de rodaje y cámara."
nota_b = ("Su nombre dice «No tocar»: la app lo propone todo sin marcar y no entra en «hacer de una vez». "
"Revísalo en «Qué va a pasar» y marca lo que quieras." if prot else "")
if plan:
fs = [f for f in plan["filas"] if f.get("paso") == "banco"]
marc = [f for f in fs if f.get("marcado")]
if fs:
añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), boton="Ver", que=que_b,
estado="toca" if marc else "espera",
cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}",
detalle=nota_b or f"{len(marc)} marcados.", ir="plan", paso="banco")
else:
añadir(clave="banco", titulo=f"Banco de material «{nombre_b}»", estado="hecho", que=que_b, cifra="",
detalle="Sus grabaciones ya están por fecha y cámara.")
else:
añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), que=que_b, estado="espera",
cifra=_gb(sum(b["bytes"] for b in bancos)), calculando=bool(REPLAN.get(raiz)),
boton="" if REPLAN.get(raiz) else "Calcular",
detalle=("Comprobando qué queda… " if REPLAN.get(raiz) else "Hay que calcular el plan para verlo. ") + nota_b,
ir="plan", paso="banco")
# --- 5. carpetas que quedaron vacías ---
vac = carpetas_vacias(raiz)
if vac["n"]:
@@ -6017,6 +6307,110 @@ def acciones(raiz):
"automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None}
def _no_tocar_dentro(con, raiz):
"""« De ellos, 366 GB están dentro de «Material Vía (No tocar)»: van sin marcar.» para la ficha de basura y proxies."""
partes = []
for b in _bancos_de(con, raiz):
if not b["protegido"]:
continue
x = con.execute("SELECT COALESCE(SUM(bytes), 0) b FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1) "
"AND ruta LIKE ? ESCAPE '!'", (raiz, esc_like(con_sep(b["ruta"])) + "%")).fetchone()["b"]
if x:
partes.append(f"{_gb(x)} están dentro de «{b['nombre']}», que dice «No tocar»")
return (" De ellos, " + "; y ".join(partes) + ": van sin marcar, para que decidas tú.") if partes else ""
def _bancos_de(con, raiz):
"""Los bancos de material de una carpeta de cliente: los de su primer nivel. Una copia dentro de otra
carpeta (en Vía Sumapaz, «_Duplicados_para_borrar\\Material Vía (No tocar)») no es el banco."""
base = raiz.rstrip(os.sep).count(os.sep) + 1
out = []
for c in con.execute("SELECT ruta, nombre, n, bytes FROM carpetas WHERE raiz = ? ORDER BY length(ruta)", (raiz,)).fetchall():
if c["ruta"].count(os.sep) != base:
continue
if R.cubeta_carpeta(c["nombre"], DICC["carpetas"])[0] == "BANCO":
out.append({"ruta": c["ruta"], "nombre": c["nombre"], "n": c["n"] or 0, "bytes": c["bytes"] or 0,
"protegido": R.es_no_tocar(c["nombre"])})
return out
def carpeta_detalle(raiz, ruta=None):
"""Lo que hay dentro de cualquier carpeta del cliente, a cualquier profundidad: sus subcarpetas con su peso
(para entrar en «Qué pesa» nivel a nivel), de qué es lo que guarda, de qué cámaras y de qué fechas, y qué
hace la app con ella. Antes «Qué pesa» solo enseñaba el primer nivel, y una carpeta de 3,6 TB era un bloque
gris sin nada dentro: parecía que el escaneo no había entrado."""
raiz = _clave(raiz)
ruta = os.path.normpath(ruta or raiz)
if ruta.lower() != raiz.lower() and not dentro_de(raiz, ruta):
return {"error": "esa carpeta no es de este cliente"}
con = db()
q = lambda sql, *a: [dict(x) for x in con.execute(sql, a).fetchall()] # noqa
pref = esc_like(con_sep(ruta)) + "%"
hijos = q("SELECT ruta, nombre, n, bytes, es_proxy, es_basura FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' "
"AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC", raiz, pref, pref + esc_like(os.sep) + "%")
rel0 = os.path.relpath(ruta, raiz)
en_banco = rel0 != "." and any(R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO" for s in rel0.split(os.sep))
for h in hijos:
cub = "PROXY" if h["es_proxy"] else "BASURA" if h["es_basura"] else \
"BANCO" if en_banco else (R.cubeta_carpeta(h["nombre"], DICC["carpetas"])[0] or "")
h.update(cubeta=cub, color=R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]))
donde = "(dir = ? OR dir LIKE ? ESCAPE '!')"
total = q(f"SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0]
sueltos = q("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, ruta)[0]
roles = q(f"SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} GROUP BY rol ORDER BY bytes DESC", raiz, ruta, pref)
nombres_f = {}
for k, _, d in R.FIRMAS:
nombres_f.setdefault(k, d)
fuentes = q(f"SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} AND fuente != '' "
f"AND fuente != 'STOCK' GROUP BY fuente ORDER BY bytes DESC", raiz, ruta, pref)
fechas = q(f"SELECT MIN(substr(fecha,1,7)) desde, MAX(substr(fecha,1,7)) hasta, COUNT(DISTINCT substr(fecha,1,7)) meses "
f"FROM archivos WHERE raiz = ? AND {donde} AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado')",
raiz, ruta, pref)[0]
apartable = q(f"SELECT COALESCE(SUM(CASE WHEN es_proxy = 1 THEN bytes END), 0) proxies, "
f"COALESCE(SUM(CASE WHEN es_basura = 1 AND es_proxy = 0 THEN bytes END), 0) basura FROM archivos "
f"WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0]
sub = [x["ruta"] for x in q("SELECT ruta FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!'", raiz, pref)]
niveles = max((s.count(os.sep) - ruta.rstrip(os.sep).count(os.sep) for s in sub), default=0)
grandes = q("SELECT nombre, bytes FROM archivos WHERE raiz = ? AND dir = ? ORDER BY bytes DESC LIMIT 6", raiz, ruta)
con.close()
# qué hace la app con ella: si está dentro de un banco de material, eso manda
trato = {"cubeta": "", "texto": ""}
rel = os.path.relpath(ruta, raiz)
if rel != ".":
segs = rel.split(os.sep)
for s in segs:
if R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO":
trato = {"cubeta": "BANCO", "texto": f"Está en el banco de material «{s}»: los temas se quedan como están y, "
f"dentro, las grabaciones van por fecha de rodaje y cámara."
+ (" Su nombre dice «No tocar»: todo se propone sin marcar." if R.es_no_tocar(s) else "")}
break
else:
cub, mot = R.cubeta_carpeta(segs[-1], DICC["carpetas"])
trato = {"cubeta": cub or "", "texto": (f"La app la reconoce como «{R.CUBETAS.get(cub, cub)}» ({mot})." if cub else
"La app no reconoce el nombre: el plan decide por lo que tiene dentro.")}
plan = PLANES.get(raiz)
en_plan = None
if plan:
rc = os.path.normcase(con_sep(ruta)) if rel != "." else ""
fs = [f for f in plan["filas"] if not rc or os.path.normcase(f["origen"] + os.sep).startswith(rc)
or any(os.path.normcase(a).startswith(rc) for a in (f["archivos"] or [])[:1])]
en_plan = {"filas": len(fs), "marcadas": sum(1 for f in fs if f["marcado"]), "bytes": sum(f["bytes"] for f in fs)}
return {"raiz": raiz, "ruta": ruta, "rel": "" if rel == "." else rel, "nombre": os.path.basename(ruta) or ruta,
"n": total["n"], "bytes": total["bytes"], "sueltos": sueltos, "hijos": hijos, "niveles": niveles,
"carpetas": len(sub), "roles": [dict(x, que=R.ROL_QUE.get(x["rol"], x["rol"])) for x in roles],
"fuentes": [dict(x, que=nombres_f.get(x["fuente"], x["fuente"])) for x in fuentes],
"fechas": fechas, "apartable": apartable, "grandes": grandes, "trato": trato, "en_plan": en_plan}
def _inventario_resumen(raiz):
"""Cuántas carpetas leyó el escaneo y hasta qué profundidad: para que se vea que entró en todas."""
con = db()
rutas = [x[0] for x in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con.close()
base = raiz.rstrip(os.sep).count(os.sep)
return {"carpetas": len(rutas), "niveles": max((x.count(os.sep) - base for x in rutas), default=0)}
def estado_carpeta(ruta):
"""El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea."""
raiz = _clave(ruta)
@@ -6054,7 +6448,8 @@ def estado_carpeta(ruta):
"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],
"plan": plan_res, "lotes": lotes[:6], "inventario": _inventario_resumen(raiz) if esc else None,
"tipo_cliente": ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo"),
"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"]},
@@ -6156,6 +6551,9 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(estado_carpeta(q["raiz"]))
if ruta == "/api/mapa":
return self._json(mapa(q["raiz"]))
if ruta == "/api/mapa/carpeta": # «Qué pesa», nivel a nivel
r = carpeta_detalle(q["raiz"], q.get("ruta"))
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/actualizacion":
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
if ruta == "/api/asistente/pasos":
@@ -6343,6 +6741,18 @@ class Manejador(BaseHTTPRequestHandler):
CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
if ruta == "/api/raiz/tipo":
# el tipo de trabajo de una carpeta, y el de su cliente con él: así no vuelven a quedar distintos
k = _clave(d.get("raiz") or "")
rc = raiz_cfg(k)
tipo = d.get("tipo")
if not rc or tipo not in PLANTILLAS:
return self._json({"error": "carpeta o tipo de trabajo desconocido"}, 400)
rc["tipo"] = tipo
CONFIG.setdefault("clientes", {}).setdefault(rc["cliente"], {})["tipo"] = tipo
guardar_json(CONFIG_PATH, CONFIG)
olvidar_plan(k)
return self._json({"ok": True, "tipo": tipo})
if ruta == "/api/clientes":
CONFIG["clientes"] = d["clientes"]
guardar_json(CONFIG_PATH, CONFIG)