1.8.0: red de seguridad tras la auditoría

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

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

Ver fichero

@@ -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: