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
This commit is contained in:
@@ -1,119 +1,201 @@
|
||||
#!/usr/bin/env bash
|
||||
# Descarcă un video (Facebook, YouTube etc.), extrage audio, transcrie cu Whisper.
|
||||
# Usage: ./transcribe_video.sh <URL> [language]
|
||||
# ./transcribe_video.sh <URL> [language] --save-kb
|
||||
#
|
||||
# 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" ro
|
||||
# ./transcribe_video.sh "https://youtu.be/xyz" auto --save-kb --bg --notify 1471916752119009432
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
URL="${1:-}"
|
||||
LANG="${2:-en}"
|
||||
SAVE_KB=0
|
||||
|
||||
# Parse flags
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--save-kb" ]]; then
|
||||
SAVE_KB=1
|
||||
fi
|
||||
done
|
||||
|
||||
WORKDIR="/tmp/transcribe_$$"
|
||||
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 (default: en)] [--save-kb]"
|
||||
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..."
|
||||
INFO_JSON=$(yt-dlp "$URL" --dump-json --no-download -q 2>/dev/null || echo "{}")
|
||||
TITLE=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('title','Unknown'))" 2>/dev/null || echo "Unknown")
|
||||
# Trunchiază titlul la primul '|' sau la 80 caractere (Facebook posts au titlul = descriere)
|
||||
TITLE_SHORT=$(echo "$TITLE" | python3 -c "
|
||||
import sys
|
||||
s = sys.stdin.read().strip()
|
||||
if '|' in s:
|
||||
s = s.split('|')[0].strip()
|
||||
if len(s) > 80:
|
||||
s = s[:77].rstrip() + '...'
|
||||
print(s)
|
||||
")
|
||||
CREATOR=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('uploader') or d.get('channel') or '')" 2>/dev/null || echo "")
|
||||
DURATION=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=d.get('duration',0); print(f'{s//60}:{s%60:02d}')" 2>/dev/null || echo "?")
|
||||
# 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
|
||||
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)
|
||||
if [[ -z "$VIDEO_FILE" ]]; then
|
||||
echo "Eroare: descărcarea a eșuat."
|
||||
exit 1
|
||||
fi
|
||||
[[ -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
|
||||
ffmpeg -i "$VIDEO_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WORKDIR/audio.wav" -y -loglevel error \
|
||||
|| fail "extragerea audio a eșuat"
|
||||
|
||||
echo "→ Transcriu cu Whisper (model: small, limbă: $LANG)..."
|
||||
TRANSCRIPT=$(python3 -c "
|
||||
import whisper
|
||||
model = whisper.load_model('small')
|
||||
result = model.transcribe('$WORKDIR/audio.wav', language='$LANG')
|
||||
print(result['text'])
|
||||
" 2>/dev/null)
|
||||
# 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 ==="
|
||||
echo "=== $TITLE_SHORT ==="
|
||||
echo "$TRANSCRIPT"
|
||||
echo ""
|
||||
echo "✓ Transcriere completă."
|
||||
echo "✓ Transcriere completă (limbă: $DETECTED)."
|
||||
|
||||
if [[ "$SAVE_KB" == "1" ]]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Slug din titlu scurt: lowercase, fără diacritice, doar alfanumerice și cratime
|
||||
SLUG=$(echo "$TITLE_SHORT" | python3 -c "
|
||||
SLUG=$(echo "$TITLE_SHORT" | "$PY" -c "
|
||||
import sys, re, unicodedata
|
||||
s = sys.stdin.read().strip()
|
||||
s = unicodedata.normalize('NFD', s)
|
||||
s = ''.join(c for c in s if unicodedata.category(c) != 'Mn')
|
||||
s = s.lower()
|
||||
s = re.sub(r'[^a-z0-9]+', '-', s)
|
||||
s = s.strip('-')[:50]
|
||||
print(s)
|
||||
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)"
|
||||
|
||||
# Detectează categoria din URL
|
||||
if echo "$URL" | grep -qi "facebook\.com"; then
|
||||
CATEGORY="facebook"
|
||||
FORMAT="Reel (~${DURATION} min)"
|
||||
CATEGORY="facebook"; FORMAT="Reel (~${DURATION} min)"
|
||||
elif echo "$URL" | grep -qi "youtube\.com\|youtu\.be"; then
|
||||
CATEGORY="youtube"
|
||||
FORMAT="Video (~${DURATION} min)"
|
||||
CATEGORY="youtube"; FORMAT="Video (~${DURATION} min)"
|
||||
else
|
||||
CATEGORY="media"
|
||||
FORMAT="Video (~${DURATION} min)"
|
||||
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
|
||||
cat > "$NOTE_FILE" <<NOTEEOF
|
||||
# $TITLE_SHORT
|
||||
|
||||
**Sursa:** $URL
|
||||
**Data:** $DATE
|
||||
**Creator:** $CREATOR
|
||||
**Format:** $FORMAT
|
||||
**Limbă:** $DETECTED
|
||||
**Tags:** @coaching
|
||||
|
||||
---
|
||||
@@ -131,9 +213,13 @@ NOTEEOF
|
||||
|
||||
echo ""
|
||||
echo "→ Notiță salvată: $NOTE_FILE"
|
||||
|
||||
echo "→ Reindexez KB..."
|
||||
python3 "$PROJECT_ROOT/tools/update_notes_index.py"
|
||||
|
||||
echo "✓ KB actualizat. Link: /echo/files.html#memory/kb/$CATEGORY/${DATE}_${SLUG}.md"
|
||||
"$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
|
||||
|
||||
Reference in New Issue
Block a user