diff --git a/scripts/fea_post_io.php b/scripts/fea_post_io.php index 7f4cf22..ab8579b 100644 --- a/scripts/fea_post_io.php +++ b/scripts/fea_post_io.php @@ -3,11 +3,16 @@ * IO mínimo de posts WP para el reprocesador EN. * get -> escribe /tmp/fea_es.json {title, content, status} * update -> actualiza post_title/post_content desde ficheros + * listpending ... -> cola de backlog TTS pendiente (ver abajo) * Carga wp-load; portable (local docker o prod via FEA_WP_LOAD). */ $WP = getenv('FEA_WP_LOAD') ?: '/var/www/html/wp-load.php'; require $WP; +// Por debajo de esto el post_content no da para locutar (prefiltro barato en SQL; +// tts_produce.py vuelve a medir el texto ya extraído y marca fea_audio_skip). +const FEA_TTS_MIN_CONTENT = 400; + $action = $argv[1] ?? ''; if ($action === 'get') { @@ -70,5 +75,55 @@ if ($action === 'unsetaudio') { // unsetaudio (rollback: despublica el au exit(0); } -fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n"); +if ($action === 'listpending') { // listpending [voz_esperada] + // Cola del backlog de TTS: posts ES publicados de un autor que todavía no tienen + // audio. La consulta ES la idempotencia — no hay fichero de estado que mantener: + // lo ya locutado deja de salir solo. Si se pasa la voz clonada del autor, también + // salen los que se locutaron en su día con otra voz (p. ej. Nico), para rehacerlos. + $autor = (int)($argv[2] ?? 0); + $desde = (int)($argv[3] ?? 0); + $hasta = (int)($argv[4] ?? 9999); + $limite = (int)($argv[5] ?? 50); + $voz = (string)($argv[6] ?? ''); + if (!$autor) { + fwrite(STDERR, "uso: listpending [voz_esperada]\n"); + exit(2); + } + if ($limite <= 0) { $limite = 100000; } + + global $wpdb; + $es = (int)$wpdb->get_var(" + SELECT tt.term_taxonomy_id FROM {$wpdb->term_taxonomy} tt + JOIN {$wpdb->terms} t ON t.term_id = tt.term_id + WHERE tt.taxonomy = 'language' AND t.slug = 'es' LIMIT 1"); + if (!$es) { fwrite(STDERR, "no encuentro el idioma 'es' de polylang\n"); exit(1); } + + $ids = $wpdb->get_col($wpdb->prepare(" + SELECT p.ID + FROM {$wpdb->posts} p + JOIN {$wpdb->term_relationships} tr + ON tr.object_id = p.ID AND tr.term_taxonomy_id = %d + LEFT JOIN {$wpdb->postmeta} done + ON done.post_id = p.ID AND done.meta_key = 'fea_audio_done' + LEFT JOIN {$wpdb->postmeta} voz + ON voz.post_id = p.ID AND voz.meta_key = 'fea_audio_voice' + LEFT JOIN {$wpdb->postmeta} skip + ON skip.post_id = p.ID AND skip.meta_key = 'fea_audio_skip' + WHERE p.post_author = %d + AND p.post_type = 'post' + AND p.post_status = 'publish' + AND YEAR(p.post_date) BETWEEN %d AND %d + AND CHAR_LENGTH(p.post_content) >= %d + AND (skip.meta_value IS NULL OR skip.meta_value <> '1') + AND (done.meta_value IS NULL + OR done.meta_value <> '1' + OR (%s <> '' AND COALESCE(voz.meta_value, '') <> %s)) + ORDER BY p.post_date DESC + LIMIT %d", $es, $autor, $desde, $hasta, FEA_TTS_MIN_CONTENT, $voz, $voz, $limite)); + + foreach ($ids as $id) { echo (int)$id . "\n"; } + exit(0); +} + +fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio|listpending\n"); exit(2); diff --git a/scripts/tts_backlog_cron.sh b/scripts/tts_backlog_cron.sh new file mode 100644 index 0000000..2afcc9b --- /dev/null +++ b/scripts/tts_backlog_cron.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Cron del backlog de TTS por autor (issue rafa/feadulta#188). +# Corre cada 5 h los lunes, viernes, sábados y domingos, aprovechando la cuota +# ociosa de MiniMax para locutar artículos antiguos. Por ventana: +# 1) Gate de cuota MiniMax: si la semana va alta, o si la ventana de 5 h ya la +# está usando alguien (la carta semanal, el backfill de summaraise...), se +# salta. El trabajo de fondo nunca le come la cuota al trabajo con dueño. +# 2) tts_produce.py --autor ... --max N: la cola sale de la BD, así que esto es +# idempotente por construcción — lo ya locutado no vuelve a salir. +# SOLO LOCAL: no toca producción. Publicar en prod es sync_audio_to_prod.py, que +# está bloqueado hasta después del cutover a Hetzner (#180). +# flock evita solapes si una ventana se alargara. Log por día. +set -uo pipefail + +export PATH="/home/rafa/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +export HOME="/home/rafa" + +REPO="/home/rafa/joomla-migration" +PY="/home/rafa/tts-local/xtts-venv/bin/python" +QUOTA="/home/rafa/ytsummaries/scripts/quota.py" +WORK="/tmp/fea-tts-backlog" +LOG="$WORK/cron-$(date +%F).log" +LOCK="$WORK/cron.lock" + +# Cola: cambiar aquí (o por entorno) para pasar de un autor a otro. +# 382 Fray Marcos · 383 Pagola · 774 Sicre · 386 Arregi +AUTOR="${FEA_TTS_AUTOR:-382}" +DESDE="${FEA_TTS_DESDE:-2025}" +HASTA="${FEA_TTS_HASTA:-2026}" +BATCH="${FEA_TTS_BATCH:-10}" + +# Gates de cuota (%). El semanal es el techo real del backlog. El de 5 h es +# "¿cabe esta tanda en lo que queda de ventana?": medido, cada audio gasta ~3-4 % +# de la ventana de 5 h, así que un batch de 10 pide ~40 %. Gate en 55 % deja sitio +# y frena solo cuando la ventana ya la está usando otro (la carta semanal son ~28 +# audios de golpe) o cuando la está llenando este mismo cron. Si se sube BATCH, +# subir también este número o se saltarán ventanas. +MAX_SEMANA="${FEA_TTS_MAX_SEMANA:-85}" +MAX_5H="${FEA_TTS_MAX_5H:-55}" + +mkdir -p "$WORK" +cd "$REPO" || exit 1 + +ts() { date +'%F %T'; } +exec 9>"$LOCK" +if ! flock -n 9; then + echo "[$(ts)] otra corrida en curso, salto." >> "$LOG"; exit 0 +fi + +echo "[$(ts)] === cron TTS backlog start (autor=$AUTOR $DESDE-$HASTA batch=$BATCH) ===" >> "$LOG" + +# 1) Gate de cuota MiniMax (ventana de 5 h y semanal) +read -r PCT5 PCTW <<< "$(python3 "$QUOTA" --json --no-local 2>/dev/null | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) + m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok")) + print(int(m.get("five_h_pct") or 100), int(m.get("week_pct") or 100)) +except Exception: + print(100, 100) # sin lectura fiable de cuota, no se gasta +' 2>/dev/null || echo "100 100")" +echo "[$(ts)] MiniMax 5h=${PCT5}% semana=${PCTW}%" >> "$LOG" + +if [ "${PCTW:-100}" -ge "$MAX_SEMANA" ]; then + echo "[$(ts)] ABORT: cuota semanal ${PCTW}% >= ${MAX_SEMANA}%, salto esta ventana." >> "$LOG" + exit 0 +fi +if [ "${PCT5:-100}" -ge "$MAX_5H" ]; then + echo "[$(ts)] ABORT: ventana de 5h al ${PCT5}% >= ${MAX_5H}%, no cabe la tanda; salto." >> "$LOG" + exit 0 +fi + +# 2) Tanda. tts_produce.py ya para solo ante rc 2056/1039 (cuota/rate limit). +echo "[$(ts)] tts_produce --autor $AUTOR --desde $DESDE --hasta $HASTA --max $BATCH ..." >> "$LOG" +"$PY" scripts/tts_produce.py --autor "$AUTOR" --desde "$DESDE" --hasta "$HASTA" \ + --max "$BATCH" >> "$LOG" 2>&1 + +# 3) Cuánto queda tras la tanda (recuento fresco de la BD, barato y sin cuota). +QUEDAN="$("$PY" scripts/tts_produce.py --autor "$AUTOR" --desde "$DESDE" --hasta "$HASTA" \ + --dry-run 2>/dev/null | sed -n 's/.*Cola: \([0-9]*\) posts.*/\1/p' | tail -1)" +echo "[$(ts)] === cron TTS backlog done. Pendientes autor $AUTOR $DESDE-$HASTA: ${QUEDAN:-?} ===" >> "$LOG" diff --git a/scripts/tts_produce.py b/scripts/tts_produce.py index fb3da2a..934b02c 100644 --- a/scripts/tts_produce.py +++ b/scripts/tts_produce.py @@ -6,9 +6,18 @@ 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 @@ -26,8 +35,9 @@ 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 fallo de cuota antes de reintentar -MAX_CONSEC_FAIL = 3 # fallos seguidos → parar (cuota probablemente agotada) +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 @@ -52,6 +62,18 @@ 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() @@ -67,11 +89,46 @@ def build_queue(): 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) - queue = build_queue() - log(f"=== INICIO orquestador TTS. Cola: {len(queue)} posts ES del gap ===") + + 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): @@ -104,16 +161,24 @@ def main(): 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 consec >= MAX_CONSEC_FAIL: - log("Demasiados fallos seguidos → cuota agotada probablemente. PARO. " + 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 - time.sleep(BACKOFF) # reintenta el mismo post tras esperar + 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. ===")