'
- . ''
+ $sha256 = get_post_meta($id, 'fea_audio_sha256', true);
+ $label = ''
. ' Escucha'
+ . ' Escucha';
+ // 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 '
' . $label
+ . ''
+ . ''
+ . '
';
+ }
+ return '
' . $label
. ''
. '
';
@@ -56,6 +68,9 @@ add_action('wp_head', function () {
.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}
.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){
.fea-audio{margin-left:0;width:100%;margin-top:.5rem}
.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;
?>
str:
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"
data = src.read_bytes()
+ local_sha256 = hashlib.sha256(data).hexdigest()
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.
+ # Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset
+ # 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())
if remote_size != len(data):
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:
@@ -185,8 +191,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
return "PLAN: subiría mp3 + setaudio"
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("setflag", str(post_id), "fea_audio_sha256", sha256)
if post_id not in state["synced"]:
state["synced"].append(post_id)
save_state(state)