diff --git a/README.md b/README.md index 81127b1..fd77f2c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,40 @@ scripts/ — scripts Python/PHP de migración, traducción, TTS y mantenimi | `translate_haiku.py` | Traducción automática vía Claude Haiku | | `tts_produce.py` | Generación de audio TTS (MiniMax) | | `sync_translations_to_prod.py` | Sincronizar traducciones local → producción | +| `sync_audio_to_prod.py` | Sincronizar UN post/carta de audio TTS local → producción (sube el mp3, verifica SHA-256 remoto y persiste `fea_audio_sha256`) | +| `sync_audio_bulk.sh` | Envoltorio de `sync_audio_to_prod.py` para lotes: para en el primer fallo, sin reintentos ciegos, resumen ok/skip | | `detect_untranslated.php` | Detectar posts sin traducir | +## Sincronizar audio TTS a producción (local → prod) + +`sync_audio_to_prod.py` sube el mp3 de UN post/carta y, desde el #213, calcula y verifica el +SHA-256 en cada subida (reproducción verificada automática, sin backfill manual — ver #212). + +Para lotes de varios posts, usar `sync_audio_bulk.sh`: es el envoltorio que añade "parar en el +primer fallo" (el script Python no lo hace por sí solo, recorre toda la lista y acumula errores). +Carga los IDs con `mapfile`/mediante array y redirige el stdin de cada invocación a `/dev/null` a +propósito — un `while read id; do ...ssh...; done < fichero` sin esa precaución se rompe en +silencio porque `ssh` se come el resto del fichero de IDs (ver +`feedback-bash-while-read-ssh-stdin` en la memoria del proyecto). + +```bash +export FEA_PROD_SSH_HOST="rafa@188.40.120.157" +export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa" + +# Dry-run primero, siempre +scripts/sync_audio_bulk.sh --ids 55519,55520,822 --dry-run + +# Aplicar de verdad +scripts/sync_audio_bulk.sh --ids 55519,55520,822 + +# Desde un fichero (un ID por línea) o toda la cola de una carta +scripts/sync_audio_bulk.sh --file /tmp/ids_pendientes.txt +scripts/sync_audio_bulk.sh --carta 54254 +``` + +Rollback de un post concreto: `python3 scripts/sync_audio_to_prod.py --rollback --ids ` +(borra el meta en prod, no toca el mp3 local). + ## Servidor local de desarrollo WordPress corre en Docker local, accesible vía Tailscale: diff --git a/scripts/sync_audio_bulk.sh b/scripts/sync_audio_bulk.sh new file mode 100755 index 0000000..384f2aa --- /dev/null +++ b/scripts/sync_audio_bulk.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# sync_audio_bulk.sh — Sincroniza a producción, uno por uno, un lote de posts con +# audio TTS ya generado en local. Envoltorio de sync_audio_to_prod.py que añade +# "parar en el primer fallo" (el script Python no lo hace: recorre toda la +# lista y acumula errores, ver #212/#213). +# +# Motivo del wrapper en bash con mapfile: un `while read id; do ...; done < +# fichero` normal comparte el file descriptor de stdin con cualquier `ssh` +# lanzado dentro del bucle. sync_audio_to_prod.py llama a ssh internamente; si +# el bucle no está protegido, ssh se come el resto del fichero de IDs y el +# bucle termina "sin error" tras procesar solo el primero. Detectado y +# documentado en memoria (feedback-bash-while-read-ssh-stdin) el 2026-08-24 +# tras un lote real que solo sincronizó 1 de 184 posts sin ningún error visible. +# +# Uso: +# scripts/sync_audio_bulk.sh --ids 55519,55520,822 +# scripts/sync_audio_bulk.sh --file /tmp/ids.txt # un ID por línea +# scripts/sync_audio_bulk.sh --carta 54254 # cola completa de la carta +# scripts/sync_audio_bulk.sh --ids 55519,55520 --dry-run # solo plan, no toca prod +# +# Requiere las mismas variables de entorno que sync_audio_to_prod.py para +# apuntar a producción (ver ese script): FEA_PROD_SSH_HOST, +# FEA_PROD_DOCKER_CONTAINER. Ejemplo: +# export FEA_PROD_SSH_HOST="rafa@188.40.120.157" +# export FEA_PROD_DOCKER_CONTAINER="wordpress-r2ssjifwj0r0ghyoqd528uaa" + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SYNC_PY="$SCRIPT_DIR/sync_audio_to_prod.py" + +usage() { + echo "Uso: $0 (--ids id1,id2,... | --file ruta | --carta post_id) [--dry-run]" >&2 + exit 2 +} + +ids_csv="" +ids_file="" +carta="" +dry_run="" + +while [ $# -gt 0 ]; do + case "$1" in + --ids) ids_csv="$2"; shift 2 ;; + --file) ids_file="$2"; shift 2 ;; + --carta) carta="$2"; shift 2 ;; + --dry-run) dry_run="--dry-run"; shift ;; + *) usage ;; + esac +done + +# Carga explícita de IDs en un array ANTES de cualquier llamada a ssh — es la +# parte que evita el bug de stdin. No usar `while read ... < fichero`. +ids=() +if [ -n "$ids_csv" ]; then + IFS=',' read -r -a ids <<< "$ids_csv" +elif [ -n "$ids_file" ]; then + mapfile -t ids < "$ids_file" +elif [ -n "$carta" ]; then + # No usar `mapfile < <(cmd)`: la sustitución de procesos no deja comprobar + # de forma fiable el exit code de `cmd` (mapfile ve su propio $?, no el de + # la tubería). Si la consulta falla, un array vacío se confundiría con + # "la carta no tiene artículos" y el lote saldría "éxito" sin sincronizar + # nada. Se resuelve a fichero temporal y se comprueba el rc de python + # explícitamente antes de poblar el array. + carta_tmpfile="$(mktemp)" + python3 - "$carta" "$SCRIPT_DIR" > "$carta_tmpfile" <<'PYEOF' +import sys +sys.path.insert(0, sys.argv[2]) +from sync_audio_to_prod import carta_article_ids +for pid in carta_article_ids(int(sys.argv[1])): + print(pid) +PYEOF + py_rc=$? + if [ $py_rc -ne 0 ]; then + echo "ERROR: fallo consultando la cola de la carta $carta (exit $py_rc), no se sincroniza nada." >&2 + rm -f "$carta_tmpfile" + exit 1 + fi + mapfile -t ids < "$carta_tmpfile" + rm -f "$carta_tmpfile" +else + usage +fi + +total=${#ids[@]} +if [ "$total" -eq 0 ]; then + echo "Nada que sincronizar (lista de IDs vacía)." >&2 + exit 0 +fi + +ok=0 +skip=0 +n=0 +for id in "${ids[@]}"; do + id="$(echo "$id" | tr -d '[:space:]')" + [ -z "$id" ] && continue + n=$((n + 1)) + # stdin del hijo explícitamente en /dev/null: cinturón y tirantes además del + # array en memoria, por si algún día se cambia el bucle. + out="$(python3 "$SYNC_PY" --ids "$id" $dry_run < /dev/null)" + rc=$? + echo "$out" + if [ $rc -ne 0 ]; then + echo "=== ABORT: fallo en #$id ($n/$total procesados, $ok ok). Deteniendo lote, sin reintentar. ===" + exit 1 + fi + if echo "$out" | grep -q "PLAN: subiría\|#$id: ok"; then + ok=$((ok + 1)) + else + skip=$((skip + 1)) + fi +done + +echo "=== FIN LOTE. ok=$ok skip=$skip de $total candidatos. ===" diff --git a/scripts/sync_audio_to_prod.py b/scripts/sync_audio_to_prod.py index 9a1ef30..b7f8e08 100644 --- a/scripts/sync_audio_to_prod.py +++ b/scripts/sync_audio_to_prod.py @@ -75,6 +75,8 @@ def local_meta(post_id: int, key: str) -> str: ["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key], capture_output=True, text=True, timeout=60, ) + if r.returncode != 0: + raise RuntimeError(f"docker exec {WP_CONTAINER} falló leyendo meta '{key}' de #{post_id} (rc={r.returncode}): {r.stderr.strip()[:300]}") return r.stdout.strip() @@ -86,6 +88,8 @@ def carta_article_ids(carta_id: int) -> list[int]: DB_NAME, "-N", "-e", q], capture_output=True, text=True, timeout=60, ) + if r.returncode != 0: + raise RuntimeError(f"consulta de _carta_id={carta_id} falló (rc={r.returncode}): {r.stderr.strip()[:300]}") return [int(x) for x in r.stdout.split() if x.isdigit()]