CesarPetrescu 3 weeks ago committed by GitHub
commit c2cdb3d20b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,12 +1,67 @@
FROM python:3.10.14-slim
RUN apt update
RUN apt-get install -y ffmpeg
RUN apt install python3-pip -y
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV REDDIT_BOT_GUI=true
RUN mkdir /app
ADD . /app
# Install system dependencies
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
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"]

@ -1,263 +1,210 @@
{% extends "layout.html" %}
{% block main %}
<!-- Delete Background Modal -->
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete background</h5>
</div>
<div class="modal-body">
Are you sure you want to delete this background?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<form action="background/delete" method="post">
<input type="hidden" id="background-key" name="background-key" value="">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backgrounds - Reddit Video Maker Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
</div>
</div>
<!-- Add Background Modal -->
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add background video</h5>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/backgrounds" class="active">Backgrounds</a></li>
<li><a href="/videos">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Backgrounds</h1>
</header>
<!-- Video Backgrounds -->
<div class="card">
<div class="card-header">
<h3>Background Videos</h3>
<label for="video-upload" class="btn btn-primary">
Upload Video
<input type="file" id="video-upload" accept=".mp4,.webm,.mov,.avi,.mkv" hidden>
</label>
</div>
<div class="modal-body">
<p class="card-description">Upload 16:9 landscape videos. They will be cropped/scaled to fit 9:16 portrait videos.</p>
<!-- Add video form -->
<form id="addBgForm" action="background/add" method="post" novalidate>
<div class="form-group row">
<label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-youtube"></i>
</div>
<input name="youtube_uri" placeholder="https://www.youtube.com/watch?v=..." type="text"
class="form-control">
</div>
<span id="feedbackYT" class="form-text feedback-invalid"></span>
</div>
</div>
<div class="form-group row">
<label for="filename" class="col-4 col-form-label">Filename</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-file-earmark"></i>
</div>
<input name="filename" placeholder="Example: cool-background" type="text"
class="form-control">
</div>
<span id="feedbackFilename" class="form-text feedback-invalid"></span>
</div>
</div>
<div class="form-group row">
<label for="citation" class="col-4 col-form-label">Credits (owner of the video)</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person-circle"></i>
</div>
<input name="citation" placeholder="YouTube Channel" type="text" class="form-control">
</div>
<span class="form-text text-muted">Include the channel name of the
owner of the background video you are adding.</span>
</div>
</div>
<div class="form-group row">
<label for="position" class="col-4 col-form-label">Position of screenshots</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-arrows-fullscreen"></i>
</div>
<input name="position" placeholder="Example: center" type="text" class="form-control">
</div>
<span class="form-text text-muted">Advanced option (you can leave it
empty). Valid options are "center" and decimal numbers</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button name="submit" type="submit" class="btn btn-success">Add background</button>
</form>
<div id="video-list" class="media-grid">
<div class="loading">Loading videos...</div>
</div>
</div>
</div>
</div>
<main>
<div class="album py-2 bg-light">
<div class="container">
<div class="row justify-content-between mt-2">
<div class="col-12 col-md-3 mb-3">
<input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
onkeyup="searchFilter()">
</div>
<div class="col-12 col-md-2 mb-3">
<button type="button" class="btn btn-primary form-control" data-toggle="modal"
data-target="#backgroundAddModal">
Add background video
</button>
</div>
<!-- Audio Backgrounds -->
<div class="card">
<div class="card-header">
<h3>Background Audio</h3>
<label for="audio-upload" class="btn btn-primary">
Upload Audio
<input type="file" id="audio-upload" accept=".mp3,.wav,.ogg,.m4a" hidden>
</label>
</div>
<p class="card-description">Upload background music tracks. Audio will loop to match video length.</p>
<div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds">
<div id="audio-list" class="media-grid">
<div class="loading">Loading audio...</div>
</div>
</div>
<!-- Upload Progress -->
<div id="upload-progress" class="upload-progress hidden">
<div class="upload-progress-bar">
<div class="upload-progress-fill"></div>
</div>
<span class="upload-progress-text">Uploading...</span>
</div>
</div>
</main>
</main>
<script>
var keys = [];
var youtube_urls = [];
<script src="/static/js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
loadVideos();
loadAudios();
// Show background videos
$(document).ready(function () {
$.getJSON("backgrounds.json",
function (data) {
delete data["__comment"];
var background = '';
$.each(data, function (key, value) {
// Add YT urls and keys (for validation)
keys.push(key);
youtube_urls.push(value[0]);
document.getElementById('video-upload').addEventListener('change', (e) => uploadFile(e, 'video'));
document.getElementById('audio-upload').addEventListener('change', (e) => uploadFile(e, 'audio'));
});
background += '<div class="col">';
background += '<div class="card shadow-sm">';
background += '<iframe class="bd-placeholder-img card-img-top" width="100%" height="225" src="https://www.youtube-nocookie.com/embed/' + value[0].split("?v=")[1] + '" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
background += '<div class="card-body">';
background += '<p class="card-text">' + value[2] + ' • ' + key + '</p>';
background += '<div class="d-flex justify-content-between align-items-center">';
background += '<div class="btn-group">';
background += '<button type="button" class="btn btn-outline-danger" data-toggle="modal" data-target="#deleteBtnModal" data-background-key="' + key + '">Delete</button>';
background += '</div>';
background += '</div>';
background += '</div>';
background += '</div>';
background += '</div>';
function loadVideos() {
fetch('/api/backgrounds/video')
.then(r => r.json())
.then(data => {
const container = document.getElementById('video-list');
if (data.videos && data.videos.length > 0) {
container.innerHTML = data.videos.map(v => `
<div class="media-item">
<div class="media-preview video-preview">
<video src="/backgrounds/video/${v.filename}" muted loop onmouseenter="this.play()" onmouseleave="this.pause(); this.currentTime=0;"></video>
</div>
<div class="media-info">
<span class="media-name">${v.name}</span>
<span class="media-size">${formatFileSize(v.size)}</span>
</div>
<button class="btn btn-danger btn-small" onclick="deleteVideo('${v.filename}')">Delete</button>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="empty-state">
<p>No custom background videos</p>
<p class="hint">Upload a 16:9 video to use as background</p>
</div>
`;
}
})
.catch(err => {
document.getElementById('video-list').innerHTML = '<div class="error">Error loading videos</div>';
});
$('#backgrounds').append(background);
});
});
// Add background key when deleting
$('#deleteBtnModal').on('show.bs.modal', function (event) {
var button = $(event.relatedTarget);
var key = button.data('background-key');
$('#background-key').prop('value', key);
});
var searchFilter = () => {
const input = document.querySelector(".searchFilter");
const cards = document.getElementsByClassName("col");
console.log(cards[1])
let filter = input.value
for (let i = 0; i < cards.length; i++) {
let title = cards[i].querySelector(".card-text");
if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
cards[i].classList.remove("d-none")
} else {
cards[i].classList.add("d-none")
}
}
}
// Validate form
$("#addBgForm").submit(function (event) {
$("#addBgForm input").each(function () {
if (!(validate($(this)))) {
event.preventDefault();
event.stopPropagation();
}
});
});
$('#addBgForm input[type="text"]').on("keyup", function () {
validate($(this));
});
function validate(object) {
let bool = check(object.prop("name"), object.prop("value"));
// Change class
if (bool) {
object.removeClass("is-invalid");
object.addClass("is-valid");
}
else {
object.removeClass("is-valid");
object.addClass("is-invalid");
function loadAudios() {
fetch('/api/backgrounds/audio')
.then(r => r.json())
.then(data => {
const container = document.getElementById('audio-list');
if (data.audios && data.audios.length > 0) {
container.innerHTML = data.audios.map(a => `
<div class="media-item">
<div class="media-preview audio-preview">
<audio src="/backgrounds/audio/${a.filename}" controls></audio>
</div>
<div class="media-info">
<span class="media-name">${a.name}</span>
<span class="media-size">${formatFileSize(a.size)}</span>
</div>
<button class="btn btn-danger btn-small" onclick="deleteAudio('${a.filename}')">Delete</button>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="empty-state">
<p>No custom background audio</p>
<p class="hint">Upload audio files to use as background music</p>
</div>
`;
}
})
.catch(err => {
document.getElementById('audio-list').innerHTML = '<div class="error">Error loading audio</div>';
});
}
return bool;
// Check values (return true/false)
function check(name, value) {
if (name == "youtube_uri") {
// URI validation
let regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
if (!(regex.test(value))) {
$("#feedbackYT").html("Invalid URI");
$("#feedbackYT").show();
return false;
}
// Check if this background already exists
if (youtube_urls.includes(value)) {
$("#feedbackYT").html("This background is already added");
$("#feedbackYT").show();
return false;
}
$("#feedbackYT").hide();
return true;
}
if (name == "filename") {
// Check if key is already taken
if (keys.includes(value)) {
$("#feedbackFilename").html("This filename is already taken");
$("#feedbackFilename").show();
return false;
function uploadFile(event, type) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const progressEl = document.getElementById('upload-progress');
progressEl.classList.remove('hidden');
fetch(`/api/backgrounds/${type}`, {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
progressEl.classList.add('hidden');
if (data.success) {
showNotification(data.message, 'success');
if (type === 'video') loadVideos();
else loadAudios();
} else {
showNotification(data.message, 'error');
}
})
.catch(err => {
progressEl.classList.add('hidden');
showNotification('Upload failed', 'error');
});
let regex = /^([a-zA-Z0-9\s_-]{1,100})$/;
if (!(regex.test(value))) {
return false;
}
// Reset file input
event.target.value = '';
}
return true;
}
function deleteVideo(filename) {
if (!confirm(`Delete video "${filename}"?`)) return;
if (name == "citation") {
if (value.trim()) {
return true;
}
}
fetch(`/api/backgrounds/video/${filename}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
if (data.success) loadVideos();
})
.catch(err => showNotification('Delete failed', 'error'));
}
if (name == "position") {
if (!(value == "center" || value.length == 0 || value % 1 == 0)) {
return false;
}
function deleteAudio(filename) {
if (!confirm(`Delete audio "${filename}"?`)) return;
return true;
}
fetch(`/api/backgrounds/audio/${filename}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
if (data.success) loadAudios();
})
.catch(err => showNotification('Delete failed', 'error'));
}
}
</script>
{% endblock %}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
</script>
</body>
</html>

@ -0,0 +1,233 @@
<!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 Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.6.0/socket.io.min.js"></script>
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
<ul class="nav-links">
<li><a href="/" class="active">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/backgrounds">Backgrounds</a></li>
<li><a href="/videos">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Dashboard</h1>
<div class="status-indicator" id="status-indicator">
<span class="dot"></span>
<span id="status-text">Ready</span>
</div>
</header>
<div class="dashboard-grid">
<!-- Quick Actions -->
<div class="card">
<h3>Quick Actions</h3>
<div class="action-buttons">
<button id="btn-generate" class="btn btn-primary btn-large">
Generate Video
</button>
<button id="btn-stop" class="btn btn-danger btn-large" disabled>
Stop Generation
</button>
</div>
</div>
<!-- Current Config -->
<div class="card">
<h3>Current Configuration</h3>
<div class="config-summary" id="config-summary">
<p>Loading...</p>
</div>
<a href="/settings" class="btn btn-secondary">Edit Settings</a>
</div>
<!-- Progress -->
<div class="card card-wide">
<h3>Current Progress</h3>
<div id="progress-container">
<div class="no-progress">
<p>No video generation in progress</p>
<p class="hint">Click "Generate Video" to start</p>
</div>
</div>
</div>
<!-- Recent Videos -->
<div class="card card-wide">
<h3>Recent Videos</h3>
<div id="recent-videos">
<p>Loading...</p>
</div>
<a href="/videos" class="btn btn-secondary">View All Videos</a>
</div>
</div>
</main>
<script src="/static/js/app.js"></script>
<script>
// Initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
loadRecentVideos();
initProgressSocket();
document.getElementById('btn-generate').addEventListener('click', startGeneration);
document.getElementById('btn-stop').addEventListener('click', stopGeneration);
});
function loadConfig() {
fetch('/api/config')
.then(r => r.json())
.then(config => {
const summary = document.getElementById('config-summary');
if (config && config.reddit) {
summary.innerHTML = `
<div class="config-item">
<strong>Subreddit:</strong> r/${config.reddit?.thread?.subreddit || 'Not set'}
</div>
<div class="config-item">
<strong>TTS:</strong> ${config.settings?.tts?.voice_choice || 'Not set'}
</div>
<div class="config-item">
<strong>Theme:</strong> ${config.settings?.theme || 'dark'}
</div>
<div class="config-item">
<strong>Resolution:</strong> ${config.settings?.resolution_w || 1080}x${config.settings?.resolution_h || 1920}
</div>
`;
} else {
summary.innerHTML = '<p class="warning">Not configured. <a href="/settings">Click here to configure.</a></p>';
}
});
}
function loadRecentVideos() {
fetch('/api/videos')
.then(r => r.json())
.then(data => {
const container = document.getElementById('recent-videos');
if (data.videos && data.videos.length > 0) {
const recent = data.videos.slice(0, 3);
container.innerHTML = recent.map(v => `
<div class="video-item">
<span class="video-name">${v.name}</span>
<span class="video-subreddit">r/${v.subreddit}</span>
<a href="${v.path}" target="_blank" class="btn btn-small">View</a>
</div>
`).join('');
} else {
container.innerHTML = '<p class="hint">No videos generated yet</p>';
}
});
}
function initProgressSocket() {
const socket = io('/progress');
socket.on('progress_update', (data) => {
updateProgress(data);
updateGenerationStatus(data);
});
}
function updateProgress(data) {
const container = document.getElementById('progress-container');
if (data.current_job) {
const job = data.current_job;
container.innerHTML = `
<div class="progress-info">
<div class="progress-title">${job.title}</div>
<div class="progress-subreddit">r/${job.subreddit}</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${job.overall_progress}%"></div>
</div>
<div class="progress-percent">${Math.round(job.overall_progress)}%</div>
<div class="progress-steps">
${job.steps.map(s => `
<div class="step ${s.status}">
<span class="step-icon">${getStepIcon(s.status)}</span>
<span class="step-name">${s.name}</span>
</div>
`).join('')}
</div>
</div>
`;
} else {
container.innerHTML = `
<div class="no-progress">
<p>No video generation in progress</p>
<p class="hint">Click "Generate Video" to start</p>
</div>
`;
}
}
function updateGenerationStatus(data) {
const btnGenerate = document.getElementById('btn-generate');
const btnStop = document.getElementById('btn-stop');
const statusIndicator = document.getElementById('status-indicator');
const statusText = document.getElementById('status-text');
if (data.current_job && data.current_job.status === 'in_progress') {
btnGenerate.disabled = true;
btnStop.disabled = false;
statusIndicator.classList.add('running');
statusText.textContent = 'Generating...';
} else {
btnGenerate.disabled = false;
btnStop.disabled = true;
statusIndicator.classList.remove('running');
statusText.textContent = 'Ready';
}
}
function getStepIcon(status) {
switch(status) {
case 'completed': return '✓';
case 'in_progress': return '◐';
case 'failed': return '✗';
default: return '○';
}
}
function startGeneration() {
fetch('/api/generate', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
showNotification('Video generation started!', 'success');
} else {
showNotification(data.message, 'error');
}
});
}
function stopGeneration() {
fetch('/api/generate/stop', { method: 'POST' })
.then(r => r.json())
.then(data => {
showNotification(data.message, data.success ? 'success' : 'error');
});
}
function showNotification(message, type) {
const notif = document.createElement('div');
notif.className = `notification ${type}`;
notif.textContent = message;
document.body.appendChild(notif);
setTimeout(() => notif.remove(), 3000);
}
</script>
</body>
</html>

@ -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>

@ -1,621 +1,390 @@
{% extends "layout.html" %}
{% block main %}
<main>
<br>
<div class="container">
<form id="settingsForm" action="/settings" method="post" novalidate>
<!-- Reddit Credentials -->
<p class="h4">Reddit Credentials</p>
<div class="row mb-2">
<label for="client_id" class="col-4">Client ID</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person"></i>
</div>
<input name="client_id" value="{{ data.client_id }}" placeholder="Your Reddit app's client ID"
type="text" class="form-control" data-toggle="tooltip"
data-original-title='Text under "personal use script" on www.reddit.com/prefs/apps'>
</div>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Reddit Video Maker Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/settings" class="active">Settings</a></li>
<li><a href="/backgrounds">Backgrounds</a></li>
<li><a href="/videos">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Settings</h1>
<button id="btn-save" class="btn btn-primary">Save All Settings</button>
</header>
<form id="settings-form">
<!-- Qwen TTS Settings -->
<div class="card">
<h3>Qwen TTS Settings</h3>
<p class="card-description">Configure your Qwen TTS server connection for text-to-speech generation.</p>
<div class="form-group">
<label for="qwen_api_url">API URL</label>
<input type="url" id="qwen_api_url" name="qwen_api_url" placeholder="http://localhost:8080">
<small>The base URL of your Qwen TTS server</small>
</div>
</div>
<div class="row mb-2">
<label for="client_secret" class="col-4">Client Secret</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-key-fill"></i>
</div>
<input name="client_secret" value="{{ data.client_secret }}"
placeholder="Your Reddit app's client secret" type="text" class="form-control"
data-toggle="tooltip" data-original-title='"Secret" on www.reddit.com/prefs/apps'>
<div class="form-row">
<div class="form-group">
<label for="qwen_email">Email</label>
<input type="email" id="qwen_email" name="qwen_email" placeholder="your@email.com">
</div>
</div>
</div>
<div class="row mb-2">
<label for="username" class="col-4">Reddit Username</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-person-fill"></i>
</div>
<input name="username" value="{{ data.username }}" placeholder="Your Reddit account's username"
type="text" class="form-control">
<div class="form-group">
<label for="qwen_password">Password</label>
<input type="password" id="qwen_password" name="qwen_password" placeholder="Enter password">
</div>
</div>
</div>
<div class="row mb-2">
<label for="password" class="col-4">Reddit Password</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-lock-fill"></i>
</div>
<input name="password" value="{{ data.password }}" placeholder="Your Reddit account's password"
type="password" class="form-control">
<div class="form-row">
<div class="form-group">
<label for="qwen_speaker">Speaker Voice</label>
<select id="qwen_speaker" name="qwen_speaker">
<option value="Chelsie">Chelsie</option>
<option value="Ethan">Ethan</option>
<option value="Vivian" selected>Vivian</option>
<option value="Asher">Asher</option>
<option value="Aria">Aria</option>
<option value="Oliver">Oliver</option>
<option value="Emma">Emma</option>
<option value="Noah">Noah</option>
<option value="Sophia">Sophia</option>
</select>
</div>
</div>
</div>
<div class="row mb-2">
<label class="col-4">Do you have Reddit 2FA enabled?</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="2fa" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
data-original-title='Check it if you have enabled 2FA on your Reddit account'>
<div class="form-group">
<label for="qwen_language">Language</label>
<select id="qwen_language" name="qwen_language">
<option value="English" selected>English</option>
<option value="Chinese">Chinese</option>
<option value="Spanish">Spanish</option>
<option value="French">French</option>
<option value="German">German</option>
<option value="Japanese">Japanese</option>
<option value="Korean">Korean</option>
</select>
</div>
<span class="form-text text-muted"><a
href="https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api"
target="_blank">Need help? Click here to open step-by-step guide.</a></span>
</div>
</div>
<!-- Reddit Thread -->
<p class="h4">Reddit Thread</p>
<div class="row mb-2">
<label class="col-4">Random Thread</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="random" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
data-original-title='If disabled, it will ask you for a thread link, instead of picking random one'>
</div>
</div>
</div>
<div class="row mb-2">
<label for="subreddit" class="col-4">Subreddit</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-reddit"></i>
</div>
<input value="{{ data.subreddit }}" name="subreddit" type="text" class="form-control"
placeholder="Subreddit to pull posts from (e.g. AskReddit)" data-toggle="tooltip"
data-original-title='You can have multiple subreddits,
add "+" between them (e.g. AskReddit+Redditdev)'>
</div>
<div class="form-group">
<label for="qwen_instruct">Voice Style Instructions</label>
<input type="text" id="qwen_instruct" name="qwen_instruct" placeholder="Warm, friendly, conversational.">
<small>Describe the speaking style you want</small>
</div>
<button type="button" id="btn-test-tts" class="btn btn-secondary">Test TTS Connection</button>
<span id="tts-status" class="status-text"></span>
</div>
<div class="row mb-2">
<label for="post_id" class="col-4">Post ID</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-file-text"></i>
</div>
<input value="{{ data.post_id }}" name="post_id" type="text" class="form-control"
placeholder="Used if you want to use a specific post (e.g. urdtfx)">
<!-- Reddit Settings -->
<div class="card">
<h3>Reddit Settings</h3>
<p class="card-description">Configure which subreddits to scrape for content. No API keys required!</p>
<div class="form-group">
<label for="subreddit">Subreddit</label>
<div class="input-with-prefix">
<span class="prefix">r/</span>
<input type="text" id="subreddit" name="subreddit" placeholder="AskReddit">
</div>
<small>Enter subreddit name without r/ prefix. Use + for multiple (e.g., AskReddit+nosleep)</small>
</div>
</div>
<div class="row mb-2">
<label for="max_comment_length" class="col-4">Max Comment Length</label>
<div class="col-8">
<div class="input-group">
<input name="max_comment_length" type="range" class="form-range" min="10" max="10000" step="1"
value="{{ data.max_comment_length }}" data-toggle="tooltip"
data-original-title="{{ data.max_comment_length }}">
<div class="form-row">
<div class="form-group">
<label for="min_comments">Minimum Comments</label>
<input type="number" id="min_comments" name="min_comments" value="20" min="1">
</div>
<span class="form-text text-muted">Max number of characters a comment can have.</span>
</div>
</div>
<div class="row mb-2">
<label for="post_lang" class="col-4">Post Language</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-translate"></i>
</div>
<input value="{{ data.post_lang }}" name="post_lang" type="text" class="form-control"
placeholder="The language you would like to translate to">
<div class="form-group">
<label for="max_comment_length">Max Comment Length</label>
<input type="number" id="max_comment_length" name="max_comment_length" value="500" min="50">
</div>
</div>
</div>
<div class="row mb-2">
<label for="min_comments" class="col-4">Minimum Comments</label>
<div class="col-8">
<div class="input-group">
<input name="min_comments" type="range" class="form-range" min="15" max="1000" step="1"
value="{{ data.min_comments }}" data-toggle="tooltip"
data-original-title="{{ data.min_comments }}">
</div>
<span class="form-text text-muted">The minimum number of comments a post should have to be
included.</span>
<div class="form-group">
<label for="post_id">Specific Post ID (Optional)</label>
<input type="text" id="post_id" name="post_id" placeholder="Leave empty for random">
<small>Enter a specific Reddit post ID, or leave empty to fetch random posts</small>
</div>
</div>
<!-- General Settings -->
<p class="h4">General Settings</p>
<div class="row mb-2">
<label class="col-4">Allow NSFW</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="allow_nsfw" class="form-check-input" type="checkbox" value="True"
data-toggle="tooltip" data-original-title='If checked NSFW posts will be allowed'>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="random" name="random" checked>
Pick random posts from subreddit
</label>
</div>
</div>
<div class="row mb-2">
<label for="theme" class="col-4">Reddit Theme</label>
<div class="col-8">
<select name="theme" class="form-select" data-toggle="tooltip"
data-original-title='Sets the theme of Reddit screenshots'>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="allow_nsfw" name="allow_nsfw">
Allow NSFW content
</label>
</div>
</div>
<div class="row mb-2">
<label for="times_to_run" class="col-4">Times To Run</label>
<div class="col-8">
<div class="input-group">
<input name="times_to_run" type="range" class="form-range" min="1" max="1000" step="1"
value="{{ data.times_to_run }}" data-toggle="tooltip"
data-original-title="{{ data.times_to_run }}">
<!-- Video Settings -->
<div class="card">
<h3>Video Settings</h3>
<p class="card-description">Configure video output and visual settings.</p>
<div class="form-row">
<div class="form-group">
<label for="resolution_w">Width</label>
<input type="number" id="resolution_w" name="resolution_w" value="1080">
</div>
<span class="form-text text-muted">Used if you want to create multiple videos.</span>
</div>
</div>
<div class="row mb-2">
<label for="opacity" class="col-4">Opacity Of Comments</label>
<div class="col-8">
<div class="input-group">
<input name="opacity" type="range" class="form-range" min="0" max="1" step="0.05"
value="{{ data.opacity }}" data-toggle="tooltip" data-original-title="{{ data.opacity }}">
<div class="form-group">
<label for="resolution_h">Height</label>
<input type="number" id="resolution_h" name="resolution_h" value="1920">
</div>
<span class="form-text text-muted">Sets the opacity of the comments when overlayed over the
background.</span>
</div>
</div>
<div class="row mb-2">
<label for="transition" class="col-4">Transition</label>
<div class="col-8">
<div class="input-group">
<input name="transition" type="range" class="form-range" min="0" max="2" step="0.05"
value="{{ data.transition }}" data-toggle="tooltip"
data-original-title="{{ data.transition }}">
<div class="form-row">
<div class="form-group">
<label for="theme">Screenshot Theme</label>
<select id="theme" name="theme">
<option value="dark" selected>Dark</option>
<option value="light">Light</option>
<option value="transparent">Transparent</option>
</select>
</div>
<span class="form-text text-muted">Sets the transition time (in seconds) between the
comments. Set to 0 if you want to disable it.</span>
</div>
</div>
<div class="row mb-2">
<label for="background_choice" class="col-4">Background Choice</label>
<div class="col-8">
<select name="background_choice" class="form-select" data-toggle="tooltip"
data-original-title='Sets the background of the video'>
<option value=" ">Random Video</option>
{% for background in checks["background_video"]["options"][1:] %}
<option value="{{background}}">{{background}}</option>
{% endfor %}
</select>
<span class="form-text text-muted"><a href="/backgrounds" target="_blank">See all available
backgrounds</a></span>
</div>
</div>
<div class="row mb-2">
<label for="background_thumbnail" class="col-4">Background Thumbnail</label>
<div class="col-8">
<div class="form-check form-switch">
<input name="background_thumbnail" class="form-check-input" type="checkbox" value="True"
data-toggle="tooltip"
data-original-title='If checked a thumbnail will be added to the video (put a thumbnail.png file in the assets/backgrounds directory for it to be used.)'>
<div class="form-group">
<label for="opacity">Opacity</label>
<input type="number" id="opacity" name="opacity" value="0.9" min="0" max="1" step="0.1">
</div>
</div>
</div>
<div class="row mb-2">
<label for="background_thumbnail_font_family" class="col-4">Background Thumbnail Font Family (.ttf)</label>
<div class="col-8">
<input name="background_thumbnail_font_family" type="text" class="form-control"
placeholder="arial" value="{{ data.background_thumbnail_font_family }}">
</div>
</div>
<div class="row mb-2">
<label for="background_thumbnail_font_size" class="col-4">Background Thumbnail Font Size (px)</label>
<div class="col-8">
<input name="background_thumbnail_font_size" type="number" class="form-control"
placeholder="96" value="{{ data.background_thumbnail_font_size }}">
</div>
</div>
<!-- need to create a color picker -->
<div class="row mb-2">
<label for="background_thumbnail_font_color" class="col-4">Background Thumbnail Font Color (rgb)</label>
<div class="col-8">
<input name="background_thumbnail_font_color" type="text" class="form-control"
placeholder="255,255,255" value="{{ data.background_thumbnail_font_color }}">
</div>
</div>
<!-- TTS Settings -->
<p class="h4">TTS Settings</p>
<div class="row mb-2">
<label for="voice_choice" class="col-4">TTS Voice Choice</label>
<div class="col-8">
<select name="voice_choice" class="form-select" data-toggle="tooltip"
data-original-title='The voice platform used for TTS generation'>
<option value="streamlabspolly">Streamlabspolly</option>
<option value="tiktok">TikTok</option>
<option value="googletranslate">Google Translate</option>
<option value="awspolly">AWS Polly</option>
<option value="pyttsx">Python TTS (pyttsx)</option>
</select>
<div class="form-group">
<label for="times_to_run">Videos per Run</label>
<input type="number" id="times_to_run" name="times_to_run" value="1" min="1" max="10">
<small>How many videos to generate when you click "Generate"</small>
</div>
</div>
<div class="row mb-2">
<label for="aws_polly_voice" class="col-4">AWS Polly Voice</label>
<div class="col-8">
<div class="input-group voices">
<select name="aws_polly_voice" class="form-select" data-toggle="tooltip"
data-original-title='The voice used for AWS Polly'>
<option value="Brian">Brian</option>
<option value="Emma">Emma</option>
<option value="Russell">Russell</option>
<option value="Joey">Joey</option>
<option value="Matthew">Matthew</option>
<option value="Joanna">Joanna</option>
<option value="Kimberly">Kimberly</option>
<option value="Amy">Amy</option>
<option value="Geraint">Geraint</option>
<option value="Nicole">Nicole</option>
<option value="Justin">Justin</option>
<option value="Ivy">Ivy</option>
<option value="Kendra">Kendra</option>
<option value="Salli">Salli</option>
<option value="Raveena">Raveena</option>
</select>
<button type="button" class="btn btn-primary"><i id="awspolly_icon"
class="bi-volume-up-fill"></i></button>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="storymode" name="storymode">
Story Mode (for narrative subreddits like r/nosleep)
</label>
</div>
</div>
<div class="row mb-2">
<label for="streamlabs_polly_voice" class="col-4">Streamlabs Polly Voice</label>
<div class="col-8">
<div class="input-group voices">
<select id="streamlabs_polly_voice" name="streamlabs_polly_voice" class="form-select"
data-toggle="tooltip" data-original-title='The voice used for Streamlabs Polly'>
<option value="Brian">Brian</option>
<option value="Emma">Emma</option>
<option value="Russell">Russell</option>
<option value="Joey">Joey</option>
<option value="Matthew">Matthew</option>
<option value="Joanna">Joanna</option>
<option value="Kimberly">Kimberly</option>
<option value="Amy">Amy</option>
<option value="Geraint">Geraint</option>
<option value="Nicole">Nicole</option>
<option value="Justin">Justin</option>
<option value="Ivy">Ivy</option>
<option value="Kendra">Kendra</option>
<option value="Salli">Salli</option>
<option value="Raveena">Raveena</option>
</select>
<button type="button" class="btn btn-primary"><i id="streamlabs_icon"
class="bi bi-volume-up-fill"></i></button>
</div>
</div>
</div>
<div class="row mb-2">
<label for="tiktok_voice" class="col-4">TikTok Voice</label>
<div class="col-8">
<div class="input-group voices">
<select name="tiktok_voice" class="form-select" data-toggle="tooltip"
data-original-title='The voice used for TikTok TTS'>
<option disabled value="">-----Disney Voices-----</option>
<option value="en_us_ghostface">Ghost Face</option>
<option value="en_us_chewbacca">Chewbacca</option>
<option value="en_us_c3po">C3PO</option>
<option value="en_us_stitch">Stitch</option>
<option value="en_us_stormtrooper">Stormtrooper</option>
<option value="en_us_rocket">Rocket</option>
<option disabled value="">-----English Voices-----</option>
<option value="en_au_001">English AU - Female</option>
<option value="en_au_002">English AU - Male</option>
<option value="en_uk_001">English UK - Male 1</option>
<option value="en_uk_003">English UK - Male 2</option>
<option value="en_us_001">English US - Female (Int. 1)</option>
<option value="en_us_002">English US - Female (Int. 2)</option>
<option value="en_us_006">English US - Male 1</option>
<option value="en_us_007">English US - Male 2</option>
<option value="en_us_009">English US - Male 3</option>
<option value="en_us_010">English US - Male 4</option>
<option disabled value="">-----European Voices-----</option>
<option value="fr_001">French - Male 1</option>
<option value="fr_002">French - Male 2</option>
<option value="de_001">German - Female</option>
<option value="de_002">German - Male</option>
<option value="es_002">Spanish - Male</option>
<option disabled value="">-----American Voices-----</option>
<option value="es_mx_002">Spanish MX - Male</option>
<option value="br_001">Portuguese BR - Female 1</option>
<option value="br_003">Portuguese BR - Female 2</option>
<option value="br_004">Portuguese BR - Female 3</option>
<option value="br_005">Portuguese BR - Male</option>
<option disabled value="">-----Asian Voices-----</option>
<option value="id_001">Indonesian - Female</option>
<option value="jp_001">Japanese - Female 1</option>
<option value="jp_003">Japanese - Female 2</option>
<option value="jp_005">Japanese - Female 3</option>
<option value="jp_006">Japanese - Male</option>
<option value="kr_002">Korean - Male 1</option>
<option value="kr_003">Korean - Female</option>
<option value="kr_004">Korean - Male 2</option>
<!-- Background Settings -->
<div class="card">
<h3>Background Settings</h3>
<p class="card-description">Select background video and audio. Upload custom backgrounds in the Backgrounds page.</p>
<div class="form-row">
<div class="form-group">
<label for="background_video">Background Video</label>
<select id="background_video" name="background_video">
<option value="minecraft">Minecraft Parkour</option>
<option value="gta">GTA V</option>
<option value="rocket-league">Rocket League</option>
<option value="subway-surfers">Subway Surfers</option>
</select>
<button type="button" class="btn btn-primary"><i id="tiktok_icon"
class="bi-volume-up-fill"></i></button>
</div>
</div>
</div>
<div class="row mb-2">
<label for="tiktok_sessionid" class="col-4">TikTok SessionId</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-mic-fill"></i>
</div>
<input value="{{ data.tiktok_sessionid }}" name="tiktok_sessionid" type="text" class="form-control"
data-toggle="tooltip"
data-original-title="TikTok sessionid needed for the TTS API request. Check documentation if you don't know how to obtain it.">
</div>
</div>
</div>
<div class="row mb-2">
<label for="python_voice" class="col-4">Python Voice</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-mic-fill"></i>
</div>
<input value="{{ data.python_voice }}" name="python_voice" type="text" class="form-control"
data-toggle="tooltip"
data-original-title='The index of the system TTS voices (can be downloaded externally, run ptt.py to find value, start from zero)'>
</div>
</div>
</div>
<div class="row mb-2">
<label for="py_voice_num" class="col-4">Py Voice Number</label>
<div class="col-8">
<div class="input-group">
<div class="input-group-text">
<i class="bi bi-headset"></i>
</div>
<input value="{{ data.py_voice_num }}" name="py_voice_num" type="text" class="form-control"
data-toggle="tooltip"
data-original-title='The number of system voices (2 are pre-installed in Windows)'>
<div class="form-group">
<label for="background_audio">Background Audio</label>
<select id="background_audio" name="background_audio">
<option value="lofi">Lo-Fi Beats</option>
<option value="lofi-2">Lo-Fi Beats 2</option>
<option value="chill-summer">Chill Summer</option>
<option value="none">No Background Audio</option>
</select>
</div>
</div>
</div>
<div class="row mb-2">
<label for="silence_duration" class="col-4">Silence Duration</label>
<div class="col-8">
<div class="input-group">
<input name="silence_duration" type="range" class="form-range" min="0" max="5" step="0.05"
value="{{ data.silence_duration }}" data-toggle="tooltip"
data-original-title="{{ data.silence_duration }}">
</div>
<span class="form-text text-muted">Time in seconds between TTS comments.</span>
<div class="form-group">
<label for="background_audio_volume">Background Audio Volume</label>
<input type="range" id="background_audio_volume" name="background_audio_volume" min="0" max="1" step="0.05" value="0.15">
<span id="volume-display">15%</span>
</div>
</div>
<div class="col text-center">
<br>
<button id="defaultSettingsBtn" type="button" class="btn btn-secondary">Default
Settings</button>
<button id="submitButton" type="submit" class="btn btn-success">Save
Changes</button>
</div>
</form>
</div>
<audio src=""></audio>
</main>
<script>
// Test voices buttons
var playing = false;
$(".voices button").click(function () {
var icon = $(this).find("i");
var audio = $("audio");
if (playing) {
playing.toggleClass("bi-volume-up-fill bi-stop-fill");
// Clicked the same button - stop audio
if (playing.prop("id") == icon.prop("id")) {
audio[0].pause();
playing = false;
return;
}
}
icon.toggleClass("bi-volume-up-fill bi-stop-fill");
let path = "voices/" + $(this).closest(".voices").find("select").prop("value").toLowerCase() + ".mp3";
audio.prop("src", path);
audio[0].play();
playing = icon;
audio[0].onended = function () {
icon.toggleClass("bi-volume-up-fill bi-stop-fill");
playing = false;
}
});
// Wait for DOM to load
$(document).ready(function () {
// Add tooltips
$('[data-toggle="tooltip"]').tooltip();
$('[data-toggle="tooltip"]').on('click', function () {
$(this).tooltip('hide');
});
// Update slider tooltip
$(".form-range").on("input", function () {
$(this).attr("value", $(this).val());
$(this).attr("data-original-title", $(this).val());
$(this).tooltip("show");
});
// Get current config
var data = JSON.parse('{{data | tojson}}');
// Set current checkboxes
$('.form-check-input').each(function () {
$(this).prop("checked", data[$(this).prop("name")]);
});
// Set current select options
$('.form-select').each(function () {
$(this).prop("value", data[$(this).prop("name")]);
});
// Submit "False" when checkbox isn't ticked
$('#settingsForm').submit(function () {
$('.form-check-input').each(function () {
if (!($(this).is(':checked'))) {
$(this).prop("value", "False");
$(this).prop("checked", true);
}
</main>
<script src="/static/js/app.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadCustomBackgrounds();
document.getElementById('btn-save').addEventListener('click', saveSettings);
document.getElementById('btn-test-tts').addEventListener('click', testTTSConnection);
// Volume slider display
const volumeSlider = document.getElementById('background_audio_volume');
const volumeDisplay = document.getElementById('volume-display');
volumeSlider.addEventListener('input', () => {
volumeDisplay.textContent = Math.round(volumeSlider.value * 100) + '%';
});
});
function loadSettings() {
fetch('/api/config')
.then(r => r.json())
.then(config => {
if (!config || !config.settings) return;
// TTS settings
const tts = config.settings.tts || {};
setFieldValue('qwen_api_url', tts.qwen_api_url);
setFieldValue('qwen_email', tts.qwen_email);
setFieldValue('qwen_speaker', tts.qwen_speaker);
setFieldValue('qwen_language', tts.qwen_language);
setFieldValue('qwen_instruct', tts.qwen_instruct);
// Reddit settings
const thread = config.reddit?.thread || {};
setFieldValue('subreddit', thread.subreddit);
setFieldValue('min_comments', thread.min_comments);
setFieldValue('max_comment_length', thread.max_comment_length);
setFieldValue('post_id', thread.post_id);
setCheckbox('random', thread.random);
setCheckbox('allow_nsfw', config.settings.allow_nsfw);
// Video settings
setFieldValue('resolution_w', config.settings.resolution_w);
setFieldValue('resolution_h', config.settings.resolution_h);
setFieldValue('theme', config.settings.theme);
setFieldValue('opacity', config.settings.opacity);
setFieldValue('times_to_run', config.settings.times_to_run);
setCheckbox('storymode', config.settings.storymode);
// Background settings
const bg = config.settings.background || {};
setFieldValue('background_video', bg.background_video);
setFieldValue('background_audio', bg.background_audio);
setFieldValue('background_audio_volume', bg.background_audio_volume);
// Update volume display
const vol = bg.background_audio_volume || 0.15;
document.getElementById('volume-display').textContent = Math.round(vol * 100) + '%';
})
.catch(err => console.error('Error loading settings:', err));
}
// Get validation values
let validateChecks = JSON.parse('{{checks | tojson}}');
// Set default values
$("#defaultSettingsBtn").click(function (event) {
$("#settingsForm input, #settingsForm select").each(function () {
let check = validateChecks[$(this).prop("name")];
if (check["default"]) {
$(this).prop("value", check["default"]);
// Update tooltip value for input[type="range"]
if ($(this).prop("type") == "range") {
$(this).attr("data-original-title", check["default"]);
function loadCustomBackgrounds() {
fetch('/api/backgrounds/video')
.then(r => r.json())
.then(data => {
const videoSelect = document.getElementById('background_video');
// Add custom videos
if (data.videos && data.videos.length > 0) {
const customGroup = document.createElement('optgroup');
customGroup.label = 'Custom Videos';
data.videos.forEach(v => {
const opt = document.createElement('option');
opt.value = 'custom:' + v.filename;
opt.textContent = v.name;
customGroup.appendChild(opt);
});
videoSelect.appendChild(customGroup);
}
}
});
});
// Validate form
$('#settingsForm').submit(function (event) {
$("#settingsForm input, #settingsForm select").each(function () {
if (!(validate($(this)))) {
event.preventDefault();
event.stopPropagation();
$("html, body").animate({
scrollTop: $(this).offset().top
});
}
});
});
$("#settingsForm input").on("keyup", function () {
validate($(this));
});
$("#settingsForm select").on("change", function () {
validate($(this));
});
function validate(object) {
let bool = check(object.prop("name"), object.prop("value"));
})
.catch(err => console.error('Error loading backgrounds:', err));
}
// Change class
if (bool) {
object.removeClass("is-invalid");
object.addClass("is-valid");
}
else {
object.removeClass("is-valid");
object.addClass("is-invalid");
function setFieldValue(id, value) {
const field = document.getElementById(id);
if (field && value !== undefined && value !== null) {
field.value = value;
}
}
return bool;
// Check values (return true/false)
function check(name, value) {
let check = validateChecks[name];
// If value is empty - check if it's optional
if (value.length == 0) {
if (check["optional"] == false) {
return false;
}
else {
object.prop("value", check["default"]);
return true;
}
}
// Check if value is too short
if (check["nmin"]) {
if (check["type"] == "int" || check["type"] == "float") {
if (value < check["nmin"]) {
return false;
}
}
else {
if (value.length < check["nmin"]) {
return false;
}
}
}
// Check if value is too long
if (check["nmax"]) {
if (check["type"] == "int" || check["type"] == "float") {
if (value > check["nmax"]) {
return false;
}
}
else {
if (value.length > check["nmax"]) {
return false;
}
function setCheckbox(id, value) {
const field = document.getElementById(id);
if (field) {
field.checked = !!value;
}
}
}
function saveSettings() {
const form = document.getElementById('settings-form');
const config = {
subreddit: form.subreddit.value,
post_id: form.post_id.value,
min_comments: parseInt(form.min_comments.value),
max_comment_length: parseInt(form.max_comment_length.value),
random: form.random.checked,
allow_nsfw: form.allow_nsfw.checked,
theme: form.theme.value,
opacity: parseFloat(form.opacity.value),
times_to_run: parseInt(form.times_to_run.value),
storymode: form.storymode.checked,
resolution_w: parseInt(form.resolution_w.value),
resolution_h: parseInt(form.resolution_h.value),
voice_choice: 'qwentts',
qwen_api_url: form.qwen_api_url.value,
qwen_email: form.qwen_email.value,
qwen_password: form.qwen_password.value,
qwen_speaker: form.qwen_speaker.value,
qwen_language: form.qwen_language.value,
qwen_instruct: form.qwen_instruct.value,
background_video: form.background_video.value,
background_audio: form.background_audio.value,
background_audio_volume: parseFloat(form.background_audio_volume.value)
};
fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showNotification('Settings saved successfully!', 'success');
} else {
showNotification('Failed to save: ' + data.message, 'error');
}
})
.catch(err => showNotification('Error saving settings', 'error'));
}
// Check if value matches regex
if (check["regex"]) {
let regex = new RegExp(check["regex"]);
if (!(regex.test(value))) {
return false;
}
function testTTSConnection() {
const statusEl = document.getElementById('tts-status');
statusEl.textContent = 'Testing...';
statusEl.className = 'status-text';
const config = {
qwen_api_url: document.getElementById('qwen_api_url').value,
qwen_email: document.getElementById('qwen_email').value,
qwen_password: document.getElementById('qwen_password').value
};
fetch('/api/tts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
})
.then(r => r.json())
.then(data => {
if (data.success) {
statusEl.textContent = 'Connection successful!';
statusEl.className = 'status-text success';
} else {
statusEl.textContent = 'Failed: ' + data.message;
statusEl.className = 'status-text error';
}
return true;
}
})
.catch(err => {
statusEl.textContent = 'Connection error';
statusEl.className = 'status-text error';
});
}
});
</script>
{% endblock %}
</script>
</body>
</html>

@ -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;
}
}

@ -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,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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 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;

@ -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();
});

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Videos - Reddit Video Maker Bot</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<nav class="sidebar">
<div class="logo">
<h2>Reddit Video Bot</h2>
</div>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/backgrounds">Backgrounds</a></li>
<li><a href="/videos" class="active">Videos</a></li>
<li><a href="/progress">Progress</a></li>
</ul>
</nav>
<main class="content">
<header>
<h1>Generated Videos</h1>
<div class="header-actions">
<select id="filter-subreddit" class="filter-select">
<option value="">All Subreddits</option>
</select>
</div>
</header>
<div id="videos-container">
<div class="loading">Loading videos...</div>
</div>
</main>
<script src="/static/js/app.js"></script>
<script>
let allVideos = [];
document.addEventListener('DOMContentLoaded', () => {
loadVideos();
document.getElementById('filter-subreddit').addEventListener('change', filterVideos);
});
function loadVideos() {
fetch('/api/videos')
.then(r => r.json())
.then(data => {
allVideos = data.videos || [];
populateFilters();
renderVideos(allVideos);
})
.catch(err => {
document.getElementById('videos-container').innerHTML = '<div class="error">Error loading videos</div>';
});
}
function populateFilters() {
const subreddits = [...new Set(allVideos.map(v => v.subreddit))];
const select = document.getElementById('filter-subreddit');
subreddits.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = 'r/' + s;
select.appendChild(opt);
});
}
function filterVideos() {
const subreddit = document.getElementById('filter-subreddit').value;
const filtered = subreddit
? allVideos.filter(v => v.subreddit === subreddit)
: allVideos;
renderVideos(filtered);
}
function renderVideos(videos) {
const container = document.getElementById('videos-container');
if (videos.length === 0) {
container.innerHTML = `
<div class="empty-state card">
<h3>No Videos Yet</h3>
<p>Generated videos will appear here.</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
`;
return;
}
container.innerHTML = `
<div class="videos-grid">
${videos.map(v => `
<div class="video-card card">
<div class="video-preview">
<video src="${v.path}" controls preload="metadata"></video>
</div>
<div class="video-info">
<span class="video-subreddit">r/${v.subreddit}</span>
<h4 class="video-name">${v.name}</h4>
<div class="video-meta">
<span class="video-size">${formatFileSize(v.size)}</span>
<span class="video-date">${formatDate(v.created)}</span>
</div>
</div>
<div class="video-actions">
<a href="${v.path}" download class="btn btn-primary btn-small">Download</a>
<button class="btn btn-secondary btn-small" onclick="copyLink('${v.path}')">Copy Link</button>
</div>
</div>
`).join('')}
</div>
`;
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
function copyLink(path) {
const url = window.location.origin + path;
navigator.clipboard.writeText(url).then(() => {
showNotification('Link copied to clipboard!', 'success');
}).catch(() => {
showNotification('Failed to copy link', 'error');
});
}
</script>
</body>
</html>

@ -1,142 +1,258 @@
# 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. No Reddit API credentials required.
Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca)
## Features
<a target="_blank" href="https://tmrrwinc.ca">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/6053155/170528535-e274dc0b-7972-4b27-af22-637f8c370133.png">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png">
<img src="https://user-images.githubusercontent.com/6053155/170528582-cb6671e7-5a2f-4bd4-a048-0e6cfa54f0f7.png" width="350">
</picture>
- **No Reddit API Keys Needed**: Uses Reddit's public `.json` endpoints (no OAuth required)
- **Multiple TTS Engines**: Qwen3 TTS (default), OpenAI TTS, ElevenLabs, TikTok, Google Translate, AWS Polly
- **Real-time Progress GUI**: Web-based dashboard showing video generation progress with live updates
- **Docker Support**: Fully containerized with docker-compose for easy deployment
- **Background Customization**: Multiple background videos and audio tracks included
- **Story Mode**: Special mode for narrative subreddits (r/nosleep, r/tifu, etc.)
- **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
cd RedditVideoMakerBot
[![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png)
](https://www.youtube.com/watch?v=3gjcY_00U1w)
# Create your config file
cp config.example.toml config.toml
# Edit config.toml with your TTS settings (no Reddit credentials needed!)
## Motivation 🤔
# Start with docker-compose
docker-compose up -d
These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort.
The only original thing being done is the editing and gathering of all materials...
# View progress at http://localhost:5000
```
... but what if we can automate that process? 🤔
## Manual Installation
## Disclaimers 🚨
### Requirements
- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that
you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues.
- Python 3.10, 3.11, or 3.12
- FFmpeg
- Playwright browsers
## Requirements
### Setup
- Python 3.10
- Playwright (this should install automatically in installation)
```bash
# Clone repository
git clone https://github.com/elebumm/RedditVideoMakerBot.git
cd RedditVideoMakerBot
## Installation 👩‍💻
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: .\venv\Scripts\activate
1. Clone this repository:
```sh
git clone https://github.com/elebumm/RedditVideoMakerBot.git
cd RedditVideoMakerBot
```
# Install dependencies
pip install -r requirements.txt
2. Create and activate a virtual environment:
- On **Windows**:
```sh
python -m venv ./venv
.\venv\Scripts\activate
```
- On **macOS and Linux**:
```sh
python3 -m venv ./venv
source ./venv/bin/activate
```
# Install Playwright browsers
playwright install
playwright install-deps
3. Install the required dependencies:
```sh
pip install -r requirements.txt
```
# Download spaCy model (for story mode)
python -m spacy download en_core_web_sm
4. Install Playwright and its dependencies:
```sh
python -m playwright install
python -m playwright install-deps
```
# Copy and configure
cp config.example.toml config.toml
# Edit config.toml with your settings
---
# Run the bot
python main.py
```
**EXPERIMENTAL!!!!**
## Configuration
- 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)
- `bash <(curl -sL https://raw.githubusercontent.com/elebumm/RedditVideoMakerBot/master/install.sh)`
- This can also be used to update the installation
Create a `config.toml` file in the project root. The bot will prompt you for settings on first run.
---
### Reddit Settings (No API Keys Required!)
5. Run the bot:
```sh
python main.py
```
The bot scrapes Reddit's public `.json` endpoints - no API credentials needed:
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`.
```toml
[reddit.scraper]
user_agent = "python:reddit_video_bot:1.0" # Customize to avoid rate limiting
request_delay = 2.0 # Seconds between requests
7. The bot will prompt you to fill in your details to connect to the Reddit API and configure the bot to your liking.
[reddit.thread]
subreddit = "AskReddit" # Target subreddit
post_id = "" # Optional: specific post ID
min_comments = 20 # Minimum comments required
```
8. Enjoy 😎
**Note**: This approach is subject to Reddit's rate limiting. If you experience 429 errors, increase `request_delay`.
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.
### Qwen TTS Setup (Default)
(Note: If you encounter any errors installing or running the bot, try using `python3` or `pip3` instead of `python` or `pip`.)
Qwen TTS requires a running Qwen TTS server:
For a more detailed guide about the bot, please refer to the [documentation](https://reddit-video-maker-bot.netlify.app/).
```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."
```
**Qwen TTS API Usage:**
```bash
# 1. Login to get token
TOKEN=$(curl -s http://localhost:8080/api/agent/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"YOUR_PASSWORD"}' \
| python -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
# 2. Generate TTS
curl -s http://localhost:8080/api/qwen-tts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "Hello!", "language": "English", "speaker": "Vivian", "instruct": "Warm, friendly."}' \
--output output.wav
```
### TTS Options
| 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) |
## Progress GUI
The bot includes a real-time progress tracking GUI.
```bash
# Enable GUI mode
export REDDIT_BOT_GUI=true
python main.py
# Or run GUI standalone
python progress_gui.py
```
Access at: http://localhost:5000
### Features
- Real-time progress updates via WebSocket
- Step-by-step visualization
- Preview images during generation
- Job history tracking
## Docker Deployment
### Using Docker Compose
```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
```
### Environment Variables
| Variable | Description |
|----------|-------------|
| `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 |
## Project Structure
```
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 scraper (no-auth)
│ ├── scraper.py # Public .json endpoint scraper
│ └── subreddit.py # Thread fetcher
├── utils/ # Utilities
│ ├── progress.py # Progress tracking
│ └── settings.py # Configuration
├── GUI/ # Web GUI templates
│ ├── progress.html
│ └── static/
├── Dockerfile
└── docker-compose.yml
```
## Output
Generated videos are saved to `results/{subreddit}/`.
## Limitations
### Reddit Scraper Limitations
- **Rate Limiting**: Reddit may throttle or block requests. Increase `request_delay` if needed.
- **~1000 Post Cap**: Reddit listings are capped at ~1000 posts. Run daily for continuous collection.
- **Incomplete Comments**: Large threads may have missing comments (\"more\" placeholders are skipped).
- **Policy Compliance**: Respect Reddit's Terms of Service when using scraped content.
## Troubleshooting
### Common Issues
**Rate Limited (429 errors)**
```toml
# Increase delay in config.toml
[reddit.scraper]
request_delay = 5.0 # Try 5+ seconds
```
**FFmpeg not found**
```bash
# Ubuntu/Debian
sudo apt install ffmpeg
# macOS
brew install ffmpeg
# Windows
# Download from https://ffmpeg.org/download.html
```
**Playwright browsers missing**
```bash
playwright install
playwright install-deps
```
**TTS authentication failed**
- Verify your Qwen TTS server is running
- Check credentials in config.toml
- Ensure the API URL is correct
## License
## Video
https://user-images.githubusercontent.com/66544866/173453972-6526e4e6-c6ef-41c5-ab40-5d275e724e7c.mp4
## Contributing & Ways to improve 📈
In its current state, this bot does exactly what it needs to do. However, improvements can always be made!
I have tried to simplify the code so anyone can read it and start contributing at any skill level. Don't be shy :) contribute!
- [ ] Creating better documentation and adding a command line interface.
- [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.
### For any questions or support join the [Discord](https://discord.gg/qfQSx45xCV) server
## Developers and maintainers.
Elebumm (Lewis#6305) - https://github.com/elebumm (Founder)
Jason Cameron - https://github.com/JasonLovesDoggo (Maintainer)
Simon (OpenSourceSimon) - https://github.com/OpenSourceSimon
CallumIO (c.#6837) - https://github.com/CallumIO
Verq (Verq#2338) - https://github.com/CordlessCoder
LukaHietala (Pix.#0001) - https://github.com/LukaHietala
Freebiell (Freebie#3263) - https://github.com/FreebieII
Aman Raza (electro199#8130) - https://github.com/electro199
Cyteon (cyteon) - https://github.com/cyteon
## LICENSE
[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,82 @@
# Reddit Video Maker Bot Configuration
# Copy this file to config.toml and configure your settings
#
# NOTE: No Reddit API credentials required!
# This bot uses Reddit's public .json endpoints (no OAuth needed).
[reddit.scraper]
# User-Agent string for Reddit requests. Customize to avoid rate limiting.
user_agent = "python:reddit_video_bot:1.0"
# Delay in seconds between Reddit requests. Increase if you get rate limited.
request_delay = 2.0
[reddit.thread]
random = true
subreddit = "AskReddit" # Can also use "AskReddit+nosleep" for multiple subreddits
post_id = "" # Optional: specific post ID to use
max_comment_length = 500
min_comment_length = 1
post_lang = "" # Optional: translate to this language (e.g., "es", "fr")
min_comments = 20
[ai]
ai_similarity_enabled = false
ai_similarity_keywords = "" # Comma-separated keywords for AI sorting
[settings]
allow_nsfw = false
theme = "dark" # Options: dark, light, transparent
times_to_run = 1
opacity = 0.9
storymode = false # Use for narrative subreddits like r/nosleep
storymodemethod = 1 # 0 = single image, 1 = multiple images
storymode_max_length = 1000
resolution_w = 1080
resolution_h = 1920
zoom = 1
channel_name = "Reddit Tales"
[settings.background]
background_video = "minecraft" # Options: minecraft, gta, rocket-league, etc.
background_audio = "lofi" # Options: lofi, lofi-2, chill-summer
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" # Options: Chelsie, Ethan, Vivian, Asher, Aria, Oliver, Emma, Noah, Sophia
qwen_language = "English" # Options: English, Chinese, Spanish, French, German, Japanese, Korean, etc.
qwen_instruct = "Warm, friendly, conversational."
# OpenAI TTS Settings (alternative)
openai_api_url = "https://api.openai.com/v1/"
openai_api_key = ""
openai_voice_name = "alloy"
openai_model = "tts-1"
# ElevenLabs Settings (alternative)
elevenlabs_voice_name = "Bella"
elevenlabs_api_key = ""
# TikTok TTS Settings (alternative)
tiktok_voice = "en_us_001"
tiktok_sessionid = ""
# AWS Polly Settings (alternative)
aws_polly_voice = "Matthew"
# Streamlabs Polly Settings (alternative)
streamlabs_polly_voice = "Matthew"

@ -0,0 +1,64 @@
version: '3.8'
services:
reddit-video-bot:
build:
context: .
dockerfile: Dockerfile
container_name: reddit-video-bot
restart: unless-stopped
ports:
- "5000:5000"
volumes:
# Config is read-write so UI can save settings
- ./config.toml:/app/config.toml
- ./results:/app/results
- ./assets:/app/assets
environment:
- REDDIT_BOT_GUI=true
# Reddit Scraper Settings (no API keys required!)
- REDDIT_SUBREDDIT=${REDDIT_SUBREDDIT:-AskReddit}
- REDDIT_REQUEST_DELAY=${REDDIT_REQUEST_DELAY:-2.0}
- REDDIT_RANDOM=${REDDIT_RANDOM:-true}
# TTS Settings
- TTS_VOICE_CHOICE=${TTS_VOICE_CHOICE:-qwentts}
- 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
# 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
restart: unless-stopped
ports:
- "8080:8080"
environment:
- TTS_MODEL=qwen3-tts
networks:
- reddit-bot-network
profiles:
- with-tts
# Uncomment if using GPU
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
networks:
reddit-bot-network:
driver: bridge
volumes:
results:
assets:

@ -0,0 +1,80 @@
#!/bin/bash
set -e
# Create config from environment if not exists
if [ ! -f /app/config.toml ]; then
echo "Creating config.toml from template..."
echo "Note: No Reddit API credentials required - using public .json endpoints"
# Create basic config from environment
cat > /app/config.toml << EOF
# Reddit Video Maker Bot Configuration
# No Reddit API credentials required - uses public .json endpoints
[reddit.scraper]
user_agent = "${REDDIT_USER_AGENT:-python:reddit_video_bot:1.0}"
request_delay = ${REDDIT_REQUEST_DELAY:-2.0}
[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 "$@"

@ -1,20 +1,25 @@
#!/usr/bin/env python
"""
Reddit Video Maker Bot
Generates short-form videos from Reddit posts with Qwen TTS.
"""
import math
import os
import sys
from os import name
from pathlib import Path
from subprocess import Popen
from typing import Dict, NoReturn
from prawcore import ResponseException
from typing import Dict, NoReturn, Optional
from reddit.subreddit import get_subreddit_threads
from reddit.scraper import RedditScraperError
from utils import settings
from utils.cleanup import cleanup
from utils.console import print_markdown, print_step, print_substep
from utils.ffmpeg_install import ffmpeg_install
from utils.id import extract_id
from utils.version import checkversion
from utils.progress import progress_tracker
from video_creation.background import (
chop_background,
download_background_audio,
@ -25,7 +30,10 @@ from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
from video_creation.voices import save_text_to_mp3
__VERSION__ = "3.4.0"
__VERSION__ = "4.0.0"
# Check if GUI mode is enabled
GUI_MODE = os.environ.get("REDDIT_BOT_GUI", "false").lower() == "true"
print(
"""
@ -38,7 +46,7 @@ print(
"""
)
print_markdown(
"### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/"
"### Reddit Video Maker Bot v4.0 - Now with Qwen TTS and Progress GUI!"
)
checkversion(__VERSION__)
@ -46,25 +54,88 @@ reddit_id: str
reddit_object: Dict[str, str | list]
def main(POST_ID=None) -> None:
def main(POST_ID: Optional[str] = None) -> None:
"""Main video generation function with progress tracking."""
global reddit_id, reddit_object
reddit_object = get_subreddit_threads(POST_ID)
reddit_id = extract_id(reddit_object)
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
length, number_of_comments = save_text_to_mp3(reddit_object)
length = math.ceil(length)
get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
download_background_video(bg_config["video"])
download_background_audio(bg_config["audio"])
chop_background(bg_config, length, reddit_object)
make_final_video(number_of_comments, length, reddit_object, bg_config)
def run_many(times) -> None:
try:
# Step 1: Fetch Reddit Post
progress_tracker.start_step("fetch_reddit", "Connecting to Reddit API...")
reddit_object = get_subreddit_threads(POST_ID)
reddit_id = extract_id(reddit_object)
# Start job tracking
progress_tracker.start_job(
reddit_id=reddit_id,
title=reddit_object.get("thread_title", "Unknown"),
subreddit=reddit_object.get("subreddit", "Unknown"),
)
progress_tracker.update_step_progress("fetch_reddit", 50, f"Found post: {reddit_id}")
print_substep(f"Thread ID is {reddit_id}", style="bold blue")
progress_tracker.complete_step("fetch_reddit", f"Loaded {len(reddit_object.get('comments', []))} comments")
# Step 2: Generate TTS Audio
progress_tracker.start_step("generate_tts", "Initializing TTS engine...")
length, number_of_comments = save_text_to_mp3(reddit_object)
length = math.ceil(length)
progress_tracker.complete_step("generate_tts", f"Generated audio for {number_of_comments} comments ({length}s)")
# Step 3: Capture Screenshots
progress_tracker.start_step("capture_screenshots", "Launching browser...")
get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
# Set preview for screenshots
screenshot_preview = f"assets/temp/{reddit_id}/png/title.png"
if os.path.exists(screenshot_preview):
progress_tracker.set_step_preview("capture_screenshots", f"/assets/temp/{reddit_id}/png/title.png")
progress_tracker.complete_step("capture_screenshots", f"Captured {number_of_comments + 1} screenshots")
# Step 4: Download Background
progress_tracker.start_step("download_background", "Loading background config...")
bg_config = {
"video": get_background_config("video"),
"audio": get_background_config("audio"),
}
progress_tracker.update_step_progress("download_background", 30, "Downloading video background...")
download_background_video(bg_config["video"])
progress_tracker.update_step_progress("download_background", 70, "Downloading audio background...")
download_background_audio(bg_config["audio"])
progress_tracker.complete_step("download_background", "Background assets ready")
# Step 5: Process Background
progress_tracker.start_step("process_background", "Chopping background to fit...")
chop_background(bg_config, length, reddit_object)
progress_tracker.complete_step("process_background", f"Background prepared for {length}s video")
# Step 6: Compose Video
progress_tracker.start_step("compose_video", "Starting video composition...")
make_final_video(number_of_comments, length, reddit_object, bg_config)
progress_tracker.complete_step("compose_video", "Video rendered successfully")
# Step 7: Finalize
progress_tracker.start_step("finalize", "Cleaning up temporary files...")
subreddit = reddit_object.get("subreddit", "Unknown")
output_path = f"/results/{subreddit}/"
progress_tracker.complete_step("finalize", "Video generation complete!")
# Mark job as completed
progress_tracker.complete_job(output_path=output_path)
print_step("Video generation completed successfully!")
except Exception as e:
# Handle errors and update progress
current_step = progress_tracker.get_current_step()
if current_step:
progress_tracker.fail_step(current_step.id, str(e))
progress_tracker.fail_job(str(e))
raise
def run_many(times: int) -> None:
"""Run video generation multiple times."""
for x in range(1, times + 1):
print_step(
f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}'
@ -74,6 +145,7 @@ def run_many(times) -> None:
def shutdown() -> NoReturn:
"""Clean up and exit."""
if "reddit_id" in globals():
print_markdown("## Clearing temp files")
cleanup(reddit_id)
@ -82,12 +154,22 @@ def shutdown() -> NoReturn:
sys.exit()
def start_gui_server():
"""Start the progress GUI server in background."""
from progress_gui import run_gui_background
print_step("Starting Progress GUI server...")
run_gui_background()
print_substep("Progress GUI available at http://localhost:5000", style="bold green")
if __name__ == "__main__":
if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:
print(
"Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again."
"This program requires Python 3.10, 3.11, or 3.12. "
"Please install a compatible Python version and try again."
)
sys.exit()
ffmpeg_install()
directory = Path().absolute()
config = settings.check_toml(
@ -95,15 +177,31 @@ if __name__ == "__main__":
)
config is False and sys.exit()
# Validate Qwen TTS settings if selected
if config["settings"]["tts"]["voice_choice"].lower() == "qwentts":
if not config["settings"]["tts"].get("qwen_email") or not config["settings"]["tts"].get("qwen_password"):
print_substep(
"Qwen TTS requires 'qwen_email' and 'qwen_password' in config! "
"Please configure these settings.",
"bold red",
)
sys.exit()
# Validate TikTok settings if selected
if (
not settings.config["settings"]["tts"]["tiktok_sessionid"]
or settings.config["settings"]["tts"]["tiktok_sessionid"] == ""
) and config["settings"]["tts"]["voice_choice"] == "tiktok":
) and config["settings"]["tts"]["voice_choice"].lower() == "tiktok":
print_substep(
"TikTok voice requires a sessionid! Check our documentation on how to obtain one.",
"TikTok voice requires a sessionid! Check documentation on how to obtain one.",
"bold red",
)
sys.exit()
# Start GUI server if enabled
if GUI_MODE:
start_gui_server()
try:
if config["reddit"]["thread"]["post_id"]:
for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")):
@ -119,16 +217,18 @@ if __name__ == "__main__":
main()
except KeyboardInterrupt:
shutdown()
except ResponseException:
print_markdown("## Invalid credentials")
print_markdown("Please check your credentials in the config.toml file")
except RedditScraperError as e:
print_markdown("## Reddit Scraper Error")
print_markdown(f"Error fetching Reddit data: {e}")
print_markdown("This may be due to rate limiting. Try again later or increase request_delay in config.")
shutdown()
except Exception as err:
config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED"
config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED"
config["settings"]["tts"]["openai_api_key"] = "REDACTED"
config["settings"]["tts"]["qwen_password"] = "REDACTED"
print_step(
f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n"
f"Sorry, something went wrong! Try again, and feel free to report this issue on GitHub.\n"
f"Version: {__VERSION__} \n"
f"Error: {err} \n"
f'Config: {config["settings"]}'

@ -0,0 +1,559 @@
#!/usr/bin/env python
"""
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 shutil
from pathlib import Path
from datetime import datetime
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):
"""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
# ==================== Pages ====================
@app.route("/")
def index():
"""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/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/<filename>", 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/<filename>", 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({
'running': running,
'progress': progress_tracker.get_status()
})
@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/<path:filename>")
def static_files(filename):
"""Serve static files."""
return send_from_directory("GUI/static", filename)
@app.route("/results/<path:name>")
def results(name):
"""Serve result videos."""
return send_from_directory("results", name)
@app.route("/preview/<path:name>")
def previews(name):
"""Serve preview images."""
return send_from_directory("assets/temp", name)
@app.route("/assets/<path:name>")
def assets(name):
"""Serve asset files."""
return send_from_directory("assets", name)
@app.route("/backgrounds/video/<path:name>")
def serve_background_video(name):
"""Serve background videos."""
return send_from_directory(BACKGROUNDS_VIDEO_PATH, name)
@app.route("/backgrounds/audio/<path:name>")
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."""
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())
# ==================== Run Server ====================
def run_gui(open_browser=True):
"""Run the GUI server."""
import webbrowser
if open_browser:
webbrowser.open(f"http://localhost:{PORT}", new=2)
print(f"Reddit Video Maker Bot UI 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,506 @@
"""
No-OAuth Reddit scraper using public .json endpoints.
No API keys required - uses Reddit's public JSON interface.
Note: This approach is subject to rate limiting and may be blocked by Reddit.
For production use, consider using the official Reddit API with OAuth.
"""
import json
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import requests
from utils.console import print_substep
# Default User-Agent - customize this to avoid rate limiting
DEFAULT_USER_AGENT = "python:reddit_video_bot:1.0 (no-oauth scraper)"
# Reddit base URLs
REDDIT_BASES = ["https://www.reddit.com", "https://old.reddit.com"]
class RedditScraperError(Exception):
"""Exception raised for Reddit scraper errors."""
pass
@dataclass
class RedditPost:
"""Represents a Reddit post/submission."""
id: str
name: str # t3_xxx
title: str
selftext: str
author: str
created_utc: float
score: int
upvote_ratio: float
num_comments: int
permalink: str
url: str
over_18: bool
stickied: bool
subreddit: str
@classmethod
def from_json(cls, data: Dict[str, Any]) -> "RedditPost":
return cls(
id=data.get("id", ""),
name=data.get("name", ""),
title=data.get("title", ""),
selftext=data.get("selftext", ""),
author=data.get("author", "[deleted]"),
created_utc=float(data.get("created_utc", 0)),
score=int(data.get("score", 0)),
upvote_ratio=float(data.get("upvote_ratio", 0)),
num_comments=int(data.get("num_comments", 0)),
permalink=data.get("permalink", ""),
url=data.get("url", ""),
over_18=bool(data.get("over_18", False)),
stickied=bool(data.get("stickied", False)),
subreddit=data.get("subreddit", ""),
)
@dataclass
class RedditComment:
"""Represents a Reddit comment."""
id: str
name: str # t1_xxx
body: str
author: str
created_utc: float
score: int
permalink: str
parent_id: str
link_id: str
depth: int
stickied: bool
@classmethod
def from_json(cls, data: Dict[str, Any], depth: int = 0) -> "RedditComment":
return cls(
id=data.get("id", ""),
name=data.get("name", ""),
body=data.get("body", ""),
author=data.get("author", "[deleted]"),
created_utc=float(data.get("created_utc", 0)),
score=int(data.get("score", 0)),
permalink=data.get("permalink", ""),
parent_id=data.get("parent_id", ""),
link_id=data.get("link_id", ""),
depth=depth,
stickied=bool(data.get("stickied", False)),
)
class RedditScraper:
"""
No-OAuth Reddit scraper using public .json endpoints.
Example usage:
scraper = RedditScraper()
posts = scraper.get_subreddit_posts("AskReddit", limit=25, sort="hot")
post, comments = scraper.get_post_with_comments(posts[0].id)
"""
def __init__(
self,
user_agent: str = DEFAULT_USER_AGENT,
base_url: str = REDDIT_BASES[0],
request_delay: float = 2.0,
timeout: float = 30.0,
max_retries: int = 5,
):
"""
Initialize the Reddit scraper.
Args:
user_agent: User-Agent string for requests
base_url: Reddit base URL (www.reddit.com or old.reddit.com)
request_delay: Delay between requests in seconds
timeout: Request timeout in seconds
max_retries: Maximum number of retries per request
"""
self.user_agent = user_agent
self.base_url = base_url.rstrip("/")
self.request_delay = request_delay
self.timeout = timeout
self.max_retries = max_retries
self.session = requests.Session()
self._last_request_time = 0.0
def _rate_limit(self) -> None:
"""Enforce rate limiting between requests."""
elapsed = time.time() - self._last_request_time
if elapsed < self.request_delay:
time.sleep(self.request_delay - elapsed)
self._last_request_time = time.time()
def _fetch_json(self, url: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""
Fetch JSON from a Reddit endpoint with retries and rate limiting.
Args:
url: Full URL to fetch
params: Query parameters
Returns:
Parsed JSON response
Raises:
RedditScraperError: If request fails after retries
"""
headers = {
"User-Agent": self.user_agent,
"Accept": "application/json",
}
if params is None:
params = {}
params["raw_json"] = 1 # Get unescaped JSON
last_error: Optional[Exception] = None
for attempt in range(self.max_retries):
self._rate_limit()
try:
response = self.session.get(
url,
params=params,
headers=headers,
timeout=self.timeout,
)
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
print_substep(f"Rate limited. Waiting {retry_after}s...", style="yellow")
time.sleep(max(self.request_delay, retry_after))
last_error = RedditScraperError(f"Rate limited (429)")
continue
# Handle server errors
if 500 <= response.status_code < 600:
wait_time = self.request_delay * (attempt + 1)
print_substep(f"Server error {response.status_code}. Retrying in {wait_time}s...", style="yellow")
time.sleep(wait_time)
last_error = RedditScraperError(f"Server error: {response.status_code}")
continue
# Handle other errors
if response.status_code != 200:
raise RedditScraperError(
f"HTTP {response.status_code}: {response.text[:200]}"
)
return response.json()
except requests.exceptions.RequestException as e:
last_error = e
wait_time = self.request_delay * (attempt + 1)
if attempt < self.max_retries - 1:
print_substep(f"Request failed: {e}. Retrying in {wait_time}s...", style="yellow")
time.sleep(wait_time)
continue
raise RedditScraperError(f"Failed after {self.max_retries} attempts: {last_error}")
def get_subreddit_posts(
self,
subreddit: str,
sort: str = "hot",
limit: int = 25,
time_filter: str = "all",
after: Optional[str] = None,
) -> List[RedditPost]:
"""
Get posts from a subreddit.
Args:
subreddit: Subreddit name (without r/ prefix)
sort: Sort method (hot, new, top, rising, controversial)
limit: Maximum number of posts to retrieve (max 100 per request)
time_filter: Time filter for top/controversial (hour, day, week, month, year, all)
after: Pagination cursor (fullname of last item)
Returns:
List of RedditPost objects
"""
# Clean subreddit name
subreddit = subreddit.strip()
if subreddit.lower().startswith("r/"):
subreddit = subreddit[2:]
url = f"{self.base_url}/r/{subreddit}/{sort}.json"
params: Dict[str, Any] = {"limit": min(limit, 100)}
if sort in ("top", "controversial"):
params["t"] = time_filter
if after:
params["after"] = after
data = self._fetch_json(url, params)
posts = []
children = data.get("data", {}).get("children", [])
for child in children:
if child.get("kind") != "t3":
continue
post_data = child.get("data", {})
if post_data:
posts.append(RedditPost.from_json(post_data))
return posts
def get_post_by_id(self, post_id: str) -> Optional[RedditPost]:
"""
Get a single post by ID.
Args:
post_id: Post ID (without t3_ prefix)
Returns:
RedditPost object or None if not found
"""
# Remove t3_ prefix if present
if post_id.startswith("t3_"):
post_id = post_id[3:]
url = f"{self.base_url}/comments/{post_id}.json"
params = {"limit": 0} # Don't fetch comments
try:
data = self._fetch_json(url, params)
except RedditScraperError:
return None
if not isinstance(data, list) or len(data) < 1:
return None
post_listing = data[0]
children = post_listing.get("data", {}).get("children", [])
if not children:
return None
post_data = children[0].get("data", {})
return RedditPost.from_json(post_data) if post_data else None
def get_post_with_comments(
self,
post_id: str,
comment_sort: str = "top",
comment_limit: int = 500,
comment_depth: int = 10,
max_comments: int = 1000,
) -> Tuple[Optional[RedditPost], List[RedditComment]]:
"""
Get a post with its comments.
Args:
post_id: Post ID (without t3_ prefix)
comment_sort: Comment sort (top, new, controversial, best, old, qa)
comment_limit: Number of comments per request (max ~500)
comment_depth: Maximum depth of comment tree
max_comments: Hard cap on total comments to return
Returns:
Tuple of (RedditPost, List[RedditComment])
"""
# Remove t3_ prefix if present
if post_id.startswith("t3_"):
post_id = post_id[3:]
url = f"{self.base_url}/comments/{post_id}.json"
params = {
"sort": comment_sort,
"limit": min(comment_limit, 500),
"depth": comment_depth,
}
data = self._fetch_json(url, params)
if not isinstance(data, list) or len(data) < 2:
raise RedditScraperError(f"Unexpected response format for post {post_id}")
# Parse post
post_listing = data[0]
post_children = post_listing.get("data", {}).get("children", [])
if not post_children:
return None, []
post_data = post_children[0].get("data", {})
post = RedditPost.from_json(post_data) if post_data else None
# Parse comments
comment_listing = data[1]
comment_children = comment_listing.get("data", {}).get("children", [])
comments: List[RedditComment] = []
self._flatten_comments(comment_children, depth=0, out=comments, max_comments=max_comments)
return post, comments
def _flatten_comments(
self,
children: List[Dict[str, Any]],
depth: int,
out: List[RedditComment],
max_comments: int,
) -> None:
"""
Recursively flatten comment tree into a list.
Ignores "more" placeholders - some comments may be missing in large threads.
"""
for child in children:
if len(out) >= max_comments:
return
kind = child.get("kind")
data = child.get("data", {})
if kind == "t1":
# This is a comment
comment = RedditComment.from_json(data, depth=depth)
out.append(comment)
# Process replies
replies = data.get("replies")
if isinstance(replies, dict):
reply_children = replies.get("data", {}).get("children", [])
if reply_children:
self._flatten_comments(
reply_children,
depth=depth + 1,
out=out,
max_comments=max_comments,
)
elif kind == "more":
# "More comments" placeholder - skip (some comments will be missing)
continue
def search_subreddit(
self,
subreddit: str,
query: str,
sort: str = "relevance",
time_filter: str = "all",
limit: int = 25,
) -> List[RedditPost]:
"""
Search posts in a subreddit.
Args:
subreddit: Subreddit name
query: Search query
sort: Sort method (relevance, hot, top, new, comments)
time_filter: Time filter (hour, day, week, month, year, all)
limit: Maximum results
Returns:
List of matching posts
"""
subreddit = subreddit.strip()
if subreddit.lower().startswith("r/"):
subreddit = subreddit[2:]
url = f"{self.base_url}/r/{subreddit}/search.json"
params = {
"q": query,
"sort": sort,
"t": time_filter,
"limit": min(limit, 100),
"restrict_sr": "on", # Restrict to subreddit
}
data = self._fetch_json(url, params)
posts = []
children = data.get("data", {}).get("children", [])
for child in children:
if child.get("kind") != "t3":
continue
post_data = child.get("data", {})
if post_data:
posts.append(RedditPost.from_json(post_data))
return posts
def get_posts_newer_than(
self,
subreddit: str,
days: int = 30,
max_posts: int = 1000,
) -> List[RedditPost]:
"""
Get posts from a subreddit newer than a specified number of days.
Note: Reddit listings are capped at ~1000 posts. If the subreddit has
more posts than this in the time window, older posts will be missed.
Args:
subreddit: Subreddit name
days: Number of days to look back
max_posts: Maximum posts to retrieve
Returns:
List of posts within the time window
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
cutoff_ts = cutoff.timestamp()
all_posts: List[RedditPost] = []
after: Optional[str] = None
while len(all_posts) < max_posts:
posts = self.get_subreddit_posts(
subreddit=subreddit,
sort="new",
limit=100,
after=after,
)
if not posts:
break
for post in posts:
# Skip stickied posts (they can be old)
if post.stickied:
continue
if post.created_utc < cutoff_ts:
# Reached posts older than cutoff
return all_posts
all_posts.append(post)
if len(all_posts) >= max_posts:
return all_posts
# Set pagination cursor
after = posts[-1].name
return all_posts
# Global scraper instance
_scraper: Optional[RedditScraper] = None
def get_scraper() -> RedditScraper:
"""Get or create the global Reddit scraper instance."""
global _scraper
if _scraper is None:
_scraper = RedditScraper()
return _scraper

@ -1,160 +1,283 @@
"""
Reddit subreddit thread fetcher using no-OAuth scraper.
No API keys required - uses Reddit's public JSON endpoints.
"""
import re
from typing import Dict, List, Optional, Any, Tuple
import praw
from praw.models import MoreComments
from prawcore.exceptions import ResponseException
from reddit.scraper import get_scraper, RedditPost, RedditComment, RedditScraperError
from utils import settings
from utils.ai_methods import sort_by_similarity
from utils.console import print_step, print_substep
from utils.posttextparser import posttextparser
from utils.subreddit import get_subreddit_undone
from utils.videos import check_done
from utils.voice import sanitize_text
def get_subreddit_threads(POST_ID: str):
"""
Returns a list of threads from the AskReddit subreddit.
class SubmissionWrapper:
"""Wrapper to make RedditPost compatible with existing utility functions."""
def __init__(self, post: RedditPost):
self.id = post.id
self.title = post.title
self.selftext = post.selftext
self.author = post.author
self.score = post.score
self.upvote_ratio = post.upvote_ratio
self.num_comments = post.num_comments
self.permalink = post.permalink
self.url = post.url
self.over_18 = post.over_18
self.stickied = post.stickied
self.subreddit_name = post.subreddit
self._post = post
def to_post(self) -> RedditPost:
return self._post
def get_subreddit_threads(POST_ID: Optional[str] = None) -> Dict[str, Any]:
"""
Fetches a Reddit thread and its comments using the no-OAuth scraper.
No API keys required.
print_substep("Logging into Reddit.")
Args:
POST_ID: Optional specific post ID to fetch
content = {}
if settings.config["reddit"]["creds"]["2fa"]:
print("\nEnter your two-factor authentication code from your authenticator app.\n")
code = input("> ")
print()
pw = settings.config["reddit"]["creds"]["password"]
passkey = f"{pw}:{code}"
else:
passkey = settings.config["reddit"]["creds"]["password"]
username = settings.config["reddit"]["creds"]["username"]
if str(username).casefold().startswith("u/"):
username = username[2:]
try:
reddit = praw.Reddit(
client_id=settings.config["reddit"]["creds"]["client_id"],
client_secret=settings.config["reddit"]["creds"]["client_secret"],
user_agent="Accessing Reddit threads",
username=username,
passkey=passkey,
check_for_async=False,
)
except ResponseException as e:
if e.response.status_code == 401:
print("Invalid credentials - please check them in config.toml")
except:
print("Something went wrong...")
Returns:
Dictionary containing thread data and comments
"""
print_substep("Connecting to Reddit (no-auth mode)...")
# Ask user for subreddit input
print_step("Getting subreddit threads...")
scraper = get_scraper()
content: Dict[str, Any] = {}
similarity_score = 0
if not settings.config["reddit"]["thread"][
"subreddit"
]: # note to user. you can have multiple subreddits via reddit.subreddit("redditdev+learnpython")
try:
subreddit = reddit.subreddit(
re.sub(r"r\/", "", input("What subreddit would you like to pull from? "))
# removes the r/ from the input
)
except ValueError:
subreddit = reddit.subreddit("askreddit")
# Get subreddit from config or user input
print_step("Getting subreddit threads...")
subreddit_name = settings.config["reddit"]["thread"].get("subreddit", "")
if not subreddit_name:
subreddit_name = input("What subreddit would you like to pull from? ")
subreddit_name = re.sub(r"^r/", "", subreddit_name.strip())
if not subreddit_name:
subreddit_name = "AskReddit"
print_substep("Subreddit not defined. Using AskReddit.")
else:
sub = settings.config["reddit"]["thread"]["subreddit"]
print_substep(f"Using subreddit: r/{sub} from TOML config")
subreddit_choice = sub
if str(subreddit_choice).casefold().startswith("r/"): # removes the r/ from the input
subreddit_choice = subreddit_choice[2:]
subreddit = reddit.subreddit(subreddit_choice)
if POST_ID: # would only be called if there are multiple queued posts
submission = reddit.submission(id=POST_ID)
elif (
settings.config["reddit"]["thread"]["post_id"]
and len(str(settings.config["reddit"]["thread"]["post_id"]).split("+")) == 1
):
submission = reddit.submission(id=settings.config["reddit"]["thread"]["post_id"])
elif settings.config["ai"]["ai_similarity_enabled"]: # ai sorting based on comparison
threads = subreddit.hot(limit=50)
keywords = settings.config["ai"]["ai_similarity_keywords"].split(",")
keywords = [keyword.strip() for keyword in keywords]
# Reformat the keywords for printing
keywords_print = ", ".join(keywords)
print(f"Sorting threads by similarity to the given keywords: {keywords_print}")
threads, similarity_scores = sort_by_similarity(threads, keywords)
submission, similarity_score = get_subreddit_undone(
threads, subreddit, similarity_scores=similarity_scores
)
else:
threads = subreddit.hot(limit=25)
submission = get_subreddit_undone(threads, subreddit)
# Clean the subreddit name
if str(subreddit_name).lower().startswith("r/"):
subreddit_name = subreddit_name[2:]
print_substep(f"Using subreddit: r/{subreddit_name} from config")
# Get the submission
submission: Optional[RedditPost] = None
try:
if POST_ID:
# Specific post ID provided (for queued posts)
submission = scraper.get_post_by_id(POST_ID)
if not submission:
raise RedditScraperError(f"Could not find post with ID: {POST_ID}")
elif settings.config["reddit"]["thread"].get("post_id"):
# Post ID from config (single post)
post_id = str(settings.config["reddit"]["thread"]["post_id"])
if "+" not in post_id: # Single post, not multiple
submission = scraper.get_post_by_id(post_id)
if not submission:
raise RedditScraperError(f"Could not find post with ID: {post_id}")
elif settings.config["ai"].get("ai_similarity_enabled"):
# AI sorting based on keyword similarity
print_substep("Fetching posts for AI similarity sorting...")
posts = scraper.get_subreddit_posts(subreddit_name, sort="hot", limit=50)
if not posts:
raise RedditScraperError(f"No posts found in r/{subreddit_name}")
keywords = settings.config["ai"].get("ai_similarity_keywords", "").split(",")
keywords = [keyword.strip() for keyword in keywords if keyword.strip()]
if keywords:
keywords_print = ", ".join(keywords)
print_substep(f"Sorting threads by similarity to: {keywords_print}")
# Convert posts to format expected by sort_by_similarity
wrappers = [SubmissionWrapper(post) for post in posts]
sorted_wrappers, similarity_scores = sort_by_similarity(wrappers, keywords)
submission, similarity_score = _get_undone_post(
sorted_wrappers, subreddit_name, similarity_scores=similarity_scores
)
else:
wrappers = [SubmissionWrapper(post) for post in posts]
submission = _get_undone_post(wrappers, subreddit_name)
else:
# Default: get hot posts
posts = scraper.get_subreddit_posts(subreddit_name, sort="hot", limit=25)
if not posts:
raise RedditScraperError(f"No posts found in r/{subreddit_name}")
wrappers = [SubmissionWrapper(post) for post in posts]
submission = _get_undone_post(wrappers, subreddit_name)
except RedditScraperError as e:
print_substep(f"Error fetching Reddit data: {e}", style="bold red")
raise
if submission is None:
return get_subreddit_threads(POST_ID) # submission already done. rerun
print_substep("No suitable submission found. Retrying...", style="yellow")
return get_subreddit_threads(POST_ID)
elif not submission.num_comments and settings.config["settings"]["storymode"] == "false":
print_substep("No comments found. Skipping.")
# Check if story mode with no comments is okay
if not submission.num_comments and not settings.config["settings"].get("storymode"):
print_substep("No comments found. Skipping.", style="bold red")
exit()
submission = check_done(submission) # double-checking
# Double-check if this post was already done
wrapper = SubmissionWrapper(submission)
checked = check_done(wrapper)
if checked is None:
print_substep("Post already processed. Finding another...", style="yellow")
return get_subreddit_threads(POST_ID)
# Display post info
upvotes = submission.score
ratio = submission.upvote_ratio * 100
num_comments = submission.num_comments
threadurl = f"https://new.reddit.com/{submission.permalink}"
thread_url = f"https://new.reddit.com{submission.permalink}"
print_substep(f"Video will be: {submission.title} :thumbsup:", style="bold green")
print_substep(f"Thread url is: {threadurl} :thumbsup:", style="bold green")
print_substep(f"Video will be: {submission.title}", style="bold green")
print_substep(f"Thread url is: {thread_url}", style="bold green")
print_substep(f"Thread has {upvotes} upvotes", style="bold blue")
print_substep(f"Thread has a upvote ratio of {ratio}%", style="bold blue")
print_substep(f"Thread has a upvote ratio of {ratio:.0f}%", style="bold blue")
print_substep(f"Thread has {num_comments} comments", style="bold blue")
if similarity_score:
print_substep(
f"Thread has a similarity score up to {round(similarity_score * 100)}%",
style="bold blue",
)
content["thread_url"] = threadurl
# Build content dictionary
content["thread_url"] = thread_url
content["thread_title"] = submission.title
content["thread_id"] = submission.id
content["is_nsfw"] = submission.over_18
content["subreddit"] = subreddit_name
content["comments"] = []
if settings.config["settings"]["storymode"]:
if settings.config["settings"]["storymodemethod"] == 1:
if settings.config["settings"].get("storymode"):
# Story mode - use the post's selftext
if settings.config["settings"].get("storymodemethod") == 1:
content["thread_post"] = posttextparser(submission.selftext)
else:
content["thread_post"] = submission.selftext
else:
for top_level_comment in submission.comments:
if isinstance(top_level_comment, MoreComments):
continue
# Comment mode - fetch and process comments
print_substep("Fetching comments...", style="bold blue")
try:
_, comments = scraper.get_post_with_comments(
submission.id,
comment_sort="top",
comment_limit=500,
max_comments=1000,
)
# Filter and process comments
max_len = int(settings.config["reddit"]["thread"].get("max_comment_length", 500))
min_len = int(settings.config["reddit"]["thread"].get("min_comment_length", 1))
for comment in comments:
# Skip non-top-level comments (depth > 0)
if comment.depth > 0:
continue
# Skip deleted/removed
if comment.body in ["[removed]", "[deleted]"]:
continue
if top_level_comment.body in ["[removed]", "[deleted]"]:
continue # # see https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/78
if not top_level_comment.stickied:
sanitised = sanitize_text(top_level_comment.body)
if not sanitised or sanitised == " ":
# Skip stickied comments
if comment.stickied:
continue
if len(top_level_comment.body) <= int(
settings.config["reddit"]["thread"]["max_comment_length"]
):
if len(top_level_comment.body) >= int(
settings.config["reddit"]["thread"]["min_comment_length"]
):
if (
top_level_comment.author is not None
and sanitize_text(top_level_comment.body) is not None
): # if errors occur with this change to if not.
content["comments"].append(
{
"comment_body": top_level_comment.body,
"comment_url": top_level_comment.permalink,
"comment_id": top_level_comment.id,
}
)
print_substep("Received subreddit threads Successfully.", style="bold green")
# Sanitize and validate
sanitized = sanitize_text(comment.body)
if not sanitized or sanitized.strip() == "":
continue
# Check length constraints
if len(comment.body) > max_len:
continue
if len(comment.body) < min_len:
continue
# Skip if author is deleted
if comment.author in ["[deleted]", "[removed]"]:
continue
content["comments"].append({
"comment_body": comment.body,
"comment_url": comment.permalink,
"comment_id": comment.id,
})
print_substep(f"Collected {len(content['comments'])} valid comments", style="bold green")
except RedditScraperError as e:
print_substep(f"Error fetching comments: {e}", style="yellow")
# Continue without comments if fetch fails
print_substep("Received subreddit threads successfully.", style="bold green")
return content
def _get_undone_post(
wrappers: List[SubmissionWrapper],
subreddit_name: str,
similarity_scores: Optional[List[float]] = None,
) -> Optional[RedditPost] | Tuple[Optional[RedditPost], float]:
"""
Find a submission that hasn't been processed yet.
Args:
wrappers: List of SubmissionWrapper objects
subreddit_name: Name of the subreddit
similarity_scores: Optional similarity scores for each submission
Returns:
First undone RedditPost, or tuple of (RedditPost, similarity_score) if scores provided
"""
allow_nsfw = settings.config["settings"].get("allow_nsfw", False)
min_comments = int(settings.config["reddit"]["thread"].get("min_comments", 20))
for i, wrapper in enumerate(wrappers):
# Skip NSFW if not allowed
if wrapper.over_18 and not allow_nsfw:
continue
# Skip stickied posts
if wrapper.stickied:
continue
# Check minimum comments (unless story mode)
if not settings.config["settings"].get("storymode"):
if wrapper.num_comments < min_comments:
continue
# Check if already done
if check_done(wrapper) is None:
continue
post = wrapper.to_post()
if similarity_scores is not None and i < len(similarity_scores):
return post, similarity_scores[i]
return post
return None

@ -3,14 +3,13 @@ botocore==1.36.8
gTTS==2.5.4
moviepy==2.2.1
playwright==1.49.1
praw==7.8.1
requests==2.32.3
rich==13.9.4
toml==0.10.2
translators==5.9.9
pyttsx3==2.98
tomlkit==0.13.2
Flask==3.1.1
Flask-SocketIO==5.3.6
clean-text==0.6.0
unidecode==1.4.0
spacy==3.8.7
@ -19,3 +18,5 @@ transformers==4.52.4
ffmpeg-python==0.2.0
elevenlabs==1.57.0
yt-dlp==2025.10.22
gevent==24.2.1
gevent-websocket==0.10.1

@ -1,10 +1,9 @@
[reddit.creds]
client_id = { optional = false, nmin = 12, nmax = 30, explanation = "The ID of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The ID should be over 12 and under 30 characters, double check your input." }
client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." }
username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" }
password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" }
2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true }
# Note: No Reddit API credentials required! This bot uses public .json endpoints.
# If you experience rate limiting, try increasing the delay between requests.
[reddit.scraper]
user_agent = { optional = true, default = "python:reddit_video_bot:1.0", example = "python:reddit_video_bot:1.0 (contact: you@example.com)", explanation = "User-Agent string for Reddit requests. Customize to avoid rate limiting." }
request_delay = { optional = true, default = 2.0, example = 3.0, type = "float", explanation = "Delay in seconds between Reddit requests. Increase if rate limited." }
[reddit.thread]
random = { optional = true, options = [true, false, ], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" }
@ -44,7 +43,7 @@ background_thumbnail_font_size = { optional = true, type = "int", default = 96,
background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" }
[settings.tts]
voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " }
voice_choice = { optional = false, default = "qwentts", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "qwentts", "OpenAI"], example = "qwentts", explanation = "The voice platform used for TTS generation." }
random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" }
elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] }
elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" }
@ -52,11 +51,15 @@ aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew",
streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" }
tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" }
tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." }
python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" }
py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" }
silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" }
no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" }
openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "The API endpoint URL for OpenAI TTS generation" }
openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" }
openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] }
openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] }
qwen_api_url = { optional = true, default = "http://localhost:8080", example = "http://localhost:8080", explanation = "The base URL for the Qwen TTS API server" }
qwen_email = { optional = true, example = "you@example.com", explanation = "Email for Qwen TTS authentication" }
qwen_password = { optional = true, example = "your_password", explanation = "Password for Qwen TTS authentication" }
qwen_speaker = { optional = false, default = "Vivian", example = "Vivian", explanation = "The speaker voice for Qwen TTS", options = ["Chelsie", "Ethan", "Vivian", "Asher", "Aria", "Oliver", "Emma", "Noah", "Sophia"] }
qwen_language = { optional = false, default = "English", example = "English", explanation = "The language for Qwen TTS output", options = ["English", "Chinese", "Spanish", "French", "German", "Japanese", "Korean", "Portuguese", "Russian", "Italian", "Arabic", "Hindi"] }
qwen_instruct = { optional = true, default = "Warm, friendly, conversational.", example = "Warm, friendly, conversational.", explanation = "Style instructions for Qwen TTS voice generation" }

@ -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()

@ -7,7 +7,7 @@ from TTS.elevenlabs import elevenlabs
from TTS.engine_wrapper import TTSEngine
from TTS.GTTS import GTTS
from TTS.openai_tts import OpenAITTS
from TTS.pyttsx import pyttsx
from TTS.qwen_tts import QwenTTS
from TTS.streamlabs_polly import StreamlabsPolly
from TTS.TikTok import TikTok
from utils import settings
@ -20,7 +20,7 @@ TTSProviders = {
"AWSPolly": AWSPolly,
"StreamlabsPolly": StreamlabsPolly,
"TikTok": TikTok,
"pyttsx": pyttsx,
"QwenTTS": QwenTTS,
"ElevenLabs": elevenlabs,
"OpenAI": OpenAITTS,
}

Loading…
Cancel
Save