#!/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 (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed). Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md). 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_HOST", "feadulta@134.0.10.170") PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF") PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php") PROD_HELPER = "/tmp/fea_post_io.php" PROD_UPLOADS_TTS = "/web/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 (glibc rota: nada de scp/sftp, todo por ssh + cat) ──────────────────── def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str: cmd = ["sshpass", "-p", PROD_PASS, "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 _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None: cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"] 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(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 (glibc rota => sin fiarse ciegamente del rc=0 de ssh) remote_size = int(_ssh_text(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(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())