Dark industrial-themed UI with WebSocket log streaming, bot trigger button, settings panel (subreddits, Whisper model, TTS voice, thresholds), and video gallery with inline player. Settings are passed to the bot subprocess via env vars so the pipeline respects UI config at runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
138 lines
3.5 KiB
Python
138 lines
3.5 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import FileResponse, HTMLResponse
|
|
|
|
app = FastAPI()
|
|
|
|
OUTPUT_DIR = Path("output")
|
|
SETTINGS_FILE = Path("bot_settings.json")
|
|
|
|
DEFAULT_SETTINGS = {
|
|
"subreddits": ["AITAH", "relationship_advice", "confessions", "tifu"],
|
|
"whisper_model": "base",
|
|
"voice": "en-US-ChristopherNeural",
|
|
"min_score": 1000,
|
|
"min_words": 200,
|
|
"max_words": 1500,
|
|
}
|
|
|
|
log_clients: list[WebSocket] = []
|
|
bot_running = False
|
|
|
|
|
|
def load_settings() -> dict:
|
|
if SETTINGS_FILE.exists():
|
|
return json.loads(SETTINGS_FILE.read_text())
|
|
return DEFAULT_SETTINGS.copy()
|
|
|
|
|
|
def save_settings(data: dict):
|
|
SETTINGS_FILE.write_text(json.dumps(data, indent=2))
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index():
|
|
return open("templates/index.html").read()
|
|
|
|
|
|
@app.get("/api/settings")
|
|
async def get_settings():
|
|
return load_settings()
|
|
|
|
|
|
@app.post("/api/settings")
|
|
async def post_settings(data: dict):
|
|
save_settings(data)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/videos")
|
|
async def list_videos():
|
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
|
videos = []
|
|
for f in sorted(OUTPUT_DIR.glob("*.mp4"), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
videos.append({
|
|
"name": f.name,
|
|
"size": f.stat().st_size,
|
|
"url": f"/videos/{f.name}",
|
|
})
|
|
return videos
|
|
|
|
|
|
@app.get("/videos/{filename}")
|
|
async def serve_video(filename: str):
|
|
path = OUTPUT_DIR / filename
|
|
if not path.exists():
|
|
return {"error": "not found"}
|
|
return FileResponse(path, media_type="video/mp4")
|
|
|
|
|
|
async def broadcast(message: str):
|
|
dead = []
|
|
for ws in log_clients:
|
|
try:
|
|
await ws.send_text(message)
|
|
except Exception:
|
|
dead.append(ws)
|
|
for ws in dead:
|
|
log_clients.remove(ws)
|
|
|
|
|
|
@app.post("/api/run")
|
|
async def run_bot():
|
|
global bot_running
|
|
if bot_running:
|
|
return {"error": "Bot is already running"}
|
|
bot_running = True
|
|
asyncio.create_task(_run_bot_task())
|
|
return {"ok": True}
|
|
|
|
|
|
async def _run_bot_task():
|
|
global bot_running
|
|
settings = load_settings()
|
|
env = os.environ.copy()
|
|
env["WHISPER_MODEL"] = settings.get("whisper_model", "base")
|
|
env["BOT_VOICE"] = settings.get("voice", "en-US-ChristopherNeural")
|
|
env["BOT_SUBREDDITS"] = ",".join(settings.get("subreddits", []))
|
|
env["BOT_MIN_SCORE"] = str(settings.get("min_score", 1000))
|
|
env["BOT_MIN_WORDS"] = str(settings.get("min_words", 200))
|
|
env["BOT_MAX_WORDS"] = str(settings.get("max_words", 1500))
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"python", "main.py",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
env=env,
|
|
)
|
|
async for line in proc.stdout:
|
|
await broadcast(line.decode().rstrip())
|
|
await proc.wait()
|
|
await broadcast(f"__DONE__{proc.returncode}")
|
|
except Exception as e:
|
|
await broadcast(f"[ERROR] {e}")
|
|
await broadcast("__DONE__1")
|
|
finally:
|
|
bot_running = False
|
|
|
|
|
|
@app.websocket("/ws/logs")
|
|
async def ws_logs(websocket: WebSocket):
|
|
await websocket.accept()
|
|
log_clients.append(websocket)
|
|
if bot_running:
|
|
await websocket.send_text("__RUNNING__")
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(30)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
finally:
|
|
if websocket in log_clients:
|
|
log_clients.remove(websocket)
|