Compare commits

...

7 Commits

Author SHA1 Message Date
rafa 4d25a8d2e5 fea-audio-player: limpiar el aviso de error cuando el audio sí llega a reproducir
Si audio.play() fallaba tras verificar el hash (p.ej. política de autoplay
del navegador tras el await de fetch+digest), el aviso "No se pudo verificar
el audio" quedaba fijo en pantalla aunque el usuario reprodujera el audio a
mano desde los controles nativos que ya estaban visibles. El evento 'play'
real del <audio> ahora limpia ese aviso y oculta el botón, sea cual sea el
camino por el que arrancó la reproducción.

Reportado por Rafa en el canal feadulta, viendo el estado atascado en
"Más allá de la trinchera..." (post 56196) tras el bloqueo 403 de Cloudflare.
2026-08-28 05:29:23 -04:00
rafa 1333a2bb64 Merge pull request 'sync_audio_bulk.sh: lote de sincronizacion de audio TTS con parada en primer fallo' (#215) from feat/issue-sync-bulk-script into main 2026-08-24 19:28:21 +00:00
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 236 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:
+74 -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,64 @@ 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 () {
// La verificación pudo fallar en el primer intento (p.ej. audio.play()
// rechazado por política de autoplay tras el await de verificación) y
// dejar el mensaje de error en pantalla. Si el audio realmente rompe a
// reproducir -en este intento o en uno posterior desde los controles
// nativos-, ese es el estado real: limpiar el aviso y ocultar el botón.
var wrap = audio.closest('.fea-audio');
if (wrap) {
var status = wrap.querySelector('.fea-audio-status');
if (status) status.textContent = '';
var button = wrap.querySelector('[data-fea-audio-verified-play]');
if (button) button.hidden = true;
}
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)