df7bf98ef1
- sync_translations_to_prod.py / sync_audio_to_prod.py / sync_carta_from_prod.py: migran de FEA_PROD_HOST/PASS (CDMON, password) a FEA_PROD_SSH_HOST/PASS + FEA_PROD_DOCKER_CONTAINER (Hetzner/Coolify, auth por clave), con wrapping docker exec y el fix del bug de redirecciones (wc -c/cat) resuelto en el host en vez de dentro del contenedor. - fea_translate_helper.php: subcomando clone_new para clonar en ID local nuevo cuando el ID de prod ya está ocupado localmente. - translate_post.py: --dry-run. - tts_produce.py: --allow-default-voice (voz Nico solo si se permite explícitamente para autores sin voz clonada) + fix voice_for_author. - minimax_tts.py: parámetro speed en t2a/_synth_chunk. - mirror-antiguo/deploy/nginx-mirror.conf: redirects de URLs históricas de catálogo y vista imprimible EFFA. - Importaciones humanas de Pagola (carta 738) + scripts/manifest de la fase A Enrique, y release_issue181_prod.sh / cdmon-retire-fewp1.sh. - .gitignore: excluye docs/backups/ (dumps SQL, mismo motivo que backups/*). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
198 lines
8.4 KiB
Python
198 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Orquestador nocturno: locuta cartas ES del gap con MiniMax, una a una,
|
|
repartido en el tiempo. Voz por defecto Nico; los artículos de autores con voz
|
|
clonada (AUTHOR_VOICES en minimax_tts.py, ej. Fray Marcos) usan la suya propia.
|
|
Reanudable (meta fea_audio_done) y con freno ante la cuota (para tras N fallos
|
|
seguidos). NO toca el front; solo genera el mp3 y asocia la URL al post (meta
|
|
fea_audio_url).
|
|
|
|
Dos modos de cola:
|
|
- cartas (por defecto): FEA_TTS_CARTAS / --cartas / --ids. Es el flujo de la
|
|
carta semanal, que tiene prioridad y no cambia.
|
|
- backlog por autor: --autor 382 [--desde 2025] [--hasta 2026] [--max 15].
|
|
La cola sale de `listpending` en fea_post_io.php (posts ES publicados sin
|
|
audio, más recientes primero). Esa consulta ES la idempotencia: no hay
|
|
fichero de estado, lo ya locutado deja de salir solo.
|
|
|
|
Lanzar: nohup ~/tts-local/xtts-venv/bin/python scripts/tts_produce.py > /tmp/feadulta-tts-prod.out 2>&1 &
|
|
Log: /tmp/feadulta-tts-prod.log
|
|
"""
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import minimax_tts as mm # get_post_text, add_pauses, t2a, OUT
|
|
import translate_post as tp # carta_article_ids
|
|
|
|
VOICE = os.environ.get("FEA_TTS_VOICE", "NicoFeadulta2026")
|
|
MODEL = "speech-2.8-hd"
|
|
CONTAINER = "wordpress-web"
|
|
PROD = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
|
LOG = Path("/tmp/feadulta-tts-prod.log")
|
|
INTERVAL = 180 # s entre cartas exitosas (reparte el ritmo)
|
|
BACKOFF = 1800 # s de espera ante errores transitorios no clasificados
|
|
MAX_CONSEC_FAIL = 3 # fallos seguidos no clasificados → parar
|
|
QUOTA_OR_RATE_ERRORS = {2056, 1039} # MiniMax: no reintentar en este proceso
|
|
MIN_CHARS = 200 # por debajo, se considera sin contenido locutable
|
|
|
|
# Cola de cartas a locutar. Override por entorno (FEA_TTS_CARTAS) para priorizar
|
|
# la carta nueva de la semana; si no, cae al orden del gap histórico.
|
|
_DEFAULT_CARTAS = "45018 44997 44975 44230 44229 44228 44090 44089 44088 44087 44086 44085 44084 44083 42590"
|
|
CARTAS = os.environ.get("FEA_TTS_CARTAS", _DEFAULT_CARTAS).replace(",", " ").split()
|
|
|
|
|
|
def log(msg):
|
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
|
print(line, flush=True)
|
|
with LOG.open("a") as f:
|
|
f.write(line + "\n")
|
|
|
|
|
|
def php(*args):
|
|
return subprocess.run(["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def meta(pid, key):
|
|
return php("getmeta", str(pid), key).stdout.strip()
|
|
|
|
|
|
def backlog_ids(autor, desde, hasta, limite):
|
|
"""Cola del backlog de un autor, delegada a la BD (ver listpending)."""
|
|
# Voz clonada del autor, si la tiene: los locutados con otra voz también
|
|
# cuentan como pendientes. Sin clon (""), pendiente = simplemente sin audio.
|
|
voz = mm.voice_for_author(autor, "")
|
|
r = php("listpending", str(autor), str(desde), str(hasta), str(limite), voz)
|
|
if r.returncode != 0:
|
|
log(f"listpending falló (rc={r.returncode}): {r.stderr.strip()[:200]}")
|
|
return []
|
|
return [int(x) for x in r.stdout.split() if x.strip().isdigit()]
|
|
|
|
|
|
def build_queue():
|
|
# Cola literal de IDs (ya filtrada/ordenada) para priorizar la carta nueva.
|
|
ids_override = os.environ.get("FEA_TTS_IDS", "").replace(",", " ").split()
|
|
if ids_override:
|
|
return [int(x) for x in ids_override if x.strip().isdigit()]
|
|
q = []
|
|
for c in CARTAS:
|
|
cid = int(c)
|
|
for pid in tp.carta_article_ids(cid):
|
|
if pid not in q:
|
|
q.append(pid)
|
|
return q
|
|
|
|
|
|
def main():
|
|
global CARTAS
|
|
parser = argparse.ArgumentParser(
|
|
description="Locuta posts ES de Fe Adulta con MiniMax; sin --ids conserva la cola programada."
|
|
)
|
|
parser.add_argument("--ids", help="CSV de IDs ES concretos, en el orden de locución deseado")
|
|
parser.add_argument("--cartas", help="CSV de cartas para construir la cola; sustituye FEA_TTS_CARTAS")
|
|
parser.add_argument("--autor", type=int,
|
|
help="WP user_id: cola del backlog de ese autor en vez de cartas")
|
|
parser.add_argument("--desde", type=int, default=0, help="año inicial del backlog (con --autor)")
|
|
parser.add_argument("--hasta", type=int, default=9999, help="año final del backlog (con --autor)")
|
|
parser.add_argument("--max", type=int, default=0,
|
|
help="para tras N audios OK en esta ejecución (0 = sin tope)")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="imprime la cola y sale, sin sintetizar ni gastar cuota")
|
|
parser.add_argument("--allow-default-voice", action="store_true",
|
|
help="permite Nico para autores sin voz digitalizada (desactivado por defecto)")
|
|
args = parser.parse_args()
|
|
if args.ids:
|
|
os.environ["FEA_TTS_IDS"] = args.ids
|
|
if args.cartas:
|
|
os.environ["FEA_TTS_CARTAS"] = args.cartas
|
|
CARTAS = args.cartas.replace(",", " ").split()
|
|
|
|
PROD.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(["docker", "cp", "scripts/fea_post_io.php", f"{CONTAINER}:/tmp/fea_post_io.php"],
|
|
capture_output=True)
|
|
|
|
if args.autor:
|
|
# Pide holgura sobre --max: parte de la cola puede caerse por contenido corto.
|
|
limite = args.max * 3 if args.max else 0
|
|
queue = backlog_ids(args.autor, args.desde, args.hasta, limite)
|
|
origen = (f"backlog autor {args.autor} ({args.desde}-{args.hasta}), "
|
|
f"voz {mm.voice_for_author(args.autor, VOICE)}")
|
|
else:
|
|
queue = build_queue()
|
|
origen = "cartas"
|
|
tope = f", tope {args.max} esta tanda" if args.max else ""
|
|
log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES [{origen}]{tope} ===")
|
|
|
|
if args.dry_run:
|
|
log("--dry-run: no sintetizo. Cola = " + (",".join(str(x) for x in queue) or "(vacía)"))
|
|
return
|
|
|
|
i = consec = ok = 0
|
|
while i < len(queue):
|
|
pid = queue[i]
|
|
if meta(pid, "fea_audio_done") == "1" or meta(pid, "fea_audio_skip") == "1":
|
|
i += 1
|
|
continue
|
|
try:
|
|
title, text, author = mm.get_post_text(pid)
|
|
except Exception as e: # noqa: BLE001
|
|
log(f"#{pid}: error leyendo ({e}); skip")
|
|
php("setflag", str(pid), "fea_audio_skip", "1")
|
|
i += 1
|
|
continue
|
|
if len(text) < MIN_CHARS:
|
|
log(f"#{pid}: sin contenido ({len(text)} car); skip")
|
|
php("setflag", str(pid), "fea_audio_skip", "1")
|
|
i += 1
|
|
continue
|
|
|
|
cloned_voice = mm.voice_for_author(author, "")
|
|
if cloned_voice:
|
|
voice = cloned_voice
|
|
elif args.allow_default_voice:
|
|
voice = VOICE
|
|
else:
|
|
log(f"#{pid}: autor sin voz digitalizada (author_id={author}); omitido")
|
|
i += 1
|
|
continue
|
|
rc = mm.t2a(mm.add_pauses(text), voice, MODEL, f"prod-{pid}")
|
|
if rc == 0:
|
|
src = mm.OUT / f"prod-{pid}.mp3"
|
|
dst = PROD / f"{pid}.mp3"
|
|
shutil.move(str(src), str(dst))
|
|
php("setaudio", str(pid), f"/wp-content/uploads/tts/{pid}.mp3", voice)
|
|
ok += 1
|
|
consec = 0
|
|
voice_tag = f" [{voice}]" if voice != VOICE else ""
|
|
log(f"#{pid} OK «{title[:45]}»{voice_tag} → tts/{pid}.mp3 (total {ok})")
|
|
i += 1
|
|
if args.max and ok >= args.max:
|
|
log(f"Tope de la tanda alcanzado ({args.max}). PARO. "
|
|
"Reanudable: la próxima ventana recalcula la cola y sigue.")
|
|
break
|
|
time.sleep(INTERVAL)
|
|
else:
|
|
consec += 1
|
|
log(f"#{pid} FALLO rc={rc} (fallo seguido {consec}/{MAX_CONSEC_FAIL})")
|
|
php("setflag", str(pid), "fea_audio_error", str(rc))
|
|
if rc in QUOTA_OR_RATE_ERRORS:
|
|
log(f"MiniMax rc={rc}: cuota/rate limit explícito. PARO sin reintentar. "
|
|
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
|
|
break
|
|
if consec >= MAX_CONSEC_FAIL:
|
|
log("Demasiados fallos seguidos no clasificados. PARO. "
|
|
"Reanudable: relanzar el script más tarde (salta lo ya hecho).")
|
|
break
|
|
time.sleep(BACKOFF) # solo errores transitorios no clasificados
|
|
|
|
log(f"=== FIN tanda. {ok} audios generados esta ejecución. ===")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|