Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 159080f0c8 | |||
| e4d2073eeb | |||
| 6dc847a151 | |||
| e1a14ec3fc | |||
| c5dcbdb997 | |||
| 4bc86b9494 | |||
| 7c5330a528 |
+58
-1
@@ -3,11 +3,16 @@
|
||||
* IO mínimo de posts WP para el reprocesador EN.
|
||||
* get <id> -> escribe /tmp/fea_es.json {title, content, status}
|
||||
* update <id> <titlef> <bodyf> -> actualiza post_title/post_content desde ficheros
|
||||
* listpending <autor> ... -> 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') {
|
||||
@@ -19,6 +24,8 @@ if ($action === 'get') {
|
||||
'title' => $p->post_title,
|
||||
'content' => $p->post_content,
|
||||
'status' => $p->post_status,
|
||||
'post_type' => $p->post_type,
|
||||
'post_name' => $p->post_name,
|
||||
'author' => (int)$p->post_author,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
exit(0);
|
||||
@@ -70,5 +77,55 @@ if ($action === 'unsetaudio') { // unsetaudio <id> (rollback: despublica el au
|
||||
exit(0);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "uso: get|update|getmeta|setaudio|setflag|unsetaudio\n");
|
||||
if ($action === 'listpending') { // listpending <autor> <desde> <hasta> <limite> [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 <autor> <desde> <hasta> <limite> [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);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reporte diario del backlog de TTS de Fe Adulta (issue rafa/feadulta#188).
|
||||
|
||||
SOLO LECTURA: no genera audio ni toca la BD. Cuenta lo hecho en las últimas 24 h,
|
||||
lo que queda por autor, la cuota de MiniMax y las ventanas que se saltaron.
|
||||
|
||||
Entregado por Hermes en modo no-agent (el stdout va directo a Rafa).
|
||||
Silencio deliberado si no hay nada que contar y todo está en orden.
|
||||
|
||||
Hermes solo ejecuta scripts que resuelvan DENTRO de ~/.hermes/scripts, y resuelve
|
||||
los symlinks antes de comprobarlo: un enlace a este fichero se bloquea. Por eso
|
||||
~/.hermes/scripts/fea_tts_backlog_report.py es un wrapper que lo llama por
|
||||
subproceso (mismo patrón que feadulta_ga4_daily.py). Este de aquí es el único
|
||||
sitio donde se edita la lógica.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path("/home/rafa/joomla-migration")
|
||||
TTS_DIR = REPO / "wordpress/wp-content/uploads/tts"
|
||||
LOG_DIR = Path("/tmp/fea-tts-backlog")
|
||||
QUOTA = Path("/home/rafa/ytsummaries/scripts/quota.py")
|
||||
CONTAINER = "wordpress-web"
|
||||
CRON = "/home/rafa/joomla-migration/scripts/tts_backlog_cron.sh"
|
||||
|
||||
# WP user_id -> (nombre, voz clonada). Mismo mapping que AUTHOR_VOICES en
|
||||
# scripts/minimax_tts.py; si se clona una voz nueva, añadirla en los dos sitios.
|
||||
AUTORES = {
|
||||
382: ("Fray Marcos", "FrayMarcosFeadulta2026"),
|
||||
383: ("Pagola", "PagolaFeadulta2026"),
|
||||
774: ("Sicre", "SicreFeadulta2026"),
|
||||
386: ("Arregi", "ArregiFeadulta2026"),
|
||||
}
|
||||
|
||||
# Autor y rango que está locutando el cron ahora mismo (los valores por defecto
|
||||
# de tts_backlog_cron.sh). Solo afecta a qué línea se marca como "en curso".
|
||||
ACTIVO = (382, 2025, 2026)
|
||||
|
||||
|
||||
def php(*args: str) -> str:
|
||||
"""fea_post_io.php dentro del contenedor. stderr fuera: WP escupe warnings."""
|
||||
r = subprocess.run(
|
||||
["docker", "exec", CONTAINER, "php", "/tmp/fea_post_io.php", *args],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def pendientes(autor: int, desde: int, hasta: int, voz: str) -> int:
|
||||
salida = php("listpending", str(autor), str(desde), str(hasta), "0", voz)
|
||||
return len([x for x in salida.split() if x.strip().isdigit()])
|
||||
|
||||
|
||||
def hechos_24h() -> dict[int, list[tuple[int, str]]]:
|
||||
"""mp3 escritos en las últimas 24 h, agrupados por autor.
|
||||
|
||||
El mtime del fichero es la fuente: es lo que se acaba de escribir, sin
|
||||
depender de metas que puedan venir de una sincronización antigua.
|
||||
"""
|
||||
corte = time.time() - 24 * 3600
|
||||
por_autor: dict[int, list[tuple[int, str]]] = {}
|
||||
if not TTS_DIR.is_dir():
|
||||
return por_autor
|
||||
recientes = [f for f in TTS_DIR.glob("*.mp3")
|
||||
if f.stat().st_mtime >= corte and f.stem.isdigit()]
|
||||
for f in sorted(recientes, key=lambda p: p.stat().st_mtime):
|
||||
pid = int(f.stem)
|
||||
# No hay meta de autor; la voz sí se guarda (fea_audio_voice) y basta
|
||||
# para atribuirlo, porque cada autor clonado tiene la suya.
|
||||
voz = php("getmeta", str(pid), "fea_audio_voice").strip()
|
||||
aid = next((a for a, (_, v) in AUTORES.items() if v == voz), 0)
|
||||
por_autor.setdefault(aid, []).append((pid, voz or "?"))
|
||||
return por_autor
|
||||
|
||||
|
||||
def cuota() -> tuple[int | None, int | None]:
|
||||
try:
|
||||
r = subprocess.run([sys.executable, str(QUOTA), "--json", "--no-local"],
|
||||
capture_output=True, text=True, timeout=40)
|
||||
d = json.loads(r.stdout)
|
||||
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
|
||||
return m.get("five_h_pct"), m.get("week_pct")
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None
|
||||
|
||||
|
||||
def logs_24h() -> tuple[int, int, list[str]]:
|
||||
"""(ventanas ejecutadas, ventanas saltadas por gate, líneas de fallo)."""
|
||||
hoy = datetime.now()
|
||||
ficheros = [LOG_DIR / f"cron-{(hoy - timedelta(days=d)).strftime('%Y-%m-%d')}.log"
|
||||
for d in (0, 1)]
|
||||
corridas = saltadas = 0
|
||||
fallos: list[str] = []
|
||||
corte = hoy - timedelta(hours=24)
|
||||
for f in ficheros:
|
||||
if not f.is_file():
|
||||
continue
|
||||
for linea in f.read_text(errors="replace").splitlines():
|
||||
m = re.match(r"\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\]", linea)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < corte:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
if "cron TTS backlog start" in linea:
|
||||
corridas += 1
|
||||
elif "ABORT:" in linea:
|
||||
saltadas += 1
|
||||
fallos.append(linea.split("ABORT:", 1)[1].strip())
|
||||
elif "FALLO rc=" in linea or "listpending falló" in linea:
|
||||
fallos.append(linea.split("] ", 1)[-1].strip())
|
||||
return corridas, saltadas, fallos
|
||||
|
||||
|
||||
def _campo_cron(campo: str, valores: range) -> set[int]:
|
||||
"""Expande un campo de crontab ('*', '*/5', '1,5,6,0', '0-4') a un set."""
|
||||
if campo == "*":
|
||||
return set(valores)
|
||||
out: set[int] = set()
|
||||
for trozo in campo.split(","):
|
||||
paso = 1
|
||||
if "/" in trozo:
|
||||
trozo, p = trozo.split("/", 1)
|
||||
paso = int(p)
|
||||
if trozo == "*":
|
||||
base = list(valores)
|
||||
elif "-" in trozo:
|
||||
a, b = (int(x) for x in trozo.split("-", 1))
|
||||
base = list(range(a, b + 1))
|
||||
else:
|
||||
base = [int(trozo)]
|
||||
out.update(base[::paso] if paso > 1 else base)
|
||||
return out
|
||||
|
||||
|
||||
def previstas_24h(ahora: datetime | None = None) -> int | None:
|
||||
"""Cuántas ventanas TENÍA que haber corrido el cron en las últimas 24 h.
|
||||
|
||||
Sin esto, los martes/miércoles/jueves (días de carta, sin cron) el informe
|
||||
daba la alarma de 'ninguna ventana dejó rastro' estando todo correcto. La
|
||||
verdad está en el crontab, no aquí: si Rafa cambia los días, esto le sigue.
|
||||
"""
|
||||
try:
|
||||
linea = next(
|
||||
l for l in subprocess.run(["crontab", "-l"], text=True, capture_output=True,
|
||||
check=True).stdout.splitlines()
|
||||
if "tts_backlog_cron.sh" in l and not l.lstrip().startswith("#"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
campos = linea.split(None, 5)
|
||||
if len(campos) < 5:
|
||||
return None
|
||||
minutos = _campo_cron(campos[0], range(60))
|
||||
horas = _campo_cron(campos[1], range(24))
|
||||
dows = {d % 7 for d in _campo_cron(campos[4], range(7))} # cron: 0 y 7 = domingo
|
||||
|
||||
ahora = ahora or datetime.now()
|
||||
n = 0
|
||||
for h in range(25):
|
||||
t = (ahora - timedelta(hours=h)).replace(second=0, microsecond=0)
|
||||
for m in minutos:
|
||||
cand = t.replace(minute=m)
|
||||
if not (ahora - timedelta(hours=24) < cand <= ahora):
|
||||
continue
|
||||
if cand.hour in horas and (cand.weekday() + 1) % 7 in dows:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def main() -> int:
|
||||
hechos = hechos_24h()
|
||||
total = sum(len(v) for v in hechos.values())
|
||||
corridas, saltadas, fallos = logs_24h()
|
||||
p5, pw = cuota()
|
||||
|
||||
lineas = [f"Fe Adulta — backlog TTS (últimas 24 h): {total} audios"]
|
||||
|
||||
if hechos:
|
||||
for aid, items in sorted(hechos.items(), key=lambda kv: -len(kv[1])):
|
||||
nombre = AUTORES.get(aid, ("otros", ""))[0]
|
||||
lineas.append(f" {nombre}: {len(items)} "
|
||||
f"({', '.join('#%d' % p for p, _ in items[:8])}"
|
||||
f"{'…' if len(items) > 8 else ''})")
|
||||
|
||||
lineas.append("")
|
||||
lineas.append("Pendientes:")
|
||||
for aid, (nombre, voz) in AUTORES.items():
|
||||
falta_todo = pendientes(aid, 0, 9999, voz)
|
||||
marca = ""
|
||||
if aid == ACTIVO[0]:
|
||||
falta_lote = pendientes(aid, ACTIVO[1], ACTIVO[2], voz)
|
||||
marca = f" ← en curso, {falta_lote} del lote {ACTIVO[1]}-{ACTIVO[2]}"
|
||||
lineas.append(f" {nombre}: {falta_todo}{marca}")
|
||||
|
||||
lineas.append("")
|
||||
if p5 is None:
|
||||
lineas.append("Cuota MiniMax: no se pudo leer")
|
||||
else:
|
||||
lineas.append(f"Cuota MiniMax: 5h {p5:.0f}% · semana {pw:.0f}%")
|
||||
previstas = previstas_24h()
|
||||
de = "" if previstas is None else f" de {previstas} previstas"
|
||||
lineas.append(f"Ventanas 24 h: {corridas} ejecutadas{de}, {saltadas} saltadas por cuota")
|
||||
if previstas == 0:
|
||||
lineas.append(" (sin ventanas previstas: día sin cron, toca carta)")
|
||||
|
||||
# Una ventana que ni arranca ni se salta es un fallo mudo: el cron no llegó a
|
||||
# correr (bit +x, WSL apagada...). Es exactamente lo que pasó el 2-ago.
|
||||
# Solo es alarma si de verdad tocaba correr; si no, es martes.
|
||||
if corridas == 0 and saltadas == 0 and previstas != 0:
|
||||
lineas.append("")
|
||||
lineas.append("⚠️ Ninguna ventana dejó rastro en 24 h. Si tocaba que corriera, "
|
||||
f"comprobar: crontab -l | grep tts_backlog · ls -l {CRON}")
|
||||
|
||||
if fallos:
|
||||
lineas.append("")
|
||||
lineas.append("Avisos:")
|
||||
for f in fallos[:10]:
|
||||
lineas.append(f" {f}")
|
||||
|
||||
print("\n".join(lineas))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -93,7 +93,15 @@ def get_post_text(pid):
|
||||
check=True, capture_output=True)
|
||||
subprocess.run(["docker", "cp", f"{CONTAINER}:/tmp/fea_es.json", "/tmp/fea_es.json"], check=True)
|
||||
d = json.load(open("/tmp/fea_es.json"))
|
||||
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", d["content"])
|
||||
# Hard gate: pages and operational/accounting entries are never TTS input.
|
||||
# This also protects explicit --ids queues, which bypass author-backlog SQL.
|
||||
raw_content = d.get("content", "")
|
||||
blocked_markers = ("fea-don-wrap", "fea-ledger", "Haz tu donación")
|
||||
if d.get("post_type") != "post":
|
||||
raise ValueError(f"post #{pid} no es un artículo (post_type={d.get('post_type')!r}); TTS excluido")
|
||||
if d.get("post_name") == "numeros" or any(marker in raw_content for marker in blocked_markers):
|
||||
raise ValueError(f"post #{pid} es contenido de cuentas/donaciones; TTS excluido")
|
||||
raw = re.sub(r"(?i)</p>|<br\s*/?>|</h[1-6]>", "\n", raw_content)
|
||||
raw = re.sub(r"<[^>]+>", "", raw)
|
||||
raw = re.sub(r"\[[^\]]+\]", "", raw)
|
||||
raw = html.unescape(raw)
|
||||
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/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) Mide la cuota y CALCULA el tamaño de la tanda para llenar la ventana hasta
|
||||
# el objetivo. Un tamaño fijo desaprovecha: deja la ventana a medias cuando
|
||||
# está libre, y no cabe cuando está medio usada. Al dimensionar por hueco
|
||||
# libre, además, deja de importar dónde caiga el cron respecto a la ventana.
|
||||
# 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}"
|
||||
|
||||
# Coste medido de un audio, en DÉCIMAS de punto porcentual (aritmética entera en
|
||||
# bash). Dos tandas de 10 el 2026-08-02: la ventana de 5 h fue 0→45→88 (~4,4 pts
|
||||
# por audio) y la semanal 10→14→18 (~0,4 pts). Artículos de Fray Marcos de
|
||||
# 4.000-5.000 caracteres; si se locuta a otro autor con textos mucho más largos,
|
||||
# revisar estos números con un par de tandas.
|
||||
COSTE_5H="${FEA_TTS_COSTE_5H:-44}"
|
||||
COSTE_SEM="${FEA_TTS_COSTE_SEM:-4}"
|
||||
|
||||
# Objetivos de llenado (%). Dejar cuota sin usar al llegar el reset es tirarla.
|
||||
OBJ_5H="${FEA_TTS_OBJ_5H:-90}"
|
||||
OBJ_SEM="${FEA_TTS_OBJ_SEM:-85}"
|
||||
|
||||
# La semanal NO se gasta a tope en cada ventana: se REPARTE entre las ventanas
|
||||
# que quedan hasta su reset. Llenar cada ventana de 5 h al 90 % son ~8 puntos de
|
||||
# semanal, y hay 20 ventanas activas por semana: 160 puntos para un presupuesto
|
||||
# de 85. Sin reparto, domingo y lunes se lo comen y el fin de semana se queda a
|
||||
# cero. Con reparto sale ~10 audios por ventana, y en la última ventana de la
|
||||
# semana el reparto vale todo lo que sobre, así que tampoco queda cuota sin usar.
|
||||
|
||||
# Tope de seguridad por tanda y override manual (FEA_TTS_BATCH fija el tamaño y
|
||||
# se salta el cálculo).
|
||||
MAX_BATCH="${FEA_TTS_MAX_BATCH:-25}"
|
||||
|
||||
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) ===" >> "$LOG"
|
||||
|
||||
# 1) Medir la cuota, y contar cuántas ventanas del cron quedan hasta que se
|
||||
# reinicie la semanal — es el denominador del reparto.
|
||||
read -r PCT5 PCTW HSEM VENTANAS <<< "$(python3 "$QUOTA" --json --no-local 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# DEBEN COINCIDIR CON EL CRONTAB: 0 */5 * * 1,5,6,0
|
||||
HORAS = {0, 5, 10, 15, 20}
|
||||
DIAS = {0, 4, 5, 6} # lun, vie, sab, dom en datetime.weekday()
|
||||
|
||||
def pct(v):
|
||||
# OJO: 0.0 es un valor legítimo (ventana entera libre) y es falsy en Python.
|
||||
# Un `v or 100` aquí aborta la tanda justo cuando hay toda la cuota disponible.
|
||||
return int(v) if v is not None else 100
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
m = next(p for p in d["providers"] if p["provider"] == "minimax" and p.get("ok"))
|
||||
horas, ventanas = 999, 1
|
||||
try:
|
||||
fin = datetime.fromisoformat(m["week_reset"]).astimezone()
|
||||
ahora = datetime.now().astimezone()
|
||||
horas = max(int((fin - ahora).total_seconds() // 3600), 0)
|
||||
# Esta corrida cuenta como una; se suman las que quedan programadas.
|
||||
t = (ahora + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
|
||||
while t < fin:
|
||||
if t.hour in HORAS and t.weekday() in DIAS:
|
||||
ventanas += 1
|
||||
t += timedelta(hours=1)
|
||||
except Exception:
|
||||
pass
|
||||
print(pct(m.get("five_h_pct")), pct(m.get("week_pct")), horas, ventanas)
|
||||
except Exception:
|
||||
print(100, 100, 999, 1) # sin lectura fiable de cuota, no se gasta
|
||||
' 2>/dev/null || echo "100 100 999 1")"
|
||||
[ "${VENTANAS:-0}" -lt 1 ] && VENTANAS=1
|
||||
|
||||
# 2) Dimensionar la tanda. Dos límites, manda el más restrictivo:
|
||||
# - la ventana de 5 h: se llena hasta OBJ_5H aquí y ahora;
|
||||
# - la semanal: solo la parte que le toca a esta ventana de lo que queda.
|
||||
CABE_5H=$(( ((OBJ_5H - PCT5) * 10) / COSTE_5H ))
|
||||
CABE_SEM=$(( (((OBJ_SEM - PCTW) * 10) / VENTANAS) / COSTE_SEM ))
|
||||
[ "$CABE_5H" -lt 0 ] && CABE_5H=0
|
||||
[ "$CABE_SEM" -lt 0 ] && CABE_SEM=0
|
||||
|
||||
BATCH=$CABE_5H
|
||||
[ "$CABE_SEM" -lt "$BATCH" ] && BATCH=$CABE_SEM
|
||||
[ "$BATCH" -gt "$MAX_BATCH" ] && BATCH=$MAX_BATCH
|
||||
# Override manual: fija el tamaño y se salta todo el cálculo.
|
||||
[ -n "${FEA_TTS_BATCH:-}" ] && BATCH="$FEA_TTS_BATCH"
|
||||
|
||||
echo "[$(ts)] MiniMax 5h=${PCT5}% semana=${PCTW}% · reset semanal en ${HSEM}h, ${VENTANAS} ventanas por delante" >> "$LOG"
|
||||
echo "[$(ts)] Caben: ${CABE_5H} por la de 5h, ${CABE_SEM} por el reparto semanal → tanda de ${BATCH}" >> "$LOG"
|
||||
|
||||
if [ "$BATCH" -lt 1 ]; then
|
||||
echo "[$(ts)] ABORT: no cabe ni un audio sin pasarse del objetivo; salto esta ventana." >> "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3) Tanda. tts_produce.py ya para solo ante rc 2056/1039 (cuota/rate limit).
|
||||
# Una tanda larga puede desbordar el reset de 5 h (~2,6 min por audio): no pasa
|
||||
# nada, lo que sobra lo absorbe la ventana siguiente y la próxima corrida la
|
||||
# mide y se redimensiona sola. Cortar por tiempo dejaría cuota sin gastar.
|
||||
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
|
||||
|
||||
# 4) 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"
|
||||
+72
-7
@@ -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. ===")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user