diff --git a/README.md b/README.md
index c5e7918..385f224 100644
--- a/README.md
+++ b/README.md
@@ -302,6 +302,36 @@ diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta
- Barra de tareas revisada: la conversión en curso llegaba como un id y la interfaz esperaba un objeto (salía
0 % sin nombre); ahora `estado.cola` trae `actual` (ruta, pct, raíz), `hechos` y `total`.
+1.8.0, auditoría y red de seguridad. Tras revisar el código con cuatro pasadas y medir sobre el material real
+(7,73 TB, 53.964 archivos):
+- **Deshacer vuelve a la interfaz.** Al rediseñar el Historial en la 1.6.0 se quitaron los botones pero la app
+ seguía prometiendo en cuatro pantallas que «se deshace desde Historial». Ahora está en el Historial, en
+ «Últimos cambios» y en el aviso que sale al aplicar («Deshacer esto»).
+- **«Elements» ya no convierte en stock lo que tenga dentro.** La marca solo la hereda una carpeta con nombre
+ completo de paquete de Envato; lo descargado se reconoce por el nombre del archivo. Medido: marcaba 1.713
+ archivos y 50,7 GB de material propio como desechable, que el respaldo excluye por defecto.
+- **El plan contrasta el mes de la carpeta con la fecha de los archivos** (`_discrepa_mes`, `Plan.mes_real`).
+ Si no cuadran, baja a «probable», no se marca y sale un aviso arriba con los dos meses. 5 de las 10 carpetas
+ de mes con material fechable no contenían el mes que decían: son 727 GB. Los nombres Blackmagic no traen
+ año, así que en esos solo se compara el mes.
+- **Un archivo bloqueado ya no corta el resto de su grupo** en `aplicar()`: cada archivo va por su cuenta y el
+ resumen dice cuántos fallaron de verdad (antes uno abierto en Resolve dejaba sin mover los 117 restantes).
+- **Un escaneo interrumpido no reemplaza el inventario:** si hubo fallos de lectura y el recuento cae más de
+ un 10 % frente al anterior, se conserva el que había y se explica por qué.
+- **Las uniones de carpeta de Windows no se recorren** (`_es_enlace`, `_id_carpeta`). Una circular dejaba el
+ escaneo dando vueltas sin fin: comprobado, un árbol de un archivo no terminaba en dos minutos.
+- **Abrir una carpeta pasó de 8,83 s a 0,21 s:** `_resumen_carpetas` calcula de una sola pasada lo que antes
+ era una consulta por carpeta que recorría la tabla entera.
+- **La papelera se llama igual en el disco que en pantalla.** Las plantillas guardadas con el nombre viejo
+ (`_Papelera`) se migran a `_Para borrar` al arrancar.
+- Falsos positivos quitados: `.dat`, `.idx` y `.bak` ya no son basura de sistema, y una carpeta llamada
+ `Cache` a secas ya no son proxies. Las abreviaturas de mes («Mar», «May») bajan a confianza media.
+- Convertidor: un clip ya convertido a un formato puede convertirse a otro (el filtro «en cola» miraba todos
+ los trabajos); los originales ya apartados no vuelven a salir como candidatos; en modo espejo cada formato
+ tiene su propio archivo; el temporal se borra si falla; y la cola no puede arrancar dos veces.
+- Interfaz: los botones ya no vuelven a su etiqueta vieja pisando lo que la acción acaba de calcular, y las
+ rutas de Windows se recortan bien (soltar un archivo registraba el archivo como carpeta de cliente).
+
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
diff --git a/manifiesto.json b/manifiesto.json
index 78172af..7e6df59 100644
--- a/manifiesto.json
+++ b/manifiesto.json
@@ -1,19 +1,19 @@
{
"nombre": "OrganiLAB",
- "version": "1.7.0",
- "builtAt": "2026-09-16T01:45:56.000Z",
- "archivo": "OrganizadorLabstream-portatil-1.7.0.zip",
- "size": 198673691,
- "sha256": "2dad6903c9484815406c4d0f3e2e3ebf65a916990fc93ee3ade07ac489bd7f9e",
+ "version": "1.8.0",
+ "builtAt": "2026-09-16T15:38:23.000Z",
+ "archivo": "OrganizadorLabstream-portatil-1.8.0.zip",
+ "size": 198682459,
+ "sha256": "7975c51317de5c63aa8271a11434d183c783893a234718d24151264638fee751",
"app": {
- "archivo": "OrganizadorLabstream-app-1.7.0.zip",
- "size": 14853941,
- "sha256": "faf85782a465f0d1cec062f6d7993e70e4e292adf2095f9864cbfafe4f33c935",
- "url": "/api/herramientas/OrganizadorLabstream-app-1.7.0.zip"
+ "archivo": "OrganizadorLabstream-app-1.8.0.zip",
+ "size": 14862710,
+ "sha256": "e8ab00045256795e182563241c7f62a9582776d6f7effc866d3eab2013b7eb8b",
+ "url": "/api/herramientas/OrganizadorLabstream-app-1.8.0.zip"
},
"notes": "",
"firma": {
"alg": "rsa2048-pkcs1v15-sha256",
- "valor": "bba9a7041615d6b3fc47ecdfee39e6835ed09d8f06bba6629b46e23d8e91a57c84ebf82ad0c9cda49632143a8f0b9baf56698393363c197604687a01f477cda7877cc7f4f6368460a85c08aaa5a17c3e2205ee5f3db598b02592b372f7ea479ca722a1498d2c0c13d6f95c02c1e8a0982ac280bf00b1cbcd71ee2d840a8a8a08038fc45fe7195cbf6ad1f2ab562d619c67060af21028bfab9f8213d9f77a6e974646e7c4c597356b094bfdc379950d2015a75ea131beaf34b29aab710934863e44b7e52055ee2b6d2108c53b2fe1c0249746f00537085a1d2ba051f653091d3b7fa9d523a3ba3d459f4c48ea3985a798d29214a985575e263cdebc4fac4cd493"
+ "valor": "41a83ab9e9918fe7d16ca9559a7524e1c88685223196c3c85b2f32ea0f71da819d8ae3b099c68fbbabc3e276782a92dcf8d9ea42facaf3ae594f3062297cec7770e2dc885f15ba083dc28e7fd5422ed4abd11ead1ebae7ef7c69a34018c88d8ac5e4bcb290c19ee789261001663234b7ae9d3372a7951947dbfe367cebb3a323f0688a8a177f07cc8827e8d293b8ac014a4f4348cabead577aa02d41d64a6f96af50c2cc70de6b809a3a4bda2dc13d6ee07c64924e19d02d5100600cdc9be5a273a121757a111c5ff20229f090306a39494e280679913d347843ba5193ab36d4f12b992f352e578815e1e535fc542f032ade94d16922bab8fc8f63c85bc760ba"
}
}
\ No newline at end of file
diff --git a/organizador.py b/organizador.py
index 95e955d..63a0a49 100644
--- a/organizador.py
+++ b/organizador.py
@@ -17,6 +17,7 @@ import json
import time
import queue
import shutil
+import stat as stat_mod
import hashlib
import sqlite3
import threading
@@ -33,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
-VERSION = "1.7.0"
+VERSION = "1.8.0"
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
@@ -238,6 +239,11 @@ for t, d in R.PLANTILLAS.items():
PLANTILLAS.setdefault(t, d)
for b, p in d.items():
PLANTILLAS[t].setdefault(b, p)
+ # la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan
+ # sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja
+ # mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
+ if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
+ PLANTILLAS[t][b] = p
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
@@ -354,10 +360,28 @@ def contexto_dir(nombre, ctx_padre, nivel_padre):
return cub, nivel_padre
+def _es_enlace(entrada):
+ """True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta:
+ para una unión devuelve False, y aun así al entrar se recorre otro árbol."""
+ try:
+ if entrada.is_symlink():
+ return True
+ return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT)
+ except (OSError, AttributeError):
+ return False
+
+
+def _id_carpeta(ruta):
+ """Identificador real de una carpeta en el disco, para no recorrer dos veces la misma."""
+ st = os.stat(ruta)
+ return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta))
+
+
def escanear(ruta):
raiz = os.path.normpath(ruta)
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
filas = []
+ fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
props = ("n", "bytes")
@@ -369,10 +393,24 @@ def escanear(ruta):
info[1] += size
info[2][fecha_am] += 1
- def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes):
+ def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None):
+ # `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows
+ # (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando
+ # vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos).
+ if vistos is None:
+ vistos = set()
+ try:
+ clave = _id_carpeta(path)
+ if clave in vistos:
+ fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra")
+ return
+ vistos = vistos | {clave}
+ except OSError:
+ pass
try:
it = os.scandir(path)
- except OSError:
+ except OSError as ex:
+ fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
return
prog["actual"] = path
with it:
@@ -384,7 +422,12 @@ def escanear(ruta):
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")
+ # una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
+ # medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de
+ # material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye
+ # por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de
+ # Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo.
+ s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip()))
c2, n2 = contexto_dir(nombre, ctx, nivel)
a2, m2 = anio, mes
if c2 == "ANIO":
@@ -394,7 +437,10 @@ def escanear(ruta):
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)
+ if _es_enlace(e):
+ fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra")
+ continue
+ walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
else:
st = e.stat(follow_symlinks=False)
ext = os.path.splitext(nombre)[1].lower()
@@ -418,12 +464,26 @@ def escanear(ruta):
if padre == d:
break
d = padre
- except OSError:
+ except OSError as ex:
+ fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
continue
try:
walk(raiz, False, False, False, "", "raiz", None, None)
+ prog["fallos"] = len(fallos)
con = db()
+ # Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de
+ # red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de
+ # ahí el plan, el respaldo y el convertidor trabajaban sobre datos parciales sin que nadie lo supiera.
+ anterior = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
+ if fallos and anterior and anterior["n"] and prog["n"] < anterior["n"] * 0.9:
+ con.close()
+ prog["estado"] = "error"
+ prog["error"] = (f"el escaneo no pudo leer {len(fallos)} sitios y solo encontró {prog['n']:,} archivos "
+ f"frente a {anterior['n']:,} del anterior: se conserva el inventario que ya había. "
+ f"Comprueba que la unidad esté conectada. Primero que falló: {fallos[0]}")
+ prog["fin"] = time.time()
+ return
with con:
# conservar el análisis de ffprobe de los archivos que no cambiaron
previo = {r["ruta"]: r for r in con.execute(
@@ -458,6 +518,8 @@ def escanear(ruta):
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
con.close()
prog["estado"] = "listo"
+ if fallos:
+ prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
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"
@@ -678,9 +740,14 @@ class Plan:
m, conflicto = R.detectar_mes(nombre)
if m:
canon = R.nombre_mes(m)
+ real, real_origen = self.mes_real(e.path)
+ discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, 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")
+ if discrepa:
+ motivo += f" · OJO: lo que hay dentro parece de {R.nombre_mes(real[1])}"
+ self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
+ "media" if (conflicto or discrepa) else "alta")
self.plan_dir(e.path, "mes", anio, m)
return
if nivel == "raiz" or (nivel == "contenedor" and not anio):
@@ -780,6 +847,24 @@ class Plan:
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 mes_real(self, ruta):
+ """((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de
+ acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar."""
+ cnt = Counter()
+ origenes = Counter()
+ for f in archivos_bajo(self.con, ruta):
+ if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime":
+ continue
+ if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"):
+ cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
+ origenes[f["fecha_origen"]] += 1
+ if not cnt:
+ return None, ""
+ (a, m), n = cnt.most_common(1)[0]
+ if n < 3 or n / sum(cnt.values()) < 0.7:
+ return None, ""
+ return (a, m), origenes.most_common(1)[0][0]
+
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
@@ -789,6 +874,7 @@ class Plan:
if not filas:
return
grupos = defaultdict(list)
+ desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
for f in filas:
if f["es_basura"] or f["es_proxy"]:
continue
@@ -810,7 +896,14 @@ class Plan:
elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0]
if anio and mes:
+ # La carpeta dice un mes, pero los archivos pueden decir otro. Medido el 16-sep-2026 sobre
+ # el material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes
+ # que anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre). Antes esto se movía
+ # con confianza alta y sin aviso; ahora se avisa y baja a media.
ay, am, fconf = anio, mes, "alta"
+ if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
+ fconf = "media"
+ desacuerdo[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
else:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime"
@@ -830,6 +923,16 @@ class Plan:
ay -= 1
grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"]))
etiqueta = "" if generico else R.etiqueta_grabacion(nombre)
+ aviso_fecha = ""
+ # hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
+ # toda la carpeta sin marcar.
+ n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime")
+ if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables):
+ (da, dm), cuantos = desacuerdo.most_common(1)[0]
+ aviso_fecha = (f" · OJO: la carpeta dice {R.nombre_mes(mes)} pero {cuantos} de sus archivos son de "
+ f"{R.nombre_mes(dm)} según su propio nombre; comprueba antes de aplicar")
+ self.avisos.append(f"«{self.rel(e.path)}» dice {R.nombre_mes(mes)} de {anio} y el nombre de {cuantos} de "
+ f"sus archivos dice {R.nombre_mes(dm)}: se propone sin marcar para que lo revises.")
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))
@@ -837,7 +940,9 @@ class Plan:
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)
+ marcado = False if aviso_fecha else None
+ self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf,
+ archivos=archivos, marcado=marcado)
def plan_archivo(self, e, nivel, anio, mes, padre):
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
@@ -1020,20 +1125,40 @@ def aplicar(raiz, filas):
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 f.get("archivos"):
+ # cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
+ # ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
+ # que había fallado uno solo.
+ especiales = f.get("destinos_archivo") or {}
+ fallos_fila = 0
+ try:
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:
+ except Exception as ex: # noqa
+ errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
+ continue
+ for a in f["archivos"]:
+ if not os.path.exists(a):
+ continue
+ try:
+ mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
+ except Exception as ex: # noqa
+ fallos_fila += 1
+ if fallos_fila <= 5:
+ errores.append(f"{os.path.basename(a)}: {ex}")
+ if fallos_fila > 5:
+ errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
+ if not fallos_fila:
+ hechas += 1
+ else:
+ try:
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}")
+ hechas += 1
+ else:
+ errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
+ 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:
@@ -1148,12 +1273,13 @@ def vocabulario(raiz):
t["bytes"] += c["bytes"] or 0
if len(t["rutas"]) < 4:
t["rutas"].append(rel)
- if tokens:
+ if tokens and any(not t["cubeta"] for t in tokens.values()):
con = db()
+ resumen = _resumen_carpetas(con, raiz)
+ con.close()
for t in tokens.values():
if not t["cubeta"]:
- _sugerir(con, raiz, t)
- con.close()
+ _sugerir(resumen, raiz, t)
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
return out
@@ -1162,27 +1288,64 @@ _ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición /
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
-def _sugerir(con, raiz, t):
+def _discrepa_mes(fecha, origen, anio, mes):
+ """True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta.
+ Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de
+ modificación, que no es de fiar, así que en ese caso solo se compara el mes."""
+ if origen == "mtime":
+ return False # la fecha de modificación no prueba nada
+ ra, rm = int(fecha[:4]), int(fecha[5:7])
+ if origen == "nombre+mtime":
+ d = abs(rm - mes)
+ return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen
+ return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2
+
+
+def _clase_rol(rol):
+ return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
+ "proyecto" if rol in ("proyecto", "diseno") else
+ "audio" if rol == "audio" else
+ "imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
+ "documento" if rol == "documento" else
+ "video" if rol in ("video", "celular_video") else "otro")
+
+
+def _resumen_carpetas(con, raiz):
+ """Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas.
+ Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y
+ cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s)."""
+ agg = defaultdict(lambda: defaultdict(lambda: [0, 0]))
+ filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos "
+ "WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall()
+ tope = os.path.normcase(os.path.normpath(raiz))
+ for f in filas:
+ k = _clase_rol(f["rol"])
+ d = os.path.normpath(f["dir"])
+ while True:
+ e = agg[os.path.normcase(d)][k]
+ e[0] += f["n"]
+ e[1] += f["b"]
+ if os.path.normcase(d) == tope:
+ break
+ padre = os.path.dirname(d)
+ if padre == d:
+ break
+ d = padre
+ return agg
+
+
+def _sugerir(resumen, raiz, t):
"""Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre.
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
import difflib
conteo = defaultdict(lambda: [0, 0])
total = [0, 0]
for rel in t["rutas"][:3]:
- for f in archivos_bajo(con, os.path.join(raiz, rel)):
- if f["es_basura"] or f["es_proxy"]:
- continue
- rol = f["rol"]
- k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
- "proyecto" if rol in ("proyecto", "diseno") else
- "audio" if rol in ("audio",) else
- "imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
- "documento" if rol == "documento" else
- "video" if rol in ("video", "celular_video") else "otro")
- conteo[k][0] += 1
- conteo[k][1] += f["bytes"]
- total[0] += 1
- total[1] += f["bytes"]
+ for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items():
+ conteo[k][0] += n
+ conteo[k][1] += b
+ total[0] += n
+ total[1] += b
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1])
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
sug, conf, por_que = "", "", ""
@@ -1195,7 +1358,7 @@ def _sugerir(con, raiz, t):
if total[0] >= 3 and orden:
k, (n, b) = orden[0]
frac = b / total[1] if total[1] else n / total[0]
- if frac >= 0.7:
+ if frac >= 0.75:
por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
if por_cont:
@@ -1203,7 +1366,10 @@ def _sugerir(con, raiz, t):
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
conf = "baja"
else:
- sug, conf, por_que = por_cont, ("alta" if frac >= 0.9 and n >= 3 else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
+ # «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos
+ # los clientes: exige que sea contundente y que haya varias carpetas iguales.
+ seguro = frac >= 0.92 and n >= 8 and total[0] >= 10
+ sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
@@ -1428,9 +1594,14 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
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()}
+ # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
+ filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
+ "AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
+ # solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe
+ # poder convertirse también a H.265, y uno que falló debe poder reintentarse.
+ en_cola = {r["ruta"]: r["estado"] for r in con.execute(
+ "SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')",
+ (formato,)).fetchall()}
con.close()
out = []
for f in filas:
@@ -1468,7 +1639,8 @@ def salida_para(ruta, raiz, formato="h265_10bit"):
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
if o.get("salida") == "espejo":
rel = os.path.relpath(base, raiz)
- return os.path.join(raiz, "_Archivo", rel + fm["ext"])
+ # el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo
+ return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"])
return base + sufijo + fm["ext"]
@@ -1600,6 +1772,10 @@ def correr_ffmpeg(t):
return
if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
+ try:
+ os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
+ except OSError:
+ pass
return
os.replace(tmp, salida)
try:
@@ -1756,17 +1932,23 @@ class ErrorCola(ValueError):
self.datos = datos
+_COLA_LOCK = threading.Lock()
+
+
def iniciar_cola():
- if COLA["hilo"] and COLA["hilo"].is_alive():
- return False
- recuperar_huerfanas()
- COLA["activa"] = True
- COLA["cancelar"] = False
- COLA["hechos"] = 0
- COLA["inicio"] = time.time()
- COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
- COLA["hilo"].start()
- return True
+ # el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
+ # alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
+ with _COLA_LOCK:
+ if COLA["hilo"] and COLA["hilo"].is_alive():
+ return False
+ recuperar_huerfanas()
+ COLA["activa"] = True
+ COLA["cancelar"] = False
+ COLA["hechos"] = 0
+ COLA["inicio"] = time.time()
+ COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
+ COLA["hilo"].start()
+ return True
def verificar(ids):
@@ -2159,7 +2341,7 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
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),
+ "cliente": cliente, "base": base, "destino_base": destino, "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},
@@ -2417,6 +2599,8 @@ class Manejador(BaseHTTPRequestHandler):
return True
def do_GET(self):
+ if self.path.startswith("/api/") and not self._es_local():
+ return self._json({"error": "petición no permitida"}, 403)
u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()}
ruta = u.path
@@ -2524,8 +2708,8 @@ class Manejador(BaseHTTPRequestHandler):
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", int(q.get("meses", "0") or 0))
PLANES_RESPALDO[raiz] = plan
- CONFIG.setdefault("respaldo", {})["destino"] = destino
- guardar_json(CONFIG_PATH, CONFIG)
+ # calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
+ # reescribía config.json)
return self._json({k: v for k, v in plan.items() if k != "items"})
if ruta == "/api/respaldo/estado":
return self._json(RESPALDO)
@@ -2679,6 +2863,10 @@ class Manejador(BaseHTTPRequestHandler):
if ruta == "/api/abrir":
return self._json(abrir_carpeta(d.get("ruta", "")))
if ruta == "/api/respaldo/copiar":
+ _rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", "")))
+ if _rp and _rp.get("destino_base"):
+ CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"]
+ guardar_json(CONFIG_PATH, CONFIG)
raiz = os.path.normpath(d["raiz"])
plan = PLANES_RESPALDO.get(raiz)
if not plan:
diff --git a/reglas.py b/reglas.py
index 7cb9ab8..7a4442a 100644
--- a/reglas.py
+++ b/reglas.py
@@ -29,6 +29,7 @@ def norm(s: str) -> str:
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"]
MESES_TITULO = [m.capitalize() for m in MESES]
+# abreviaturas: se entienden, pero nunca con confianza alta (ver MESES_DUDOSOS)
MESES_ALIAS = {
"setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre",
"sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo",
@@ -38,6 +39,8 @@ MESES_ALIAS = {
"june": "junio", "july": "julio", "august": "agosto", "september": "septiembre",
"october": "octubre", "november": "noviembre", "december": "diciembre",
}
+# «Mar», «May» o «Ago» pueden ser un nombre propio, no un mes: se proponen con confianza media
+MESES_DUDOSOS = {"sep", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "oct", "nov", "dic"}
def detectar_anio(nombre: str):
@@ -55,10 +58,12 @@ def detectar_mes(nombre: str):
return None, False
num = int(m.group(1)) if m.group(1) else None
palabra = m.group(2)
+ abreviada = palabra in MESES_DUDOSOS
palabra = MESES_ALIAS.get(palabra, palabra)
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
if mes_por_nombre:
- return mes_por_nombre, (num is not None and num != mes_por_nombre)
+ # abreviada sin número («Mar») o número que no cuadra con el nombre: confianza media
+ return mes_por_nombre, (num is not None and num != mes_por_nombre) or (abreviada and num is None)
if num and 1 <= num <= 12 and not palabra:
return num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta
return None, False
@@ -153,10 +158,14 @@ EXT = {
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"},
}
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
-BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk", ".bak", ".ims", ".idx", ".dat"}
+# solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos
+# (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito.
+BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"}
BASURA_DIRS = {"__macosx", "#recycle", ".synologyworkingdirectory", "@eadir", "media cache", "media cache files", "peak files"}
BASURA_DIRS_CONTIENE = ["almacenamiento automatico", "auto save", "autosave", "auto-save", "adobe premiere pro auto"]
-PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media", "cache"}
+# «cache» a secas no entra: es un nombre demasiado genérico para mandar la carpeta entera a _Para borrar
+PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media",
+ "cache files", "render cache"}
def es_basura_archivo(nombre: str) -> bool:
diff --git a/static/index.html b/static/index.html
index 9810a99..1af0e68 100644
--- a/static/index.html
+++ b/static/index.html
@@ -437,7 +437,7 @@
Cada vez que la app mueve algo lo apunta aquí, con ejemplos de qué fue a dónde. ?La app nunca borra: lo apartado en _Para borrar lo revisa y lo borra una persona desde el Explorador. Cada vez que la app mueve algo lo apunta aquí, y Deshacer devuelve cada archivo a donde estaba. ?Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en _Para borrar lo revisa y lo borra una persona desde el Explorador.Historial
Se mueven ${m.length} cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de ${esc(PLAN.raiz)}.
Nada se borra. Es un cambio de sitio dentro del mismo disco. Cierra Resolve y Premiere si tienen abierto material de esta carpeta para que no bloqueen archivos.
`, () => accion(btn, async () => { + modal(titulo, `Se mueven ${m.length} cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de ${esc(PLAN.raiz)}.
Nada se borra y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. Cierra Resolve y Premiere si tienen abierto material de esta carpeta para que no bloqueen archivos.
`, () => accion(btn, async () => { const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; }); const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos}); PLAN = null; MAPA = null; - aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); + aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(res.lote)}); if (res.errores.length) modal('Algunos no se pudieron mover', `${esc(res.errores.join('\n'))}`, null);
await cargarEstado(); await calcularPlan();
}));
@@ -1078,7 +1085,8 @@ async function cargarBit(){
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `${esc(a.detalle)}«${esc(nombre)}»
`, null); const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre)); - if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\/][^\/]*$/, '') : s.ruta); + if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\\/][^\\/]*$/, '') : s.ruta); } modal('Buscando la carpeta…', `El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…
`, null); const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));