7c5330a528
Aprovechar la cuota ociosa de MiniMax para locutar el backlog historico, empezando por Fray Marcos (2026 pendientes + 2025 = 86 articulos). - fea_post_io.php: accion `listpending <autor> <desde> <hasta> <limite> [voz]`. Devuelve los posts ES publicados de un autor que no tienen audio, mas recientes primero. Si se pasa la voz clonada del autor, tambien salen los locutados con otra voz, para rehacerlos. Esa consulta ES la idempotencia: no hay fichero de estado que mantener, lo ya locutado deja de salir solo. Relanzar o solapar no duplica ni gasta cuota. - tts_produce.py: flags --autor/--desde/--hasta/--max/--dry-run. Sin flags, el modo cartas de la carta semanal no cambia (retrocompatible). --max acota la tanda a la ventana de 5h. - tts_backlog_cron.sh: wrapper de cron con flock y gate de cuota MiniMax (semanal >=85% o ventana de 5h >=55% -> se salta la ventana, para no comerle la cuota a la carta semanal). Cola configurable por entorno. Crontab: 0 */5 * * 1,5,6,0 (lunes, viernes, sabado y domingo). SOLO LOCAL: no toca produccion. Publicar el backlog en prod (sync_audio_to_prod.py) queda bloqueado hasta despues del cutover a Hetzner (#180). De paso entran al historial los cambios de tts_produce.py que estaban sin commitear desde el 24-jul (argparse con --ids/--cartas y QUOTA_OR_RATE_ERRORS para no reintentar ante rc 2056/1039). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
8.0 KiB
Python
188 lines
8.0 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 = "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")
|
|
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
|
|
|
|
voice = mm.voice_for_author(author, VOICE)
|
|
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()
|