-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-{% endblock %}
+
+
+
diff --git a/GUI/static/css/app.css b/GUI/static/css/app.css
new file mode 100644
index 0000000..04ff831
--- /dev/null
+++ b/GUI/static/css/app.css
@@ -0,0 +1,774 @@
+/* Reddit Video Maker Bot - Main Application 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);
+ --sidebar-width: 240px;
+}
+
+* {
+ 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;
+}
+
+/* Sidebar Navigation */
+.sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: var(--sidebar-width);
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
+ padding: 20px 0;
+ overflow-y: auto;
+ z-index: 100;
+}
+
+.sidebar .logo {
+ padding: 20px;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 20px;
+}
+
+.sidebar .logo h2 {
+ font-size: 1.2rem;
+ background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.nav-links {
+ list-style: none;
+}
+
+.nav-links li a {
+ display: block;
+ padding: 12px 24px;
+ color: var(--text-secondary);
+ text-decoration: none;
+ transition: all 0.2s ease;
+ border-left: 3px solid transparent;
+}
+
+.nav-links li a:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.nav-links li a.active {
+ background: rgba(255, 69, 0, 0.1);
+ color: var(--accent-primary);
+ border-left-color: var(--accent-primary);
+}
+
+/* Main Content */
+.content {
+ margin-left: var(--sidebar-width);
+ padding: 30px;
+ min-height: 100vh;
+}
+
+.content header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 30px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.content header h1 {
+ font-size: 2rem;
+ font-weight: 600;
+}
+
+.header-actions {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+/* 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 h3 {
+ font-size: 1.3rem;
+ margin-bottom: 8px;
+ color: var(--text-primary);
+}
+
+.card-description {
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ margin-bottom: 20px;
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.card-wide {
+ grid-column: span 2;
+}
+
+/* Dashboard Grid */
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 24px;
+}
+
+@media (max-width: 1024px) {
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+ .card-wide {
+ grid-column: span 1;
+ }
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 20px;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-decoration: none;
+ gap: 8px;
+}
+
+.btn-primary {
+ background: var(--accent-primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-secondary);
+}
+
+.btn-primary:disabled {
+ background: var(--bg-tertiary);
+ color: var(--text-muted);
+ cursor: not-allowed;
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-secondary);
+ border-color: var(--text-muted);
+}
+
+.btn-danger {
+ background: var(--error);
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #d32f2f;
+}
+
+.btn-danger:disabled {
+ background: var(--bg-tertiary);
+ color: var(--text-muted);
+ cursor: not-allowed;
+}
+
+.btn-small {
+ padding: 6px 12px;
+ font-size: 0.8rem;
+}
+
+.btn-large {
+ padding: 14px 28px;
+ font-size: 1rem;
+ width: 100%;
+ margin-bottom: 12px;
+}
+
+.action-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 12px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-size: 0.95rem;
+ transition: border-color 0.2s ease;
+}
+
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: var(--accent-primary);
+}
+
+.form-group input::placeholder {
+ color: var(--text-muted);
+}
+
+.form-group small {
+ display: block;
+ margin-top: 6px;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+.form-group input[type="range"] {
+ padding: 0;
+ height: 8px;
+ background: var(--bg-tertiary);
+ border: none;
+ cursor: pointer;
+}
+
+.form-group input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 20px;
+ height: 20px;
+ background: var(--accent-primary);
+ border-radius: 50%;
+ cursor: pointer;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+}
+
+@media (max-width: 600px) {
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+.input-with-prefix {
+ display: flex;
+ align-items: stretch;
+}
+
+.input-with-prefix .prefix {
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-right: none;
+ border-radius: 8px 0 0 8px;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.input-with-prefix input {
+ border-radius: 0 8px 8px 0;
+}
+
+.checkbox-group label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ font-weight: 400;
+}
+
+.checkbox-group input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+}
+
+/* Status Indicator */
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ background: var(--bg-tertiary);
+ border-radius: 20px;
+}
+
+.status-indicator .dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--success);
+}
+
+.status-indicator.running .dot {
+ background: var(--info);
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* Config Summary */
+.config-summary .config-item {
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.config-summary .config-item:last-child {
+ border-bottom: none;
+}
+
+.config-summary .warning {
+ color: var(--warning);
+}
+
+.config-summary a {
+ color: var(--accent-primary);
+}
+
+/* Progress */
+.progress-info {
+ padding: 16px;
+ background: var(--bg-tertiary);
+ border-radius: 8px;
+}
+
+.progress-title {
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.progress-subreddit {
+ color: var(--accent-primary);
+ font-size: 0.9rem;
+ margin-bottom: 16px;
+}
+
+.progress-bar-container {
+ height: 8px;
+ background: var(--bg-primary);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+
+.progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+.progress-percent {
+ text-align: right;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 16px;
+}
+
+.progress-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.progress-steps .step {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border-radius: 6px;
+}
+
+.progress-steps .step.completed {
+ color: var(--success);
+}
+
+.progress-steps .step.in_progress {
+ color: var(--info);
+}
+
+.progress-steps .step.failed {
+ color: var(--error);
+}
+
+.step-icon {
+ width: 20px;
+ text-align: center;
+}
+
+.no-progress {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-muted);
+}
+
+.no-progress .hint {
+ margin-top: 10px;
+ font-size: 0.9rem;
+}
+
+/* Video Items */
+.video-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.video-item:last-child {
+ border-bottom: none;
+}
+
+.video-name {
+ font-weight: 500;
+}
+
+.video-subreddit {
+ color: var(--accent-primary);
+ font-size: 0.85rem;
+}
+
+/* Videos Grid */
+.videos-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 24px;
+}
+
+.video-card .video-preview {
+ margin: -24px -24px 16px -24px;
+ border-radius: 12px 12px 0 0;
+ overflow: hidden;
+ background: #000;
+}
+
+.video-card .video-preview video {
+ width: 100%;
+ max-height: 300px;
+ display: block;
+}
+
+.video-card .video-info {
+ margin-bottom: 16px;
+}
+
+.video-card .video-name {
+ font-size: 1rem;
+ margin-top: 4px;
+ word-break: break-word;
+}
+
+.video-card .video-meta {
+ display: flex;
+ gap: 16px;
+ margin-top: 8px;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.video-card .video-actions {
+ display: flex;
+ gap: 8px;
+}
+
+/* Media Grid */
+.media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.media-item {
+ background: var(--bg-tertiary);
+ border-radius: 8px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.media-preview {
+ border-radius: 6px;
+ overflow: hidden;
+ background: #000;
+}
+
+.media-preview video {
+ width: 100%;
+ height: 160px;
+ object-fit: cover;
+}
+
+.media-preview audio {
+ width: 100%;
+}
+
+.media-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.media-name {
+ font-weight: 500;
+}
+
+.media-size {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--text-secondary);
+}
+
+.empty-state h3 {
+ margin-bottom: 10px;
+}
+
+.empty-state p {
+ margin-bottom: 20px;
+}
+
+.empty-state .hint {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+}
+
+/* Loading */
+.loading {
+ text-align: center;
+ padding: 40px;
+ color: var(--text-muted);
+}
+
+/* Error */
+.error {
+ text-align: center;
+ padding: 40px;
+ color: var(--error);
+}
+
+/* Upload Progress */
+.upload-progress {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 16px 24px;
+ box-shadow: var(--card-shadow);
+ z-index: 1000;
+}
+
+.upload-progress.hidden {
+ display: none;
+}
+
+.upload-progress-bar {
+ width: 200px;
+ height: 6px;
+ background: var(--bg-tertiary);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+
+.upload-progress-fill {
+ height: 100%;
+ background: var(--accent-primary);
+ animation: indeterminate 1.5s infinite linear;
+}
+
+@keyframes indeterminate {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(200%); }
+}
+
+/* Status Text */
+.status-text {
+ margin-left: 12px;
+ font-size: 0.9rem;
+}
+
+.status-text.success {
+ color: var(--success);
+}
+
+.status-text.error {
+ color: var(--error);
+}
+
+/* Filter Select */
+.filter-select {
+ padding: 10px 16px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-size: 0.9rem;
+}
+
+/* Notifications */
+.notification {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ padding: 16px 24px;
+ border-radius: 8px;
+ font-weight: 500;
+ z-index: 1000;
+ animation: slideIn 0.3s ease;
+}
+
+.notification.success {
+ background: var(--success);
+ color: white;
+}
+
+.notification.error {
+ background: var(--error);
+ color: white;
+}
+
+.notification.info {
+ background: var(--info);
+ color: white;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sidebar {
+ width: 100%;
+ height: auto;
+ position: relative;
+ border-right: none;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .sidebar .logo {
+ display: none;
+ }
+
+ .nav-links {
+ display: flex;
+ overflow-x: auto;
+ padding: 0 10px;
+ }
+
+ .nav-links li a {
+ padding: 12px 16px;
+ white-space: nowrap;
+ border-left: none;
+ border-bottom: 3px solid transparent;
+ }
+
+ .nav-links li a.active {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent-primary);
+ }
+
+ .content {
+ margin-left: 0;
+ padding: 20px;
+ }
+
+ .content header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+ }
+
+ .content header h1 {
+ font-size: 1.5rem;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .videos-grid,
+ .media-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/GUI/static/js/app.js b/GUI/static/js/app.js
new file mode 100644
index 0000000..25535da
--- /dev/null
+++ b/GUI/static/js/app.js
@@ -0,0 +1,174 @@
+/**
+ * Reddit Video Maker Bot - Shared JavaScript Utilities
+ */
+
+// Show notification toast
+function showNotification(message, type = 'info') {
+ const notif = document.createElement('div');
+ notif.className = `notification ${type}`;
+ notif.textContent = message;
+ document.body.appendChild(notif);
+ setTimeout(() => {
+ notif.style.opacity = '0';
+ notif.style.transform = 'translateY(20px)';
+ setTimeout(() => notif.remove(), 300);
+ }, 3000);
+}
+
+// Format file size
+function formatBytes(bytes, decimals = 2) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+}
+
+// Format duration in seconds to mm:ss
+function formatDuration(seconds) {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+// Format ISO date string to readable format
+function formatDate(isoString) {
+ const date = new Date(isoString);
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+// Debounce function for search inputs
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
+// Escape HTML to prevent XSS
+function escapeHtml(unsafe) {
+ return unsafe
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+// Copy text to clipboard
+async function copyToClipboard(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ showNotification('Copied to clipboard!', 'success');
+ } catch (err) {
+ showNotification('Failed to copy', 'error');
+ }
+}
+
+// API helper functions
+const api = {
+ get: async (url) => {
+ const response = await fetch(url);
+ return response.json();
+ },
+
+ post: async (url, data) => {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ return response.json();
+ },
+
+ delete: async (url) => {
+ const response = await fetch(url, { method: 'DELETE' });
+ return response.json();
+ },
+
+ upload: async (url, file, onProgress) => {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ const formData = new FormData();
+ formData.append('file', file);
+
+ xhr.upload.addEventListener('progress', (e) => {
+ if (e.lengthComputable && onProgress) {
+ onProgress((e.loaded / e.total) * 100);
+ }
+ });
+
+ xhr.addEventListener('load', () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(JSON.parse(xhr.responseText));
+ } else {
+ reject(new Error(xhr.statusText));
+ }
+ });
+
+ xhr.addEventListener('error', () => reject(new Error('Upload failed')));
+ xhr.open('POST', url);
+ xhr.send(formData);
+ });
+ }
+};
+
+// WebSocket connection manager for progress updates
+class ProgressSocket {
+ constructor(namespace = '/progress') {
+ this.socket = null;
+ this.namespace = namespace;
+ this.callbacks = [];
+ }
+
+ connect() {
+ if (typeof io !== 'undefined') {
+ this.socket = io(this.namespace);
+
+ this.socket.on('connect', () => {
+ console.log('Connected to progress socket');
+ });
+
+ this.socket.on('disconnect', () => {
+ console.log('Disconnected from progress socket');
+ });
+
+ this.socket.on('progress_update', (data) => {
+ this.callbacks.forEach(cb => cb(data));
+ });
+ }
+ }
+
+ onUpdate(callback) {
+ this.callbacks.push(callback);
+ }
+
+ requestStatus() {
+ if (this.socket) {
+ this.socket.emit('request_status');
+ }
+ }
+
+ disconnect() {
+ if (this.socket) {
+ this.socket.disconnect();
+ }
+ }
+}
+
+// Export for use in other scripts
+window.showNotification = showNotification;
+window.formatBytes = formatBytes;
+window.formatDuration = formatDuration;
+window.formatDate = formatDate;
+window.debounce = debounce;
+window.escapeHtml = escapeHtml;
+window.copyToClipboard = copyToClipboard;
+window.api = api;
+window.ProgressSocket = ProgressSocket;
diff --git a/GUI/videos.html b/GUI/videos.html
new file mode 100644
index 0000000..2ba7a06
--- /dev/null
+++ b/GUI/videos.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+
Videos - Reddit Video Maker Bot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 14d0e43..865d45c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,8 @@ services:
ports:
- "5000:5000"
volumes:
- - ./config.toml:/app/config.toml:ro
+ # Config is read-write so UI can save settings
+ - ./config.toml:/app/config.toml
- ./results:/app/results
- ./assets:/app/assets
environment:
@@ -19,18 +20,20 @@ services:
- REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit}
- REDDIT_REQUEST_DELAY=${REDDIT_REQUEST_DELAY:-2.0}
- REDDIT_RANDOM=${REDDIT_RANDOM:-true}
- # TTS Settings (Qwen TTS)
+ # TTS Settings
- TTS_VOICE_CHOICE=${TTS_VOICE_CHOICE:-qwentts}
- - QWEN_API_URL=${QWEN_API_URL:-http://qwen-tts:8080}
+ - 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}
networks:
- reddit-bot-network
- depends_on:
- - qwen-tts
+ # Run the web UI by default
+ command: python progress_gui.py
+ # Optional: Qwen TTS Server (if running locally)
+ # Uncomment or use: docker-compose --profile with-tts up -d
qwen-tts:
image: qwen-tts-server:latest
container_name: qwen-tts
@@ -41,6 +44,8 @@ services:
- TTS_MODEL=qwen3-tts
networks:
- reddit-bot-network
+ profiles:
+ - with-tts
# Uncomment if using GPU
# deploy:
# resources:
@@ -50,27 +55,6 @@ services:
# 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
diff --git a/progress_gui.py b/progress_gui.py
index 98485cd..1107272 100644
--- a/progress_gui.py
+++ b/progress_gui.py
@@ -1,30 +1,129 @@
#!/usr/bin/env python
"""
-Progress GUI for Reddit Video Maker Bot.
-Real-time progress tracking with steps and previews.
+Reddit Video Maker Bot - Web UI
+Complete web interface for configuration, video generation, and progress tracking.
"""
import os
import json
+import subprocess
import threading
-import webbrowser
+import shutil
from pathlib import Path
+from datetime import datetime
-from flask import Flask, render_template, send_from_directory, jsonify, request
+import toml
+import requests
+from flask import Flask, render_template, send_from_directory, jsonify, request, redirect, url_for
from flask_socketio import SocketIO, emit
+from werkzeug.utils import secure_filename
from utils.progress import progress_tracker
# Configuration
HOST = "0.0.0.0"
PORT = 5000
+CONFIG_PATH = Path("config.toml")
+BACKGROUNDS_VIDEO_PATH = Path("assets/backgrounds/video")
+BACKGROUNDS_AUDIO_PATH = Path("assets/backgrounds/audio")
+RESULTS_PATH = Path("results")
+ALLOWED_VIDEO_EXTENSIONS = {'mp4', 'webm', 'mov', 'avi', 'mkv'}
+ALLOWED_AUDIO_EXTENSIONS = {'mp3', 'wav', 'ogg', 'm4a'}
+
+# Ensure directories exist
+BACKGROUNDS_VIDEO_PATH.mkdir(parents=True, exist_ok=True)
+BACKGROUNDS_AUDIO_PATH.mkdir(parents=True, exist_ok=True)
+RESULTS_PATH.mkdir(parents=True, exist_ok=True)
# Configure Flask app
app = Flask(__name__, template_folder="GUI", static_folder="GUI/static")
app.secret_key = os.urandom(24)
+app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB max upload
# Configure SocketIO for real-time updates
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="gevent")
+# Track running generation process
+generation_process = None
+generation_thread = None
+
+
+def allowed_video_file(filename):
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_VIDEO_EXTENSIONS
+
+
+def allowed_audio_file(filename):
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AUDIO_EXTENSIONS
+
+
+def load_config():
+ """Load current configuration."""
+ if CONFIG_PATH.exists():
+ try:
+ return toml.load(CONFIG_PATH)
+ except Exception as e:
+ print(f"Error loading config: {e}")
+ return {}
+
+
+def save_config(config):
+ """Save configuration to file."""
+ try:
+ with open(CONFIG_PATH, 'w') as f:
+ toml.dump(config, f)
+ return True
+ except Exception as e:
+ print(f"Error saving config: {e}")
+ return False
+
+
+def get_background_videos():
+ """Get list of available background videos."""
+ videos = []
+ if BACKGROUNDS_VIDEO_PATH.exists():
+ for f in BACKGROUNDS_VIDEO_PATH.iterdir():
+ if f.is_file() and allowed_video_file(f.name):
+ videos.append({
+ 'name': f.stem,
+ 'filename': f.name,
+ 'size': f.stat().st_size,
+ 'path': str(f)
+ })
+ return videos
+
+
+def get_background_audios():
+ """Get list of available background audio tracks."""
+ audios = []
+ if BACKGROUNDS_AUDIO_PATH.exists():
+ for f in BACKGROUNDS_AUDIO_PATH.iterdir():
+ if f.is_file() and allowed_audio_file(f.name):
+ audios.append({
+ 'name': f.stem,
+ 'filename': f.name,
+ 'size': f.stat().st_size,
+ 'path': str(f)
+ })
+ return audios
+
+
+def get_generated_videos():
+ """Get list of generated videos."""
+ videos = []
+ if RESULTS_PATH.exists():
+ for subreddit_dir in RESULTS_PATH.iterdir():
+ if subreddit_dir.is_dir():
+ for f in subreddit_dir.iterdir():
+ if f.is_file() and f.suffix.lower() == '.mp4':
+ videos.append({
+ 'name': f.stem,
+ 'filename': f.name,
+ 'subreddit': subreddit_dir.name,
+ 'size': f.stat().st_size,
+ 'created': datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
+ 'path': f"/results/{subreddit_dir.name}/{f.name}"
+ })
+ return sorted(videos, key=lambda x: x['created'], reverse=True)
+
# Progress update callback
def broadcast_progress(data):
@@ -45,55 +144,379 @@ def after_request(response):
return response
+# ==================== Pages ====================
+
@app.route("/")
def index():
- """Main progress dashboard."""
+ """Main dashboard - redirect to settings if not configured."""
+ config = load_config()
+ if not config:
+ return redirect(url_for('settings_page'))
+ return render_template("dashboard.html")
+
+
+@app.route("/settings")
+def settings_page():
+ """Settings/configuration page."""
+ return render_template("settings.html")
+
+
+@app.route("/backgrounds")
+def backgrounds_page():
+ """Background videos management page."""
+ return render_template("backgrounds.html")
+
+
+@app.route("/videos")
+def videos_page():
+ """Generated videos page."""
+ return render_template("videos.html")
+
+
+@app.route("/progress")
+def progress_page():
+ """Progress tracking page."""
return render_template("progress.html")
+# ==================== API Endpoints ====================
+
+@app.route("/api/config", methods=["GET"])
+def api_get_config():
+ """Get current configuration."""
+ config = load_config()
+ # Mask sensitive fields
+ if config.get('settings', {}).get('tts', {}).get('qwen_password'):
+ config['settings']['tts']['qwen_password'] = '********'
+ if config.get('settings', {}).get('tts', {}).get('openai_api_key'):
+ config['settings']['tts']['openai_api_key'] = '********'
+ return jsonify(config)
+
+
+@app.route("/api/config", methods=["POST"])
+def api_save_config():
+ """Save configuration."""
+ try:
+ data = request.json
+
+ # Load existing config to preserve masked fields
+ existing_config = load_config()
+
+ # Build new config
+ config = {
+ 'reddit': {
+ 'scraper': {
+ 'user_agent': data.get('user_agent', 'python:reddit_video_bot:1.0'),
+ 'request_delay': float(data.get('request_delay', 2.0))
+ },
+ 'thread': {
+ 'subreddit': data.get('subreddit', 'AskReddit'),
+ 'post_id': data.get('post_id', ''),
+ 'random': data.get('random', True),
+ 'max_comment_length': int(data.get('max_comment_length', 500)),
+ 'min_comment_length': int(data.get('min_comment_length', 1)),
+ 'min_comments': int(data.get('min_comments', 20)),
+ 'post_lang': data.get('post_lang', '')
+ }
+ },
+ 'ai': {
+ 'ai_similarity_enabled': data.get('ai_similarity_enabled', False),
+ 'ai_similarity_keywords': data.get('ai_similarity_keywords', '')
+ },
+ 'settings': {
+ 'allow_nsfw': data.get('allow_nsfw', False),
+ 'theme': data.get('theme', 'dark'),
+ 'times_to_run': int(data.get('times_to_run', 1)),
+ 'opacity': float(data.get('opacity', 0.9)),
+ 'storymode': data.get('storymode', False),
+ 'storymodemethod': int(data.get('storymodemethod', 1)),
+ 'storymode_max_length': int(data.get('storymode_max_length', 1000)),
+ 'resolution_w': int(data.get('resolution_w', 1080)),
+ 'resolution_h': int(data.get('resolution_h', 1920)),
+ 'zoom': float(data.get('zoom', 1)),
+ 'channel_name': data.get('channel_name', 'Reddit Tales'),
+ 'background': {
+ 'background_video': data.get('background_video', 'minecraft'),
+ 'background_audio': data.get('background_audio', 'lofi'),
+ 'background_audio_volume': float(data.get('background_audio_volume', 0.15)),
+ 'enable_extra_audio': data.get('enable_extra_audio', False),
+ 'background_thumbnail': data.get('background_thumbnail', False)
+ },
+ 'tts': {
+ 'voice_choice': data.get('voice_choice', 'qwentts'),
+ 'random_voice': data.get('random_voice', True),
+ 'silence_duration': float(data.get('silence_duration', 0.3)),
+ 'no_emojis': data.get('no_emojis', False),
+ 'qwen_api_url': data.get('qwen_api_url', 'http://localhost:8080'),
+ 'qwen_email': data.get('qwen_email', ''),
+ 'qwen_speaker': data.get('qwen_speaker', 'Vivian'),
+ 'qwen_language': data.get('qwen_language', 'English'),
+ 'qwen_instruct': data.get('qwen_instruct', 'Warm, friendly, conversational.'),
+ 'elevenlabs_voice_name': data.get('elevenlabs_voice_name', 'Bella'),
+ 'elevenlabs_api_key': '',
+ 'tiktok_voice': data.get('tiktok_voice', 'en_us_001'),
+ 'tiktok_sessionid': data.get('tiktok_sessionid', ''),
+ 'openai_api_url': data.get('openai_api_url', 'https://api.openai.com/v1/'),
+ 'openai_voice_name': data.get('openai_voice_name', 'alloy'),
+ 'openai_model': data.get('openai_model', 'tts-1'),
+ 'aws_polly_voice': data.get('aws_polly_voice', 'Matthew'),
+ 'streamlabs_polly_voice': data.get('streamlabs_polly_voice', 'Matthew')
+ }
+ }
+ }
+
+ # Preserve password if not changed
+ if data.get('qwen_password') and data['qwen_password'] != '********':
+ config['settings']['tts']['qwen_password'] = data['qwen_password']
+ elif existing_config.get('settings', {}).get('tts', {}).get('qwen_password'):
+ config['settings']['tts']['qwen_password'] = existing_config['settings']['tts']['qwen_password']
+ else:
+ config['settings']['tts']['qwen_password'] = ''
+
+ # Preserve API keys if not changed
+ if data.get('openai_api_key') and data['openai_api_key'] != '********':
+ config['settings']['tts']['openai_api_key'] = data['openai_api_key']
+ elif existing_config.get('settings', {}).get('tts', {}).get('openai_api_key'):
+ config['settings']['tts']['openai_api_key'] = existing_config['settings']['tts']['openai_api_key']
+ else:
+ config['settings']['tts']['openai_api_key'] = ''
+
+ if save_config(config):
+ return jsonify({'success': True, 'message': 'Configuration saved successfully'})
+ else:
+ return jsonify({'success': False, 'message': 'Failed to save configuration'}), 500
+
+ except Exception as e:
+ return jsonify({'success': False, 'message': str(e)}), 500
+
+
@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."""
+@app.route("/api/backgrounds/video", methods=["GET"])
+def api_get_background_videos():
+ """Get list of background videos."""
+ return jsonify({'videos': get_background_videos()})
+
+
+@app.route("/api/backgrounds/audio", methods=["GET"])
+def api_get_background_audios():
+ """Get list of background audio tracks."""
+ return jsonify({'audios': get_background_audios()})
+
+
+@app.route("/api/backgrounds/video", methods=["POST"])
+def api_upload_background_video():
+ """Upload a background video."""
+ if 'file' not in request.files:
+ return jsonify({'success': False, 'message': 'No file provided'}), 400
+
+ file = request.files['file']
+ if file.filename == '':
+ return jsonify({'success': False, 'message': 'No file selected'}), 400
+
+ if file and allowed_video_file(file.filename):
+ filename = secure_filename(file.filename)
+ filepath = BACKGROUNDS_VIDEO_PATH / filename
+ file.save(filepath)
+ return jsonify({'success': True, 'message': f'Video "{filename}" uploaded successfully'})
+
+ return jsonify({'success': False, 'message': 'Invalid file type'}), 400
+
+
+@app.route("/api/backgrounds/audio", methods=["POST"])
+def api_upload_background_audio():
+ """Upload a background audio track."""
+ if 'file' not in request.files:
+ return jsonify({'success': False, 'message': 'No file provided'}), 400
+
+ file = request.files['file']
+ if file.filename == '':
+ return jsonify({'success': False, 'message': 'No file selected'}), 400
+
+ if file and allowed_audio_file(file.filename):
+ filename = secure_filename(file.filename)
+ filepath = BACKGROUNDS_AUDIO_PATH / filename
+ file.save(filepath)
+ return jsonify({'success': True, 'message': f'Audio "{filename}" uploaded successfully'})
+
+ return jsonify({'success': False, 'message': 'Invalid file type'}), 400
+
+
+@app.route("/api/backgrounds/video/
", methods=["DELETE"])
+def api_delete_background_video(filename):
+ """Delete a background video."""
+ filepath = BACKGROUNDS_VIDEO_PATH / secure_filename(filename)
+ if filepath.exists():
+ filepath.unlink()
+ return jsonify({'success': True, 'message': 'Video deleted'})
+ return jsonify({'success': False, 'message': 'File not found'}), 404
+
+
+@app.route("/api/backgrounds/audio/", methods=["DELETE"])
+def api_delete_background_audio(filename):
+ """Delete a background audio track."""
+ filepath = BACKGROUNDS_AUDIO_PATH / secure_filename(filename)
+ if filepath.exists():
+ filepath.unlink()
+ return jsonify({'success': True, 'message': 'Audio deleted'})
+ return jsonify({'success': False, 'message': 'File not found'}), 404
+
+
+@app.route("/api/videos", methods=["GET"])
+def api_get_videos():
+ """Get list of generated videos."""
+ return jsonify({'videos': get_generated_videos()})
+
+
+@app.route("/api/generate", methods=["POST"])
+def api_generate_video():
+ """Start video generation."""
+ global generation_process, generation_thread
+
+ if generation_process and generation_process.poll() is None:
+ return jsonify({'success': False, 'message': 'Generation already in progress'}), 400
+
+ config = load_config()
+ if not config:
+ return jsonify({'success': False, 'message': 'Please configure settings first'}), 400
+
+ def run_generation():
+ global generation_process
+ try:
+ generation_process = subprocess.Popen(
+ ['python', 'main.py'],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ cwd=str(Path(__file__).parent)
+ )
+ generation_process.wait()
+ except Exception as e:
+ print(f"Generation error: {e}")
+
+ generation_thread = threading.Thread(target=run_generation)
+ generation_thread.start()
+
+ return jsonify({'success': True, 'message': 'Video generation started'})
+
+
+@app.route("/api/generate/stop", methods=["POST"])
+def api_stop_generation():
+ """Stop video generation."""
+ global generation_process
+
+ if generation_process and generation_process.poll() is None:
+ generation_process.terminate()
+ return jsonify({'success': True, 'message': 'Generation stopped'})
+
+ return jsonify({'success': False, 'message': 'No generation in progress'})
+
+
+@app.route("/api/generate/status", methods=["GET"])
+def api_generation_status():
+ """Get generation status."""
+ global generation_process
+
+ running = generation_process and generation_process.poll() is None
return jsonify({
- "jobs": [job.to_dict() for job in progress_tracker.job_history]
+ 'running': running,
+ 'progress': progress_tracker.get_status()
})
-# Serve static files
+@app.route("/api/tts/test", methods=["POST"])
+def api_test_tts():
+ """Test Qwen TTS connection."""
+ try:
+ data = request.json
+ api_url = data.get('qwen_api_url', '').rstrip('/')
+ email = data.get('qwen_email', '')
+ password = data.get('qwen_password', '')
+
+ if not api_url or not email or not password:
+ return jsonify({'success': False, 'message': 'Missing required fields'})
+
+ # Test authentication
+ login_url = f"{api_url}/api/agent/api/auth/login"
+ auth_response = requests.post(
+ login_url,
+ json={'email': email, 'password': password},
+ headers={'Content-Type': 'application/json'},
+ timeout=10
+ )
+
+ if auth_response.status_code == 200:
+ result = auth_response.json()
+ if 'access_token' in result:
+ return jsonify({'success': True, 'message': 'Authentication successful'})
+ else:
+ return jsonify({'success': False, 'message': 'Invalid response from server'})
+ elif auth_response.status_code == 401:
+ return jsonify({'success': False, 'message': 'Invalid credentials'})
+ else:
+ return jsonify({'success': False, 'message': f'Server error: {auth_response.status_code}'})
+
+ except requests.exceptions.ConnectionError:
+ return jsonify({'success': False, 'message': 'Cannot connect to TTS server'})
+ except requests.exceptions.Timeout:
+ return jsonify({'success': False, 'message': 'Connection timeout'})
+ except Exception as e:
+ return jsonify({'success': False, 'message': str(e)})
+
+
+@app.route("/api/backgrounds", methods=["GET"])
+def api_get_all_backgrounds():
+ """Get all backgrounds (videos and audio)."""
+ return jsonify({
+ 'videos': get_background_videos(),
+ 'audios': get_background_audios()
+ })
+
+
+# ==================== Static Files ====================
+
@app.route("/static/")
def static_files(filename):
"""Serve static files."""
return send_from_directory("GUI/static", filename)
-# Serve result videos
@app.route("/results/")
def results(name):
"""Serve result videos."""
return send_from_directory("results", name)
-# Serve preview images
@app.route("/preview/")
def previews(name):
"""Serve preview images."""
return send_from_directory("assets/temp", name)
-# Serve temp assets (screenshots, audio visualizations)
@app.route("/assets/")
def assets(name):
"""Serve asset files."""
return send_from_directory("assets", name)
-# SocketIO Events
+@app.route("/backgrounds/video/")
+def serve_background_video(name):
+ """Serve background videos."""
+ return send_from_directory(BACKGROUNDS_VIDEO_PATH, name)
+
+
+@app.route("/backgrounds/audio/")
+def serve_background_audio(name):
+ """Serve background audio."""
+ return send_from_directory(BACKGROUNDS_AUDIO_PATH, name)
+
+
+# ==================== SocketIO Events ====================
+
@socketio.on("connect", namespace="/progress")
def handle_connect():
"""Handle client connection."""
@@ -112,12 +535,15 @@ def handle_request_status():
emit("progress_update", progress_tracker.get_status())
+# ==================== Run Server ====================
+
def run_gui(open_browser=True):
- """Run the progress GUI server."""
+ """Run the GUI server."""
+ import webbrowser
if open_browser:
webbrowser.open(f"http://localhost:{PORT}", new=2)
- print(f"Progress GUI running at http://localhost:{PORT}")
+ print(f"Reddit Video Maker Bot UI running at http://localhost:{PORT}")
socketio.run(app, host=HOST, port=PORT, debug=False)