#!/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` si FEA_PROD_DOCKER_CONTAINER está definido). 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 json import os 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 el mp3 se suben/ejecutan dentro del contenedor. PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "") PROD_HELPER = "/tmp/fea_post_io.php" 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" 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 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 sh_quote(s: str) -> str: return "'" + s.replace("'", "'\\''") + "'" 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. El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST, no en el contenedor. """ if PROD_DOCKER_CONTAINER: return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_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) _prod_helper_ready = False def prod_helper(subcmd: str, *args: str) -> str: global _prod_helper_ready if not _prod_helper_ready: _ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER) _prod_helper_ready = True inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) return _ssh_text(_remote_wrap(inner), timeout=60) def prod_upload_mp3(post_id: int) -> None: src = LOCAL_TTS_DIR / f"{post_id}.mp3" data = src.read_bytes() remote_path = f"{PROD_UPLOADS_TTS}/{post_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 #{post_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" if dry_run: return "PLAN: subiría mp3 + setaudio" voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026" prod_upload_mp3(post_id) prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice) if post_id not in state["synced"]: state["synced"].append(post_id) save_state(state) return "ok" def rollback_one(post_id: int, state: dict) -> str: prod_helper("unsetaudio", str(post_id)) prod_remove_mp3(post_id) if post_id in state["synced"]: state["synced"].remove(post_id) save_state(state) return "ok" 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 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) if res == "ok" or res.startswith("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())