1.8.0: red de seguridad tras la auditoría

- Deshacer vuelve a la interfaz (Historial, Últimos cambios y el aviso al aplicar).
- «Elements» ya no marca como stock lo que tenga dentro: 50,7 GB de material
  propio dejaban de respaldarse y salían como desechables.
- El plan contrasta el mes de la carpeta con la fecha de los archivos y avisa
  cuando no cuadran (727 GB en el material real iban al mes equivocado en verde).
- aplicar(): un archivo bloqueado ya no corta el resto de su grupo.
- Un escaneo interrumpido no reemplaza el inventario bueno.
- Las uniones de carpeta de Windows no se recorren (colgaban el escaneo).
- Abrir una carpeta pasó de 8,83 s a 0,21 s.
- Plantillas: _Papelera migra a _Para borrar.
- .dat/.idx/.bak dejan de ser basura; Cache deja de ser proxies; abreviaturas
  de mes bajan a confianza media.
- Convertidor: varios formatos por clip, originales apartados fuera de
  candidatos, sufijo en modo espejo, temporal borrado al fallar, una sola cola.
- Interfaz: accion() no pisa el estado del botón; rutas de Windows bien recortadas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 10:43:42 -05:00
padre 6bdf6a8457
commit b977fe1b3e
Se han modificado 5 ficheros con 315 adiciones y 80 borrados

Ver fichero

@@ -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 - 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`. 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 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "1.7.0", "version": "1.8.0",
"builtAt": "2026-09-16T01:45:56.000Z", "builtAt": "2026-09-16T15:38:23.000Z",
"archivo": "OrganizadorLabstream-portatil-1.7.0.zip", "archivo": "OrganizadorLabstream-portatil-1.8.0.zip",
"size": 198673691, "size": 198682459,
"sha256": "2dad6903c9484815406c4d0f3e2e3ebf65a916990fc93ee3ade07ac489bd7f9e", "sha256": "7975c51317de5c63aa8271a11434d183c783893a234718d24151264638fee751",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-1.7.0.zip", "archivo": "OrganizadorLabstream-app-1.8.0.zip",
"size": 14853941, "size": 14862710,
"sha256": "faf85782a465f0d1cec062f6d7993e70e4e292adf2095f9864cbfafe4f33c935", "sha256": "e8ab00045256795e182563241c7f62a9582776d6f7effc866d3eab2013b7eb8b",
"url": "/api/herramientas/OrganizadorLabstream-app-1.7.0.zip" "url": "/api/herramientas/OrganizadorLabstream-app-1.8.0.zip"
}, },
"notes": "", "notes": "",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "bba9a7041615d6b3fc47ecdfee39e6835ed09d8f06bba6629b46e23d8e91a57c84ebf82ad0c9cda49632143a8f0b9baf56698393363c197604687a01f477cda7877cc7f4f6368460a85c08aaa5a17c3e2205ee5f3db598b02592b372f7ea479ca722a1498d2c0c13d6f95c02c1e8a0982ac280bf00b1cbcd71ee2d840a8a8a08038fc45fe7195cbf6ad1f2ab562d619c67060af21028bfab9f8213d9f77a6e974646e7c4c597356b094bfdc379950d2015a75ea131beaf34b29aab710934863e44b7e52055ee2b6d2108c53b2fe1c0249746f00537085a1d2ba051f653091d3b7fa9d523a3ba3d459f4c48ea3985a798d29214a985575e263cdebc4fac4cd493" "valor": "41a83ab9e9918fe7d16ca9559a7524e1c88685223196c3c85b2f32ea0f71da819d8ae3b099c68fbbabc3e276782a92dcf8d9ea42facaf3ae594f3062297cec7770e2dc885f15ba083dc28e7fd5422ed4abd11ead1ebae7ef7c69a34018c88d8ac5e4bcb290c19ee789261001663234b7ae9d3372a7951947dbfe367cebb3a323f0688a8a177f07cc8827e8d293b8ac014a4f4348cabead577aa02d41d64a6f96af50c2cc70de6b809a3a4bda2dc13d6ee07c64924e19d02d5100600cdc9be5a273a121757a111c5ff20229f090306a39494e280679913d347843ba5193ab36d4f12b992f352e578815e1e535fc542f032ade94d16922bab8fc8f63c85bc760ba"
} }
} }

Ver fichero

@@ -17,6 +17,7 @@ import json
import time import time
import queue import queue
import shutil import shutil
import stat as stat_mod
import hashlib import hashlib
import sqlite3 import sqlite3
import threading import threading
@@ -33,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "1.7.0" VERSION = "1.8.0"
CONGELADO = getattr(sys, "frozen", False) 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 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) PLANTILLAS.setdefault(t, d)
for b, p in d.items(): for b, p in d.items():
PLANTILLAS[t].setdefault(b, p) 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) guardar_json(PLANTILLAS_PATH, PLANTILLAS)
@@ -354,10 +360,28 @@ def contexto_dir(nombre, ctx_padre, nivel_padre):
return cub, 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): def escanear(ruta):
raiz = os.path.normpath(ruta) raiz = os.path.normpath(ruta)
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""} prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
filas = [] 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] dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
props = ("n", "bytes") props = ("n", "bytes")
@@ -369,10 +393,24 @@ def escanear(ruta):
info[1] += size info[1] += size
info[2][fecha_am] += 1 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: try:
it = os.scandir(path) it = os.scandir(path)
except OSError: except OSError as ex:
fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
return return
prog["actual"] = path prog["actual"] = path
with it: with it:
@@ -384,7 +422,12 @@ def escanear(ruta):
continue continue
p = en_proxy or R.es_proxy_dir(nombre) 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 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) c2, n2 = contexto_dir(nombre, ctx, nivel)
a2, m2 = anio, mes a2, m2 = anio, mes
if c2 == "ANIO": if c2 == "ANIO":
@@ -394,7 +437,10 @@ def escanear(ruta):
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre]) dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
dirs[e.path][3] = 1 if p else 0 dirs[e.path][3] = 1 if p else 0
dirs[e.path][4] = 1 if b 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: else:
st = e.stat(follow_symlinks=False) st = e.stat(follow_symlinks=False)
ext = os.path.splitext(nombre)[1].lower() ext = os.path.splitext(nombre)[1].lower()
@@ -418,12 +464,26 @@ def escanear(ruta):
if padre == d: if padre == d:
break break
d = padre d = padre
except OSError: except OSError as ex:
fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
continue continue
try: try:
walk(raiz, False, False, False, "", "raiz", None, None) walk(raiz, False, False, False, "", "raiz", None, None)
prog["fallos"] = len(fallos)
con = db() 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: with con:
# conservar el análisis de ffprobe de los archivos que no cambiaron # conservar el análisis de ffprobe de los archivos que no cambiaron
previo = {r["ruta"]: r for r in con.execute( 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"])) (raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
con.close() con.close()
prog["estado"] = "listo" 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 PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
except Exception as ex: # noqa except Exception as ex: # noqa
prog["estado"] = "error" prog["estado"] = "error"
@@ -678,9 +740,14 @@ class Plan:
m, conflicto = R.detectar_mes(nombre) m, conflicto = R.detectar_mes(nombre)
if m: if m:
canon = R.nombre_mes(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: if nombre != canon:
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "") 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) self.plan_dir(e.path, "mes", anio, m)
return return
if nivel == "raiz" or (nivel == "contenedor" and not anio): 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)) 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) 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): 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).""" """Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
nombre = e.name nombre = e.name
@@ -789,6 +874,7 @@ class Plan:
if not filas: if not filas:
return return
grupos = defaultdict(list) grupos = defaultdict(list)
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
for f in filas: for f in filas:
if f["es_basura"] or f["es_proxy"]: if f["es_basura"] or f["es_proxy"]:
continue continue
@@ -810,7 +896,14 @@ class Plan:
elif R.detectar_mes(seg)[0]: elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0] mes_ruta = R.detectar_mes(seg)[0]
if anio and mes: 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" 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: else:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime" por_nombre = f["fecha_origen"] != "mtime"
@@ -830,6 +923,16 @@ class Plan:
ay -= 1 ay -= 1
grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"])) grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"]))
etiqueta = "" if generico else R.etiqueta_grabacion(nombre) 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(): for (ay, am, fuente, fconf), archivos in grupos.items():
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente) 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)) 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): if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
continue 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 "")) 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): def plan_archivo(self, e, nivel, anio, mes, padre):
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone() fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
@@ -1020,18 +1125,38 @@ def aplicar(raiz, filas):
APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz)) APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
for f in filas: for f in filas:
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "") APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
try:
if f.get("archivos"): 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 {} especiales = f.get("destinos_archivo") or {}
fallos_fila = 0
try:
if not especiales: if not especiales:
os.makedirs(f["destino"], exist_ok=True) os.makedirs(f["destino"], exist_ok=True)
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"]: for a in f["archivos"]:
if os.path.exists(a): 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) 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: else:
try:
if os.path.exists(f["origen"]): if os.path.exists(f["origen"]):
mover(f["origen"], f["destino"], lote, movidos) mover(f["origen"], f["destino"], lote, movidos)
hechas += 1 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 except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: {ex}") 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» APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
@@ -1148,12 +1273,13 @@ def vocabulario(raiz):
t["bytes"] += c["bytes"] or 0 t["bytes"] += c["bytes"] or 0
if len(t["rutas"]) < 4: if len(t["rutas"]) < 4:
t["rutas"].append(rel) t["rutas"].append(rel)
if tokens: if tokens and any(not t["cubeta"] for t in tokens.values()):
con = db() con = db()
resumen = _resumen_carpetas(con, raiz)
con.close()
for t in tokens.values(): for t in tokens.values():
if not t["cubeta"]: if not t["cubeta"]:
_sugerir(con, raiz, t) _sugerir(resumen, raiz, t)
con.close()
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
return out 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"} "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. """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).""" Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
import difflib import difflib
conteo = defaultdict(lambda: [0, 0]) conteo = defaultdict(lambda: [0, 0])
total = [0, 0] total = [0, 0]
for rel in t["rutas"][:3]: for rel in t["rutas"][:3]:
for f in archivos_bajo(con, os.path.join(raiz, rel)): for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items():
if f["es_basura"] or f["es_proxy"]: conteo[k][0] += n
continue conteo[k][1] += b
rol = f["rol"] total[0] += n
k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else total[1] += b
"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"]
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1]) 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" 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 = "", "", "" sug, conf, por_que = "", "", ""
@@ -1195,7 +1358,7 @@ def _sugerir(con, raiz, t):
if total[0] >= 3 and orden: if total[0] >= 3 and orden:
k, (n, b) = orden[0] k, (n, b) = orden[0]
frac = b / total[1] if total[1] else n / total[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", por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k) "documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
if por_cont: if por_cont:
@@ -1203,7 +1366,10 @@ def _sugerir(con, raiz, t):
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}" por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
conf = "baja" conf = "baja"
else: 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 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"]) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
con = db() con = db()
limite = time.time() - int(meses) * 30.4 * 86400 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", # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
(raiz, int(min_mb) * 1024 * 1024)).fetchall() filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").fetchall()} "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() con.close()
out = [] out = []
for f in filas: 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"] sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
if o.get("salida") == "espejo": if o.get("salida") == "espejo":
rel = os.path.relpath(base, raiz) 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"] return base + sufijo + fm["ext"]
@@ -1600,6 +1772,10 @@ def correr_ffmpeg(t):
return return
if p.returncode != 0 or not os.path.exists(tmp): if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) 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 return
os.replace(tmp, salida) os.replace(tmp, salida)
try: try:
@@ -1756,7 +1932,13 @@ class ErrorCola(ValueError):
self.datos = datos self.datos = datos
_COLA_LOCK = threading.Lock()
def iniciar_cola(): def iniciar_cola():
# 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(): if COLA["hilo"] and COLA["hilo"].is_alive():
return False return False
recuperar_huerfanas() recuperar_huerfanas()
@@ -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"] por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
omit_raw.sort(key=lambda x: -x["bytes"]) omit_raw.sort(key=lambda x: -x["bytes"])
return { 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"])], "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]}, "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}, "stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
@@ -2417,6 +2599,8 @@ class Manejador(BaseHTTPRequestHandler):
return True return True
def do_GET(self): 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) u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()} q = {k: v[0] for k, v in parse_qs(u.query).items()}
ruta = u.path ruta = u.path
@@ -2524,8 +2708,8 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400) 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)) 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 PLANES_RESPALDO[raiz] = plan
CONFIG.setdefault("respaldo", {})["destino"] = destino # calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
guardar_json(CONFIG_PATH, CONFIG) # reescribía config.json)
return self._json({k: v for k, v in plan.items() if k != "items"}) return self._json({k: v for k, v in plan.items() if k != "items"})
if ruta == "/api/respaldo/estado": if ruta == "/api/respaldo/estado":
return self._json(RESPALDO) return self._json(RESPALDO)
@@ -2679,6 +2863,10 @@ class Manejador(BaseHTTPRequestHandler):
if ruta == "/api/abrir": if ruta == "/api/abrir":
return self._json(abrir_carpeta(d.get("ruta", ""))) return self._json(abrir_carpeta(d.get("ruta", "")))
if ruta == "/api/respaldo/copiar": 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"]) raiz = os.path.normpath(d["raiz"])
plan = PLANES_RESPALDO.get(raiz) plan = PLANES_RESPALDO.get(raiz)
if not plan: if not plan:

Ver fichero

@@ -29,6 +29,7 @@ def norm(s: str) -> str:
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"] "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
MESES_TITULO = [m.capitalize() for m in MESES] MESES_TITULO = [m.capitalize() for m in MESES]
# abreviaturas: se entienden, pero nunca con confianza alta (ver MESES_DUDOSOS)
MESES_ALIAS = { MESES_ALIAS = {
"setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre", "setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre",
"sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo", "sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo",
@@ -38,6 +39,8 @@ MESES_ALIAS = {
"june": "junio", "july": "julio", "august": "agosto", "september": "septiembre", "june": "junio", "july": "julio", "august": "agosto", "september": "septiembre",
"october": "octubre", "november": "noviembre", "december": "diciembre", "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): def detectar_anio(nombre: str):
@@ -55,10 +58,12 @@ def detectar_mes(nombre: str):
return None, False return None, False
num = int(m.group(1)) if m.group(1) else None num = int(m.group(1)) if m.group(1) else None
palabra = m.group(2) palabra = m.group(2)
abreviada = palabra in MESES_DUDOSOS
palabra = MESES_ALIAS.get(palabra, palabra) palabra = MESES_ALIAS.get(palabra, palabra)
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
if mes_por_nombre: 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: 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 num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta
return None, False return None, False
@@ -153,10 +158,14 @@ EXT = {
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"}, "programa": {".exe", ".msi", ".pkg", ".app", ".lnk"},
} }
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"} 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 = {"__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"] 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: def es_basura_archivo(nombre: str) -> bool:

Ver fichero

@@ -437,7 +437,7 @@
<!-- ============ HISTORIAL ============ --> <!-- ============ HISTORIAL ============ -->
<section id="t-historial"> <section id="t-historial">
<div class="cab"><h2 class="v">Historial</h2><div class="der"><label class="mut" style="font-size:12.5px"><input type="checkbox" id="bitSoloRaiz" checked> solo esta carpeta</label></div></div> <div class="cab"><h2 class="v">Historial</h2><div class="der"><label class="mut" style="font-size:12.5px"><input type="checkbox" id="bitSoloRaiz" checked> solo esta carpeta</label></div></div>
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, con ejemplos de qué fue a dónde. <span class="q">?</span><span class="mas">La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p> <p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, y <b>Deshacer</b> devuelve cada archivo a donde estaba. <span class="q">?</span><span class="mas">Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
<div id="papeleraLinea" class="hint" style="display:none"></div> <div id="papeleraLinea" class="hint" style="display:none"></div>
<div id="historial"></div> <div id="historial"></div>
</section> </section>
@@ -503,10 +503,14 @@ function aviso(texto, tipo, enlace, detalle){
} }
async function accion(btn, fn){ async function accion(btn, fn){
if (btn.disabled) return; if (btn.disabled) return;
const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span>' + antes; // se marca lo que pusimos nosotros: si la acción deja OTRA etiqueta (porque recalculó el plan, vació la
// lista o volvió a bloquear el botón), esa manda. Antes el finally la pisaba y quedaban botones
// habilitados con la cuenta vieja: «Aplicar todo lo marcado (23)» sobre un plan que ya no tenía 23.
const antes = btn.innerHTML, marca = '<span class="spin"></span>' + antes;
btn.disabled = true; btn.innerHTML = marca;
try { await fn(); } try { await fn(); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
finally { btn.disabled = false; btn.innerHTML = antes; } finally { if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; } }
} }
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí // qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí
const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || '')); const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
@@ -578,9 +582,10 @@ function huella(E){
let SONDEO = null; let SONDEO = null;
async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => console.warn(e)).finally(() => { SONDEO = null; }); } async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => console.warn(e)).finally(() => { SONDEO = null; }); }
setInterval(sondear, 2500); setInterval(sondear, 2500);
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); }; $('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; VOC = []; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
function limpiarTablas(){ function limpiarTablas(){
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; estadoPlan(); $('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; PLAN_ABIERTOS = new Set();
$('#planIntro').innerHTML = 'Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan();
$('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir'; $('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir';
$('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero'); $('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero');
$('#respCards').innerHTML = ''; $('#respTipos').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respRawLista').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respMuestra').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero'); $('#respCards').innerHTML = ''; $('#respTipos').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respRawLista').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respMuestra').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero');
@@ -594,7 +599,7 @@ function tareasDe(E){
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); } if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); } if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
}); });
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); } if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); } if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)}); else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)});
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); } if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
@@ -690,7 +695,8 @@ async function cargarCarpeta(){
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : ''); $('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
} }
const lotes = CARPETA.lotes || []; const lotes = CARPETA.lotes || [];
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span></div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>'; $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && l.lote.startsWith('optimizar') ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
} }
function queSeHizo(lote){ function queSeHizo(lote){
if (!lote) return 'Cambio'; if (!lote) return 'Cambio';
@@ -886,7 +892,8 @@ function fraseDe(r){
function pintarPlan(){ function pintarPlan(){
if (!PLAN) { estadoPlan(); return; } if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase(); const f = ($('#filtroPlan').value || '').toLowerCase();
$('#planAvisos').innerHTML = (PLAN.avisos || []).map(a => `<div class="hint warn">${esc(a)}</div>`).join(''); const avs = PLAN.avisos || [];
$('#planAvisos').innerHTML = avs.length ? `<div class="hint warn"><b>${avs.length === 1 ? 'Revisa esto antes de aplicar' : 'Revisa estas ' + avs.length + ' cosas antes de aplicar'}:</b><ul style="margin:6px 0 0;padding-left:18px">${avs.map(a => `<li>${esc(a)}</li>`).join('')}</ul></div>` : '';
const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []); const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []);
PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r)); PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r));
const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length; const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length;
@@ -912,11 +919,11 @@ function pintarPlan(){
$('#filtroPlan').oninput = pintarPlan; $('#filtroPlan').oninput = pintarPlan;
function aplicarFilas(m, btn, titulo){ function aplicarFilas(m, btn, titulo){
if (!m.length) return; if (!m.length) return;
modal(titulo, `<p>Se mueven <b>${m.length}</b> 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 <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra.</b> Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => { modal(titulo, `<p>Se mueven <b>${m.length}</b> 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 <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra</b> y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; }); 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}); const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos});
PLAN = null; MAPA = null; 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', `<pre>${esc(res.errores.join('\n'))}</pre>`, null); if (res.errores.length) modal('Algunos no se pudieron mover', `<pre>${esc(res.errores.join('\n'))}</pre>`, null);
await cargarEstado(); await calcularPlan(); 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. // 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 av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : ''; const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span><span></span></div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>'); $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); }; const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
} }
$('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad')); $('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'));
@@ -1132,7 +1140,7 @@ document.addEventListener('drop', seguro(async e => {
if (window.pywebview || (window.chrome && window.chrome.webview)) { if (window.pywebview || (window.chrome && window.chrome.webview)) {
modal('Leyendo la carpeta…', `<p><span class="spin"></span> «${esc(nombre)}»</p>`, null); modal('Leyendo la carpeta…', `<p><span class="spin"></span> «${esc(nombre)}»</p>`, null);
const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre)); 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…', `<p><span class="spin"></span> El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…</p>`, null); modal('Buscando la carpeta…', `<p><span class="spin"></span> El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…</p>`, null);
const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre)); const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));