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