Compare commits

...

5 Commits

Author SHA1 Message Date
rafa 654c3d3c35 sync_audio_to_prod.py: local_meta() propaga fallos de docker exec
Mismo patrón que carta_article_ids() (commit anterior): si el docker exec
contra el contenedor WP local fallaba (contenedor caído/inexistente),
local_meta() devolvía stdout vacío en vez de comprobar returncode.
sync_one() interpretaba la cadena vacía como "sin-audio-local" (skip), no
como error, así que un lote entero podía acabar "ok=0/skip=N" tras perder
Docker en vez de abortar.

Ahora lanza RuntimeError con el stderr si docker exec falla; se propaga
por sync_one() hasta el try/except de main(), que ya cuenta err y devuelve
1 — el wrapper aborta correctamente con exit=1 y 0 sincronizaciones.

Probado: FEA_WP_CONTAINER inexistente + --ids → aborta en el primer ID
(exit=1, ok=0). Camino normal (--ids con contenedor real, dry-run) sigue
dando el mismo resultado que antes.

Revisión de Codix en el PR #215.
2026-08-24 15:27:41 -04:00
rafa 7af2173014 sync_audio_bulk.sh: parada dura real en --carta + ruta absoluta
- carta_article_ids() en sync_audio_to_prod.py ahora comprueba returncode
  del docker exec y lanza RuntimeError si falla, en vez de devolver una
  lista vacía silenciosa (bug preexistente: un fallo de Docker/consulta se
  confundía con "la carta no tiene artículos").
- El wrapper resuelve --carta a fichero temporal en vez de mapfile con
  sustitución de procesos (mapfile < <(cmd) no deja comprobar el exit
  code real de cmd), captura el rc de python explícitamente y aborta con
  exit 1 sin sincronizar nada si la consulta falla.
- sys.path ya no depende del cwd ("scripts" relativo): se pasa la ruta
  absoluta de SCRIPT_DIR al script inline.

Probado: --carta real sin cambios de comportamiento (28 candidatos,
ok=21/skip=7); consulta rota a propósito (FEA_DB_CONTAINER inexistente)
aborta limpio con exit=1 y 0 sincronizaciones; invocación desde otro
directorio de trabajo da el mismo resultado que desde la raíz del repo.

Revisión de Codix en el PR #215.
2026-08-24 15:26:05 -04:00
rafa 483c68bf5f sync_audio_bulk.sh: lote de sincronización audio TTS con parada en primer fallo
Envoltorio de sync_audio_to_prod.py para lotes de posts: carga los IDs con
un array (mapfile/--ids/--file) antes de cualquier ssh y redirige el stdin
del proceso hijo a /dev/null, evitando el bug de un while-read compartiendo
stdin con ssh (ssh se come el resto del fichero de IDs y el bucle termina
sin error tras procesar solo el primero — visto en real el 2026-08-24,
lote de 184 que solo sincronizó 1). Para en el primer fallo real, sin
reintentos ciegos, y da un resumen ok/skip al cierre.

Documentado en README con ejemplo de uso y variables de entorno.
2026-08-24 15:20:27 -04:00
rafa 2260343ec4 Merge pull request 'Verify complete audio assets before browser playback' (#213) from feat/accesibilidad-196 into main 2026-08-21 11:10:49 +00:00
rafa bc3af3cbb9 Verify complete audio assets before browser playback 2026-08-20 11:50:00 -04:00
4 changed files with 224 additions and 16 deletions
+32
View File
@@ -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 | | `translate_haiku.py` | Traducción automática vía Claude Haiku |
| `tts_produce.py` | Generación de audio TTS (MiniMax) | | `tts_produce.py` | Generación de audio TTS (MiniMax) |
| `sync_translations_to_prod.py` | Sincronizar traducciones local → producción | | `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 | | `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 <id>`
(borra el meta en prod, no toca el mp3 local).
## Servidor local de desarrollo ## Servidor local de desarrollo
WordPress corre en Docker local, accesible vía Tailscale: WordPress corre en Docker local, accesible vía Tailscale:
+62 -12
View File
@@ -13,11 +13,23 @@ function fea_audio_player_html(): string {
$url = get_post_meta($id, 'fea_audio_url', true); $url = get_post_meta($id, 'fea_audio_url', true);
if (!$url) return ''; if (!$url) return '';
$voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026'; $voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026';
return '<div class="fea-audio">' $sha256 = get_post_meta($id, 'fea_audio_sha256', true);
. '<span class="fea-audio-label">' $label = '<span class="fea-audio-label">'
. '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">' . '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">'
. '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>' . '<path fill="currentColor" d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z"/>'
. '</svg> Escucha</span>' . '</svg> Escucha</span>';
// Para un asset con hash registrado, descarga y verifica el fichero completo
// antes de delegar playback al navegador. Evita que un stream Range mezclado
// por una caché reproduzca fragmentos de otro MP3.
if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
return '<div class="fea-audio">' . $label
. '<button type="button" class="fea-audio-verified-play" data-fea-audio-verified-play'
. ' data-src="' . esc_url($url) . '" data-sha256="' . esc_attr(strtolower($sha256)) . '"'
. ' data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '">Reproducir audio</button>'
. '<audio controls preload="none" hidden data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '<span class="fea-audio-status" role="status" aria-live="polite"></span></div>';
}
return '<div class="fea-audio">' . $label
. '<audio controls preload="none" src="' . esc_url($url) . '"' . '<audio controls preload="none" src="' . esc_url($url) . '"'
. ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>' . ' data-fea-audio-track data-post-id="' . esc_attr($id) . '" data-voice="' . esc_attr($voice) . '"></audio>'
. '</div>'; . '</div>';
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
.fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem; .fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;
font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1} font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1}
.fea-audio audio{height:32px;width:230px;max-width:44vw} .fea-audio audio{height:32px;width:230px;max-width:44vw}
.fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer}
.fea-audio-verified-play:disabled{opacity:.65;cursor:wait}
.fea-audio-status{font-size:.76rem;color:#6b5c60}
@media(max-width:600px){ @media(max-width:600px){
.fea-audio{margin-left:0;width:100%;margin-top:.5rem} .fea-audio{margin-left:0;width:100%;margin-top:.5rem}
.fea-audio audio{flex:1 1 auto;width:auto;max-width:none} .fea-audio audio{flex:1 1 auto;width:auto;max-width:none}
@@ -73,17 +88,52 @@ add_action('wp_footer', function () {
if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return; if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return;
?> ?>
<script> <script>
document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) { (function () {
var fired = false; function hex(buffer) {
audio.addEventListener('play', function () { return Array.from(new Uint8Array(buffer)).map(function (n) {
if (fired || typeof gtag !== 'function') return; return n.toString(16).padStart(2, '0');
fired = true; }).join('');
gtag('event', 'audio_play', { }
post_id: audio.dataset.postId, document.querySelectorAll('[data-fea-audio-verified-play]').forEach(function (button) {
voice: audio.dataset.voice button.addEventListener('click', async function () {
var wrap = button.closest('.fea-audio');
var audio = wrap.querySelector('audio[data-fea-audio-track]');
var status = wrap.querySelector('.fea-audio-status');
button.disabled = true;
status.textContent = 'Verificando audio…';
try {
// Se descarga el objeto entero una vez y se valida su hash. Sólo
// entonces se crea un blob local: el <audio> no hará Range contra
// CDN y jamás podrá mezclar fragmentos de otro MP3.
var response = await fetch(button.dataset.src, {cache: 'no-store'});
if (!response.ok) throw new Error('HTTP ' + response.status);
var bytes = await response.arrayBuffer();
var digest = hex(await crypto.subtle.digest('SHA-256', bytes));
if (digest !== button.dataset.sha256) throw new Error('La copia recibida no coincide con el audio verificado');
audio.src = URL.createObjectURL(new Blob([bytes], {type: 'audio/mpeg'}));
audio.hidden = false;
button.hidden = true;
status.textContent = '';
await audio.play();
} catch (error) {
status.textContent = 'No se pudo verificar el audio. Inténtalo de nuevo.';
button.disabled = false;
console.error('Fe Adulta audio verification failed:', error);
}
}); });
}); });
}); document.querySelectorAll('audio[data-fea-audio-track]').forEach(function (audio) {
var fired = false;
audio.addEventListener('play', function () {
if (fired || typeof gtag !== 'function') return;
fired = true;
gtag('event', 'audio_play', {
post_id: audio.dataset.postId,
voice: audio.dataset.voice
});
});
});
}());
</script> </script>
<?php <?php
}); });
+115
View File
@@ -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. ==="
+15 -4
View File
@@ -19,6 +19,7 @@ Rollback (despublica en prod lo que este script publicó):
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import os import os
import subprocess import subprocess
@@ -74,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], ["docker", "exec", WP_CONTAINER, "php", "/tmp/fea_post_io.php", "getmeta", str(post_id), key],
capture_output=True, text=True, timeout=60, 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() return r.stdout.strip()
@@ -85,6 +88,8 @@ def carta_article_ids(carta_id: int) -> list[int]:
DB_NAME, "-N", "-e", q], DB_NAME, "-N", "-e", q],
capture_output=True, text=True, timeout=60, 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()] return [int(x) for x in r.stdout.split() if x.isdigit()]
@@ -144,16 +149,21 @@ def prod_helper(subcmd: str, *args: str) -> str:
return _ssh_text(_remote_wrap(inner), timeout=60) return _ssh_text(_remote_wrap(inner), timeout=60)
def prod_upload_mp3(post_id: int) -> None: def prod_upload_mp3(post_id: int) -> str:
src = LOCAL_TTS_DIR / f"{post_id}.mp3" src = LOCAL_TTS_DIR / f"{post_id}.mp3"
data = src.read_bytes() data = src.read_bytes()
local_sha256 = hashlib.sha256(data).hexdigest()
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3" remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
_ssh_upload_bytes(data, remote_path) _ssh_upload_bytes(data, remote_path)
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe # Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap. # sea el mismo. El hash se persiste como meta y permite playback verificado.
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip()) remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
if remote_size != len(data): if remote_size != len(data):
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}") raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip()
if remote_sha256 != local_sha256:
raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}")
return local_sha256
def prod_remove_mp3(post_id: int) -> None: def prod_remove_mp3(post_id: int) -> None:
@@ -185,8 +195,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "PLAN: subiría mp3 + setaudio" return "PLAN: subiría mp3 + setaudio"
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026" voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
prod_upload_mp3(post_id) sha256 = prod_upload_mp3(post_id)
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice) prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256)
if post_id not in state["synced"]: if post_id not in state["synced"]:
state["synced"].append(post_id) state["synced"].append(post_id)
save_state(state) save_state(state)