#!/usr/bin/env python3
"""
MoviePluz Pipeline Bot — HuggingFace Space
/start → fetch movie from API → run full pipeline directly on this Space
Fixes:
- Keep-alive thread: pings self every 4 min so HF Space never sleeps mid-run
- Realtime logs via /logs endpoint (last N lines) + /logs_stream SSE
- /status command shows detailed progress from the live status message
- Pipeline crash recovery with full traceback sent to log channel
"""
import os
import sys
import threading
import logging
import traceback
import time
import collections
import requests
import config as _cfg
from flask import Flask, request, jsonify, Response, stream_with_context
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
BOT_TOKEN = _cfg.BOT_TOKEN
ALLOWED_IDS = os.environ.get("ALLOWED_USER_IDS", "")
# ── Pipeline state ─────────────────────────────────────────────────────────────
_pipeline_running = False
_pipeline_lock = threading.Lock()
_pipeline_start = None # epoch time when last run started
# ── In-memory log ring buffer (last 200 lines) ────────────────────────────────
_log_buffer = collections.deque(maxlen=200)
_log_lock = threading.Lock()
_log_callbacks = [] # SSE subscribers
def _log(line: str):
ts = time.strftime("%H:%M:%S")
entry = f"[{ts}] {line}"
with _log_lock:
_log_buffer.append(entry)
for cb in list(_log_callbacks):
try: cb(entry)
except Exception: pass
logger.info(line)
# Patch print so pipeline prints also go to buffer
_orig_print = print
def _patched_print(*args, **kwargs):
msg = " ".join(str(a) for a in args)
_log(msg)
_orig_print(*args, **kwargs)
import builtins
builtins.print = _patched_print
# ── Keep-alive: ping this Space's own health endpoint every 4 minutes ─────────
def _keep_alive():
url = "https://mrpoddaa-burn.hf.space/"
while True:
time.sleep(240) # every 4 minutes
with _pipeline_lock:
running = _pipeline_running
if running:
try:
requests.get(url, timeout=10)
_log("💓 Keep-alive ping sent")
except Exception as e:
_log(f"⚠️ Keep-alive ping failed: {e}")
threading.Thread(target=_keep_alive, daemon=True).start()
# ── Telegram helpers ───────────────────────────────────────────────────────────
def allowed_user(user_id: int) -> bool:
if not ALLOWED_IDS.strip():
return True
return str(user_id) in [x.strip() for x in ALLOWED_IDS.split(",")]
def tg_send(chat_id, text, parse_mode="HTML"):
try:
requests.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
json={"chat_id": chat_id, "text": text, "parse_mode": parse_mode},
timeout=10,
)
except Exception as e:
logger.error(f"tg_send error: {e}")
# ── Pipeline thread ────────────────────────────────────────────────────────────
def run_pipeline_thread():
global _pipeline_running, _pipeline_start
_log("🚀 Pipeline thread started")
try:
import pipeline
pipeline.main()
_log("✅ Pipeline finished successfully")
except SystemExit as e:
_log(f"Pipeline exited (code {e.code})")
except Exception as e:
tb = traceback.format_exc()
_log(f"❌ Pipeline CRASHED: {e}\n{tb}")
try:
import pipeline as _p
_p.tg_log_error(f"Pipeline crashed:\n{tb[:3000]}")
except Exception:
pass
finally:
with _pipeline_lock:
_pipeline_running = False
_log("🏁 Pipeline thread finished — idle")
# ── Webhook ────────────────────────────────────────────────────────────────────
@app.route("/webhook", methods=["POST"])
def webhook():
global _pipeline_running, _pipeline_start
data = request.get_json(silent=True)
if not data:
return jsonify({"ok": False}), 400
message = data.get("message") or data.get("edited_message")
if not message:
return jsonify({"ok": True})
chat_id = message["chat"]["id"]
user_id = message.get("from", {}).get("id", 0)
text = message.get("text", "").strip()
# ── /start ─────────────────────────────────────────────────────────────────
if text == "/start":
if not allowed_user(user_id):
tg_send(chat_id, "⛔ You are not authorised to use this bot.")
return jsonify({"ok": True})
with _pipeline_lock:
if _pipeline_running:
elapsed = int(time.time() - (_pipeline_start or time.time()))
m, s = divmod(elapsed, 60)
tg_send(chat_id,
f"⚠️ Pipeline already running!\n"
f"⏱ Running for {m}m {s}s\n"
f"Send /status for details or /logs for live output.",
parse_mode="HTML"
)
return jsonify({"ok": True})
_pipeline_running = True
_pipeline_start = time.time()
with _log_lock:
_log_buffer.clear()
tg_send(chat_id,
"🚀 Pipeline started!\n\n"
"Fetching movie details and beginning encode...\n"
"📋 Check the log channel for live progress.\n"
"🔍 Use /logs for live output here.",
parse_mode="HTML"
)
t = threading.Thread(target=run_pipeline_thread, daemon=True)
t.start()
_log(f"/start triggered by user {user_id}")
# ── /status ────────────────────────────────────────────────────────────────
elif text == "/status":
with _pipeline_lock:
running = _pipeline_running
start = _pipeline_start
if running and start:
elapsed = int(time.time() - start)
m, s = divmod(elapsed, 60)
h, m = divmod(m, 60)
if h:
dur = f"{h}h {m}m {s}s"
else:
dur = f"{m}m {s}s"
# Last 5 log lines as preview
with _log_lock:
last_lines = list(_log_buffer)[-5:]
preview = "\n".join(f" {l}" for l in last_lines) or " (no logs yet)"
tg_send(chat_id,
f"⚙️ Pipeline RUNNING\n"
f"⏱ Running for: {dur}\n\n"
f"📋 Recent activity:\n{preview}\n\n"
f"Use /logs for full output.",
parse_mode="HTML"
)
else:
tg_send(chat_id,
"💤 Pipeline is idle.\n"
"Send /start to trigger.",
parse_mode="HTML"
)
# ── /logs ──────────────────────────────────────────────────────────────────
elif text == "/logs":
with _log_lock:
lines = list(_log_buffer)[-30:]
if not lines:
tg_send(chat_id, "📋 No logs yet. Start the pipeline with /start.")
else:
chunk = "\n".join(lines)
tg_send(chat_id,
f"📋 Last {len(lines)} log lines:\n{chunk[:4000]}",
parse_mode="HTML"
)
# ── /help ──────────────────────────────────────────────────────────────────
elif text == "/help":
tg_send(chat_id,
"🤖 MoviePluz Pipeline Bot\n\n"
"/start — Fetch next movie & run full pipeline\n"
"/status — Check running status + recent activity\n"
"/logs — Show last 30 log lines\n"
"/help — Show this message",
parse_mode="HTML"
)
return jsonify({"ok": True})
# ── Health check ───────────────────────────────────────────────────────────────
@app.route("/", methods=["GET"])
def health():
with _pipeline_lock:
running = _pipeline_running
start = _pipeline_start
elapsed = int(time.time() - start) if (running and start) else 0
return jsonify({
"status": "ok",
"pipeline_running": running,
"elapsed_seconds": elapsed,
})
# ── /logs endpoint (HTTP — last N lines) ──────────────────────────────────────
@app.route("/logs")
def logs_http():
n = min(int(request.args.get("n", 50)), 200)
with _log_lock:
lines = list(_log_buffer)[-n:]
return jsonify({"lines": lines, "count": len(lines)})
# ── /logs_stream — SSE realtime streaming ─────────────────────────────────────
@app.route("/logs_stream")
def logs_stream():
"""
Server-Sent Events endpoint.
Connect with: curl -N https://YOUR-SPACE.hf.space/logs_stream
Or open in browser for live tail.
"""
q = []
def _cb(line):
q.append(line)
with _log_lock:
# Send existing buffer first
existing = list(_log_buffer)
_log_callbacks.append(_cb)
def generate():
# Replay existing lines
for line in existing:
yield f"data: {line}\n\n"
# Stream new lines
while True:
if q:
line = q.pop(0)
yield f"data: {line}\n\n"
else:
yield ": heartbeat\n\n"
time.sleep(2)
def cleanup():
try:
with _log_lock:
_log_callbacks.remove(_cb)
except ValueError:
pass
response = Response(
stream_with_context(generate()),
mimetype="text/event-stream"
)
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
return response
# ── Webhook registration ───────────────────────────────────────────────────────
@app.route("/set_webhook", methods=["GET"])
def set_webhook():
webhook_url = request.args.get("url", "")
if not webhook_url:
return jsonify({"error": "Pass ?url=https://your-space.hf.space/webhook"}), 400
r = requests.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/setWebhook",
json={"url": webhook_url, "allowed_updates": ["message"]},
timeout=10,
)
return jsonify(r.json())
if __name__ == "__main__":
port = int(os.environ.get("PORT", 7860))
app.run(host="0.0.0.0", port=port)