3.8.0: «database is locked» y los 753 que no se movieron, arreglados; fechas raras, escaneo profundo, buscar, otros discos, banco por fecha y compilar en el estudio

- Tras «Aplicar» en una carpeta grande el inventario se ponía al día con la base tomada más de diez minutos y
  «Buscar clips» se rendía con «database is locked». Ahora las preguntas al disco van antes y en paralelo y
  lo de dentro de cada carpeta se busca por índice: 1,3 s para 2.493 movimientos, mismas filas de archivos
  que antes y 183 carpetas que antes se perdían. La lectura de clips reintenta si la base está ocupada.
- Las fotos de «Alta» y «Baja» chocaban al juntarse en una carpeta: las subcarpetas con sentido se
  conservan, el plan avisa antes de dos archivos iguales en una fila, y un número entre dos palabras
  («Fiesta 6 Años») ya no se quita.
- Los fallos de «Aplicar» se agrupan por motivo y dicen qué hacer; el banco de material se explica en llano.
- Fechas raras (el reloj de una cámara), escaneo profundo, buscar en todos los clientes, traer lo que el
  cliente tiene en otros discos, el banco por temas o por fecha, y «Calcular de nuevo» más rápido.
- Mover reintenta un momento si el archivo está abierto un instante (lectura de clips, antivirus).
- .github/workflows/publicar.yml para compilar y publicar desde un PC del estudio; las pruebas devuelven
  error si algo falla.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 08:27:27 -05:00
padre 41155cd834
commit e74b8f7ece
Se han modificado 9 ficheros con 931 adiciones y 63 borrados

Ver fichero

@@ -42,7 +42,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "3.7.1"
VERSION = "3.8.0"
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
@@ -655,6 +655,7 @@ def escanear(ruta):
if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2:
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0]))
_aplicar_relojes(con, raiz) # las fechas corregidas a mano siguen corregidas
cfilas = []
for d, info in dirs.items():
n, b, fechas, p, bs, nombre = info
@@ -786,10 +787,218 @@ def lanzar_escaneo(ruta):
p = PROGRESO.get(raiz)
if p and p["estado"] == "corriendo":
return False
threading.Thread(target=escanear, args=(raiz,), daemon=True).start()
threading.Thread(target=_escanear_y_leer, args=(raiz,), daemon=True).start()
return True
def _escanear_y_leer(raiz):
"""El escaneo y, si está el escaneo profundo (por defecto sí), después la lectura de cada clip: códec,
duración y resolución, en segundo plano y solo de lo que no se había leído. El escaneo solo mira nombres,
tamaños y fechas; sin leer los clips no se sabe si dos que se llaman igual son el mismo con otra calidad
(2.387 «por comparar» en Vía Sumapaz) ni cuáles conviene convertir."""
escanear(raiz)
ajuste = CONFIG.get("escaneo") or {}
if (PROGRESO.get(raiz) or {}).get("estado") != "listo" or not ajuste.get("profundo", True):
return
a = ANALISIS.get(raiz)
if a and a.get("estado") == "corriendo":
return
analizar(raiz, int(ajuste.get("min_mb", 5)))
# ---- el reloj de una cámara
# Una cámara con el reloj sin poner fecha sus clips en 2008 o 2009 (medido en Vía Sumapaz: 250 clips de la
# Blackmagic «del 28 de julio de 2008»). La app lo detecta; aquí se corrige: se dice la fecha real del primer
# clip de esa tanda y los demás se corren lo mismo. La corrección se guarda por archivo (nombre y peso), así
# sobrevive a volver a escanear y a que el plan mueva los clips de carpeta.
_ROLES_RELOJ = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara")
_ANIO_RELOJ = "2014"
def _tabla_relojes(con):
con.execute("CREATE TABLE IF NOT EXISTS relojes (raiz TEXT, nombre TEXT, bytes INTEGER, dias INTEGER, grupo TEXT, "
"dir TEXT, fuente TEXT, ts REAL)")
def _aplicar_relojes(con, raiz):
"""Vuelve a aplicar las correcciones de reloj de esta carpeta (después de escanear, la fecha vuelve a ser la
de la cámara)."""
_tabla_relojes(con)
con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), (SELECT r.dias || ' days' FROM relojes r "
"WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes LIMIT 1)), "
"fecha_origen = 'corregida' WHERE raiz = ? AND fecha_origen != 'corregida' AND EXISTS (SELECT 1 FROM "
"relojes r WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes)", (raiz,))
def _nombres_fuente():
out = {}
for k, _, d in R.FIRMAS:
out.setdefault(k, d)
return out
def relojes(raiz):
"""Las tandas de clips con fecha imposible, con una pista de su fecha real; y las ya corregidas."""
raiz = _clave(raiz)
con = db()
_tabla_relojes(con)
roles = ",".join("?" * len(_ROLES_RELOJ))
grupos = [dict(x) for x in con.execute(
f"SELECT dir, fuente, COUNT(*) n, SUM(bytes) bytes, MIN(substr(fecha, 1, 10)) desde, MAX(substr(fecha, 1, 10)) hasta, "
f"MIN(nombre) ejemplo FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({roles}) "
f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ? GROUP BY dir, fuente "
f"ORDER BY SUM(bytes) DESC", (raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()]
nf = _nombres_fuente()
for g in grupos:
g["rel"] = os.path.relpath(g["dir"], raiz)
g["camara"] = nf.get(g["fuente"], g["fuente"] or "")
# la pista: la fecha de otros clips de esa misma carpeta que sí la traen bien, o el año que diga la ruta
bien = [x[0] for x in con.execute(
f"SELECT substr(fecha, 1, 10) FROM archivos WHERE raiz = ? AND dir = ? AND es_basura = 0 AND rol IN ({roles}, 'celular_video') "
f"AND (fecha_origen IN ('nombre', 'corregida') OR substr(fecha, 1, 4) >= ?) ORDER BY fecha",
(raiz, g["dir"]) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()]
sug, por_que = "", ""
if bien:
sug = bien[len(bien) // 2]
por_que = (f"es la fecha de otro clip de esa misma carpeta que sí la trae bien" if len(bien) == 1 else
f"es la fecha de otros {len(bien):,} clips de esa misma carpeta que sí la traen bien".replace(",", "."))
else:
anios = [a for a in (R.detectar_anio(s) for s in g["rel"].split(os.sep)) if a]
if anios:
sug = f"{anios[-1]}-{g['desde'][5:]}"
por_que = f"la carpeta dice {anios[-1]}; el mes y el día son los de la cámara"
g.update(sugerencia=sug, por_que=por_que)
hechos = [dict(x) for x in con.execute(
"SELECT grupo, dir, fuente, COUNT(*) n, MAX(dias) dias, MAX(ts) ts FROM relojes WHERE raiz = ? GROUP BY grupo ORDER BY ts DESC",
(raiz,)).fetchall()]
for h in hechos:
h["rel"] = os.path.relpath(h["dir"], raiz) if h["dir"] else ""
h["camara"] = nf.get(h["fuente"], h["fuente"] or "")
con.close()
return {"grupos": grupos, "corregidos": hechos}
def corregir_reloj(raiz, dir_, fuente, fecha_real):
raiz = _clave(raiz)
try:
real = datetime.date.fromisoformat((fecha_real or "")[:10])
except ValueError:
return {"error": "escribe la fecha real del primer clip"}
if real > datetime.date.today():
return {"error": "esa fecha todavía no ha llegado"}
con = db()
_tabla_relojes(con)
cond = (f"raiz = ? AND dir = ? AND fuente = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) "
f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?")
args = (raiz, os.path.normpath(dir_ or ""), fuente or "") + _ROLES_RELOJ + (_ANIO_RELOJ,)
filas = con.execute(f"SELECT nombre, bytes, substr(fecha, 1, 10) f FROM archivos WHERE {cond}", args).fetchall()
if not filas:
con.close()
return {"error": "ahí ya no queda nada que corregir"}
desde = min(x["f"] for x in filas)
dias = (real - datetime.date.fromisoformat(desde)).days
grupo = f"{time.time():.3f}"
with con:
con.executemany("INSERT INTO relojes VALUES (?,?,?,?,?,?,?,?)",
[(raiz, x["nombre"], x["bytes"], dias, grupo, os.path.normpath(dir_), fuente or "", time.time()) for x in filas])
_aplicar_relojes(con, raiz)
con.close()
bitacora({"op": "reloj", "raiz": raiz, "dir": dir_, "fuente": fuente, "dias": dias, "n": len(filas)})
olvidar_plan(raiz)
return {"ok": True, "n": len(filas), "dias": dias, "desde": desde, "a": real.isoformat()}
def deshacer_reloj(raiz, grupo):
raiz = _clave(raiz)
con = db()
_tabla_relojes(con)
filas = con.execute("SELECT nombre, bytes, dias FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo)).fetchall()
if not filas:
con.close()
return {"error": "esa corrección ya no está"}
with con:
for x in filas:
con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), ?), fecha_origen = 'mtime' WHERE raiz = ? AND "
"nombre = ? AND bytes = ? AND fecha_origen = 'corregida'", (f"{-x['dias']} days", raiz, x["nombre"], x["bytes"]))
con.execute("DELETE FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo))
con.close()
olvidar_plan(raiz)
return {"ok": True, "n": len(filas)}
# ---- buscar en todos los clientes
_SIN_TILDES = str.maketrans("áéíóúüñàèìòùÁÉÍÓÚÜÑÀÈÌÒÙ", "aeiouunaeiouAEIOUUNAEIOU")
def _llano(t):
return (t or "").translate(_SIN_TILDES).lower()
def buscar(texto, limite=200):
"""Un archivo o una carpeta por su nombre, en todas las carpetas de clientes escaneadas. Antes solo había
«¿Dónde va a parar este archivo?», que dice dónde iría, no dónde está."""
pals = [p for p in _llano(texto).replace("\\", " ").replace("/", " ").split() if p]
if not pals or max(len(p) for p in pals) < 2:
return {"error": "escribe al menos dos letras"}
clientes = {_clave(r["ruta"]).lower(): r["cliente"] for r in CONFIG["raices"]}
con = db()
# un primer filtro en la base (sin tildes no se puede: SQLite solo pasa a minúsculas lo que no lleva tilde)
ancla = max(pals, key=len)
filas = con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos "
"WHERE lower(ruta) LIKE ? ESCAPE '!' OR ruta LIKE ? ESCAPE '!'",
("%" + esc_like(ancla) + "%", "%" + esc_like(ancla.upper()) + "%")).fetchall() \
if ancla.isascii() else con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos").fetchall()
con.close()
hay = []
for f in filas:
r = _llano(f["ruta"])
if all(p in r for p in pals):
en_nombre = all(p in _llano(f["nombre"]) for p in pals)
hay.append((0 if en_nombre else 1, -(f["bytes"] or 0), f))
hay.sort(key=lambda x: (x[0], x[1]))
out = []
for _, _, f in hay[:limite]:
out.append({"ruta": f["ruta"], "dir": f["dir"], "nombre": f["nombre"], "bytes": f["bytes"], "fecha": (f["fecha"] or "")[:10],
"rol": R.ROL_QUE.get(f["rol"], f["rol"]), "raiz": f["raiz"], "cliente": clientes.get((f["raiz"] or "").lower(), f["raiz"]),
"rel": os.path.relpath(f["dir"], f["raiz"]) if f["raiz"] else f["dir"],
"apartado": bool(f["es_basura"] or f["es_proxy"])})
return {"total": len(hay), "resultados": out, "carpetas": len(clientes)}
# ---- el mismo cliente en otros discos
_DISCOS_REFRESCO = {"activo": False}
def _refrescar_discos():
try:
discos()
finally:
_DISCOS_REFRESCO["activo"] = False
def otros_sitios(raiz):
"""Dónde más está el cliente de esta carpeta, según los discos conectados. No espera: usa lo último que se
miró y, si es viejo, lo vuelve a mirar en segundo plano (mirar todos los discos de red tarda)."""
r = raiz_cfg(raiz)
if not r:
return []
if time.time() - (_DISCOS_CACHE.get("ts") or 0) > 120 and not _DISCOS_REFRESCO["activo"]:
_DISCOS_REFRESCO["activo"] = True
threading.Thread(target=_refrescar_discos, daemon=True).start()
d = _DISCOS_CACHE.get("datos")
if not d:
return []
registradas = [_clave(x["ruta"]).lower() for x in CONFIG["raices"]]
out = []
for ruta in d["clientes"].get(r["cliente"], []):
rl = os.path.normpath(ruta).lower()
if any(rl == x or rl.startswith(x + os.sep) or x.startswith(rl + os.sep) for x in registradas):
continue
disco = next((x for x in d["discos"] if x["letra"].lower() == ruta[:2].lower()), {})
out.append({"ruta": ruta, "unc": disco.get("unc", ""), "disco": disco.get("nombre", "")})
return out
# ==========================================================================
# Utilidades de fechas / nombres para el plan
# ==========================================================================
@@ -1049,6 +1258,7 @@ class Plan:
self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)]
self.filas = []
self.bancos = [] # (ruta normcase + sep, nombre, protegido): los bancos de material
self.bancos_info = [] # lo mismo para la interfaz: nombre, si dice «No tocar», y cómo se ordena
self._carpeta_existe = {} # destino -> si ya existe en el disco (para no preguntar dos veces)
self._bytes = None # ruta -> bytes, para partir filas de archivos
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos
@@ -1137,24 +1347,16 @@ class Plan:
fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone()
n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0)
aviso_colision = ""
# Lo que hay que preguntarle al disco (¿ya hay en el destino un archivo con ese nombre?, ¿la carpeta
# destino existe?) se apunta y se pregunta al final, todo a la vez y en paralelo (`comprobar_destinos`).
# Medido el 14-oct-2026 en Vía Sumapaz, después de aplicar: 1.362 preguntas una detrás de otra, a 24-46 ms
# cada una con el NAS ocupado; 43 de los 58 segundos que tardaba «Calcular de nuevo».
chk = None
if archivos is not None and accion != "renombrar":
# también para los grupos de archivos: si el destino ya tiene un archivo con ese nombre,
# mover chocaría. Antes solo se comprobaba en las filas de carpeta.
especiales = kw_destinos or {}
# solo los primeros: cada comprobación es una consulta al disco de red, y para avisar
# basta con encontrar uno
choques = [os.path.basename(a[0]) for a in archivos[:40]
if self._hay_carpeta(os.path.dirname(especiales.get(a[0]) or os.path.join(destino, "x")))
and os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))]
if choques:
aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre "
f"(«{choques[0]}»): esos no se moverían")
confianza, marcado = "baja", False
elif archivos is None and accion != "renombrar" and os.path.isfile(destino):
aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá"
confianza, marcado = "baja", False
elif archivos is None and not solo_mayusculas and os.path.isdir(destino) and os.path.isdir(origen):
aviso_colision = " · la carpeta destino ya existe: se fusionan"
# solo los primeros archivos de cada fila: para avisar basta con encontrar uno
chk = {"tipo": "archivos", "primeros": [a[0] for a in archivos[:40]], "kw": dict(kw_destinos or {})}
elif archivos is None and accion != "renombrar":
chk = {"tipo": "carpeta", "fusion": not solo_mayusculas}
self.filas.append({
"id": len(self.filas) + 1,
"origen": origen,
@@ -1174,6 +1376,7 @@ class Plan:
# piezas), 3 el resto (exportados, proyectos, marca, música, dudosos)
"paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"),
"_chk": chk,
})
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
@@ -1229,6 +1432,7 @@ class Plan:
self.plan_auditoria()
self.volcar_sueltos()
self.plan_basura()
self.comprobar_destinos()
self.revisar_proxies()
self.proteger_bancos()
self.con.close()
@@ -1238,7 +1442,8 @@ class Plan:
self.marcar_colisiones()
for i, f in enumerate(self.filas, 1):
f["id"] = i
return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos}
return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos,
"bancos": self.bancos_info}
def marcar_colisiones(self):
"""Dos movimientos del mismo plan que acaban en el mismo archivo. La comprobación de `agregar`
@@ -1256,6 +1461,9 @@ class Plan:
if otra is not None and otra is not f:
self.avisar_colision(f, otra, os.path.basename(d))
break
if otra is f:
self.avisar_repetido(f, os.path.basename(d))
break
destinos.setdefault(d, f)
elif f["accion"] == "renombrar":
d = f["destino"].lower()
@@ -1265,6 +1473,16 @@ class Plan:
else:
carpetas[d] = f
def avisar_repetido(self, fila, que):
aviso = f" · OJO: dentro de «{fila['origen_rel']}» hay dos archivos «{que}» que acabarían en la misma carpeta"
if aviso not in fila["motivo"]:
fila["motivo"] += aviso
fila["confianza"] = "baja"
fila["marcado"] = False
self.avisos.append(f"En «{fila['origen_rel']}» hay dos archivos que se llaman «{que}» (por ejemplo, la misma foto "
f"en dos tamaños) y los dos irían a «{fila['destino_rel']}». Uno no cabría: la fila queda sin "
f"marcar para que lo mires antes.")
def avisar_colision(self, fila, otra, que):
aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»"
if aviso not in fila["motivo"]:
@@ -1549,6 +1767,9 @@ class Plan:
entero a un mes inventado. Si el nombre dice «No tocar», todo lo de dentro va sin marcar."""
protegido = R.es_no_tocar(e.name)
self.bancos.append((os.path.normcase(os.path.normpath(e.path)) + os.sep, e.name, protegido))
# cómo se ordena: por temas (lo de siempre) o por fecha, a la 00_Grabacion de su mes con el tema de etiqueta
modo = ((CONFIG.get("bancos") or {}).get(self.raiz.lower()) or {}).get(R.norm(e.name), "temas")
self.bancos_info.append({"nombre": e.name, "protegido": protegido, "modo": modo})
grupos = defaultdict(list)
for f in archivos_bajo(self.con, e.path):
if f["es_basura"] or f["es_proxy"] or f["rol"] not in _ROLES_BANCO or not f["fecha"]:
@@ -1563,20 +1784,63 @@ class Plan:
fiable = f["fecha_origen"] != "mtime" or _fecha_de_camara(f)
# una cámara con el reloj sin poner fecha en 2008 o 2009: medido en Vía Sumapaz, 250 clips de la
# Blackmagic «del 28 de julio de 2008» y 100 del dron «del 19 de enero de 2009»
reloj = f["fecha_origen"] != "nombre" and int(f["fecha"][:4]) < 2014
destino = os.path.join(e.path, *segs, f["fecha"][:10], fuente, *([R.DUPLICADOS] if repetido else []))
reloj = f["fecha_origen"] not in ("nombre", "corregida") and int(f["fecha"][:4]) < 2014
tema = segs[-1] if segs else e.name
if modo == "fecha":
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
sub = os.path.join(*[x for x in (limpiar_nombre(tema), fuente, R.DUPLICADOS if repetido else "") if x])
destino = self.abs_(destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=self.dia_de(f["fecha"], ay, am),
sub=sub, nombre=limpiar_nombre(tema)))
else:
destino = os.path.join(e.path, *segs, f["fecha"][:10], fuente, *([R.DUPLICADOS] if repetido else []))
if os.path.normcase(os.path.normpath(destino)) == os.path.normcase(os.path.normpath(f["dir"])):
continue
tema = segs[-1] if segs else e.name
por_que = "reloj" if reloj else "" if fiable else "mtime"
grupos[(f["dir"], destino, tema, "media" if fiable and not reloj else "baja", por_que)].append((f["ruta"], f["bytes"]))
for (origen, destino, tema, conf, por_que), archivos in sorted(grupos.items()):
self.agregar(origen, destino, "GRABACION", "mover archivos",
f"banco de material «{e.name}»: el tema «{tema}» se queda; dentro, por fecha de rodaje y cámara"
(f"banco de material «{e.name}», por fecha: a la grabación de su mes, con «{tema}» de etiqueta" if modo == "fecha" else
f"banco de material «{e.name}»: el tema «{tema}» se queda; dentro, por fecha de rodaje y cámara")
+ {"": "", "mtime": " (la fecha sale de la fecha de modificación: puede fallar)",
"reloj": f" (la fecha, {next((x for x in destino.split(os.sep) if _RX_FECHA_DIA.match(x)), '')}, parece de una cámara con el reloj sin poner: no es de fiar)"}[por_que],
conf, archivos=archivos, marcado=False if protegido else None, paso="banco")
self.filas[-1]["no_tocar"] = protegido
self.filas[-1]["banco"] = e.name
def comprobar_destinos(self):
"""Las comprobaciones contra el disco de todas las filas, a la vez: primero qué carpetas existen y luego,
solo en las que existen, qué archivos de destino ya están. Lo que choca queda sin marcar y dudoso."""
pend, dirs = [], set()
for f in self.filas:
chk = f.pop("_chk", None)
if not chk:
continue
if chk["tipo"] == "archivos":
esp = dict(f.get("destinos_archivo") or {})
esp.update(chk["kw"])
dests = [esp.get(a) or os.path.join(f["destino"], os.path.basename(a)) for a in chk["primeros"]]
dirs.update(os.path.dirname(d) for d in dests)
pend.append((f, "archivos", dests))
else:
dirs.update((f["destino"], f["origen"]))
pend.append((f, "carpeta", chk["fusion"]))
if not pend:
return
hay_dir = _existen(dirs)
candidatos = {d for f, t, x in pend if t == "archivos" for d in x if hay_dir.get(os.path.dirname(d))}
candidatos |= {f["destino"] for f, t, x in pend if t == "carpeta" and not hay_dir.get(f["destino"])}
hay_archivo = _son_archivos(candidatos)
for f, t, x in pend:
if t == "archivos":
choques = [os.path.basename(d) for d in x if hay_archivo.get(d)]
if choques:
f["motivo"] += f" · OJO: en el destino ya hay archivos con el mismo nombre («{choques[0]}»): esos no se moverían"
f["confianza"], f["marcado"] = "baja", False
elif hay_archivo.get(f["destino"]):
f["motivo"] += " · ya existe un archivo con ese nombre en el destino: no se moverá"
f["confianza"], f["marcado"] = "baja", False
elif x and hay_dir.get(f["destino"]) and hay_dir.get(f["origen"]):
f["motivo"] += " · la carpeta destino ya existe: se fusionan"
def _partir(self, f, aparte, motivo, confianza=None, no_tocar=False):
"""Saca de una fila de archivos los que cumplen `aparte` a una fila propia, sin marcar. Si son todos,
@@ -1729,6 +1993,7 @@ class Plan:
# lo que digan las carpetas intermedias: el año o el mes («Blackmagic (BRAW)/2023/…»), el
# nombre de la sesión («Grabaciones/Tomas consultorio/…») y si ya estaba apartado por repetido
anio_ruta, mes_ruta, etiqueta, repetido = None, None, etiqueta_base, False
resto = [] # subcarpetas con sentido debajo de la sesión: se conservan debajo de la cámara
segs = [] if os.path.normcase(os.path.normpath(f["dir"])) == aqui else os.path.relpath(f["dir"], e.path).split(os.sep)
for seg in segs:
if R.detectar_anio(seg):
@@ -1739,6 +2004,11 @@ class Plan:
repetido = True
elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg):
etiqueta = R.etiqueta_grabacion(seg)
elif not _solo_fuente(seg) and R.norm(seg) not in R.DICCIONARIO_SEMILLA:
# Medido el 14-oct-2026 en Vía Sumapaz: cada evento de «Fotos varias» guarda la misma foto
# en «Alta» y en «Baja», con el mismo nombre. Juntarlas en una sola carpeta hacía chocar
# una con otra: 753 fotos no se movieron. Lo que no dice cámara, tarjeta ni día se conserva.
resto.append(limpiar_nombre(seg))
fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado»
if anio and mes:
@@ -1772,7 +2042,7 @@ class Plan:
hoy = datetime.date.today()
if (ay, am) > (hoy.year, hoy.month):
ay -= 1
grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, repetido, fconf)].append((f["ruta"], f["bytes"]))
grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, tuple(resto), repetido, fconf)].append((f["ruta"], f["bytes"]))
aviso_fecha = ""
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
# toda la carpeta sin marcar.
@@ -1784,8 +2054,8 @@ class Plan:
self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de "
f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que "
f"se grabaron, no en el que dice la carpeta.")
for (ay, am, dia, etiqueta, fuente, repetido, fconf), archivos in grupos.items():
sub = os.path.join(*[x for x in (etiqueta, fuente, R.DUPLICADOS if repetido else "") if x])
for (ay, am, dia, etiqueta, fuente, resto, repetido, fconf), archivos in grupos.items():
sub = os.path.join(*[x for x in (etiqueta, fuente, *resto, R.DUPLICADOS if repetido else "") if x])
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub,
nombre=limpiar_nombre(nombre))
# si todos ya están en esa carpeta, no hay nada que hacer
@@ -2033,6 +2303,21 @@ def nombre_libre(destino):
return f"{base} ({i}){ext}"
def _renombrar(origen, destino, intentos=4):
"""os.rename, esperando un momento si el archivo lo tiene abierto un instante otro programa: la lectura de
clips del escaneo profundo, el antivirus o la vista previa del Explorador. Windows no deja mover un archivo
abierto (error 32), y antes eso contaba como fallo aunque al segundo siguiente ya estuviera libre. Lo que
sigue abierto de verdad (un proyecto de Resolve) falla igual, tras menos de dos segundos."""
for i in range(intentos):
try:
os.rename(origen, destino)
return
except OSError as ex:
if getattr(ex, "winerror", None) not in (32, 33) or i == intentos - 1:
raise
time.sleep(0.3 * (i + 1))
def mover(origen, destino, lote, movidos):
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
origen = os.path.normpath(origen)
@@ -2044,7 +2329,7 @@ def mover(origen, destino, lote, movidos):
# que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una
# carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que
# nunca ocurría: os.rename sí sabe hacerlo en NTFS.
os.rename(origen, destino)
_renombrar(origen, destino)
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino))
return
@@ -2075,7 +2360,7 @@ def mover(origen, destino, lote, movidos):
raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo")
destino = nombre_libre(destino)
try:
os.rename(origen, destino)
_renombrar(origen, destino)
except OSError:
# entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen
if os.path.isdir(origen):
@@ -2101,11 +2386,37 @@ APLICANDO = {"hechas": 0, "total": 0, "actual": "", "archivo": "", "unidades": 0
"movidos": 0, "fallos": 0, "lote": "", "inicio": 0, "fin": 0}
def _motivo_fallo(ex):
"""Por qué no se pudo mover algo, en una palabra: para agrupar los fallos y decir qué hacer con cada clase."""
t = str(ex).lower()
w = getattr(ex, "winerror", None)
if isinstance(ex, FileExistsError) or "ya existe" in t:
return "existe"
if "en uso" in t or w in (32, 33):
return "en_uso"
if isinstance(ex, PermissionError) or w == 5 or "acceso denegado" in t:
return "permiso"
if isinstance(ex, FileNotFoundError) or "ya no está" in t or w in (2, 3):
return "no_esta"
if "incompleta" in t:
return "copia"
if w in (53, 59, 64, 67, 1231) or "red" in t:
return "red"
return "otro"
def aplicar(raiz, filas):
lote = time.strftime("%Y%m%d-%H%M%S")
movidos = []
errores = []
motivos = {} # motivo -> {"n", "ejemplos"}: los fallos agrupados, para explicarlos
hechas = 0
def fallo(ex, que):
m = motivos.setdefault(_motivo_fallo(ex), {"n": 0, "ejemplos": []})
m["n"] += 1
if len(m["ejemplos"]) < 8:
m["ejemplos"].append(que)
filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas)
fallos = 0
@@ -2133,6 +2444,7 @@ def aplicar(raiz, filas):
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
fallo(ex, os.path.relpath(a, raiz))
if fallos_fila <= 5:
errores.append(f"{os.path.basename(a)}: {ex}")
APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila)
@@ -2149,9 +2461,11 @@ def aplicar(raiz, filas):
else:
fallos += 1
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
fallo(FileNotFoundError("ya no está"), f.get("origen_rel") or "")
except Exception as ex: # noqa
fallos += 1
errores.append(f"{f.get('origen_rel')}: {ex}")
fallo(ex, f.get("origen_rel") or "")
APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos)
# terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos),
@@ -2164,8 +2478,10 @@ def aplicar(raiz, filas):
actualizar_inventario(movidos)
# qué pasos del plan entraron (limpiar, fechas, resto, banco): Inicio les pone su ✓ con esto
pasos = dict(Counter(f.get("paso") or "resto" for f in filas))
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos})
return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores}
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos,
"motivos": motivos})
return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores, "motivos": motivos,
"fallos": sum(m["n"] for m in motivos.values())}
def actualizar_inventario(movidos):
@@ -2176,25 +2492,34 @@ def actualizar_inventario(movidos):
pasos siguientes salían con «0 archivos · 0 B» y sin fecha. La marca de basura/proxy heredada de una
carpeta (por ejemplo lo que cuelga de «Adobe Premiere Pro Auto-Save») también se conserva: antes se
recalculaba desde cero y se perdía."""
if not movidos:
return
con = db()
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
# Antes de tomar la base, qué carpetas tocadas siguen existiendo: eso hay que preguntarlo al disco, pero
# todo de una vez y en paralelo. Medido el 14-oct-2026 en Vía Sumapaz, 2.493 movimientos: se preguntaba al
# disco de red dos veces por movimiento, a 41 ms cada vez con el NAS ocupado, y todo con la base tomada;
# más de diez minutos en los que «Buscar clips» se rendía con «database is locked». Lo que hay dentro de
# cada carpeta se mira en el inventario, por índice, movimiento a movimiento (no una vez al principio: una
# carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior).
tocadas = _tocadas(movidos)
existe = _existen({d for d, _ in tocadas})
with con:
for de, a in movidos:
en_papelera = 1 if any(m in a for m in marcas) else 0
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, "
"es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(a, os.path.dirname(a), os.path.basename(a), en_papelera, de))
pref = de + os.sep
for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall():
lo, hi = _rango(de + os.sep)
for r in con.execute("SELECT ruta FROM archivos WHERE ruta >= ? AND ruta < ?", (lo, hi)).fetchall():
nueva = a + r["ruta"][len(de):]
con.execute("UPDATE archivos SET ruta = ?, dir = ?, "
"es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(nueva, os.path.dirname(nueva), en_papelera, r["ruta"]))
# las carpetas siguen al material: se mueven de ruta, no se borran
viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'",
(de, esc_like(pref) + "%")).fetchall()
con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%"))
if os.path.isdir(a):
viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi)).fetchall()
con.execute("DELETE FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi))
if viejas:
for v in viejas:
nueva = a if v["ruta"] == de else a + v["ruta"][len(de):]
fila = dict(v)
@@ -2206,21 +2531,45 @@ def actualizar_inventario(movidos):
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
fila["es_proxy"], fila["es_basura"]))
_recontar_carpetas(con, movidos)
_recontar_carpetas(con, tocadas, existe)
con.close()
# el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo
for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}:
olvidar_plan(k)
def _recontar_carpetas(con, movidos):
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del
escaneo."""
def _rango(prefijo):
"""(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta».
«LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento."""
return prefijo, prefijo[:-1] + chr(ord(prefijo[-1]) + 1)
def _existen(rutas):
"""{carpeta: si existe}, preguntando al disco en paralelo: por la red cada pregunta tarda decenas de ms."""
rutas = list(rutas)
if not rutas:
return {}
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=24) as ex:
return dict(zip(rutas, ex.map(os.path.isdir, rutas)))
def _son_archivos(rutas):
"""{ruta: si es un archivo}, preguntando al disco en paralelo."""
rutas = list(rutas)
if not rutas:
return {}
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=24) as ex:
return dict(zip(rutas, ex.map(os.path.isfile, rutas)))
def _tocadas(movidos):
"""Las carpetas cuyo recuento cambia con estos movimientos: las de origen y destino y todas las de encima. El
destino mismo entra siempre: si era un archivo, el disco dirá que no es una carpeta y no se apunta nada."""
tocadas = set()
for de, a in movidos:
for ruta in (os.path.dirname(de), os.path.dirname(a), a if os.path.isdir(a) else None):
for ruta in (os.path.dirname(de), os.path.dirname(a), a):
if not ruta:
continue
raiz = _raiz_de(ruta)
@@ -2233,13 +2582,22 @@ def _recontar_carpetas(con, movidos):
if padre == d:
break
d = padre
return tocadas
def _recontar_carpetas(con, tocadas, existe):
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del
escaneo. Si existen o no ya se preguntó al disco antes (`existe`), fuera de la base."""
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
for d, raiz in tocadas:
if not os.path.isdir(d):
if not existe.get(d):
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
continue
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
(d, esc_like(d + os.sep) + "%")).fetchone()
lo, hi = _rango(d + os.sep)
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR (dir >= ? AND dir < ?)",
(d, lo, hi)).fetchone()
previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone()
en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
@@ -2884,6 +3242,20 @@ def analizar(raiz, min_mb, reintentar=False):
lote = []
def guardar(lote):
# si la base está ocupada (otro proceso guardando, por ejemplo después de «Aplicar»), se espera y
# se reintenta: antes «Buscar clips» se rendía al minuto con «database is locked»
for intento in range(40):
try:
_guardar(lote)
prog.pop("esperando", None)
return
except sqlite3.OperationalError as ex:
if "locked" not in str(ex) or intento == 39:
raise
prog["esperando"] = "la base está ocupada guardando otros cambios; se espera"
time.sleep(3)
def _guardar(lote):
with con:
for ruta, info in lote:
if info:
@@ -2921,7 +3293,8 @@ def analizar(raiz, min_mb, reintentar=False):
prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)"
except Exception as ex: # noqa
prog["estado"] = "error"
prog["error"] = str(ex)
prog["error"] = ("la base de datos siguió ocupada guardando otros cambios más de dos minutos; vuelve a pulsar «Buscar clips»"
if "locked" in str(ex) else str(ex))
finally:
con.close()
@@ -6018,8 +6391,8 @@ def _nombre_tipo(t):
# el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece
_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "fechas", "resto", "banco", "vacias", "stock",
"repetidos", "convertir", "respaldar"]
_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock",
"repetidos", "convertir", "juntar", "respaldar"]
def _historial_carpeta(raiz):
@@ -6044,6 +6417,10 @@ def _historial_carpeta(raiz):
continue
if op == "tipo":
pon("tipo", ts, f"elegiste «{_nombre_tipo(e.get('tipo'))}»")
elif op == "reloj":
pon("relojes", ts, f"{_plural(e.get('n') or 0, 'clip corrido', 'clips corridos')} {e.get('dias') or 0:+d} días")
elif op == "banco_modo":
pon("banco", ts, f"elegiste ordenarlo por {e.get('modo')}")
elif op == "nombrar":
pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»")
elif op == "respaldo":
@@ -6069,6 +6446,15 @@ def _historial_carpeta(raiz):
return h
def _traidos_de(raiz):
"""origen (minúsculas) -> cuándo se trajo, de los «Traer material» hechos a esta carpeta."""
out = {}
for e in leer_bitacora(20000):
if e.get("op") == "fin" and (e.get("lote") or "").startswith("entrada-") and (e.get("raiz") or "").lower() == raiz.lower() and e.get("origen"):
out[os.path.normpath(e["origen"]).lower()] = e.get("ts")
return out
def _cuando(ts):
"""«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40»."""
if not ts:
@@ -6230,6 +6616,18 @@ def acciones(raiz):
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
# --- clips con fecha imposible: una cámara con el reloj sin poner ---
rr = con.execute(f"SELECT COUNT(*) n, COUNT(DISTINCT dir || '|' || fuente) g, COALESCE(SUM(bytes), 0) b FROM archivos "
f"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) "
f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?",
(raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchone()
if rr["n"]:
añadir(clave="relojes", titulo=f"Corregir la fecha de {_plural(rr['n'], 'clip', 'clips')} de cámara", boton="Corregir",
que="Su cámara tenía el reloj sin poner (2008, 2009…): se ordenarían en un año que no es.",
cifra=f"{_plural(rr['g'], 'tanda', 'tandas')} · {_gb(rr['b'])}", estado="espera",
detalle="Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. Se deshace.",
ir="relojes")
# --- el banco de material: los temas se quedan; dentro, por fecha y cámara ---
bancos = _bancos_de(con, raiz)
if bancos:
@@ -6357,6 +6755,22 @@ def acciones(raiz):
detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.",
ir="convertir")
# --- el mismo cliente en otros discos ---
sitios = otros_sitios(raiz)
if sitios:
traidos = _traidos_de(raiz)
for s in sitios:
s["traido"] = traidos.get(os.path.normpath(s["ruta"]).lower())
faltan = [s for s in sitios if not s["traido"]]
añadir(clave="juntar", titulo=(f"{r['cliente']} también está en {_plural(len(faltan), 'sitio', 'sitios')} más" if faltan
else f"Lo de {r['cliente']} en otros discos"),
boton="Ver" if faltan else "", estado="espera" if faltan else "hecho",
que=("Se puede traer aquí con copia comprobada: el original no se toca, y lo que llega se reparte por mes y cámara."
if faltan else "Ya está traído todo lo que había en otros discos."),
cifra=" · ".join(s["ruta"] for s in faltan[:3]) + (f" y {len(faltan) - 3} más" if len(faltan) > 3 else ""),
detalle="Míralo antes: una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está." if faltan else "",
ir="juntar", sitios=sitios)
# --- 8. respaldo, al final ---
dest = CONFIG.get("respaldo", {}).get("destino", "")
añadir(clave="respaldar", titulo="Copiar el respaldo a otro disco",
@@ -6373,7 +6787,8 @@ def acciones(raiz):
("entrada", "Bandeja de entrada", "Vacía: lo que entró ya está repartido."),
("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."),
("repetidos", "Material repetido", "No quedan copias de más por juntar."),
("convertir", "Clips de cámara", "No quedan clips que compense convertir.")):
("convertir", "Clips de cámara", "No quedan clips que compense convertir."),
("relojes", "Fechas de cámara", "No queda ningún clip con fecha imposible.")):
if clave in hist and clave not in hay:
añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="")
for a in out:
@@ -6641,6 +7056,11 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(estado_carpeta(q["raiz"]))
if ruta == "/api/mapa":
return self._json(mapa(q["raiz"]))
if ruta == "/api/relojes":
return self._json(relojes(q["raiz"]))
if ruta == "/api/buscar":
r = buscar(q.get("q", ""))
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/mapa/carpeta": # «Qué pesa», nivel a nivel
r = carpeta_detalle(q["raiz"], q.get("ruta"))
return self._json(r, 400 if r.get("error") else 200)
@@ -6831,6 +7251,22 @@ class Manejador(BaseHTTPRequestHandler):
CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
if ruta == "/api/relojes":
r = corregir_reloj(d.get("raiz") or "", d.get("dir") or "", d.get("fuente") or "", d.get("fecha") or "")
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/relojes/deshacer":
r = deshacer_reloj(d.get("raiz") or "", d.get("grupo") or "")
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/banco/modo":
k = _clave(d.get("raiz") or "")
modo = d.get("modo")
if not raiz_cfg(k) or modo not in ("temas", "fecha") or not d.get("nombre"):
return self._json({"error": "carpeta, banco o forma de ordenar desconocida"}, 400)
CONFIG.setdefault("bancos", {}).setdefault(k.lower(), {})[R.norm(d["nombre"])] = modo
guardar_json(CONFIG_PATH, CONFIG)
bitacora({"op": "banco_modo", "raiz": k, "banco": d["nombre"], "modo": modo})
olvidar_plan(k)
return self._json({"ok": True, "modo": modo})
if ruta == "/api/raiz/tipo":
# el tipo de trabajo de una carpeta, y el de su cliente con él: así no vuelven a quedar distintos
k = _clave(d.get("raiz") or "")
@@ -6854,7 +7290,7 @@ class Manejador(BaseHTTPRequestHandler):
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
"horario", "pausar_si_edito", "aligerar", "avisar"},
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
"respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}}
for k, v in d.items():
if k in permitidas and isinstance(v, dict):
CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})