Add FastAPI web dashboard with live logs and video gallery

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>
This commit is contained in:
sylyx 2026-05-16 10:41:42 +02:00
parent 98e829c995
commit c5f724bac4
6 changed files with 606 additions and 10 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.env
bot_settings.json
__pycache__/
*.pyc
*.pyo

View File

@ -4,3 +4,5 @@ edge-tts>=6.1.9
openai-whisper>=20231117
moviepy>=1.0.3
python-dotenv>=1.0.0
fastapi>=0.111.0
uvicorn[standard]>=0.29.0

View File

@ -1,13 +1,15 @@
import os
import sqlite3
import praw
from config import REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USER_AGENT
DB_PATH = "processed.db"
SUBREDDITS = ["AITAH", "relationship_advice", "confessions", "tifu", "AmItheAsshole"]
MIN_SCORE = 1000
MIN_WORDS = 200
MAX_WORDS = 1500
_env_subs = os.environ.get("BOT_SUBREDDITS", "")
SUBREDDITS = _env_subs.split(",") if _env_subs else ["AITAH", "relationship_advice", "confessions", "tifu", "AmItheAsshole"]
MIN_SCORE = int(os.environ.get("BOT_MIN_SCORE", 1000))
MIN_WORDS = int(os.environ.get("BOT_MIN_WORDS", 200))
MAX_WORDS = int(os.environ.get("BOT_MAX_WORDS", 1500))
def _init_db():

View File

@ -1,14 +1,13 @@
import asyncio
import os
import edge_tts
VOICE = "en-US-ChristopherNeural"
async def _synthesize(text: str, output_path: str):
communicate = edge_tts.Communicate(text, VOICE)
async def _synthesize(text: str, output_path: str, voice: str):
communicate = edge_tts.Communicate(text, voice)
await communicate.save(output_path)
def generate_audio(text: str, output_path: str):
"""Generate an MP3 from text using Edge TTS and save to output_path."""
asyncio.run(_synthesize(text, output_path))
voice = os.environ.get("BOT_VOICE", "en-US-ChristopherNeural")
asyncio.run(_synthesize(text, output_path, voice))

455
templates/index.html Normal file
View File

@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reddit Video Bot</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500&family=Fira+Code:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #080810;
--card: #0e0e1a;
--card2: #12121e;
--border: #1c1c30;
--accent: #ff3a1a;
--glow: rgba(255,58,26,0.35);
--success: #00e5a0;
--muted: #505070;
--text: #dde0f0;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
min-height: 100vh;
}
/* subtle noise overlay */
body::before {
content: '';
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
opacity: 0.5;
}
.z { position: relative; z-index: 1; }
/* Typography */
.display { font-family: 'Bebas Neue', cursive; }
.mono { font-family: 'Fira Code', monospace; }
.label { font-family: 'Bebas Neue', cursive; font-size: 10px; letter-spacing: 0.22em; color: var(--muted); }
/* Card */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
}
/* ON AIR button */
#runBtn {
width: 152px; height: 152px;
border-radius: 50%;
border: none; cursor: pointer;
background: radial-gradient(circle at 38% 32%, #ff6040 0%, #be1800 100%);
box-shadow:
0 0 0 8px rgba(255,58,26,0.12),
0 0 0 16px rgba(255,58,26,0.06),
0 10px 40px rgba(255,58,26,0.45),
inset 0 -6px 16px rgba(0,0,0,0.45),
inset 0 4px 8px rgba(255,120,80,0.25);
transition: transform 0.18s, box-shadow 0.18s;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px;
}
#runBtn:hover:not(:disabled) {
transform: scale(1.04);
box-shadow:
0 0 0 10px rgba(255,58,26,0.2),
0 0 0 22px rgba(255,58,26,0.08),
0 16px 60px rgba(255,58,26,0.65),
inset 0 -6px 16px rgba(0,0,0,0.45),
inset 0 4px 8px rgba(255,120,80,0.25);
}
#runBtn:active:not(:disabled) { transform: scale(0.97); }
#runBtn:disabled { cursor: not-allowed; }
#runBtn.running {
animation: pulse-halo 1.6s ease-in-out infinite;
}
@keyframes pulse-halo {
0%,100% { box-shadow: 0 0 0 8px rgba(255,58,26,0.18), 0 0 0 16px rgba(255,58,26,0.07), 0 10px 40px rgba(255,58,26,0.5), inset 0 -6px 16px rgba(0,0,0,0.45), inset 0 4px 8px rgba(255,120,80,0.25); }
50% { box-shadow: 0 0 0 14px rgba(255,58,26,0.08), 0 0 0 30px rgba(255,58,26,0.03), 0 10px 60px rgba(255,58,26,0.75), inset 0 -6px 16px rgba(0,0,0,0.45), inset 0 4px 8px rgba(255,120,80,0.25); }
}
/* LED */
.led {
width: 9px; height: 9px;
border-radius: 50%;
display: inline-block;
}
.led-idle { background: var(--muted); }
.led-running {
background: var(--success);
box-shadow: 0 0 10px var(--success);
animation: blink 1s ease-in-out infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* Progress bar */
#progressBar {
height: 2px; width: 0%;
background: linear-gradient(90deg, var(--accent), #ff7040);
transition: width 0.4s;
}
#progressBar.running {
width: 100% !important;
animation: slide 1.4s ease-in-out infinite;
}
@keyframes slide {
0% { transform: translateX(-100%) scaleX(0.4); }
50% { transform: translateX(0%) scaleX(1); }
100% { transform: translateX(100%) scaleX(0.4); }
}
/* Terminal */
#logOutput {
font-family: 'Fira Code', monospace;
font-size: 11.5px; line-height: 1.7;
color: #90ffb0;
}
.log-err { color: #ff6060; }
.log-done { color: var(--success); }
.log-info { color: #7070cc; }
/* Scanlines on terminal */
.scanlines { position: relative; }
.scanlines::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.07) 3px, rgba(0,0,0,0.07) 4px);
}
/* Form controls */
input, select, textarea {
background: #090914;
border: 1px solid var(--border);
color: var(--text);
border-radius: 7px;
padding: 6px 10px;
font-family: 'DM Sans', sans-serif;
font-size: 13px;
width: 100%;
outline: none;
transition: border-color 0.2s;
appearance: none;
}
input:focus, select:focus { border-color: rgba(255,58,26,0.5); }
select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23505070' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
/* Buttons */
.btn-ghost {
background: rgba(255,58,26,0.1);
border: 1px solid rgba(255,58,26,0.3);
color: var(--accent);
border-radius: 7px;
padding: 5px 14px;
font-family: 'Bebas Neue', cursive;
font-size: 13px; letter-spacing: 0.08em;
cursor: pointer; transition: all 0.18s;
white-space: nowrap;
}
.btn-ghost:hover { background: rgba(255,58,26,0.2); border-color: var(--accent); }
/* Tag */
.tag {
display: inline-flex; align-items: center; gap: 5px;
background: #14142a; border: 1px solid #252540;
border-radius: 5px; padding: 2px 8px;
font-family: 'Fira Code', monospace; font-size: 11px; color: #a0a0d0;
}
.tag-x { cursor: pointer; color: var(--muted); transition: color 0.15s; }
.tag-x:hover { color: var(--accent); }
/* Video card */
.vcard { transition: transform 0.2s, box-shadow 0.2s; }
.vcard:hover { transform: translateY(-4px); box-shadow: 0 16px 48px rgba(0,0,0,0.7); }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #252540; border-radius: 2px; }
</style>
</head>
<body>
<!-- Header -->
<header class="z border-b border-[#1c1c30] px-7 py-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<polygon points="14,2 26,8 26,20 14,26 2,20 2,8" fill="none" stroke="#ff3a1a" stroke-width="1.5"/>
<polygon points="14,7 21,11 21,17 14,21 7,17 7,11" fill="#ff3a1a" opacity="0.2"/>
<circle cx="14" cy="14" r="3" fill="#ff3a1a"/>
</svg>
<div>
<h1 class="display text-[22px] tracking-[0.18em] text-[var(--text)] leading-none">REDDIT VIDEO BOT</h1>
<div class="mono text-[10px] text-[var(--muted)] tracking-[0.15em] mt-0.5">AUTOMATED CONTENT PIPELINE</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class="led led-idle" id="statusLed"></span>
<span class="mono text-[11px] text-[var(--muted)] tracking-widest" id="statusText">IDLE</span>
</div>
</header>
<!-- Progress -->
<div class="z overflow-hidden" style="height:2px;background:var(--border)">
<div id="progressBar"></div>
</div>
<!-- Main -->
<main class="z p-6 grid gap-6" style="grid-template-columns: 280px 1fr; min-height: calc(100vh - 70px)">
<!-- Left -->
<div class="flex flex-col gap-5">
<!-- Control -->
<div class="card p-6 flex flex-col items-center gap-5">
<span class="label">CONTROL</span>
<button id="runBtn" onclick="runBot()">
<span class="display text-white text-[30px] leading-none tracking-wide" style="text-shadow:0 2px 12px rgba(0,0,0,0.6)">RUN</span>
<span class="display text-[11px] tracking-[0.35em]" style="color:rgba(255,255,255,0.5)">BOT</span>
</button>
<div class="mono text-[11px] text-[var(--muted)] tracking-widest" id="btnLabel">READY</div>
</div>
<!-- Settings -->
<div class="card p-5 flex flex-col gap-4 flex-1">
<div class="flex items-center justify-between">
<span class="label">SETTINGS</span>
<button class="btn-ghost" id="saveBtn" onclick="saveSettings()">SAVE</button>
</div>
<!-- Subreddits -->
<div>
<div class="label mb-2">SUBREDDITS</div>
<div class="flex flex-wrap gap-1.5 mb-2 min-h-[24px]" id="tagContainer"></div>
<div class="flex gap-2">
<input type="text" id="newSub" placeholder="add subreddit…" onkeydown="if(event.key==='Enter')addSub()">
<button class="btn-ghost px-3" onclick="addSub()" style="padding:5px 12px">+</button>
</div>
</div>
<!-- Whisper -->
<div>
<div class="label mb-2">WHISPER MODEL</div>
<select id="whisperModel">
<option value="base">base — fast, lower accuracy</option>
<option value="small">small — balanced</option>
<option value="medium">medium — accurate, slow</option>
<option value="large">large — best quality</option>
</select>
</div>
<!-- Voice -->
<div>
<div class="label mb-2">TTS VOICE</div>
<select id="voice">
<option value="en-US-ChristopherNeural">Christopher · Male · US</option>
<option value="en-US-GuyNeural">Guy · Male · US</option>
<option value="en-US-JennyNeural">Jenny · Female · US</option>
<option value="en-US-AriaNeural">Aria · Female · US</option>
<option value="en-GB-RyanNeural">Ryan · Male · UK</option>
<option value="en-AU-WilliamNeural">William · Male · AU</option>
</select>
</div>
<!-- Thresholds -->
<div class="grid grid-cols-2 gap-3">
<div>
<div class="label mb-2">MIN SCORE</div>
<input type="number" id="minScore">
</div>
<div>
<div class="label mb-2">MIN WORDS</div>
<input type="number" id="minWords">
</div>
</div>
<div>
<div class="label mb-2">MAX WORDS</div>
<input type="number" id="maxWords">
</div>
</div>
</div>
<!-- Right -->
<div class="flex flex-col gap-5">
<!-- Log terminal -->
<div class="card flex flex-col" style="height:370px">
<div class="px-5 py-3 border-b border-[#1c1c30] flex items-center justify-between flex-shrink-0">
<span class="label">LIVE LOG</span>
<button onclick="clearLog()" class="mono text-[10px] text-[var(--muted)] hover:text-[var(--text)] transition-colors">CLEAR</button>
</div>
<div class="flex-1 overflow-y-auto p-4 scanlines" id="logScroll">
<div id="logOutput"><div class="log-info">// Waiting for run…</div></div>
</div>
</div>
<!-- Video gallery -->
<div class="card flex-1 p-5">
<div class="flex items-center justify-between mb-4">
<span class="label">OUTPUT VIDEOS</span>
<button onclick="loadVideos()" class="mono text-[10px] text-[var(--muted)] hover:text-[var(--text)] transition-colors">REFRESH</button>
</div>
<div id="gallery" class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))">
<div class="col-span-full text-center text-[var(--muted)] mono text-xs py-10">No videos yet.</div>
</div>
</div>
</div>
</main>
<script>
let ws, isRunning = false, subreddits = [];
// ── WebSocket ─────────────────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/logs`);
ws.onmessage = ({ data }) => {
if (data === '__RUNNING__') { setRunning(true); return; }
if (data.startsWith('__DONE__')) { onDone(parseInt(data.slice(8))); return; }
appendLog(data);
};
ws.onclose = () => setTimeout(connect, 3000);
}
function appendLog(text, cls = '') {
const d = document.createElement('div');
if (cls) d.className = cls;
else if (text.includes('[ERROR]') || text.includes('Error')) d.className = 'log-err';
else if (text.includes('//')) d.className = 'log-info';
d.textContent = text;
document.getElementById('logOutput').appendChild(d);
const s = document.getElementById('logScroll');
s.scrollTop = s.scrollHeight;
}
function clearLog() {
document.getElementById('logOutput').innerHTML = '<div class="log-info">// Log cleared.</div>';
}
// ── Bot ───────────────────────────────────────────────
async function runBot() {
if (isRunning) return;
const r = await fetch('/api/run', { method: 'POST' });
const d = await r.json();
if (d.error) { appendLog(`// ${d.error}`, 'log-err'); return; }
setRunning(true);
appendLog('// Bot started…', 'log-info');
}
function onDone(code) {
setRunning(false);
appendLog(code === 0 ? '// ✓ Completed successfully.' : `// ✗ Exited with code ${code}.`, code === 0 ? 'log-done' : 'log-err');
loadVideos();
}
function setRunning(v) {
isRunning = v;
const btn = document.getElementById('runBtn');
const led = document.getElementById('statusLed');
const bar = document.getElementById('progressBar');
btn.disabled = v;
btn.classList.toggle('running', v);
led.className = v ? 'led led-running' : 'led led-idle';
document.getElementById('statusText').textContent = v ? 'RUNNING' : 'IDLE';
document.getElementById('btnLabel').textContent = v ? 'PROCESSING…' : 'READY';
bar.classList.toggle('running', v);
if (!v) bar.style.width = '0%';
}
// ── Settings ──────────────────────────────────────────
async function loadSettings() {
const s = await (await fetch('/api/settings')).json();
subreddits = s.subreddits || [];
renderTags();
document.getElementById('whisperModel').value = s.whisper_model || 'base';
document.getElementById('voice').value = s.voice || 'en-US-ChristopherNeural';
document.getElementById('minScore').value = s.min_score ?? 1000;
document.getElementById('minWords').value = s.min_words ?? 200;
document.getElementById('maxWords').value = s.max_words ?? 1500;
}
async function saveSettings() {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subreddits,
whisper_model: document.getElementById('whisperModel').value,
voice: document.getElementById('voice').value,
min_score: +document.getElementById('minScore').value,
min_words: +document.getElementById('minWords').value,
max_words: +document.getElementById('maxWords').value,
}),
});
const btn = document.getElementById('saveBtn');
btn.textContent = 'SAVED ✓';
setTimeout(() => btn.textContent = 'SAVE', 1800);
}
function renderTags() {
const c = document.getElementById('tagContainer');
c.innerHTML = '';
subreddits.forEach((s, i) => {
const t = document.createElement('span');
t.className = 'tag';
t.innerHTML = `r/${s}<span class="tag-x" onclick="removeSub(${i})">×</span>`;
c.appendChild(t);
});
}
function addSub() {
const inp = document.getElementById('newSub');
const v = inp.value.trim().replace(/^r\//, '');
if (v && !subreddits.includes(v)) { subreddits.push(v); renderTags(); }
inp.value = '';
}
function removeSub(i) { subreddits.splice(i, 1); renderTags(); }
// ── Videos ────────────────────────────────────────────
async function loadVideos() {
const videos = await (await fetch('/api/videos')).json();
const g = document.getElementById('gallery');
if (!videos.length) {
g.innerHTML = '<div class="col-span-full text-center text-[var(--muted)] mono text-xs py-10">No videos yet.</div>';
return;
}
g.innerHTML = '';
videos.forEach(v => {
const c = document.createElement('div');
c.className = 'vcard card overflow-hidden';
c.innerHTML = `
<video controls preload="metadata" style="width:100%;display:block;background:#000;max-height:280px">
<source src="${v.url}" type="video/mp4">
</video>
<div class="p-3">
<div class="mono text-[11px] text-[var(--text)] truncate" title="${v.name}">${v.name}</div>
<div class="mono text-[10px] text-[var(--muted)] mt-1">${(v.size/1024/1024).toFixed(1)} MB</div>
<a href="${v.url}" download class="btn-ghost mt-2 block text-center text-[11px]" style="text-decoration:none;padding:4px 0">DOWNLOAD</a>
</div>`;
g.appendChild(c);
});
}
// Init
connect();
loadSettings();
loadVideos();
</script>
</body>
</html>

137
web.py Normal file
View File

@ -0,0 +1,137 @@
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)