Convertir: horario y pausa si edito, aligerar solo lo viejo, aviso de Windows, resumen de la tanda con CSV, y marcar con criterio
Las tres propuestas que faltaban del lienzo: - La cola respeta un horario (espera entre clip y clip) y espera si Resolve, Premiere o After Effects están usando la tarjeta (nvidia-smi pmon). La pasada automática puede meter en cola lo de más de N meses hasta un tope de GB. Al acabar, un aviso de Windows con la identidad del programa. - Cada tanda deja un resumen en la bitácora (op tanda): clips, pesos, tiempo, nota, fallos, por carpeta. Sale en Convertir con «Exportar CSV» y «Abrir la carpeta», y en Historial cuenta clips. - Barra de marcar con criterio (tamaño, cámara, mes, antigüedad) y buscador. Pruebas: probar 19, prueba_orden 33, prueba_funcional 32 (nueva: resumen de la tanda), prueba_ventana 18. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
275
organizador.py
275
organizador.py
@@ -2850,7 +2850,197 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
|
||||
|
||||
# empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en
|
||||
# esta tanda, para decir cuánto falta
|
||||
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": []}
|
||||
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": [],
|
||||
"esperando": "", "hechos_ids": [], "ultima": None}
|
||||
|
||||
|
||||
def _opt(k, defecto=None):
|
||||
v = (CONFIG.get("optimizar") or {}).get(k)
|
||||
return defecto if v is None else v
|
||||
|
||||
|
||||
def en_horario(ahora=None):
|
||||
"""Si la cola tiene horario, ¿estamos dentro? (True, "") o (False, "hasta las 20:00"). El horario puede
|
||||
pasar por medianoche (20:00 a 07:00)."""
|
||||
h = _opt("horario") or {}
|
||||
if not h.get("activo"):
|
||||
return True, ""
|
||||
try:
|
||||
d1, m1 = map(int, (h.get("desde") or "20:00").split(":"))
|
||||
d2, m2 = map(int, (h.get("hasta") or "07:00").split(":"))
|
||||
except ValueError:
|
||||
return True, ""
|
||||
t = time.localtime(ahora or time.time())
|
||||
x, a, b = t.tm_hour * 60 + t.tm_min, d1 * 60 + m1, d2 * 60 + m2
|
||||
dentro = (a <= x < b) if a < b else (x >= a or x < b)
|
||||
return (True, "") if dentro else (False, f"hasta las {h.get('desde') or '20:00'}")
|
||||
|
||||
|
||||
APPS_EDICION = {"resolve.exe": "DaVinci Resolve", "adobe premiere pro.exe": "Premiere Pro", "afterfx.exe": "After Effects"}
|
||||
|
||||
|
||||
def editando():
|
||||
"""Qué programa de edición está usando la tarjeta ahora mismo, o "". Lo dice nvidia-smi pmon (uso de
|
||||
cómputo, codificador y decodificador por proceso); si no hay nvidia-smi, basta con que el programa esté
|
||||
abierto. Solo cuenta si está activado optimizar.pausar_si_edito."""
|
||||
if not _opt("pausar_si_edito", True):
|
||||
return ""
|
||||
try:
|
||||
r = subprocess.run(["nvidia-smi", "pmon", "-c", "1", "-s", "u"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
|
||||
if r.returncode == 0 and r.stdout:
|
||||
for linea in r.stdout.splitlines():
|
||||
partes = linea.split(None, 9)
|
||||
if linea.startswith("#") or len(partes) < 10:
|
||||
continue
|
||||
nombre = partes[9].strip().lower()
|
||||
uso = [float(u) for u in partes[3:7] if u.replace(".", "", 1).isdigit()]
|
||||
for exe, bonito in APPS_EDICION.items():
|
||||
if nombre and exe.startswith(nombre[:10]) and any(u > 5 for u in uso): # pmon recorta el nombre
|
||||
return bonito
|
||||
return ""
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
|
||||
bajo = (r.stdout or "").lower()
|
||||
for exe, bonito in APPS_EDICION.items():
|
||||
if f'"{exe}"' in bajo:
|
||||
return bonito
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _esperar_turno():
|
||||
"""Antes de cada clip: si hay horario y estamos fuera, o si un programa de edición está usando la
|
||||
tarjeta, la cola espera (sigue activa) y lo dice. Nunca corta un clip a medias. False si la detuvieron."""
|
||||
while COLA["activa"] and not COLA["cancelar"]:
|
||||
dentro, hasta = en_horario()
|
||||
motivo = "" if dentro else f"espera {hasta}, por el horario de la cola"
|
||||
if not motivo:
|
||||
app = editando()
|
||||
motivo = f"espera a que {app} deje la tarjeta" if app else ""
|
||||
COLA["esperando"] = motivo
|
||||
if not motivo:
|
||||
return True
|
||||
COLA["mensaje"] = motivo
|
||||
time.sleep(20)
|
||||
COLA["esperando"] = ""
|
||||
return False
|
||||
|
||||
|
||||
def notificar(titulo, texto):
|
||||
"""Un aviso de Windows (la burbuja de la esquina), con la identidad del programa. Va en un PowerShell
|
||||
aparte y sin esperar: si falla, no pasa nada."""
|
||||
if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"):
|
||||
return False
|
||||
esc_ = lambda s: str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace("'", "'")
|
||||
ps = ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n"
|
||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n"
|
||||
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n"
|
||||
f"$xml.LoadXml('<toast><visual><binding template=\"ToastGeneric\"><text>{esc_(titulo)}</text><text>{esc_(texto)}</text></binding></visual></toast>')\n"
|
||||
"$t = New-Object Windows.UI.Notifications.ToastNotification $xml\n"
|
||||
"try { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Labstream.OrganiLAB').Show($t) }\n"
|
||||
"catch { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($t) }\n")
|
||||
try:
|
||||
ruta = os.path.join(DATOS, "aviso.ps1")
|
||||
with open(ruta, "w", encoding="utf-8-sig") as fh:
|
||||
fh.write(ps.replace("\\n", "\n"))
|
||||
subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ruta],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=SIN_VENTANA)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _resumen_tanda():
|
||||
"""Al acabar la cola: qué se hizo en esta tanda (clips, pesos, tiempo, nota media, fallos, por carpeta).
|
||||
Queda en la bitácora (op «tanda»), se recuerda para la interfaz y, si está activado, se avisa."""
|
||||
ids = list(COLA.get("hechos_ids") or [])
|
||||
if not ids:
|
||||
return None
|
||||
con = db()
|
||||
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||||
f"WHERE t.id IN ({','.join('?' * len(ids))})", ids).fetchall()]
|
||||
con.close()
|
||||
bien = [t for t in ts if t["estado"] in ("listo", "verificado", "reemplazado")]
|
||||
notas = [t["nota"] for t in bien if t.get("nota")]
|
||||
carpetas = {}
|
||||
for t in bien:
|
||||
try:
|
||||
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
|
||||
except ValueError:
|
||||
rel = os.path.dirname(t["ruta"])
|
||||
c = carpetas.setdefault(rel, {"carpeta": rel, "clips": 0, "bytes_in": 0, "bytes_out": 0})
|
||||
c["clips"] += 1
|
||||
c["bytes_in"] += t["bytes_in"] or 0
|
||||
c["bytes_out"] += t["bytes_out"] or 0
|
||||
raices = sorted({t["raiz"] for t in ts if t.get("raiz")})
|
||||
seg = round(time.time() - (COLA["inicio"] or time.time()))
|
||||
r = {"lote": "tanda-" + time.strftime("%Y%m%d-%H%M%S"), "inicio": COLA["inicio"], "seg": seg, "clips": len(bien), "pedidos": len(ts),
|
||||
"bytes_in": sum(t["bytes_in"] or 0 for t in bien), "bytes_out": sum(t["bytes_out"] or 0 for t in bien),
|
||||
"fallos": sum(1 for t in ts if t["estado"] in ("error", "cancelado")), "descartados": sum(1 for t in ts if t["estado"] == "descartado"),
|
||||
"reemplazados": sum(1 for t in ts if t["estado"] == "reemplazado"), "verificados": sum(1 for t in ts if t["estado"] == "verificado"),
|
||||
"nota": round(sum(notas) / len(notas), 1) if notas else None, "nota_min": min(notas) if notas else None,
|
||||
"raiz": raices[0] if len(raices) == 1 else "", "raices": raices,
|
||||
"cliente": ", ".join((raiz_cfg(x) or {}).get("cliente") or os.path.basename(x) for x in raices),
|
||||
"carpetas": sorted(carpetas.values(), key=lambda c: -c["bytes_in"]), "ids": ids}
|
||||
bitacora({"op": "tanda", **r})
|
||||
COLA["ultima"] = dict(r, ts=time.time())
|
||||
if _opt("avisar", True) and (r["clips"] or r["fallos"]):
|
||||
que = f"{r['clips']} clip{'s' if r['clips'] != 1 else ''} convertido{'s' if r['clips'] != 1 else ''}"
|
||||
if r["clips"]:
|
||||
que += f" y verificado{'s' if r['clips'] != 1 else ''} en {_tiempo_llano(seg)}. " + ("Ya reemplazados." if r["reemplazados"] == r["clips"] else "Falta reemplazar.")
|
||||
if r["fallos"]:
|
||||
que += f" {r['fallos']} fallaron."
|
||||
notificar("OrganiLAB terminó" if r["clips"] else "OrganiLAB: la tanda no salió", que)
|
||||
return r
|
||||
|
||||
|
||||
def _tiempo_llano(seg):
|
||||
seg = int(seg or 0)
|
||||
if seg < 60:
|
||||
return f"{seg} s"
|
||||
if seg < 3600:
|
||||
return f"{seg // 60} min"
|
||||
return f"{seg // 3600} h {round(seg % 3600 / 60)} min"
|
||||
|
||||
|
||||
def _ultima_tanda_guardada():
|
||||
for e in reversed(leer_bitacora(3000)):
|
||||
if e.get("op") == "tanda":
|
||||
return e
|
||||
return {}
|
||||
|
||||
|
||||
def tanda_csv(lote):
|
||||
"""Los clips de una tanda, en CSV (para el cliente o para mirar en Excel)."""
|
||||
import csv, io
|
||||
e = next((x for x in reversed(leer_bitacora(3000)) if x.get("op") == "tanda" and x.get("lote") == lote), None)
|
||||
if not e:
|
||||
return None
|
||||
ids = e.get("ids") or []
|
||||
con = db()
|
||||
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||||
f"WHERE t.id IN ({','.join('?' * len(ids))}) ORDER BY t.id", ids).fetchall()] if ids else []
|
||||
con.close()
|
||||
s = io.StringIO()
|
||||
w = csv.writer(s, delimiter=";", lineterminator="\n")
|
||||
w.writerow(["clip", "carpeta", "estado", "antes (MB)", "después (MB)", "ahorro %", "duración (s)", "nota de calidad", "formato", "salida"])
|
||||
for t in ts:
|
||||
try:
|
||||
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
|
||||
except ValueError:
|
||||
rel = os.path.dirname(t["ruta"])
|
||||
bi, bo = t["bytes_in"] or 0, t["bytes_out"] or 0
|
||||
w.writerow([os.path.basename(t["ruta"]), rel, EST_CSV.get(t["estado"], t["estado"]), round(bi / 1e6, 1), round(bo / 1e6, 1) if bo else "",
|
||||
round((1 - bo / bi) * 100) if bi and bo else "", round(t["dur"] or 0, 1) if t.get("dur") else "", t.get("nota") or "",
|
||||
formato_de(t), t["salida"] or ""])
|
||||
return s.getvalue()
|
||||
|
||||
|
||||
EST_CSV = {"pendiente": "en espera", "corriendo": "convirtiendo", "listo": "convertido", "verificado": "verificado", "reemplazado": "reemplazado",
|
||||
"error": "falló", "cancelado": "cancelado", "descartado": "no compensa"}
|
||||
|
||||
|
||||
def set_trabajo(id_, **kw):
|
||||
@@ -3242,6 +3432,8 @@ def bucle_cola():
|
||||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY orden DESC, id LIMIT 1").fetchone()
|
||||
lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()]
|
||||
con.close()
|
||||
if t and not _esperar_turno():
|
||||
break
|
||||
if t:
|
||||
COLA["actual"] = t["id"]
|
||||
COLA["empezo"] = time.time()
|
||||
@@ -3258,6 +3450,7 @@ def bucle_cola():
|
||||
except Exception as ex: # noqa
|
||||
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
|
||||
COLA["hechos"] += 1
|
||||
COLA["hechos_ids"].append(t["id"])
|
||||
elif lote_resolve:
|
||||
COLA["actual"] = lote_resolve[0]["id"]
|
||||
try:
|
||||
@@ -3267,6 +3460,7 @@ def bucle_cola():
|
||||
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
|
||||
_verificar_listos([r["id"] for r in lote_resolve])
|
||||
COLA["hechos"] += len(lote_resolve)
|
||||
COLA["hechos_ids"].extend(r["id"] for r in lote_resolve)
|
||||
else:
|
||||
break
|
||||
COLA["actual"] = None
|
||||
@@ -3280,6 +3474,12 @@ def bucle_cola():
|
||||
COLA["activa"] = False
|
||||
COLA["actual"] = None
|
||||
COLA["hilo"] = None
|
||||
COLA["esperando"] = ""
|
||||
try:
|
||||
_resumen_tanda()
|
||||
except Exception as ex: # noqa
|
||||
COLA["mensaje"] = f"no se pudo apuntar el resumen de la tanda: {ex}"
|
||||
COLA["hechos_ids"] = []
|
||||
|
||||
|
||||
def _anotar_ritmo(id_, segundos):
|
||||
@@ -3526,6 +3726,8 @@ def iniciar_cola():
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["ritmo"] = []
|
||||
COLA["hechos_ids"] = []
|
||||
COLA["esperando"] = ""
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
@@ -5337,12 +5539,12 @@ def automatico(en_hilo=False, origen="a mano"):
|
||||
raices = [_clave(r["ruta"]) for r in CONFIG.get("raices", [])]
|
||||
AUTO.update(activo=True, raiz="", hechas=0, total=len(raices), inicio=time.time())
|
||||
if en_hilo:
|
||||
threading.Thread(target=_automatico, args=(raices, origen), daemon=True).start()
|
||||
threading.Thread(target=_automatico, args=(raices, origen, False), daemon=True).start()
|
||||
return {"lanzado": True, "carpetas": len(raices)}
|
||||
return _automatico(raices, origen)
|
||||
return _automatico(raices, origen, True)
|
||||
|
||||
|
||||
def _automatico(raices, origen):
|
||||
def _automatico(raices, origen, sincrono=False):
|
||||
limite = int((CONFIG.get("automatico") or {}).get("limite") or 3000)
|
||||
resumen = {"ts": time.time(), "origen": origen, "version": VERSION, "carpetas": []}
|
||||
try:
|
||||
@@ -5355,6 +5557,10 @@ def _automatico(raices, origen):
|
||||
"estado": "con errores", "detalle": repr(ex), "movimientos": 0, "lotes": [],
|
||||
"dudosos": 0, "avisos": [], "errores": [repr(ex)]})
|
||||
AUTO["hechas"] += 1
|
||||
try:
|
||||
aligerar_automatico(resumen, sincrono=sincrono)
|
||||
except Exception as ex: # noqa
|
||||
resumen["aligerar"] = {"error": repr(ex)}
|
||||
resumen["seg"] = round(time.time() - resumen["ts"], 1)
|
||||
guardar_json(AUTOMATICO_PATH, resumen)
|
||||
finally:
|
||||
@@ -5362,6 +5568,42 @@ def _automatico(raices, origen):
|
||||
return resumen
|
||||
|
||||
|
||||
def aligerar_automatico(resumen, sincrono=False):
|
||||
"""Con optimizar.aligerar activado, la pasada mete en cola lo de más de N meses, de más pesado a menos,
|
||||
hasta el tope de GB por noche, en todas las carpetas conectadas. Convierte y verifica; no reemplaza
|
||||
(salvo que esté puesto auto_reemplazar). Si el programa corre sin ventana (la tarea de Windows), la cola
|
||||
va aquí mismo, en este proceso; si no, arranca la cola de siempre."""
|
||||
a = _opt("aligerar") or {}
|
||||
if not a.get("activo"):
|
||||
return
|
||||
o = CONFIG["optimizar"]
|
||||
meses, tope = int(a.get("meses") or 6), float(a.get("gb_noche") or 100) * 1e9
|
||||
total_b, por_raiz = 0, {}
|
||||
for r in CONFIG.get("raices", []):
|
||||
raiz = _clave(r["ruta"])
|
||||
if not os.path.isdir(raiz):
|
||||
continue
|
||||
AUTO["raiz"] = raiz
|
||||
analizar(raiz, o["min_mb"]) # lee con ffprobe lo que falte, aquí mismo
|
||||
for c in sorted(candidatos(raiz, meses, o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")), key=lambda c: -c["bytes"]):
|
||||
if c["excluido"] or c["en_cola"] or total_b + c["bytes"] > tope:
|
||||
continue
|
||||
total_b += c["bytes"]
|
||||
por_raiz.setdefault(raiz, []).append(c["ruta"])
|
||||
n = 0
|
||||
for raiz, rutas in por_raiz.items():
|
||||
n += len(encolar(rutas, o["perfil"], o.get("chroma", "420"), o.get("color", "rec709"), raiz, o.get("formato", "h265_10bit")) or [])
|
||||
resumen["aligerar"] = {"clips": n, "bytes": total_b, "meses": meses, "tope_gb": a.get("gb_noche") or 100}
|
||||
if not n:
|
||||
return
|
||||
if sincrono:
|
||||
with _COLA_LOCK:
|
||||
COLA.update(activa=True, cancelar=False, hechos=0, inicio=time.time(), ritmo=[], hechos_ids=[], esperando="")
|
||||
bucle_cola()
|
||||
else:
|
||||
iniciar_cola()
|
||||
|
||||
|
||||
def estado_automatico():
|
||||
c = CONFIG.get("automatico") or {}
|
||||
return {"activo": bool(c.get("activo")), "hora": c.get("hora") or "02:00", "limite": int(c.get("limite") or 3000),
|
||||
@@ -5835,6 +6077,11 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")})
|
||||
if e.get("op") == "mkdir": # una pieza nueva: cuentan las carpetas creadas
|
||||
lo["movimientos"] += 1
|
||||
if e.get("op") == "tanda": # una tanda del convertidor: su resumen
|
||||
lo["raiz"] = e.get("raiz", "") or ", ".join(e.get("raices") or [])
|
||||
lo["movimientos"] = e.get("clips", 0)
|
||||
lo["errores"] = e.get("fallos", 0)
|
||||
lo["tanda"] = {k: e.get(k) for k in ("clips", "bytes_in", "bytes_out", "seg", "nota", "reemplazados", "descartados", "cliente")}
|
||||
if e.get("op") == "fin":
|
||||
lo["raiz"] = e.get("raiz", "")
|
||||
lo["errores"] = len(e.get("errores") or [])
|
||||
@@ -5867,7 +6114,22 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
eta = eta_cola(filas) if COLA["activa"] else None
|
||||
except Exception: # noqa
|
||||
eta = None
|
||||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta}})
|
||||
if COLA.get("ultima") is None:
|
||||
COLA["ultima"] = _ultima_tanda_guardada()
|
||||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta,
|
||||
"esperando": COLA.get("esperando") or "", "ultima": COLA.get("ultima") or None}})
|
||||
if ruta == "/api/optimizar/tanda.csv": # la tanda, en CSV
|
||||
csv_ = tanda_csv(q.get("lote") or "")
|
||||
if csv_ is None:
|
||||
return self._json({"error": "no hay una tanda con ese nombre"}, 404)
|
||||
cuerpo = csv_.encode("utf-8-sig")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/csv; charset=utf-8")
|
||||
self.send_header("Content-Disposition", f"attachment; filename=\"OrganiLAB-{q.get('lote') or 'tanda'}.csv\"")
|
||||
self.send_header("Content-Length", str(len(cuerpo)))
|
||||
self.end_headers()
|
||||
self.wfile.write(cuerpo)
|
||||
return
|
||||
if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar»
|
||||
try:
|
||||
jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0))
|
||||
@@ -5971,7 +6233,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"ok": True})
|
||||
if ruta == "/api/config":
|
||||
# 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"},
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
|
||||
"horario", "pausar_si_edito", "aligerar", "avisar"},
|
||||
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
|
||||
for k, v in d.items():
|
||||
if k in permitidas and isinstance(v, dict):
|
||||
|
||||
Referencia en una nueva incidencia
Block a user