Merge 2c4a8a8a64 into 902ff00cb0
commit
c2cdb3d20b
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Copy text to clipboard
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showNotification('Copied to clipboard!', 'success');
|
||||
} catch (err) {
|
||||
showNotification('Failed to copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// API helper functions
|
||||
const api = {
|
||||
get: async (url) => {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
post: async (url, data) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
delete: async (url) => {
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
return response.json();
|
||||
},
|
||||
|
||||
upload: async (url, file, onProgress) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
reject(new Error(xhr.statusText));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
|
||||
xhr.open('POST', url);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket connection manager for progress updates
|
||||
class ProgressSocket {
|
||||
constructor(namespace = '/progress') {
|
||||
this.socket = null;
|
||||
this.namespace = namespace;
|
||||
this.callbacks = [];
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (typeof io !== 'undefined') {
|
||||
this.socket = io(this.namespace);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to progress socket');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from progress socket');
|
||||
});
|
||||
|
||||
this.socket.on('progress_update', (data) => {
|
||||
this.callbacks.forEach(cb => cb(data));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(callback) {
|
||||
this.callbacks.push(callback);
|
||||
}
|
||||
|
||||
requestStatus() {
|
||||
if (this.socket) {
|
||||
this.socket.emit('request_status');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.showNotification = showNotification;
|
||||
window.formatBytes = formatBytes;
|
||||
window.formatDuration = formatDuration;
|
||||
window.formatDate = formatDate;
|
||||
window.debounce = debounce;
|
||||
window.escapeHtml = escapeHtml;
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
window.api = api;
|
||||
window.ProgressSocket = ProgressSocket;
|
||||
@ -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
|
||||
|
||||
[
|
||||
](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 "$@"
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,317 @@
|
||||
"""
|
||||
Progress tracking module for Reddit Video Maker Bot.
|
||||
Provides real-time progress updates via WebSocket for the GUI.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Optional, List, Callable
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
status: StepStatus = StepStatus.PENDING
|
||||
progress: float = 0.0
|
||||
message: str = ""
|
||||
preview_path: Optional[str] = None
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
"progress": self.progress,
|
||||
"message": self.message,
|
||||
"preview_path": self.preview_path,
|
||||
"started_at": self.started_at,
|
||||
"completed_at": self.completed_at,
|
||||
"error": self.error,
|
||||
"duration": (self.completed_at - self.started_at) if self.completed_at and self.started_at else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoJob:
|
||||
id: str
|
||||
reddit_id: str
|
||||
title: str
|
||||
subreddit: str
|
||||
status: StepStatus = StepStatus.PENDING
|
||||
steps: List[Step] = field(default_factory=list)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
completed_at: Optional[float] = None
|
||||
output_path: Optional[str] = None
|
||||
thumbnail_path: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"reddit_id": self.reddit_id,
|
||||
"title": self.title,
|
||||
"subreddit": self.subreddit,
|
||||
"status": self.status.value,
|
||||
"steps": [step.to_dict() for step in self.steps],
|
||||
"created_at": self.created_at,
|
||||
"completed_at": self.completed_at,
|
||||
"output_path": self.output_path,
|
||||
"thumbnail_path": self.thumbnail_path,
|
||||
"error": self.error,
|
||||
"overall_progress": self.get_overall_progress(),
|
||||
}
|
||||
|
||||
def get_overall_progress(self) -> float:
|
||||
if not self.steps:
|
||||
return 0.0
|
||||
completed = sum(1 for s in self.steps if s.status == StepStatus.COMPLETED)
|
||||
return (completed / len(self.steps)) * 100
|
||||
|
||||
|
||||
class ProgressTracker:
|
||||
"""
|
||||
Singleton progress tracker that manages video generation jobs and steps.
|
||||
Provides callbacks for real-time GUI updates.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if ProgressTracker._initialized:
|
||||
return
|
||||
ProgressTracker._initialized = True
|
||||
|
||||
self.current_job: Optional[VideoJob] = None
|
||||
self.job_history: List[VideoJob] = []
|
||||
self._update_callbacks: List[Callable] = []
|
||||
self._preview_dir = Path("assets/temp/previews")
|
||||
self._preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def add_update_callback(self, callback: Callable):
|
||||
"""Register a callback function to be called on progress updates."""
|
||||
self._update_callbacks.append(callback)
|
||||
|
||||
def remove_update_callback(self, callback: Callable):
|
||||
"""Remove a callback function."""
|
||||
if callback in self._update_callbacks:
|
||||
self._update_callbacks.remove(callback)
|
||||
|
||||
def _notify_update(self):
|
||||
"""Notify all registered callbacks of a progress update."""
|
||||
data = self.get_status()
|
||||
for callback in self._update_callbacks:
|
||||
try:
|
||||
callback(data)
|
||||
except Exception as e:
|
||||
print(f"Error in progress callback: {e}")
|
||||
|
||||
def start_job(self, reddit_id: str, title: str, subreddit: str) -> VideoJob:
|
||||
"""Start a new video generation job."""
|
||||
job = VideoJob(
|
||||
id=f"job_{int(time.time())}_{reddit_id}",
|
||||
reddit_id=reddit_id,
|
||||
title=title,
|
||||
subreddit=subreddit,
|
||||
status=StepStatus.IN_PROGRESS,
|
||||
steps=self._create_default_steps(),
|
||||
)
|
||||
self.current_job = job
|
||||
self._notify_update()
|
||||
return job
|
||||
|
||||
def _create_default_steps(self) -> List[Step]:
|
||||
"""Create the default pipeline steps."""
|
||||
return [
|
||||
Step(
|
||||
id="fetch_reddit",
|
||||
name="Fetch Reddit Post",
|
||||
description="Fetching post and comments from Reddit",
|
||||
),
|
||||
Step(
|
||||
id="generate_tts",
|
||||
name="Generate Audio",
|
||||
description="Converting text to speech using Qwen TTS",
|
||||
),
|
||||
Step(
|
||||
id="capture_screenshots",
|
||||
name="Capture Screenshots",
|
||||
description="Taking screenshots of Reddit comments",
|
||||
),
|
||||
Step(
|
||||
id="download_background",
|
||||
name="Download Background",
|
||||
description="Downloading and preparing background video/audio",
|
||||
),
|
||||
Step(
|
||||
id="process_background",
|
||||
name="Process Background",
|
||||
description="Chopping background to fit video length",
|
||||
),
|
||||
Step(
|
||||
id="compose_video",
|
||||
name="Compose Video",
|
||||
description="Combining all elements into final video",
|
||||
),
|
||||
Step(
|
||||
id="finalize",
|
||||
name="Finalize",
|
||||
description="Final processing and cleanup",
|
||||
),
|
||||
]
|
||||
|
||||
def start_step(self, step_id: str, message: str = ""):
|
||||
"""Mark a step as in progress."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.id == step_id:
|
||||
step.status = StepStatus.IN_PROGRESS
|
||||
step.started_at = time.time()
|
||||
step.message = message
|
||||
step.progress = 0
|
||||
break
|
||||
|
||||
self._notify_update()
|
||||
|
||||
def update_step_progress(self, step_id: str, progress: float, message: str = ""):
|
||||
"""Update the progress of a step."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.id == step_id:
|
||||
step.progress = min(100, max(0, progress))
|
||||
if message:
|
||||
step.message = message
|
||||
break
|
||||
|
||||
self._notify_update()
|
||||
|
||||
def set_step_preview(self, step_id: str, preview_path: str):
|
||||
"""Set a preview image/video for a step."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.id == step_id:
|
||||
step.preview_path = preview_path
|
||||
break
|
||||
|
||||
self._notify_update()
|
||||
|
||||
def complete_step(self, step_id: str, message: str = ""):
|
||||
"""Mark a step as completed."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.id == step_id:
|
||||
step.status = StepStatus.COMPLETED
|
||||
step.completed_at = time.time()
|
||||
step.progress = 100
|
||||
if message:
|
||||
step.message = message
|
||||
break
|
||||
|
||||
self._notify_update()
|
||||
|
||||
def fail_step(self, step_id: str, error: str):
|
||||
"""Mark a step as failed."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.id == step_id:
|
||||
step.status = StepStatus.FAILED
|
||||
step.completed_at = time.time()
|
||||
step.error = error
|
||||
step.message = f"Failed: {error}"
|
||||
break
|
||||
|
||||
self.current_job.status = StepStatus.FAILED
|
||||
self.current_job.error = error
|
||||
self._notify_update()
|
||||
|
||||
def skip_step(self, step_id: str, reason: str = ""):
|
||||
"""Mark a step as skipped."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.id == step_id:
|
||||
step.status = StepStatus.SKIPPED
|
||||
step.completed_at = time.time()
|
||||
step.message = reason or "Skipped"
|
||||
break
|
||||
|
||||
self._notify_update()
|
||||
|
||||
def complete_job(self, output_path: str, thumbnail_path: Optional[str] = None):
|
||||
"""Mark the current job as completed."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
self.current_job.status = StepStatus.COMPLETED
|
||||
self.current_job.completed_at = time.time()
|
||||
self.current_job.output_path = output_path
|
||||
self.current_job.thumbnail_path = thumbnail_path
|
||||
|
||||
self.job_history.append(self.current_job)
|
||||
self._notify_update()
|
||||
|
||||
def fail_job(self, error: str):
|
||||
"""Mark the current job as failed."""
|
||||
if not self.current_job:
|
||||
return
|
||||
|
||||
self.current_job.status = StepStatus.FAILED
|
||||
self.current_job.completed_at = time.time()
|
||||
self.current_job.error = error
|
||||
|
||||
self.job_history.append(self.current_job)
|
||||
self._notify_update()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get the current status of all jobs."""
|
||||
return {
|
||||
"current_job": self.current_job.to_dict() if self.current_job else None,
|
||||
"job_history": [job.to_dict() for job in self.job_history[-10:]], # Last 10 jobs
|
||||
}
|
||||
|
||||
def get_current_step(self) -> Optional[Step]:
|
||||
"""Get the currently active step."""
|
||||
if not self.current_job:
|
||||
return None
|
||||
|
||||
for step in self.current_job.steps:
|
||||
if step.status == StepStatus.IN_PROGRESS:
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
# Global progress tracker instance
|
||||
progress_tracker = ProgressTracker()
|
||||
Loading…
Reference in new issue