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:
parent
98e829c995
commit
c5f724bac4
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.env
|
||||
bot_settings.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
455
templates/index.html
Normal 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
137
web.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user