Files
feadulta/scripts/sync_audio_to_prod.py
T
rafa de91485854 sync audio: el recuento final contaba las subidas buenas como saltadas
sync_one devuelve "ok prod#<id>" desde que el id de prod puede diferir del
local, pero el contador seguia comparando con la igualdad exacta "ok". Resultado:
el 8-ago la subida de los 90 audios de Fray Marcos, sin un solo fallo, cerro con
"ok=0 skip=90". El susto es lo de menos; lo malo es que un fallo real se camufla
en ese ruido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:57:04 -04:00

282 lines
12 KiB
Python

#!/usr/bin/env python3
"""
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
`docker exec -i`), sin depender de scp/sftp — mismo patrón que
sync_translations_to_prod.py y sync_carta_from_prod.py.
Uso:
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
python3 sync_audio_to_prod.py --ids 912,919,3001 # ids concretos
python3 sync_audio_to_prod.py --carta 54254 --dry-run # solo plan, no toca prod
Rollback (despublica en prod lo que este script publicó):
python3 sync_audio_to_prod.py --rollback --carta 54254
python3 sync_audio_to_prod.py --rollback --ids 912,919
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import shlex
import subprocess
import time
from pathlib import Path
WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web")
DB_CONTAINER = os.environ.get("FEA_DB_CONTAINER", "wordpress-mysql")
DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
# define, el helper y la subida de mp3 se ejecutan dentro del contenedor.
# Espejo de sync_translations_to_prod.py / sync_carta_from_prod.py.
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
# Se evalúa en memoria con `php -r`: no se copia un helper temporal a prod.
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
) + "));"
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
LOG_FILE = Path(os.environ.get(
"FEA_AUDIO_SYNC_LOG",
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync.log"),
))
STATE_FILE = Path(os.environ.get(
"FEA_AUDIO_SYNC_STATE",
str(Path(__file__).resolve().parent.parent / "logs/feadulta-audio-sync-state.json"),
))
def log(msg: str) -> None:
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
print(line, flush=True)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def sh(cmd: list[str], *, input_bytes: bytes | None = None, timeout: int = 120) -> str:
r = subprocess.run(cmd, input=input_bytes, capture_output=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"cmd falló ({r.returncode}): {' '.join(cmd[:4])}\n{r.stderr.decode(errors='replace')[:400]}")
return r.stdout.decode(errors="replace")
# ── Local ────────────────────────────────────────────────────────────────────
def local_meta(post_id: int, key: str) -> str:
r = subprocess.run(
["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
capture_output=True, text=True, timeout=60,
)
return r.stdout.strip()
def prod_id_for_local(local_id: int) -> int:
"""Resolve a remapped local post to its original production ID.
``sync_carta_from_prod.py --remap-conflicts`` keeps this mapping in
``fea_prod_source_id``. Audio files remain named with the local ID, but
remote paths and production post meta must use the source ID.
"""
source_id = local_meta(local_id, "fea_prod_source_id")
return int(source_id) if source_id.isdigit() else local_id
def carta_article_ids(carta_id: int) -> list[int]:
q = ("SELECT post_id FROM wp_postmeta "
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
r = subprocess.run(
["docker", "exec", DB_CONTAINER, "mysql", f"-u{DB_USER}", f"-p{DB_PASS}",
DB_NAME, "-N", "-e", q],
capture_output=True, text=True, timeout=60,
)
return [int(x) for x in r.stdout.split() if x.isdigit()]
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}\n{r.stderr.strip()[:400]}")
return r.stdout
def _remote_wrap(inner_cmd: str) -> str:
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
Importante: el wrapping (incluidas redirecciones como `< ruta`) debe quedar
DENTRO de la shell del contenedor (`sh -c '...'`), porque `docker exec
CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST,
no dentro del contenedor.
"""
if PROD_DOCKER_CONTAINER:
return f"docker exec -i {shlex.quote(PROD_DOCKER_CONTAINER)} sh -c {shlex.quote(inner_cmd)}"
return inner_cmd
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
remote_cmd = _remote_wrap(f"cat > {remote_path}")
if PROD_PASS:
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
else:
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
sh(cmd, input_bytes=data, timeout=timeout)
def prod_helper(subcmd: str, *args: str) -> str:
"""Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
inner = (
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
if PROD_DOCKER_CONTAINER:
remote_cmd = (
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
)
else:
remote_cmd = inner
return _ssh_text(remote_cmd, timeout=60)
def prod_upload_mp3(local_id: int, prod_id: int) -> None:
src = LOCAL_TTS_DIR / f"{local_id}.mp3"
data = src.read_bytes()
remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
_ssh_upload_bytes(data, remote_path)
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data):
raise RuntimeError(
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
)
def prod_remove_mp3(post_id: int) -> None:
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
# ── Estado ───────────────────────────────────────────────────────────────────
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
pass
return {"synced": []}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
# ── Sync ─────────────────────────────────────────────────────────────────────
def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
if local_meta(post_id, "fea_audio_done") != "1":
return "sin-audio-local"
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
return "mp3-local-ausente"
prod_id = prod_id_for_local(post_id)
if dry_run:
return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id, prod_id)
prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
if prod_id not in state["synced"]:
state["synced"].append(prod_id)
save_state(state)
return f"ok prod#{prod_id}"
def rollback_one(post_id: int, state: dict) -> str:
prod_id = prod_id_for_local(post_id)
prod_helper("unsetaudio", str(prod_id))
prod_remove_mp3(prod_id)
if prod_id in state["synced"]:
state["synced"].remove(prod_id)
save_state(state)
return f"ok prod#{prod_id}"
def main() -> int:
ap = argparse.ArgumentParser(description="Sincroniza audio TTS local→prod (o lo despublica con --rollback).")
ap.add_argument("--carta", type=int, default=0, help="WP post ID de la carta local; sincroniza toda su cola.")
ap.add_argument("--ids", default="", help="Lista CSV de post IDs concretos.")
ap.add_argument("--rollback", action="store_true", help="Despublica en prod en vez de publicar.")
ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no toca prod.")
args = ap.parse_args()
if args.ids:
ids = [int(x) for x in args.ids.split(",") if x.strip().isdigit()]
elif args.carta:
ids = carta_article_ids(args.carta)
else:
ap.error("hace falta --carta o --ids")
return 2
state = load_state()
mode = "ROLLBACK" if args.rollback else "SYNC"
log(f"=== INICIO {mode} audio→prod. {len(ids)} posts candidatos: {ids} ===")
ok = skip = err = 0
for pid in ids:
try:
if args.rollback:
if prod_id_for_local(pid) not in state["synced"] and not args.ids:
res = "no-estaba-sincronizado"
skip += 1
else:
res = rollback_one(pid, state)
ok += 1
else:
res = sync_one(pid, state, dry_run=args.dry_run)
# `startswith`, no `==`: sync_one devuelve "ok prod#<id>" desde que
# el id de prod puede diferir del local. Con la igualdad exacta, una
# subida perfecta se contaba entera como saltada — el 8-ago-2026 el
# pie del log dijo "ok=0 skip=90" tras subir los 90 audios de Fray
# Marcos sin un solo fallo. Peor que el susto: así un fallo real se
# confunde con este ruido y pasa desapercibido.
if res.startswith(("ok", "PLAN")):
ok += 1
else:
skip += 1
log(f" #{pid}: {res}")
except Exception as exc: # noqa: BLE001
err += 1
log(f" #{pid}: ERROR {exc}")
log(f"=== FIN {mode}. ok={ok} skip={skip} error={err}. Estado: {STATE_FILE} ===")
return 1 if err else 0
if __name__ == "__main__":
raise SystemExit(main())