Files
echo-core/scripts/transcribe_video.sh
Marius Mutu cb2eedac86 fix(transcribe): rulare detașată, ca joburile video să nu mai moară la timeout
Două linkuri Facebook trimise pe Discord n-au produs niciun răspuns: scriptul
rula în interiorul turnului de chat, iar yt-dlp + ffmpeg + whisper depășeau
constant DEFAULT_TIMEOUT (300s, src/claude_session.py:37). Procesul era omorât
la mijlocul transcrierii, de cinci ori pe patru încercări.

Efecte în cascadă, toate reparate aici:
- retry-uri oarbe: linkul 1 reprocesat de două ori (notițe duplicate), linkul 2
  niciodată atins
- notițe cu TL;DR gol — arată „gata" în index dar nu sunt
- orfani de 33MB în /tmp (trap EXIT nu rulează la SIGKILL)
- userul nu primea nici rezultat, nici eroare acționabilă

Schimbări:
- --bg (setsid nohup) + --notify <channel_id>; turnul confirmă pornirea, nu
  terminarea. send_text() nou în tools/discord_send_file.py, pentru că un job
  detașat supraviețuiește turnului și nu mai poate raporta prin on_text.
- faster-whisper int8 în locul openai-whisper: 554s audio în 113s (~5x realtime)
  vs. peste 300s. Era deja în requirements.txt; openai-whisper nici măcar nu era
  declarat. cpu_threads pe core-uri fizice, nu logice.
- limba implicită „auto": rularea forțată cu `ro` pe audio englezesc producea
  transcriere halucinată (română inventată amestecată cu CJK)
- .venv/bin/python3 explicit; whisper nu există în python-ul de sistem, scriptul
  mergea doar când era chemat cu venv-ul activat
- metadata yt-dlp pe disc, parsată o dată: descrierile Facebook conțin caractere
  de control care corup variabila de shell, iar `|| echo Unknown` masca eșecul
- titlul sare segmentele de statistici: Facebook emite „830K views · 15K
  reactions | Titlul real | Pagina", iar split('|')[0] producea notițe numite
  2026-08-31_807k-views-15k-reactions.md
- curăță workdir-urile orfane mai vechi de 60 min la pornire

.gitignore: bridge/whatsapp/auth.bak-*/ — backup-urile Baileys conțin creds.json
(chei de sesiune vii), la fel ca auth/ care era deja ignorat.

Verificat end-to-end pe ambele linkuri: titlu corect, limbă en auto-detectată,
notiță + reindex + link, trap curăță /tmp, zero orfani.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188YmDTUAzVvZDVh8JL8xSa
2026-08-31 21:52:30 +00:00

226 lines
8.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Descarcă un video (Facebook, YouTube etc.), extrage audio, transcrie cu Whisper.
#
# Usage: ./transcribe_video.sh <URL> [language] [--save-kb] [--bg] [--notify <channel_id>]
#
# language cod ISO (ro, en, ...) sau "auto" (implicit) pentru detecție automată.
# NU forța limba dacă nu ești sigur: `ro` pe audio englezesc face
# Whisper să halucineze text incoerent (incident 2026-08-31).
# --save-kb scrie notița în memory/kb/<categorie>/ și reindexează.
# --bg rulează detașat și întoarce imediat controlul. Obligatoriu când
# scriptul e chemat dintr-un turn de chat: turnul are timeout de
# 300s (DEFAULT_TIMEOUT, src/claude_session.py) iar un video lung
# îl depășește, procesul e omorât la mijloc și userul nu primește
# nimic. Cu --bg turnul răspunde imediat, jobul continuă singur.
# --notify channel ID Discord unde se raportează finalizarea (util cu --bg).
#
# Exemple:
# ./transcribe_video.sh "https://www.facebook.com/share/v/1EdPt3q2sq/"
# ./transcribe_video.sh "https://www.facebook.com/share/r/1akfPJYvTw/" ro --save-kb
# ./transcribe_video.sh "https://youtu.be/xyz" auto --save-kb --bg --notify 1471916752119009432
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# Whisper trăiește în venv, nu în python-ul de sistem. Scriptul mergea înainte
# doar din noroc, când era chemat dintr-un shell cu venv-ul deja activat.
PY="$PROJECT_ROOT/.venv/bin/python3"
KB_DIR="$PROJECT_ROOT/memory/kb"
LOG_DIR="$PROJECT_ROOT/logs/transcribe"
URL=""
LANG="auto"
SAVE_KB=0
BG=0
NOTIFY=""
# --- parse argumente (poziționale: URL, apoi limba; restul flag-uri) ---
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--save-kb) SAVE_KB=1; shift ;;
--bg) BG=1; shift ;;
--notify) NOTIFY="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
URL="${POSITIONAL[0]:-}"
LANG="${POSITIONAL[1]:-auto}"
if [[ -z "$URL" ]]; then
echo "Usage: $0 <URL> [language|auto] [--save-kb] [--bg] [--notify <channel_id>]"
exit 1
fi
export PATH="/home/moltbot/bin:$PATH"
# --- mod background: re-exec detașat, întoarce imediat ---
if [[ "$BG" == "1" ]]; then
mkdir -p "$LOG_DIR"
JOB_ID="$(date +%Y%m%d-%H%M%S)-$$"
JOB_LOG="$LOG_DIR/$JOB_ID.log"
ARGS=("$URL" "$LANG")
[[ "$SAVE_KB" == "1" ]] && ARGS+=(--save-kb)
[[ -n "$NOTIFY" ]] && ARGS+=(--notify "$NOTIFY")
setsid nohup "$0" "${ARGS[@]}" > "$JOB_LOG" 2>&1 &
echo "→ Job pornit în background: $JOB_ID (pid $!)"
echo " Log: $JOB_LOG"
[[ -n "$NOTIFY" ]] && echo " Raportez pe canalul $NOTIFY la final."
exit 0
fi
# --- curăță workdir-uri orfane (trap-ul EXIT nu rulează la SIGKILL) ---
find /tmp -maxdepth 1 -type d -name 'transcribe_*' -mmin +60 -exec rm -rf {} + 2>/dev/null || true
WORKDIR="/tmp/transcribe_$$"
mkdir -p "$WORKDIR"
trap 'rm -rf "$WORKDIR"' EXIT
notify() {
[[ -z "$NOTIFY" ]] && return 0
"$PY" "$PROJECT_ROOT/tools/discord_send_file.py" \
--channel "$NOTIFY" --text "$1" >/dev/null 2>&1 || true
}
fail() {
echo "Eroare: $1"
notify "❌ Transcriere eșuată pentru $URL — $1"
exit 1
}
echo "→ Obțin informații video..."
# JSON-ul merge pe disc, nu printr-o variabilă de shell: descrierile Facebook conțin
# caractere de control și emoji care corup variabila (zsh: "character not in range"),
# iar `|| echo Unknown` din versiunea veche masca eșecul în loc să-l semnaleze.
yt-dlp "$URL" --dump-json --no-download -q > "$WORKDIR/info.json" 2>/dev/null || echo '{}' > "$WORKDIR/info.json"
# Titlul, creatorul și durata într-o singură trecere.
# Facebook împachetează titlul ca "830K views · 15K reactions | Titlul real | Pagina | ...";
# luarea oarbă a primului segment producea notițe numite "807k-views-15k-reactions"
# (incident 2026-08-31), deci sărim segmentele de statistici și pe cel egal cu numele paginii.
eval "$("$PY" - "$WORKDIR/info.json" <<'PYMETA'
import json, re, shlex, sys
try:
d = json.load(open(sys.argv[1], encoding="utf-8"))
except Exception:
d = {}
creator = (d.get("uploader") or d.get("channel") or "").strip()
secs = int(d.get("duration") or 0)
duration = f"{secs // 60}:{secs % 60:02d}" if secs else "?"
raw = (d.get("title") or "Unknown").strip()
STATS = re.compile(r"^[\d.,]+\s*[KMB]?\s*(views|reactions|comments|shares|likes)\b", re.I)
parts = [p.strip() for p in raw.split("|") if p.strip()]
title = next(
(p for p in parts if not STATS.match(p) and p.lower() != creator.lower() and len(p) > 3),
parts[0] if parts else raw,
)
title = " ".join(title.split())
if len(title) > 80:
title = title[:77].rstrip() + "..."
for name, val in (("TITLE_SHORT", title), ("CREATOR", creator), ("DURATION", duration)):
print(f"{name}={shlex.quote(val)}")
PYMETA
)"
echo "→ Descarc video: $TITLE_SHORT..."
yt-dlp "$URL" -o "$WORKDIR/video.%(ext)s" --no-playlist -q || fail "descărcarea a eșuat"
VIDEO_FILE=$(ls "$WORKDIR"/video.* 2>/dev/null | head -1)
[[ -z "$VIDEO_FILE" ]] && fail "descărcarea a eșuat (niciun fișier)"
echo "→ Extrag audio..."
ffmpeg -i "$VIDEO_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WORKDIR/audio.wav" -y -loglevel error \
|| fail "extragerea audio a eșuat"
# cpu_threads = core-uri FIZICE; hyperthread-urile încetinesc int8 compute-bound.
THREADS=$(lscpu -p=CORE 2>/dev/null | grep -v '^#' | sort -u | wc -l)
[[ -z "$THREADS" || "$THREADS" -lt 1 ]] && THREADS=4
echo "→ Transcriu cu faster-whisper (model: small, int8, ${THREADS} threads, limbă: $LANG)..."
# faster-whisper (declarat în requirements.txt) în locul openai-whisper: ~5x realtime
# pe acest CPU vs. peste 300s pentru 9 minute de audio — exact ce depășea timeout-ul.
"$PY" - "$WORKDIR/audio.wav" "$LANG" "$THREADS" > "$WORKDIR/transcript.txt" 2>"$WORKDIR/whisper.err" <<'PYEOF' || fail "transcrierea a eșuat ($(tail -1 "$WORKDIR/whisper.err" 2>/dev/null))"
import sys
from faster_whisper import WhisperModel
wav, lang, threads = sys.argv[1], sys.argv[2], int(sys.argv[3])
model = WhisperModel("small", device="cpu", compute_type="int8", cpu_threads=threads)
segments, info = model.transcribe(wav, language=None if lang == "auto" else lang, vad_filter=True)
text = " ".join(s.text.strip() for s in segments)
print(text)
print(f"[detected_language={info.language}]", file=sys.stderr)
PYEOF
TRANSCRIPT=$(cat "$WORKDIR/transcript.txt")
DETECTED=$(grep -oP 'detected_language=\K\w+' "$WORKDIR/whisper.err" 2>/dev/null || echo "$LANG")
[[ -z "$TRANSCRIPT" ]] && fail "transcrierea a ieșit goală"
echo ""
echo "=== $TITLE_SHORT ==="
echo "$TRANSCRIPT"
echo ""
echo "✓ Transcriere completă (limbă: $DETECTED)."
if [[ "$SAVE_KB" == "1" ]]; then
DATE=$(date +%Y-%m-%d)
SLUG=$(echo "$TITLE_SHORT" | "$PY" -c "
import sys, re, unicodedata
s = unicodedata.normalize('NFD', sys.stdin.read().strip())
s = ''.join(c for c in s if unicodedata.category(c) != 'Mn').lower()
print(re.sub(r'[^a-z0-9]+', '-', s).strip('-')[:50])
")
[[ -z "$SLUG" ]] && SLUG="video-$(date +%H%M%S)"
if echo "$URL" | grep -qi "facebook\.com"; then
CATEGORY="facebook"; FORMAT="Reel (~${DURATION} min)"
elif echo "$URL" | grep -qi "youtube\.com\|youtu\.be"; then
CATEGORY="youtube"; FORMAT="Video (~${DURATION} min)"
else
CATEGORY="media"; FORMAT="Video (~${DURATION} min)"
fi
NOTE_DIR="$KB_DIR/$CATEGORY"
mkdir -p "$NOTE_DIR"
NOTE_FILE="$NOTE_DIR/${DATE}_${SLUG}.md"
cat > "$NOTE_FILE" <<NOTEEOF
# $TITLE_SHORT
**Sursa:** $URL
**Data:** $DATE
**Creator:** $CREATOR
**Format:** $FORMAT
**Limbă:** $DETECTED
**Tags:** @coaching
---
## TL;DR
<!-- Completează un rezumat de 2-3 rânduri -->
---
## Transcrierea
$TRANSCRIPT
NOTEEOF
echo ""
echo "→ Notiță salvată: $NOTE_FILE"
echo "→ Reindexez KB..."
"$PY" "$PROJECT_ROOT/tools/update_notes_index.py" >/dev/null
KB_LINK="https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/$CATEGORY/${DATE}_${SLUG}.md"
echo "✓ KB actualizat. Link: $KB_LINK"
notify "✅ Transcriere gata: **$TITLE_SHORT** (${DURATION} min, $DETECTED)
$KB_LINK
⚠️ TL;DR-ul e gol — completează-l."
else
notify "✅ Transcriere gata: **$TITLE_SHORT** (${DURATION} min, $DETECTED). Rulat fără --save-kb."
fi