#!/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" # El repo NO se fija a mano: se deduce de dónde vive este script. El checkout # principal cambia de rama a menudo, y en las ramas que no llevan estos scripts # el cron se quedaba llamando a un fichero inexistente y fallaba en silencio # (pasó del 5 al 8 de agosto de 2026: 7 ventanas perdidas). Por eso el cron # apunta al worktree fijo ~/worktrees/fea-tts-backlog, y desde aquí se # autodetecta: donde esté el script, ahí está su repo. REPO="${FEA_TTS_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" 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"