1.8.0: red de seguridad tras la auditoría
- Deshacer vuelve a la interfaz (Historial, Últimos cambios y el aviso al aplicar). - «Elements» ya no marca como stock lo que tenga dentro: 50,7 GB de material propio dejaban de respaldarse y salían como desechables. - El plan contrasta el mes de la carpeta con la fecha de los archivos y avisa cuando no cuadran (727 GB en el material real iban al mes equivocado en verde). - aplicar(): un archivo bloqueado ya no corta el resto de su grupo. - Un escaneo interrumpido no reemplaza el inventario bueno. - Las uniones de carpeta de Windows no se recorren (colgaban el escaneo). - Abrir una carpeta pasó de 8,83 s a 0,21 s. - Plantillas: _Papelera migra a _Para borrar. - .dat/.idx/.bak dejan de ser basura; Cache deja de ser proxies; abreviaturas de mes bajan a confianza media. - Convertidor: varios formatos por clip, originales apartados fuera de candidatos, sufijo en modo espejo, temporal borrado al fallar, una sola cola. - Interfaz: accion() no pisa el estado del botón; rutas de Windows bien recortadas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
298
organizador.py
298
organizador.py
@@ -17,6 +17,7 @@ import json
|
||||
import time
|
||||
import queue
|
||||
import shutil
|
||||
import stat as stat_mod
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import threading
|
||||
@@ -33,7 +34,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "1.7.0"
|
||||
VERSION = "1.8.0"
|
||||
|
||||
CONGELADO = getattr(sys, "frozen", False)
|
||||
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
|
||||
@@ -238,6 +239,11 @@ for t, d in R.PLANTILLAS.items():
|
||||
PLANTILLAS.setdefault(t, d)
|
||||
for b, p in d.items():
|
||||
PLANTILLAS[t].setdefault(b, p)
|
||||
# la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan
|
||||
# sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja
|
||||
# mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
|
||||
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
|
||||
PLANTILLAS[t][b] = p
|
||||
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
|
||||
|
||||
|
||||
@@ -354,10 +360,28 @@ def contexto_dir(nombre, ctx_padre, nivel_padre):
|
||||
return cub, nivel_padre
|
||||
|
||||
|
||||
def _es_enlace(entrada):
|
||||
"""True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta:
|
||||
para una unión devuelve False, y aun así al entrar se recorre otro árbol."""
|
||||
try:
|
||||
if entrada.is_symlink():
|
||||
return True
|
||||
return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
except (OSError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def _id_carpeta(ruta):
|
||||
"""Identificador real de una carpeta en el disco, para no recorrer dos veces la misma."""
|
||||
st = os.stat(ruta)
|
||||
return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta))
|
||||
|
||||
|
||||
def escanear(ruta):
|
||||
raiz = os.path.normpath(ruta)
|
||||
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
|
||||
filas = []
|
||||
fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale
|
||||
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
|
||||
props = ("n", "bytes")
|
||||
|
||||
@@ -369,10 +393,24 @@ def escanear(ruta):
|
||||
info[1] += size
|
||||
info[2][fecha_am] += 1
|
||||
|
||||
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes):
|
||||
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None):
|
||||
# `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows
|
||||
# (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando
|
||||
# vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos).
|
||||
if vistos is None:
|
||||
vistos = set()
|
||||
try:
|
||||
clave = _id_carpeta(path)
|
||||
if clave in vistos:
|
||||
fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra")
|
||||
return
|
||||
vistos = vistos | {clave}
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
it = os.scandir(path)
|
||||
except OSError:
|
||||
except OSError as ex:
|
||||
fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
|
||||
return
|
||||
prog["actual"] = path
|
||||
with it:
|
||||
@@ -384,7 +422,12 @@ def escanear(ruta):
|
||||
continue
|
||||
p = en_proxy or R.es_proxy_dir(nombre)
|
||||
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
|
||||
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) or R.norm(nombre) in ("elements", "elemnts")
|
||||
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
|
||||
# medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de
|
||||
# material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye
|
||||
# por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de
|
||||
# Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo.
|
||||
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip()))
|
||||
c2, n2 = contexto_dir(nombre, ctx, nivel)
|
||||
a2, m2 = anio, mes
|
||||
if c2 == "ANIO":
|
||||
@@ -394,7 +437,10 @@ def escanear(ruta):
|
||||
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
|
||||
dirs[e.path][3] = 1 if p else 0
|
||||
dirs[e.path][4] = 1 if b else 0
|
||||
walk(e.path, p, b, s, c2, n2, a2, m2)
|
||||
if _es_enlace(e):
|
||||
fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra")
|
||||
continue
|
||||
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
|
||||
else:
|
||||
st = e.stat(follow_symlinks=False)
|
||||
ext = os.path.splitext(nombre)[1].lower()
|
||||
@@ -418,12 +464,26 @@ def escanear(ruta):
|
||||
if padre == d:
|
||||
break
|
||||
d = padre
|
||||
except OSError:
|
||||
except OSError as ex:
|
||||
fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
|
||||
continue
|
||||
|
||||
try:
|
||||
walk(raiz, False, False, False, "", "raiz", None, None)
|
||||
prog["fallos"] = len(fallos)
|
||||
con = db()
|
||||
# Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de
|
||||
# red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de
|
||||
# ahí el plan, el respaldo y el convertidor trabajaban sobre datos parciales sin que nadie lo supiera.
|
||||
anterior = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
|
||||
if fallos and anterior and anterior["n"] and prog["n"] < anterior["n"] * 0.9:
|
||||
con.close()
|
||||
prog["estado"] = "error"
|
||||
prog["error"] = (f"el escaneo no pudo leer {len(fallos)} sitios y solo encontró {prog['n']:,} archivos "
|
||||
f"frente a {anterior['n']:,} del anterior: se conserva el inventario que ya había. "
|
||||
f"Comprueba que la unidad esté conectada. Primero que falló: {fallos[0]}")
|
||||
prog["fin"] = time.time()
|
||||
return
|
||||
with con:
|
||||
# conservar el análisis de ffprobe de los archivos que no cambiaron
|
||||
previo = {r["ruta"]: r for r in con.execute(
|
||||
@@ -458,6 +518,8 @@ def escanear(ruta):
|
||||
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
|
||||
con.close()
|
||||
prog["estado"] = "listo"
|
||||
if fallos:
|
||||
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
|
||||
PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
|
||||
except Exception as ex: # noqa
|
||||
prog["estado"] = "error"
|
||||
@@ -678,9 +740,14 @@ class Plan:
|
||||
m, conflicto = R.detectar_mes(nombre)
|
||||
if m:
|
||||
canon = R.nombre_mes(m)
|
||||
real, real_origen = self.mes_real(e.path)
|
||||
discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, m))
|
||||
if nombre != canon:
|
||||
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
|
||||
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if conflicto else "alta")
|
||||
if discrepa:
|
||||
motivo += f" · OJO: lo que hay dentro parece de {R.nombre_mes(real[1])}"
|
||||
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
|
||||
"media" if (conflicto or discrepa) else "alta")
|
||||
self.plan_dir(e.path, "mes", anio, m)
|
||||
return
|
||||
if nivel == "raiz" or (nivel == "contenedor" and not anio):
|
||||
@@ -780,6 +847,24 @@ class Plan:
|
||||
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))
|
||||
self.agregar(e.path, self.abs_(destino), cub, "mover carpeta", motivo, conf)
|
||||
|
||||
def mes_real(self, ruta):
|
||||
"""((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de
|
||||
acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar."""
|
||||
cnt = Counter()
|
||||
origenes = Counter()
|
||||
for f in archivos_bajo(self.con, ruta):
|
||||
if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime":
|
||||
continue
|
||||
if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"):
|
||||
cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
|
||||
origenes[f["fecha_origen"]] += 1
|
||||
if not cnt:
|
||||
return None, ""
|
||||
(a, m), n = cnt.most_common(1)[0]
|
||||
if n < 3 or n / sum(cnt.values()) < 0.7:
|
||||
return None, ""
|
||||
return (a, m), origenes.most_common(1)[0][0]
|
||||
|
||||
def plan_grabacion(self, e, nivel, anio, mes, motivo):
|
||||
"""Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
|
||||
nombre = e.name
|
||||
@@ -789,6 +874,7 @@ class Plan:
|
||||
if not filas:
|
||||
return
|
||||
grupos = defaultdict(list)
|
||||
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
|
||||
for f in filas:
|
||||
if f["es_basura"] or f["es_proxy"]:
|
||||
continue
|
||||
@@ -810,7 +896,14 @@ class Plan:
|
||||
elif R.detectar_mes(seg)[0]:
|
||||
mes_ruta = R.detectar_mes(seg)[0]
|
||||
if anio and mes:
|
||||
# La carpeta dice un mes, pero los archivos pueden decir otro. Medido el 16-sep-2026 sobre
|
||||
# el material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes
|
||||
# que anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre). Antes esto se movía
|
||||
# con confianza alta y sin aviso; ahora se avisa y baja a media.
|
||||
ay, am, fconf = anio, mes, "alta"
|
||||
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
|
||||
fconf = "media"
|
||||
desacuerdo[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
|
||||
else:
|
||||
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
|
||||
por_nombre = f["fecha_origen"] != "mtime"
|
||||
@@ -830,6 +923,16 @@ class Plan:
|
||||
ay -= 1
|
||||
grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"]))
|
||||
etiqueta = "" if generico else R.etiqueta_grabacion(nombre)
|
||||
aviso_fecha = ""
|
||||
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
|
||||
# toda la carpeta sin marcar.
|
||||
n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime")
|
||||
if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables):
|
||||
(da, dm), cuantos = desacuerdo.most_common(1)[0]
|
||||
aviso_fecha = (f" · OJO: la carpeta dice {R.nombre_mes(mes)} pero {cuantos} de sus archivos son de "
|
||||
f"{R.nombre_mes(dm)} según su propio nombre; comprueba antes de aplicar")
|
||||
self.avisos.append(f"«{self.rel(e.path)}» dice {R.nombre_mes(mes)} de {anio} y el nombre de {cuantos} de "
|
||||
f"sus archivos dice {R.nombre_mes(dm)}: se propone sin marcar para que lo revises.")
|
||||
for (ay, am, fuente, fconf), archivos in grupos.items():
|
||||
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente)
|
||||
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, sub=sub, nombre=limpiar_nombre(nombre))
|
||||
@@ -837,7 +940,9 @@ class Plan:
|
||||
if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
|
||||
continue
|
||||
mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else ""))
|
||||
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot, fconf, archivos=archivos)
|
||||
marcado = False if aviso_fecha else None
|
||||
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf,
|
||||
archivos=archivos, marcado=marcado)
|
||||
|
||||
def plan_archivo(self, e, nivel, anio, mes, padre):
|
||||
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
|
||||
@@ -1020,20 +1125,40 @@ def aplicar(raiz, filas):
|
||||
APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
|
||||
for f in filas:
|
||||
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
|
||||
try:
|
||||
if f.get("archivos"):
|
||||
especiales = f.get("destinos_archivo") or {}
|
||||
if f.get("archivos"):
|
||||
# cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
|
||||
# ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
|
||||
# que había fallado uno solo.
|
||||
especiales = f.get("destinos_archivo") or {}
|
||||
fallos_fila = 0
|
||||
try:
|
||||
if not especiales:
|
||||
os.makedirs(f["destino"], exist_ok=True)
|
||||
for a in f["archivos"]:
|
||||
if os.path.exists(a):
|
||||
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
|
||||
else:
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
|
||||
continue
|
||||
for a in f["archivos"]:
|
||||
if not os.path.exists(a):
|
||||
continue
|
||||
try:
|
||||
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
|
||||
except Exception as ex: # noqa
|
||||
fallos_fila += 1
|
||||
if fallos_fila <= 5:
|
||||
errores.append(f"{os.path.basename(a)}: {ex}")
|
||||
if fallos_fila > 5:
|
||||
errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
|
||||
if not fallos_fila:
|
||||
hechas += 1
|
||||
else:
|
||||
try:
|
||||
if os.path.exists(f["origen"]):
|
||||
mover(f["origen"], f["destino"], lote, movidos)
|
||||
hechas += 1
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: {ex}")
|
||||
hechas += 1
|
||||
else:
|
||||
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: {ex}")
|
||||
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
|
||||
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
|
||||
for f in filas:
|
||||
@@ -1148,12 +1273,13 @@ def vocabulario(raiz):
|
||||
t["bytes"] += c["bytes"] or 0
|
||||
if len(t["rutas"]) < 4:
|
||||
t["rutas"].append(rel)
|
||||
if tokens:
|
||||
if tokens and any(not t["cubeta"] for t in tokens.values()):
|
||||
con = db()
|
||||
resumen = _resumen_carpetas(con, raiz)
|
||||
con.close()
|
||||
for t in tokens.values():
|
||||
if not t["cubeta"]:
|
||||
_sugerir(con, raiz, t)
|
||||
con.close()
|
||||
_sugerir(resumen, raiz, t)
|
||||
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
|
||||
return out
|
||||
|
||||
@@ -1162,27 +1288,64 @@ _ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición /
|
||||
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
|
||||
|
||||
|
||||
def _sugerir(con, raiz, t):
|
||||
def _discrepa_mes(fecha, origen, anio, mes):
|
||||
"""True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta.
|
||||
Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de
|
||||
modificación, que no es de fiar, así que en ese caso solo se compara el mes."""
|
||||
if origen == "mtime":
|
||||
return False # la fecha de modificación no prueba nada
|
||||
ra, rm = int(fecha[:4]), int(fecha[5:7])
|
||||
if origen == "nombre+mtime":
|
||||
d = abs(rm - mes)
|
||||
return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen
|
||||
return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2
|
||||
|
||||
|
||||
def _clase_rol(rol):
|
||||
return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
|
||||
"proyecto" if rol in ("proyecto", "diseno") else
|
||||
"audio" if rol == "audio" else
|
||||
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
|
||||
"documento" if rol == "documento" else
|
||||
"video" if rol in ("video", "celular_video") else "otro")
|
||||
|
||||
|
||||
def _resumen_carpetas(con, raiz):
|
||||
"""Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas.
|
||||
Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y
|
||||
cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s)."""
|
||||
agg = defaultdict(lambda: defaultdict(lambda: [0, 0]))
|
||||
filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos "
|
||||
"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall()
|
||||
tope = os.path.normcase(os.path.normpath(raiz))
|
||||
for f in filas:
|
||||
k = _clase_rol(f["rol"])
|
||||
d = os.path.normpath(f["dir"])
|
||||
while True:
|
||||
e = agg[os.path.normcase(d)][k]
|
||||
e[0] += f["n"]
|
||||
e[1] += f["b"]
|
||||
if os.path.normcase(d) == tope:
|
||||
break
|
||||
padre = os.path.dirname(d)
|
||||
if padre == d:
|
||||
break
|
||||
d = padre
|
||||
return agg
|
||||
|
||||
|
||||
def _sugerir(resumen, raiz, t):
|
||||
"""Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre.
|
||||
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
|
||||
import difflib
|
||||
conteo = defaultdict(lambda: [0, 0])
|
||||
total = [0, 0]
|
||||
for rel in t["rutas"][:3]:
|
||||
for f in archivos_bajo(con, os.path.join(raiz, rel)):
|
||||
if f["es_basura"] or f["es_proxy"]:
|
||||
continue
|
||||
rol = f["rol"]
|
||||
k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
|
||||
"proyecto" if rol in ("proyecto", "diseno") else
|
||||
"audio" if rol in ("audio",) else
|
||||
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
|
||||
"documento" if rol == "documento" else
|
||||
"video" if rol in ("video", "celular_video") else "otro")
|
||||
conteo[k][0] += 1
|
||||
conteo[k][1] += f["bytes"]
|
||||
total[0] += 1
|
||||
total[1] += f["bytes"]
|
||||
for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items():
|
||||
conteo[k][0] += n
|
||||
conteo[k][1] += b
|
||||
total[0] += n
|
||||
total[1] += b
|
||||
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1])
|
||||
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
|
||||
sug, conf, por_que = "", "", ""
|
||||
@@ -1195,7 +1358,7 @@ def _sugerir(con, raiz, t):
|
||||
if total[0] >= 3 and orden:
|
||||
k, (n, b) = orden[0]
|
||||
frac = b / total[1] if total[1] else n / total[0]
|
||||
if frac >= 0.7:
|
||||
if frac >= 0.75:
|
||||
por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
|
||||
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
|
||||
if por_cont:
|
||||
@@ -1203,7 +1366,10 @@ def _sugerir(con, raiz, t):
|
||||
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
|
||||
conf = "baja"
|
||||
else:
|
||||
sug, conf, por_que = por_cont, ("alta" if frac >= 0.9 and n >= 3 else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
|
||||
# «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos
|
||||
# los clientes: exige que sea contundente y que haya varias carpetas iguales.
|
||||
seguro = frac >= 0.92 and n >= 8 and total[0] >= 10
|
||||
sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
|
||||
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
|
||||
|
||||
|
||||
@@ -1428,9 +1594,14 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
con = db()
|
||||
limite = time.time() - int(meses) * 30.4 * 86400
|
||||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC",
|
||||
(raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").fetchall()}
|
||||
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
|
||||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
|
||||
"AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
# solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe
|
||||
# poder convertirse también a H.265, y uno que falló debe poder reintentarse.
|
||||
en_cola = {r["ruta"]: r["estado"] for r in con.execute(
|
||||
"SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')",
|
||||
(formato,)).fetchall()}
|
||||
con.close()
|
||||
out = []
|
||||
for f in filas:
|
||||
@@ -1468,7 +1639,8 @@ def salida_para(ruta, raiz, formato="h265_10bit"):
|
||||
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
|
||||
if o.get("salida") == "espejo":
|
||||
rel = os.path.relpath(base, raiz)
|
||||
return os.path.join(raiz, "_Archivo", rel + fm["ext"])
|
||||
# el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo
|
||||
return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"])
|
||||
return base + sufijo + fm["ext"]
|
||||
|
||||
|
||||
@@ -1600,6 +1772,10 @@ def correr_ffmpeg(t):
|
||||
return
|
||||
if p.returncode != 0 or not os.path.exists(tmp):
|
||||
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
|
||||
try:
|
||||
os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
os.replace(tmp, salida)
|
||||
try:
|
||||
@@ -1756,17 +1932,23 @@ class ErrorCola(ValueError):
|
||||
self.datos = datos
|
||||
|
||||
|
||||
_COLA_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def iniciar_cola():
|
||||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||||
return False
|
||||
recuperar_huerfanas()
|
||||
COLA["activa"] = True
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
# el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
|
||||
# alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
|
||||
with _COLA_LOCK:
|
||||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||||
return False
|
||||
recuperar_huerfanas()
|
||||
COLA["activa"] = True
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
|
||||
|
||||
def verificar(ids):
|
||||
@@ -2159,7 +2341,7 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
|
||||
por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
|
||||
omit_raw.sort(key=lambda x: -x["bytes"])
|
||||
return {
|
||||
"cliente": cliente, "base": base, "n": len(items), "bytes": sum(i["bytes"] for i in items),
|
||||
"cliente": cliente, "base": base, "destino_base": destino, "n": len(items), "bytes": sum(i["bytes"] for i in items),
|
||||
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
|
||||
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
|
||||
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
|
||||
@@ -2417,6 +2599,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/") and not self._es_local():
|
||||
return self._json({"error": "petición no permitida"}, 403)
|
||||
u = urlparse(self.path)
|
||||
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
ruta = u.path
|
||||
@@ -2524,8 +2708,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
|
||||
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
|
||||
PLANES_RESPALDO[raiz] = plan
|
||||
CONFIG.setdefault("respaldo", {})["destino"] = destino
|
||||
guardar_json(CONFIG_PATH, CONFIG)
|
||||
# calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
|
||||
# reescribía config.json)
|
||||
return self._json({k: v for k, v in plan.items() if k != "items"})
|
||||
if ruta == "/api/respaldo/estado":
|
||||
return self._json(RESPALDO)
|
||||
@@ -2679,6 +2863,10 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
if ruta == "/api/abrir":
|
||||
return self._json(abrir_carpeta(d.get("ruta", "")))
|
||||
if ruta == "/api/respaldo/copiar":
|
||||
_rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", "")))
|
||||
if _rp and _rp.get("destino_base"):
|
||||
CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"]
|
||||
guardar_json(CONFIG_PATH, CONFIG)
|
||||
raiz = os.path.normpath(d["raiz"])
|
||||
plan = PLANES_RESPALDO.get(raiz)
|
||||
if not plan:
|
||||
|
||||
Referencia en una nueva incidencia
Block a user