Major changes: - Add Qwen3 TTS provider with authentication support - Remove local pyttsx3 TTS (replaced with cloud TTS) - Add real-time progress GUI with WebSocket updates - Comprehensive Docker setup with docker-compose - Updated README with new documentation New features: - Qwen TTS: Supports multiple speakers and languages - Progress GUI: Live step-by-step tracking at http://localhost:5000 - Docker: Full containerization with environment variables - Config: Example config file for easy setup Files added: - TTS/qwen_tts.py - Qwen3 TTS provider - progress_gui.py - Flask/SocketIO progress server - utils/progress.py - Progress tracking module - GUI/progress.html - Progress dashboard template - GUI/static/css/progress.css - Progress GUI styles - GUI/static/js/progress.js - WebSocket client - docker-compose.yml - Docker orchestration - docker-entrypoint.sh - Container startup script - config.example.toml - Example configuration https://claude.ai/code/session_01HLLH3WjpmRzvaoY6eYSFADpull/2456/head
parent
902ff00cb0
commit
94d8e45cf7
@ -1,12 +1,67 @@
|
|||||||
FROM python:3.10.14-slim
|
FROM python:3.10.14-slim
|
||||||
|
|
||||||
RUN apt update
|
# Set environment variables
|
||||||
RUN apt-get install -y ffmpeg
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
RUN apt install python3-pip -y
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV REDDIT_BOT_GUI=true
|
||||||
|
|
||||||
RUN mkdir /app
|
# Install system dependencies
|
||||||
ADD . /app
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libxcb1 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libx11-6 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libasound2 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN pip install -r requirements.txt
|
|
||||||
|
|
||||||
CMD ["python3", "main.py"]
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Install Playwright browsers
|
||||||
|
RUN playwright install chromium
|
||||||
|
RUN playwright install-deps chromium
|
||||||
|
|
||||||
|
# Download spaCy language model
|
||||||
|
RUN python -m spacy download en_core_web_sm
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p assets/temp assets/backgrounds/video assets/backgrounds/audio results
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["python", "main.py"]
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reddit Video Maker - Progress</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/progress.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.6.0/socket.io.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Reddit Video Maker Bot</h1>
|
||||||
|
<p class="subtitle">Real-time Progress Tracker</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Current Job Section -->
|
||||||
|
<section id="current-job" class="card">
|
||||||
|
<h2>Current Job</h2>
|
||||||
|
<div id="no-job" class="no-job">
|
||||||
|
<div class="waiting-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12,6 12,12 16,14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>Waiting for video generation to start...</p>
|
||||||
|
<p class="hint">Start the bot with: <code>python main.py</code></p>
|
||||||
|
</div>
|
||||||
|
<div id="job-info" class="job-info hidden">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">
|
||||||
|
<span class="subreddit" id="job-subreddit"></span>
|
||||||
|
<h3 id="job-title"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="job-status">
|
||||||
|
<span id="job-status-badge" class="status-badge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall Progress -->
|
||||||
|
<div class="overall-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="overall-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text" id="overall-progress-text">0%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div class="steps-container" id="steps-container">
|
||||||
|
<!-- Steps will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div class="preview-section" id="preview-section">
|
||||||
|
<h4>Preview</h4>
|
||||||
|
<div class="preview-container" id="preview-container">
|
||||||
|
<div class="preview-placeholder">
|
||||||
|
<p>Preview will appear here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Job History Section -->
|
||||||
|
<section id="history" class="card">
|
||||||
|
<h2>Recent Jobs</h2>
|
||||||
|
<div id="history-list" class="history-list">
|
||||||
|
<p class="no-history">No completed jobs yet</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Reddit Video Maker Bot - Progress GUI</p>
|
||||||
|
<div class="connection-status">
|
||||||
|
<span class="status-dot" id="connection-dot"></span>
|
||||||
|
<span id="connection-text">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/progress.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,539 @@
|
|||||||
|
/* Reddit Video Maker Bot - Progress GUI Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0f0f0f;
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--bg-tertiary: #252525;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-muted: #666666;
|
||||||
|
--accent-primary: #ff4500;
|
||||||
|
--accent-secondary: #ff6b35;
|
||||||
|
--success: #4caf50;
|
||||||
|
--warning: #ff9800;
|
||||||
|
--error: #f44336;
|
||||||
|
--info: #2196f3;
|
||||||
|
--border-color: #333333;
|
||||||
|
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Job State */
|
||||||
|
.no-job {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-job .waiting-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.5; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-job .hint {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-job code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Job Info */
|
||||||
|
.job-info.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title .subreddit {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.in_progress {
|
||||||
|
background: rgba(33, 150, 243, 0.2);
|
||||||
|
color: var(--info);
|
||||||
|
animation: glow 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 5px rgba(33, 150, 243, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 15px rgba(33, 150, 243, 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.completed {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.failed {
|
||||||
|
background: rgba(244, 67, 54, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.skipped {
|
||||||
|
background: rgba(255, 152, 0, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overall Progress */
|
||||||
|
.overall-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
.steps-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
border-left: 3px solid var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.failed {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
border-left: 3px solid var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.pending .step-icon {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active .step-icon {
|
||||||
|
background: var(--info);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed .step-icon {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.failed .step-icon {
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.skipped .step-icon {
|
||||||
|
background: var(--warning);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-message {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-progress {
|
||||||
|
width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--info);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-progress-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Section */
|
||||||
|
.preview-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section h4 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History */
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .subreddit {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-history {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-progress {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .actions {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item .actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Reddit Video Maker Bot - Progress GUI JavaScript
|
||||||
|
* Real-time progress tracking via WebSocket
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ProgressTracker {
|
||||||
|
constructor() {
|
||||||
|
this.socket = null;
|
||||||
|
this.connected = false;
|
||||||
|
this.currentJob = null;
|
||||||
|
this.jobHistory = [];
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
this.elements = {
|
||||||
|
noJob: document.getElementById('no-job'),
|
||||||
|
jobInfo: document.getElementById('job-info'),
|
||||||
|
jobSubreddit: document.getElementById('job-subreddit'),
|
||||||
|
jobTitle: document.getElementById('job-title'),
|
||||||
|
jobStatusBadge: document.getElementById('job-status-badge'),
|
||||||
|
overallProgressFill: document.getElementById('overall-progress-fill'),
|
||||||
|
overallProgressText: document.getElementById('overall-progress-text'),
|
||||||
|
stepsContainer: document.getElementById('steps-container'),
|
||||||
|
previewContainer: document.getElementById('preview-container'),
|
||||||
|
historyList: document.getElementById('history-list'),
|
||||||
|
connectionDot: document.getElementById('connection-dot'),
|
||||||
|
connectionText: document.getElementById('connection-text'),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stepIcons = {
|
||||||
|
pending: '○',
|
||||||
|
in_progress: '◐',
|
||||||
|
completed: '✓',
|
||||||
|
failed: '✗',
|
||||||
|
skipped: '⊘',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.connectWebSocket();
|
||||||
|
this.fetchInitialStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}`;
|
||||||
|
|
||||||
|
this.socket = io(wsUrl + '/progress', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
this.connected = true;
|
||||||
|
this.updateConnectionStatus(true);
|
||||||
|
console.log('Connected to progress server');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.updateConnectionStatus(false);
|
||||||
|
console.log('Disconnected from progress server');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('progress_update', (data) => {
|
||||||
|
this.handleProgressUpdate(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
this.updateConnectionStatus(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInitialStatus() {
|
||||||
|
fetch('/api/status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => this.handleProgressUpdate(data))
|
||||||
|
.catch(error => console.error('Error fetching status:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConnectionStatus(connected) {
|
||||||
|
const { connectionDot, connectionText } = this.elements;
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
connectionDot.classList.add('connected');
|
||||||
|
connectionDot.classList.remove('disconnected');
|
||||||
|
connectionText.textContent = 'Connected';
|
||||||
|
} else {
|
||||||
|
connectionDot.classList.remove('connected');
|
||||||
|
connectionDot.classList.add('disconnected');
|
||||||
|
connectionText.textContent = 'Disconnected - Reconnecting...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleProgressUpdate(data) {
|
||||||
|
this.currentJob = data.current_job;
|
||||||
|
this.jobHistory = data.job_history || [];
|
||||||
|
|
||||||
|
this.renderCurrentJob();
|
||||||
|
this.renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCurrentJob() {
|
||||||
|
const { noJob, jobInfo, jobSubreddit, jobTitle, jobStatusBadge,
|
||||||
|
overallProgressFill, overallProgressText, stepsContainer, previewContainer } = this.elements;
|
||||||
|
|
||||||
|
if (!this.currentJob) {
|
||||||
|
noJob.classList.remove('hidden');
|
||||||
|
jobInfo.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noJob.classList.add('hidden');
|
||||||
|
jobInfo.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Update job info
|
||||||
|
jobSubreddit.textContent = `r/${this.currentJob.subreddit}`;
|
||||||
|
jobTitle.textContent = this.currentJob.title;
|
||||||
|
|
||||||
|
// Update status badge
|
||||||
|
jobStatusBadge.textContent = this.formatStatus(this.currentJob.status);
|
||||||
|
jobStatusBadge.className = `status-badge ${this.currentJob.status}`;
|
||||||
|
|
||||||
|
// Update overall progress
|
||||||
|
const progress = this.currentJob.overall_progress || 0;
|
||||||
|
overallProgressFill.style.width = `${progress}%`;
|
||||||
|
overallProgressText.textContent = `${Math.round(progress)}%`;
|
||||||
|
|
||||||
|
// Render steps
|
||||||
|
this.renderSteps(stepsContainer, this.currentJob.steps);
|
||||||
|
|
||||||
|
// Update preview
|
||||||
|
this.updatePreview(previewContainer, this.currentJob.steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSteps(container, steps) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
steps.forEach((step, index) => {
|
||||||
|
const stepEl = document.createElement('div');
|
||||||
|
stepEl.className = `step ${this.getStepClass(step.status)}`;
|
||||||
|
|
||||||
|
const icon = this.getStepIcon(step.status);
|
||||||
|
const isActive = step.status === 'in_progress';
|
||||||
|
|
||||||
|
stepEl.innerHTML = `
|
||||||
|
<div class="step-icon">
|
||||||
|
${isActive ? '<div class="spinner"></div>' : icon}
|
||||||
|
</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<div class="step-name">${step.name}</div>
|
||||||
|
<div class="step-description">${step.description}</div>
|
||||||
|
${step.message ? `<div class="step-message">${step.message}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${step.status === 'in_progress' ? `
|
||||||
|
<div class="step-progress">
|
||||||
|
<div class="step-progress-bar">
|
||||||
|
<div class="step-progress-fill" style="width: ${step.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="step-progress-text">${Math.round(step.progress)}%</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(stepEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStepClass(status) {
|
||||||
|
const classMap = {
|
||||||
|
pending: 'pending',
|
||||||
|
in_progress: 'active',
|
||||||
|
completed: 'completed',
|
||||||
|
failed: 'failed',
|
||||||
|
skipped: 'skipped',
|
||||||
|
};
|
||||||
|
return classMap[status] || 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
getStepIcon(status) {
|
||||||
|
return this.stepIcons[status] || this.stepIcons.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatStatus(status) {
|
||||||
|
const statusMap = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
failed: 'Failed',
|
||||||
|
skipped: 'Skipped',
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreview(container, steps) {
|
||||||
|
// Find the latest step with a preview
|
||||||
|
let previewPath = null;
|
||||||
|
for (let i = steps.length - 1; i >= 0; i--) {
|
||||||
|
if (steps[i].preview_path) {
|
||||||
|
previewPath = steps[i].preview_path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewPath) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="preview-placeholder">
|
||||||
|
<p>Preview will appear here during processing</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if it's an image or video
|
||||||
|
const extension = previewPath.split('.').pop().toLowerCase();
|
||||||
|
const isVideo = ['mp4', 'webm', 'mov'].includes(extension);
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<video class="preview-video" controls autoplay muted loop>
|
||||||
|
<source src="${previewPath}" type="video/${extension}">
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `
|
||||||
|
<img class="preview-image" src="${previewPath}" alt="Preview">
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHistory() {
|
||||||
|
const { historyList } = this.elements;
|
||||||
|
|
||||||
|
if (!this.jobHistory || this.jobHistory.length === 0) {
|
||||||
|
historyList.innerHTML = '<p class="no-history">No completed jobs yet</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyList.innerHTML = '';
|
||||||
|
|
||||||
|
// Show most recent first
|
||||||
|
const sortedHistory = [...this.jobHistory].reverse();
|
||||||
|
|
||||||
|
sortedHistory.forEach(job => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'history-item';
|
||||||
|
|
||||||
|
const duration = job.completed_at && job.created_at
|
||||||
|
? this.formatDuration(job.completed_at - job.created_at)
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
|
const statusClass = job.status === 'completed' ? 'completed' : 'failed';
|
||||||
|
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="step-icon" style="background: var(--${job.status === 'completed' ? 'success' : 'error'}); color: white;">
|
||||||
|
${job.status === 'completed' ? '✓' : '✗'}
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<div class="subreddit">r/${job.subreddit}</div>
|
||||||
|
<div class="title">${job.title}</div>
|
||||||
|
<div class="meta">
|
||||||
|
${this.formatDate(job.created_at)} • ${duration}
|
||||||
|
${job.error ? `• <span style="color: var(--error);">${job.error}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
${job.output_path ? `
|
||||||
|
<a href="${job.output_path}" class="btn btn-primary" target="_blank">
|
||||||
|
View Video
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
historyList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDuration(seconds) {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.round(seconds)}s`;
|
||||||
|
} else if (seconds < 3600) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.round(seconds % 60);
|
||||||
|
return `${mins}m ${secs}s`;
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}h ${mins}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(timestamp) {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the progress tracker when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.progressTracker = new ProgressTracker();
|
||||||
|
});
|
||||||
@ -1,142 +1,213 @@
|
|||||||
# Reddit Video Maker Bot 🎥
|
# Reddit Video Maker Bot
|
||||||
|
|
||||||
All done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨.
|
Automatically generate short-form videos from Reddit posts. Supports multiple TTS engines including Qwen3 TTS.
|
||||||
|
|
||||||
Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca)
|
## Features
|
||||||
|
|
||||||
<a target="_blank" href="https://tmrrwinc.ca">
|
- **Multiple TTS Engines**: Qwen3 TTS (default), OpenAI TTS, ElevenLabs, TikTok, Google Translate, AWS Polly
|
||||||
<picture>
|
- **Real-time Progress GUI**: Web-based dashboard showing video generation progress with live updates
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/6053155/170528535-e274dc0b-7972-4b27-af22-637f8c370133.png">
|
- **Docker Support**: Fully containerized with docker-compose for easy deployment
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png">
|
- **Background Customization**: Multiple background videos and audio tracks included
|
||||||
<img src="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png" width="350">
|
- **Story Mode**: Special mode for narrative subreddits (r/nosleep, r/tifu, etc.)
|
||||||
</picture>
|
- **AI Content Sorting**: Optional semantic similarity sorting for relevant posts
|
||||||
|
|
||||||
</a>
|
## Quick Start with Docker
|
||||||
|
|
||||||
## Video Explainer
|
```bash
|
||||||
|
# Clone the repository
|
||||||
[
|
git clone https://github.com/elebumm/RedditVideoMakerBot.git
|
||||||
](https://www.youtube.com/watch?v=3gjcY_00U1w)
|
cd RedditVideoMakerBot
|
||||||
|
|
||||||
## Motivation 🤔
|
|
||||||
|
|
||||||
These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort.
|
# Create your config file
|
||||||
The only original thing being done is the editing and gathering of all materials...
|
cp config.example.toml config.toml
|
||||||
|
# Edit config.toml with your credentials
|
||||||
|
|
||||||
... but what if we can automate that process? 🤔
|
# Start with docker-compose
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
## Disclaimers 🚨
|
# View progress at http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that
|
## Manual Installation
|
||||||
you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues.
|
|
||||||
|
|
||||||
## Requirements
|
### Requirements
|
||||||
|
|
||||||
- Python 3.10
|
- Python 3.10, 3.11, or 3.12
|
||||||
- Playwright (this should install automatically in installation)
|
- FFmpeg
|
||||||
|
- Playwright browsers
|
||||||
|
|
||||||
## Installation 👩💻
|
### Setup
|
||||||
|
|
||||||
1. Clone this repository:
|
```bash
|
||||||
```sh
|
# Clone repository
|
||||||
git clone https://github.com/elebumm/RedditVideoMakerBot.git
|
git clone https://github.com/elebumm/RedditVideoMakerBot.git
|
||||||
cd RedditVideoMakerBot
|
cd RedditVideoMakerBot
|
||||||
```
|
|
||||||
|
|
||||||
2. Create and activate a virtual environment:
|
# Create virtual environment
|
||||||
- On **Windows**:
|
python -m venv venv
|
||||||
```sh
|
source venv/bin/activate # On Windows: .\venv\Scripts\activate
|
||||||
python -m venv ./venv
|
|
||||||
.\venv\Scripts\activate
|
|
||||||
```
|
|
||||||
- On **macOS and Linux**:
|
|
||||||
```sh
|
|
||||||
python3 -m venv ./venv
|
|
||||||
source ./venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Install the required dependencies:
|
# Install dependencies
|
||||||
```sh
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
|
||||||
|
|
||||||
4. Install Playwright and its dependencies:
|
# Install Playwright browsers
|
||||||
```sh
|
playwright install
|
||||||
python -m playwright install
|
playwright install-deps
|
||||||
python -m playwright install-deps
|
|
||||||
|
# Download spaCy model (for story mode)
|
||||||
|
python -m spacy download en_core_web_sm
|
||||||
|
|
||||||
|
# Run the bot
|
||||||
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Configuration
|
||||||
|
|
||||||
**EXPERIMENTAL!!!!**
|
Create a `config.toml` file in the project root. The bot will prompt you for settings on first run.
|
||||||
|
|
||||||
- On macOS and Linux (Debian, Arch, Fedora, CentOS, and based on those), you can run an installation script that will automatically install steps 1 to 3. (requires bash)
|
### Reddit API Setup
|
||||||
- `bash <(curl -sL https://raw.githubusercontent.com/elebumm/RedditVideoMakerBot/master/install.sh)`
|
|
||||||
- This can also be used to update the installation
|
|
||||||
|
|
||||||
---
|
1. Go to [Reddit Apps](https://www.reddit.com/prefs/apps)
|
||||||
|
2. Create a new app with type "script"
|
||||||
|
3. Note your `client_id` and `client_secret`
|
||||||
|
|
||||||
5. Run the bot:
|
### Qwen TTS Setup (Default)
|
||||||
```sh
|
|
||||||
python main.py
|
Qwen TTS requires a running Qwen TTS server:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings.tts]
|
||||||
|
voice_choice = "qwentts"
|
||||||
|
qwen_api_url = "http://localhost:8080"
|
||||||
|
qwen_email = "your_email@example.com"
|
||||||
|
qwen_password = "your_password"
|
||||||
|
qwen_speaker = "Vivian" # Options: Chelsie, Ethan, Vivian, Asher, Aria, Oliver, Emma, Noah, Sophia
|
||||||
|
qwen_language = "English"
|
||||||
|
qwen_instruct = "Warm, friendly, conversational."
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Visit [the Reddit Apps page](https://www.reddit.com/prefs/apps), and set up an app that is a "script". Paste any URL in the redirect URL field, for example: `https://jasoncameron.dev`.
|
### TTS Options
|
||||||
|
|
||||||
7. The bot will prompt you to fill in your details to connect to the Reddit API and configure the bot to your liking.
|
| Provider | Key | Requirements |
|
||||||
|
|----------|-----|--------------|
|
||||||
|
| Qwen TTS | `qwentts` | Qwen TTS server |
|
||||||
|
| OpenAI | `openai` | API key |
|
||||||
|
| ElevenLabs | `elevenlabs` | API key |
|
||||||
|
| TikTok | `tiktok` | Session ID |
|
||||||
|
| Google Translate | `googletranslate` | None (free) |
|
||||||
|
| AWS Polly | `awspolly` | AWS credentials |
|
||||||
|
| Streamlabs Polly | `streamlabspolly` | None (rate limited) |
|
||||||
|
|
||||||
8. Enjoy 😎
|
## Progress GUI
|
||||||
|
|
||||||
|
The bot includes a real-time progress tracking GUI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable GUI mode
|
||||||
|
export REDDIT_BOT_GUI=true
|
||||||
|
python main.py
|
||||||
|
|
||||||
9. If you need to reconfigure the bot, simply open the `config.toml` file and delete the lines that need to be changed. On the next run of the bot, it will help you reconfigure those options.
|
# Or run GUI standalone
|
||||||
|
python progress_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
(Note: If you encounter any errors installing or running the bot, try using `python3` or `pip3` instead of `python` or `pip`.)
|
Access at: http://localhost:5000
|
||||||
|
|
||||||
For a more detailed guide about the bot, please refer to the [documentation](https://reddit-video-maker-bot.netlify.app/).
|
### Features
|
||||||
|
|
||||||
## Video
|
- Real-time progress updates via WebSocket
|
||||||
|
- Step-by-step visualization
|
||||||
|
- Preview images during generation
|
||||||
|
- Job history tracking
|
||||||
|
|
||||||
https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4
|
## Docker Deployment
|
||||||
|
|
||||||
## Contributing & Ways to improve 📈
|
### Using Docker Compose
|
||||||
|
|
||||||
In its current state, this bot does exactly what it needs to do. However, improvements can always be made!
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
reddit-video-bot:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
- ./results:/app/results
|
||||||
|
environment:
|
||||||
|
- REDDIT_BOT_GUI=true
|
||||||
|
```
|
||||||
|
|
||||||
I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute!
|
### Environment Variables
|
||||||
|
|
||||||
- [ ] Creating better documentation and adding a command line interface.
|
All config options can be set via environment variables:
|
||||||
- [x] Allowing the user to choose background music for their videos.
|
|
||||||
- [x] Allowing users to choose a reddit thread instead of being randomized.
|
|
||||||
- [x] Allowing users to choose a background that is picked instead of the Minecraft one.
|
|
||||||
- [x] Allowing users to choose between any subreddit.
|
|
||||||
- [x] Allowing users to change voice.
|
|
||||||
- [x] Checks if a video has already been created
|
|
||||||
- [x] Light and Dark modes
|
|
||||||
- [x] NSFW post filter
|
|
||||||
|
|
||||||
Please read our [contributing guidelines](CONTRIBUTING.md) for more detailed information.
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `REDDIT_CLIENT_ID` | Reddit API client ID |
|
||||||
|
| `REDDIT_CLIENT_SECRET` | Reddit API client secret |
|
||||||
|
| `REDDIT_USERNAME` | Reddit username |
|
||||||
|
| `REDDIT_PASSWORD` | Reddit password |
|
||||||
|
| `REDDIT_SUBREDDIT` | Target subreddit |
|
||||||
|
| `TTS_VOICE_CHOICE` | TTS provider |
|
||||||
|
| `QWEN_API_URL` | Qwen TTS server URL |
|
||||||
|
| `QWEN_EMAIL` | Qwen TTS email |
|
||||||
|
| `QWEN_PASSWORD` | Qwen TTS password |
|
||||||
|
|
||||||
### For any questions or support join the [Discord](https://discord.gg/qfQSx45xCV) server
|
## Project Structure
|
||||||
|
|
||||||
## Developers and maintainers.
|
```
|
||||||
|
RedditVideoMakerBot/
|
||||||
|
├── main.py # Entry point
|
||||||
|
├── progress_gui.py # Progress GUI server
|
||||||
|
├── config.toml # Configuration file
|
||||||
|
├── TTS/ # TTS engine modules
|
||||||
|
│ ├── qwen_tts.py # Qwen TTS provider
|
||||||
|
│ ├── openai_tts.py # OpenAI TTS provider
|
||||||
|
│ └── ...
|
||||||
|
├── video_creation/ # Video generation
|
||||||
|
├── reddit/ # Reddit API
|
||||||
|
├── utils/ # Utilities
|
||||||
|
│ ├── progress.py # Progress tracking
|
||||||
|
│ └── settings.py # Configuration
|
||||||
|
├── GUI/ # Web GUI templates
|
||||||
|
│ ├── progress.html
|
||||||
|
│ └── static/
|
||||||
|
├── Dockerfile
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
Elebumm (Lewis#6305) - https://github.com/elebumm (Founder)
|
## Output
|
||||||
|
|
||||||
Jason Cameron - https://github.com/JasonLovesDoggo (Maintainer)
|
Generated videos are saved to `results/{subreddit}/`.
|
||||||
|
|
||||||
Simon (OpenSourceSimon) - https://github.com/OpenSourceSimon
|
## Troubleshooting
|
||||||
|
|
||||||
CallumIO (c.#6837) - https://github.com/CallumIO
|
### Common Issues
|
||||||
|
|
||||||
Verq (Verq#2338) - https://github.com/CordlessCoder
|
**FFmpeg not found**
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install ffmpeg
|
||||||
|
|
||||||
LukaHietala (Pix.#0001) - https://github.com/LukaHietala
|
# macOS
|
||||||
|
brew install ffmpeg
|
||||||
|
|
||||||
Freebiell (Freebie#3263) - https://github.com/FreebieII
|
# Windows
|
||||||
|
# Download from https://ffmpeg.org/download.html
|
||||||
|
```
|
||||||
|
|
||||||
Aman Raza (electro199#8130) - https://github.com/electro199
|
**Playwright browsers missing**
|
||||||
|
```bash
|
||||||
|
playwright install
|
||||||
|
playwright install-deps
|
||||||
|
```
|
||||||
|
|
||||||
Cyteon (cyteon) - https://github.com/cyteon
|
**TTS authentication failed**
|
||||||
|
- Verify your Qwen TTS server is running
|
||||||
|
- Check credentials in config.toml
|
||||||
|
- Ensure the API URL is correct
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
## LICENSE
|
|
||||||
[Roboto Fonts](https://fonts.google.com/specimen/Roboto/about) are licensed under [Apache License V2](https://www.apache.org/licenses/LICENSE-2.0)
|
[Roboto Fonts](https://fonts.google.com/specimen/Roboto/about) are licensed under [Apache License V2](https://www.apache.org/licenses/LICENSE-2.0)
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
import random
|
|
||||||
|
|
||||||
import pyttsx3
|
|
||||||
|
|
||||||
from utils import settings
|
|
||||||
|
|
||||||
|
|
||||||
class pyttsx:
|
|
||||||
def __init__(self):
|
|
||||||
self.max_chars = 5000
|
|
||||||
self.voices = []
|
|
||||||
|
|
||||||
def run(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
filepath: str,
|
|
||||||
random_voice=False,
|
|
||||||
):
|
|
||||||
voice_id = settings.config["settings"]["tts"]["python_voice"]
|
|
||||||
voice_num = settings.config["settings"]["tts"]["py_voice_num"]
|
|
||||||
if voice_id == "" or voice_num == "":
|
|
||||||
voice_id = 2
|
|
||||||
voice_num = 3
|
|
||||||
raise ValueError("set pyttsx values to a valid value, switching to defaults")
|
|
||||||
else:
|
|
||||||
voice_id = int(voice_id)
|
|
||||||
voice_num = int(voice_num)
|
|
||||||
for i in range(voice_num):
|
|
||||||
self.voices.append(i)
|
|
||||||
i = +1
|
|
||||||
if random_voice:
|
|
||||||
voice_id = self.randomvoice()
|
|
||||||
engine = pyttsx3.init()
|
|
||||||
voices = engine.getProperty("voices")
|
|
||||||
engine.setProperty(
|
|
||||||
"voice", voices[voice_id].id
|
|
||||||
) # changing index changes voices but ony 0 and 1 are working here
|
|
||||||
engine.save_to_file(text, f"{filepath}")
|
|
||||||
engine.runAndWait()
|
|
||||||
|
|
||||||
def randomvoice(self):
|
|
||||||
return random.choice(self.voices)
|
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
import random
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from utils import settings
|
||||||
|
|
||||||
|
|
||||||
|
class QwenTTS:
|
||||||
|
"""
|
||||||
|
A Text-to-Speech engine that uses the Qwen3 TTS API endpoint to generate audio from text.
|
||||||
|
|
||||||
|
This TTS provider connects to a Qwen TTS server and authenticates using email/password
|
||||||
|
to obtain a bearer token, then sends TTS requests.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
max_chars (int): Maximum number of characters allowed per API call.
|
||||||
|
api_base_url (str): Base URL for the Qwen TTS API server.
|
||||||
|
email (str): Email for authentication.
|
||||||
|
password (str): Password for authentication.
|
||||||
|
token (str): Bearer token obtained after login.
|
||||||
|
available_voices (list): List of supported Qwen TTS voices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Available Qwen TTS speakers
|
||||||
|
AVAILABLE_SPEAKERS = [
|
||||||
|
"Chelsie",
|
||||||
|
"Ethan",
|
||||||
|
"Vivian",
|
||||||
|
"Asher",
|
||||||
|
"Aria",
|
||||||
|
"Oliver",
|
||||||
|
"Emma",
|
||||||
|
"Noah",
|
||||||
|
"Sophia",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Available languages
|
||||||
|
AVAILABLE_LANGUAGES = [
|
||||||
|
"English",
|
||||||
|
"Chinese",
|
||||||
|
"Spanish",
|
||||||
|
"French",
|
||||||
|
"German",
|
||||||
|
"Japanese",
|
||||||
|
"Korean",
|
||||||
|
"Portuguese",
|
||||||
|
"Russian",
|
||||||
|
"Italian",
|
||||||
|
"Arabic",
|
||||||
|
"Hindi",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.max_chars = 5000
|
||||||
|
self.token = None
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
tts_config = settings.config["settings"]["tts"]
|
||||||
|
|
||||||
|
self.api_base_url = tts_config.get("qwen_api_url", "http://localhost:8080")
|
||||||
|
if self.api_base_url.endswith("/"):
|
||||||
|
self.api_base_url = self.api_base_url[:-1]
|
||||||
|
|
||||||
|
self.email = tts_config.get("qwen_email")
|
||||||
|
self.password = tts_config.get("qwen_password")
|
||||||
|
|
||||||
|
if not self.email or not self.password:
|
||||||
|
raise ValueError(
|
||||||
|
"Qwen TTS requires 'qwen_email' and 'qwen_password' in settings! "
|
||||||
|
"Please configure these in your config.toml file."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.available_voices = self.AVAILABLE_SPEAKERS
|
||||||
|
self._authenticate()
|
||||||
|
|
||||||
|
def _authenticate(self):
|
||||||
|
"""
|
||||||
|
Authenticate with the Qwen TTS server and obtain a bearer token.
|
||||||
|
"""
|
||||||
|
login_url = f"{self.api_base_url}/api/agent/api/auth/login"
|
||||||
|
payload = {"email": self.email, "password": self.password}
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(login_url, json=payload, headers=headers, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qwen TTS authentication failed: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
self.token = data.get("access_token")
|
||||||
|
if not self.token:
|
||||||
|
raise RuntimeError("Qwen TTS authentication failed: No access_token in response")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise RuntimeError(f"Failed to connect to Qwen TTS server: {str(e)}")
|
||||||
|
|
||||||
|
def get_available_voices(self):
|
||||||
|
"""
|
||||||
|
Return a list of supported voices for Qwen TTS.
|
||||||
|
"""
|
||||||
|
return self.AVAILABLE_SPEAKERS
|
||||||
|
|
||||||
|
def randomvoice(self):
|
||||||
|
"""
|
||||||
|
Select and return a random voice from the available voices.
|
||||||
|
"""
|
||||||
|
return random.choice(self.available_voices)
|
||||||
|
|
||||||
|
def run(self, text: str, filepath: str, random_voice: bool = False):
|
||||||
|
"""
|
||||||
|
Convert the provided text to speech and save the resulting audio to the specified filepath.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The input text to convert.
|
||||||
|
filepath (str): The file path where the generated audio will be saved.
|
||||||
|
random_voice (bool): If True, select a random voice from the available voices.
|
||||||
|
"""
|
||||||
|
tts_config = settings.config["settings"]["tts"]
|
||||||
|
|
||||||
|
# Choose voice based on configuration or randomly if requested
|
||||||
|
if random_voice:
|
||||||
|
speaker = self.randomvoice()
|
||||||
|
else:
|
||||||
|
speaker = tts_config.get("qwen_speaker", "Vivian")
|
||||||
|
|
||||||
|
# Get language and instruct settings
|
||||||
|
language = tts_config.get("qwen_language", "English")
|
||||||
|
instruct = tts_config.get("qwen_instruct", "Warm, friendly, conversational.")
|
||||||
|
|
||||||
|
# Build TTS request
|
||||||
|
tts_url = f"{self.api_base_url}/api/qwen-tts"
|
||||||
|
payload = {
|
||||||
|
"text": text,
|
||||||
|
"language": language,
|
||||||
|
"speaker": speaker,
|
||||||
|
"instruct": instruct,
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(tts_url, json=payload, headers=headers, timeout=120)
|
||||||
|
|
||||||
|
# Handle token expiration - re-authenticate and retry
|
||||||
|
if response.status_code == 401:
|
||||||
|
self._authenticate()
|
||||||
|
headers["Authorization"] = f"Bearer {self.token}"
|
||||||
|
response = requests.post(tts_url, json=payload, headers=headers, timeout=120)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Qwen TTS generation failed: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write the audio response to file
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise RuntimeError("Qwen TTS request timed out. The server may be overloaded.")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise RuntimeError(f"Failed to generate audio with Qwen TTS: {str(e)}")
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
# Reddit Video Maker Bot Configuration
|
||||||
|
# Copy this file to config.toml and fill in your credentials
|
||||||
|
|
||||||
|
[reddit.creds]
|
||||||
|
client_id = "your_reddit_client_id"
|
||||||
|
client_secret = "your_reddit_client_secret"
|
||||||
|
username = "your_reddit_username"
|
||||||
|
password = "your_reddit_password"
|
||||||
|
2fa = false
|
||||||
|
|
||||||
|
[reddit.thread]
|
||||||
|
random = true
|
||||||
|
subreddit = "AskReddit"
|
||||||
|
post_id = ""
|
||||||
|
max_comment_length = 500
|
||||||
|
min_comment_length = 1
|
||||||
|
post_lang = ""
|
||||||
|
min_comments = 20
|
||||||
|
|
||||||
|
[ai]
|
||||||
|
ai_similarity_enabled = false
|
||||||
|
ai_similarity_keywords = ""
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
allow_nsfw = false
|
||||||
|
theme = "dark"
|
||||||
|
times_to_run = 1
|
||||||
|
opacity = 0.9
|
||||||
|
storymode = false
|
||||||
|
storymodemethod = 1
|
||||||
|
storymode_max_length = 1000
|
||||||
|
resolution_w = 1080
|
||||||
|
resolution_h = 1920
|
||||||
|
zoom = 1
|
||||||
|
channel_name = "Reddit Tales"
|
||||||
|
|
||||||
|
[settings.background]
|
||||||
|
background_video = "minecraft"
|
||||||
|
background_audio = "lofi"
|
||||||
|
background_audio_volume = 0.15
|
||||||
|
enable_extra_audio = false
|
||||||
|
background_thumbnail = false
|
||||||
|
background_thumbnail_font_family = "arial"
|
||||||
|
background_thumbnail_font_size = 96
|
||||||
|
background_thumbnail_font_color = "255,255,255"
|
||||||
|
|
||||||
|
[settings.tts]
|
||||||
|
# TTS Provider: qwentts, elevenlabs, tiktok, googletranslate, awspolly, streamlabspolly, openai
|
||||||
|
voice_choice = "qwentts"
|
||||||
|
random_voice = true
|
||||||
|
silence_duration = 0.3
|
||||||
|
no_emojis = false
|
||||||
|
|
||||||
|
# Qwen TTS Settings (default)
|
||||||
|
qwen_api_url = "http://localhost:8080"
|
||||||
|
qwen_email = "your_email@example.com"
|
||||||
|
qwen_password = "your_password"
|
||||||
|
qwen_speaker = "Vivian"
|
||||||
|
qwen_language = "English"
|
||||||
|
qwen_instruct = "Warm, friendly, conversational."
|
||||||
|
|
||||||
|
# OpenAI TTS Settings
|
||||||
|
openai_api_url = "https://api.openai.com/v1/"
|
||||||
|
openai_api_key = ""
|
||||||
|
openai_voice_name = "alloy"
|
||||||
|
openai_model = "tts-1"
|
||||||
|
|
||||||
|
# ElevenLabs Settings
|
||||||
|
elevenlabs_voice_name = "Bella"
|
||||||
|
elevenlabs_api_key = ""
|
||||||
|
|
||||||
|
# TikTok TTS Settings
|
||||||
|
tiktok_voice = "en_us_001"
|
||||||
|
tiktok_sessionid = ""
|
||||||
|
|
||||||
|
# AWS Polly Settings
|
||||||
|
aws_polly_voice = "Matthew"
|
||||||
|
|
||||||
|
# Streamlabs Polly Settings
|
||||||
|
streamlabs_polly_voice = "Matthew"
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
reddit-video-bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: reddit-video-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
- ./results:/app/results
|
||||||
|
- ./assets:/app/assets
|
||||||
|
environment:
|
||||||
|
- REDDIT_BOT_GUI=true
|
||||||
|
# Reddit Credentials (can also be set in config.toml)
|
||||||
|
- REDDIT_CLIENT_ID=${REDDIT_CLIENT_ID:-}
|
||||||
|
- REDDIT_CLIENT_SECRET=${REDDIT_CLIENT_SECRET:-}
|
||||||
|
- REDDIT_USERNAME=${REDDIT_USERNAME:-}
|
||||||
|
- REDDIT_PASSWORD=${REDDIT_PASSWORD:-}
|
||||||
|
- REDDIT_2FA=${REDDIT_2FA:-false}
|
||||||
|
# Reddit Thread Settings
|
||||||
|
- REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit}
|
||||||
|
- REDDIT_RANDOM=${REDDIT_RANDOM:-true}
|
||||||
|
# TTS Settings (Qwen TTS)
|
||||||
|
- TTS_VOICE_CHOICE=${TTS_VOICE_CHOICE:-qwentts}
|
||||||
|
- QWEN_API_URL=${QWEN_API_URL:-http://qwen-tts:8080}
|
||||||
|
- QWEN_EMAIL=${QWEN_EMAIL:-}
|
||||||
|
- QWEN_PASSWORD=${QWEN_PASSWORD:-}
|
||||||
|
- QWEN_SPEAKER=${QWEN_SPEAKER:-Vivian}
|
||||||
|
- QWEN_LANGUAGE=${QWEN_LANGUAGE:-English}
|
||||||
|
networks:
|
||||||
|
- reddit-bot-network
|
||||||
|
depends_on:
|
||||||
|
- qwen-tts
|
||||||
|
|
||||||
|
qwen-tts:
|
||||||
|
image: qwen-tts-server:latest
|
||||||
|
container_name: qwen-tts
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- TTS_MODEL=qwen3-tts
|
||||||
|
networks:
|
||||||
|
- reddit-bot-network
|
||||||
|
# Uncomment if using GPU
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# reservations:
|
||||||
|
# devices:
|
||||||
|
# - driver: nvidia
|
||||||
|
# count: 1
|
||||||
|
# capabilities: [gpu]
|
||||||
|
|
||||||
|
# Optional: Progress GUI only mode
|
||||||
|
progress-gui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: reddit-video-gui
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5001:5000"
|
||||||
|
volumes:
|
||||||
|
- ./config.toml:/app/config.toml:ro
|
||||||
|
- ./results:/app/results
|
||||||
|
- ./assets:/app/assets
|
||||||
|
environment:
|
||||||
|
- REDDIT_BOT_GUI=true
|
||||||
|
command: python progress_gui.py
|
||||||
|
networks:
|
||||||
|
- reddit-bot-network
|
||||||
|
profiles:
|
||||||
|
- gui-only
|
||||||
|
|
||||||
|
networks:
|
||||||
|
reddit-bot-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
results:
|
||||||
|
assets:
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create config from environment if not exists
|
||||||
|
if [ ! -f /app/config.toml ]; then
|
||||||
|
echo "Creating config.toml from template..."
|
||||||
|
|
||||||
|
# Check if all required environment variables are set
|
||||||
|
if [ -z "$REDDIT_CLIENT_ID" ] || [ -z "$REDDIT_CLIENT_SECRET" ] || [ -z "$REDDIT_USERNAME" ] || [ -z "$REDDIT_PASSWORD" ]; then
|
||||||
|
echo "Warning: Reddit credentials not set via environment variables."
|
||||||
|
echo "Please set REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD"
|
||||||
|
echo "Or mount your config.toml file to /app/config.toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create basic config from environment
|
||||||
|
cat > /app/config.toml << EOF
|
||||||
|
[reddit.creds]
|
||||||
|
client_id = "${REDDIT_CLIENT_ID:-}"
|
||||||
|
client_secret = "${REDDIT_CLIENT_SECRET:-}"
|
||||||
|
username = "${REDDIT_USERNAME:-}"
|
||||||
|
password = "${REDDIT_PASSWORD:-}"
|
||||||
|
2fa = ${REDDIT_2FA:-false}
|
||||||
|
|
||||||
|
[reddit.thread]
|
||||||
|
random = ${REDDIT_RANDOM:-true}
|
||||||
|
subreddit = "${REDDIT_SUBREDDIT:-AskReddit}"
|
||||||
|
post_id = "${REDDIT_POST_ID:-}"
|
||||||
|
max_comment_length = ${MAX_COMMENT_LENGTH:-500}
|
||||||
|
min_comment_length = ${MIN_COMMENT_LENGTH:-1}
|
||||||
|
post_lang = "${POST_LANG:-}"
|
||||||
|
min_comments = ${MIN_COMMENTS:-20}
|
||||||
|
|
||||||
|
[ai]
|
||||||
|
ai_similarity_enabled = ${AI_SIMILARITY_ENABLED:-false}
|
||||||
|
ai_similarity_keywords = "${AI_SIMILARITY_KEYWORDS:-}"
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
allow_nsfw = ${ALLOW_NSFW:-false}
|
||||||
|
theme = "${THEME:-dark}"
|
||||||
|
times_to_run = ${TIMES_TO_RUN:-1}
|
||||||
|
opacity = ${OPACITY:-0.9}
|
||||||
|
storymode = ${STORYMODE:-false}
|
||||||
|
storymodemethod = ${STORYMODEMETHOD:-1}
|
||||||
|
storymode_max_length = ${STORYMODE_MAX_LENGTH:-1000}
|
||||||
|
resolution_w = ${RESOLUTION_W:-1080}
|
||||||
|
resolution_h = ${RESOLUTION_H:-1920}
|
||||||
|
zoom = ${ZOOM:-1}
|
||||||
|
channel_name = "${CHANNEL_NAME:-Reddit Tales}"
|
||||||
|
|
||||||
|
[settings.background]
|
||||||
|
background_video = "${BACKGROUND_VIDEO:-minecraft}"
|
||||||
|
background_audio = "${BACKGROUND_AUDIO:-lofi}"
|
||||||
|
background_audio_volume = ${BACKGROUND_AUDIO_VOLUME:-0.15}
|
||||||
|
enable_extra_audio = ${ENABLE_EXTRA_AUDIO:-false}
|
||||||
|
background_thumbnail = ${BACKGROUND_THUMBNAIL:-false}
|
||||||
|
background_thumbnail_font_family = "${THUMBNAIL_FONT_FAMILY:-arial}"
|
||||||
|
background_thumbnail_font_size = ${THUMBNAIL_FONT_SIZE:-96}
|
||||||
|
background_thumbnail_font_color = "${THUMBNAIL_FONT_COLOR:-255,255,255}"
|
||||||
|
|
||||||
|
[settings.tts]
|
||||||
|
voice_choice = "${TTS_VOICE_CHOICE:-qwentts}"
|
||||||
|
random_voice = ${TTS_RANDOM_VOICE:-true}
|
||||||
|
elevenlabs_voice_name = "${ELEVENLABS_VOICE_NAME:-Bella}"
|
||||||
|
elevenlabs_api_key = "${ELEVENLABS_API_KEY:-}"
|
||||||
|
aws_polly_voice = "${AWS_POLLY_VOICE:-Matthew}"
|
||||||
|
streamlabs_polly_voice = "${STREAMLABS_POLLY_VOICE:-Matthew}"
|
||||||
|
tiktok_voice = "${TIKTOK_VOICE:-en_us_001}"
|
||||||
|
tiktok_sessionid = "${TIKTOK_SESSIONID:-}"
|
||||||
|
silence_duration = ${TTS_SILENCE_DURATION:-0.3}
|
||||||
|
no_emojis = ${TTS_NO_EMOJIS:-false}
|
||||||
|
openai_api_url = "${OPENAI_API_URL:-https://api.openai.com/v1/}"
|
||||||
|
openai_api_key = "${OPENAI_API_KEY:-}"
|
||||||
|
openai_voice_name = "${OPENAI_VOICE_NAME:-alloy}"
|
||||||
|
openai_model = "${OPENAI_MODEL:-tts-1}"
|
||||||
|
qwen_api_url = "${QWEN_API_URL:-http://localhost:8080}"
|
||||||
|
qwen_email = "${QWEN_EMAIL:-}"
|
||||||
|
qwen_password = "${QWEN_PASSWORD:-}"
|
||||||
|
qwen_speaker = "${QWEN_SPEAKER:-Vivian}"
|
||||||
|
qwen_language = "${QWEN_LANGUAGE:-English}"
|
||||||
|
qwen_instruct = "${QWEN_INSTRUCT:-Warm, friendly, conversational.}"
|
||||||
|
EOF
|
||||||
|
echo "Config file created successfully!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute the command passed to docker run
|
||||||
|
exec "$@"
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Progress GUI for Reddit Video Maker Bot.
|
||||||
|
Real-time progress tracking with steps and previews.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, render_template, send_from_directory, jsonify, request
|
||||||
|
from flask_socketio import SocketIO, emit
|
||||||
|
|
||||||
|
from utils.progress import progress_tracker
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
HOST = "0.0.0.0"
|
||||||
|
PORT = 5000
|
||||||
|
|
||||||
|
# Configure Flask app
|
||||||
|
app = Flask(__name__, template_folder="GUI", static_folder="GUI/static")
|
||||||
|
app.secret_key = os.urandom(24)
|
||||||
|
|
||||||
|
# Configure SocketIO for real-time updates
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent")
|
||||||
|
|
||||||
|
|
||||||
|
# Progress update callback
|
||||||
|
def broadcast_progress(data):
|
||||||
|
"""Broadcast progress updates to all connected clients."""
|
||||||
|
socketio.emit("progress_update", data, namespace="/progress")
|
||||||
|
|
||||||
|
|
||||||
|
# Register the callback
|
||||||
|
progress_tracker.add_update_callback(broadcast_progress)
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def after_request(response):
|
||||||
|
"""Ensure responses aren't cached."""
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Expires"] = 0
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
"""Main progress dashboard."""
|
||||||
|
return render_template("progress.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/status")
|
||||||
|
def get_status():
|
||||||
|
"""Get current progress status."""
|
||||||
|
return jsonify(progress_tracker.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/history")
|
||||||
|
def get_history():
|
||||||
|
"""Get job history."""
|
||||||
|
return jsonify({
|
||||||
|
"jobs": [job.to_dict() for job in progress_tracker.job_history]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
@app.route("/static/<path:filename>")
|
||||||
|
def static_files(filename):
|
||||||
|
"""Serve static files."""
|
||||||
|
return send_from_directory("GUI/static", filename)
|
||||||
|
|
||||||
|
|
||||||
|
# Serve result videos
|
||||||
|
@app.route("/results/<path:name>")
|
||||||
|
def results(name):
|
||||||
|
"""Serve result videos."""
|
||||||
|
return send_from_directory("results", name)
|
||||||
|
|
||||||
|
|
||||||
|
# Serve preview images
|
||||||
|
@app.route("/preview/<path:name>")
|
||||||
|
def previews(name):
|
||||||
|
"""Serve preview images."""
|
||||||
|
return send_from_directory("assets/temp", name)
|
||||||
|
|
||||||
|
|
||||||
|
# Serve temp assets (screenshots, audio visualizations)
|
||||||
|
@app.route("/assets/<path:name>")
|
||||||
|
def assets(name):
|
||||||
|
"""Serve asset files."""
|
||||||
|
return send_from_directory("assets", name)
|
||||||
|
|
||||||
|
|
||||||
|
# SocketIO Events
|
||||||
|
@socketio.on("connect", namespace="/progress")
|
||||||
|
def handle_connect():
|
||||||
|
"""Handle client connection."""
|
||||||
|
emit("progress_update", progress_tracker.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("disconnect", namespace="/progress")
|
||||||
|
def handle_disconnect():
|
||||||
|
"""Handle client disconnection."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("request_status", namespace="/progress")
|
||||||
|
def handle_request_status():
|
||||||
|
"""Handle status request from client."""
|
||||||
|
emit("progress_update", progress_tracker.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
def run_gui(open_browser=True):
|
||||||
|
"""Run the progress GUI server."""
|
||||||
|
if open_browser:
|
||||||
|
webbrowser.open(f"http://localhost:{PORT}", new=2)
|
||||||
|
|
||||||
|
print(f"Progress GUI running at http://localhost:{PORT}")
|
||||||
|
socketio.run(app, host=HOST, port=PORT, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
def run_gui_background():
|
||||||
|
"""Run the GUI server in a background thread."""
|
||||||
|
thread = threading.Thread(target=lambda: socketio.run(app, host=HOST, port=PORT, debug=False, use_reloader=False))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_gui()
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import pyttsx3
|
|
||||||
|
|
||||||
engine = pyttsx3.init()
|
|
||||||
voices = engine.getProperty("voices")
|
|
||||||
for voice in voices:
|
|
||||||
print(voice, voice.id)
|
|
||||||
engine.setProperty("voice", voice.id)
|
|
||||||
engine.say("Hello World!")
|
|
||||||
engine.runAndWait()
|
|
||||||
engine.stop()
|
|
||||||
@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
Progress tracking module for Reddit Video Maker Bot.
|
||||||
|
Provides real-time progress updates via WebSocket for the GUI.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from typing import Optional, List, Callable
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class StepStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Step:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
status: StepStatus = StepStatus.PENDING
|
||||||
|
progress: float = 0.0
|
||||||
|
message: str = ""
|
||||||
|
preview_path: Optional[str] = None
|
||||||
|
started_at: Optional[float] = None
|
||||||
|
completed_at: Optional[float] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"status": self.status.value,
|
||||||
|
"progress": self.progress,
|
||||||
|
"message": self.message,
|
||||||
|
"preview_path": self.preview_path,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"completed_at": self.completed_at,
|
||||||
|
"error": self.error,
|
||||||
|
"duration": (self.completed_at - self.started_at) if self.completed_at and self.started_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoJob:
|
||||||
|
id: str
|
||||||
|
reddit_id: str
|
||||||
|
title: str
|
||||||
|
subreddit: str
|
||||||
|
status: StepStatus = StepStatus.PENDING
|
||||||
|
steps: List[Step] = field(default_factory=list)
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
completed_at: Optional[float] = None
|
||||||
|
output_path: Optional[str] = None
|
||||||
|
thumbnail_path: Optional[str] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"reddit_id": self.reddit_id,
|
||||||
|
"title": self.title,
|
||||||
|
"subreddit": self.subreddit,
|
||||||
|
"status": self.status.value,
|
||||||
|
"steps": [step.to_dict() for step in self.steps],
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"completed_at": self.completed_at,
|
||||||
|
"output_path": self.output_path,
|
||||||
|
"thumbnail_path": self.thumbnail_path,
|
||||||
|
"error": self.error,
|
||||||
|
"overall_progress": self.get_overall_progress(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_overall_progress(self) -> float:
|
||||||
|
if not self.steps:
|
||||||
|
return 0.0
|
||||||
|
completed = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED)
|
||||||
|
return (completed / len(self.steps)) * 100
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressTracker:
|
||||||
|
"""
|
||||||
|
Singleton progress tracker that manages video generation jobs and steps.
|
||||||
|
Provides callbacks for real-time GUI updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
_initialized = False
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if ProgressTracker._initialized:
|
||||||
|
return
|
||||||
|
ProgressTracker._initialized = True
|
||||||
|
|
||||||
|
self.current_job: Optional[VideoJob] = None
|
||||||
|
self.job_history: List[VideoJob] = []
|
||||||
|
self._update_callbacks: List[Callable] = []
|
||||||
|
self._preview_dir = Path("assets/temp/previews")
|
||||||
|
self._preview_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def add_update_callback(self, callback: Callable):
|
||||||
|
"""Register a callback function to be called on progress updates."""
|
||||||
|
self._update_callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_update_callback(self, callback: Callable):
|
||||||
|
"""Remove a callback function."""
|
||||||
|
if callback in self._update_callbacks:
|
||||||
|
self._update_callbacks.remove(callback)
|
||||||
|
|
||||||
|
def _notify_update(self):
|
||||||
|
"""Notify all registered callbacks of a progress update."""
|
||||||
|
data = self.get_status()
|
||||||
|
for callback in self._update_callbacks:
|
||||||
|
try:
|
||||||
|
callback(data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
|
def start_job(self, reddit_id: str, title: str, subreddit: str) -> VideoJob:
|
||||||
|
"""Start a new video generation job."""
|
||||||
|
job = VideoJob(
|
||||||
|
id=f"job_{int(time.time())}_{reddit_id}",
|
||||||
|
reddit_id=reddit_id,
|
||||||
|
title=title,
|
||||||
|
subreddit=subreddit,
|
||||||
|
status=StepStatus.IN_PROGRESS,
|
||||||
|
steps=self._create_default_steps(),
|
||||||
|
)
|
||||||
|
self.current_job = job
|
||||||
|
self._notify_update()
|
||||||
|
return job
|
||||||
|
|
||||||
|
def _create_default_steps(self) -> List[Step]:
|
||||||
|
"""Create the default pipeline steps."""
|
||||||
|
return [
|
||||||
|
Step(
|
||||||
|
id="fetch_reddit",
|
||||||
|
name="Fetch Reddit Post",
|
||||||
|
description="Fetching post and comments from Reddit",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
id="generate_tts",
|
||||||
|
name="Generate Audio",
|
||||||
|
description="Converting text to speech using Qwen TTS",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
id="capture_screenshots",
|
||||||
|
name="Capture Screenshots",
|
||||||
|
description="Taking screenshots of Reddit comments",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
id="download_background",
|
||||||
|
name="Download Background",
|
||||||
|
description="Downloading and preparing background video/audio",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
id="process_background",
|
||||||
|
name="Process Background",
|
||||||
|
description="Chopping background to fit video length",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
id="compose_video",
|
||||||
|
name="Compose Video",
|
||||||
|
description="Combining all elements into final video",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
id="finalize",
|
||||||
|
name="Finalize",
|
||||||
|
description="Final processing and cleanup",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def start_step(self, step_id: str, message: str = ""):
|
||||||
|
"""Mark a step as in progress."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.id == step_id:
|
||||||
|
step.status = StepStatus.IN_PROGRESS
|
||||||
|
step.started_at = time.time()
|
||||||
|
step.message = message
|
||||||
|
step.progress = 0
|
||||||
|
break
|
||||||
|
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def update_step_progress(self, step_id: str, progress: float, message: str = ""):
|
||||||
|
"""Update the progress of a step."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.id == step_id:
|
||||||
|
step.progress = min(100, max(0, progress))
|
||||||
|
if message:
|
||||||
|
step.message = message
|
||||||
|
break
|
||||||
|
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def set_step_preview(self, step_id: str, preview_path: str):
|
||||||
|
"""Set a preview image/video for a step."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.id == step_id:
|
||||||
|
step.preview_path = preview_path
|
||||||
|
break
|
||||||
|
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def complete_step(self, step_id: str, message: str = ""):
|
||||||
|
"""Mark a step as completed."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.id == step_id:
|
||||||
|
step.status = StepStatus.COMPLETED
|
||||||
|
step.completed_at = time.time()
|
||||||
|
step.progress = 100
|
||||||
|
if message:
|
||||||
|
step.message = message
|
||||||
|
break
|
||||||
|
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def fail_step(self, step_id: str, error: str):
|
||||||
|
"""Mark a step as failed."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.id == step_id:
|
||||||
|
step.status = StepStatus.FAILED
|
||||||
|
step.completed_at = time.time()
|
||||||
|
step.error = error
|
||||||
|
step.message = f"Failed: {error}"
|
||||||
|
break
|
||||||
|
|
||||||
|
self.current_job.status = StepStatus.FAILED
|
||||||
|
self.current_job.error = error
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def skip_step(self, step_id: str, reason: str = ""):
|
||||||
|
"""Mark a step as skipped."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.id == step_id:
|
||||||
|
step.status = StepStatus.SKIPPED
|
||||||
|
step.completed_at = time.time()
|
||||||
|
step.message = reason or "Skipped"
|
||||||
|
break
|
||||||
|
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def complete_job(self, output_path: str, thumbnail_path: Optional[str] = None):
|
||||||
|
"""Mark the current job as completed."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_job.status = StepStatus.COMPLETED
|
||||||
|
self.current_job.completed_at = time.time()
|
||||||
|
self.current_job.output_path = output_path
|
||||||
|
self.current_job.thumbnail_path = thumbnail_path
|
||||||
|
|
||||||
|
self.job_history.append(self.current_job)
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def fail_job(self, error: str):
|
||||||
|
"""Mark the current job as failed."""
|
||||||
|
if not self.current_job:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_job.status = StepStatus.FAILED
|
||||||
|
self.current_job.completed_at = time.time()
|
||||||
|
self.current_job.error = error
|
||||||
|
|
||||||
|
self.job_history.append(self.current_job)
|
||||||
|
self._notify_update()
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get the current status of all jobs."""
|
||||||
|
return {
|
||||||
|
"current_job": self.current_job.to_dict() if self.current_job else None,
|
||||||
|
"job_history": [job.to_dict() for job in self.job_history[-10:]], # Last 10 jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_current_step(self) -> Optional[Step]:
|
||||||
|
"""Get the currently active step."""
|
||||||
|
if not self.current_job:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for step in self.current_job.steps:
|
||||||
|
if step.status == StepStatus.IN_PROGRESS:
|
||||||
|
return step
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Global progress tracker instance
|
||||||
|
progress_tracker = ProgressTracker()
|
||||||
Loading…
Reference in new issue