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:
438
organizador.py
438
organizador.py
@@ -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)
|
||||
|
||||
Referencia en una nueva incidencia
Block a user