import io import json import os import sys import threading import time import webbrowser from copy import deepcopy from pathlib import Path # Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump" import tomlkit from flask import ( Flask, abort, jsonify, redirect, render_template, request, send_file, send_from_directory, url_for, ) import utils.gui_utils as gui from utils.docker_bootstrap import ensure_runtime_state from utils.settings import apply_template_defaults ensure_runtime_state() # Set the hostname and port HOST = os.environ.get("GUI_HOST", "0.0.0.0") PORT = int(os.environ.get("GUI_PORT", "4000")) OPEN_BROWSER = os.environ.get("GUI_OPEN_BROWSER", "1").lower() in {"1", "true", "yes", "on"} BROWSER_URL = os.environ.get("GUI_BROWSER_URL", f"http://localhost:{PORT}") # Configure application app = Flask(__name__, template_folder="GUI") # Configure secret key — env var for production, random per-startup for dev app.secret_key = os.environ.get("FLASK_SECRET_KEY") or os.urandom(32) # Ensure responses aren't cached + security headers @app.after_request def after_request(response): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Expires"] = 0 response.headers["Pragma"] = "no-cache" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" return response # Simple CSRF check: require same-origin for all mutating requests @app.before_request def csrf_check(): if request.method in ("POST", "PUT", "PATCH", "DELETE"): origin = request.headers.get("Origin") if origin: # Allow same-origin only (localhost dev ports) from urllib.parse import urlparse origin_host = urlparse(origin).hostname request_host = urlparse(request.host_url).hostname if origin_host not in (request_host, "localhost", "127.0.0.1"): return jsonify({"error": "CSRF check failed"}), 403 # Display index.html @app.route("/") def index(): return render_template("index.html", file="videos.json") @app.route("/backgrounds", methods=["GET"]) def backgrounds(): return render_template("backgrounds.html", file="backgrounds.json") @app.route("/background/add", methods=["POST"]) def background_add(): # Get form values youtube_uri = request.form.get("youtube_uri", "").strip() filename = request.form.get("filename", "").strip() citation = request.form.get("citation", "").strip() position = request.form.get("position", "").strip() gui.add_background(youtube_uri, filename, citation, position) return redirect(url_for("backgrounds")) @app.route("/background/delete", methods=["POST"]) def background_delete(): key = request.form.get("background-key") gui.delete_background(key) return redirect(url_for("backgrounds")) _SENSITIVE_KEYS = {"password", "client_secret", "access_token", "2fa_secret", "tiktok_sessionid", "elevenlabs_api_key", "openai_api_key"} def _redact_secrets(data: dict) -> dict: """Return a copy with sensitive values masked for safe HTML embedding.""" return { k: ("********" if any(s in k for s in _SENSITIVE_KEYS) and v else v) for k, v in data.items() } @app.route("/settings", methods=["GET", "POST"]) def settings(): config_load = tomlkit.loads(Path("config.toml").read_text()) config = gui.get_config(apply_template_defaults(deepcopy(config_load))) # Get checks for all values checks = gui.get_checks() if request.method == "POST": # Get data from form as dict data = request.form.to_dict() # Change settings gui.modify_settings(data, config_load, checks) config = gui.get_config(apply_template_defaults(deepcopy(config_load))) return render_template("settings.html", file="config.toml", data=_redact_secrets(config), checks=checks) # Make videos.json accessible @app.route("/videos.json") def videos_json(): return send_from_directory("video_creation/data", "videos.json") # Make backgrounds.json accessible @app.route("/backgrounds.json") def backgrounds_json(): return send_from_directory("utils", "background_videos.json") # Make videos in results folder accessible @app.route("/results/") def results(name): as_attachment = request.args.get("download", "0").lower() in {"1", "true", "yes"} return send_from_directory("results", name, as_attachment=as_attachment) # Serve a video by its videos.json id (handles filenames with unsafe chars like newlines) @app.route("/video/") def video_by_id(video_id): try: with open("video_creation/data/videos.json", "r", encoding="utf-8") as f: videos = json.load(f) except (OSError, json.JSONDecodeError): abort(404) entry = next((v for v in videos if v.get("id") == video_id), None) if not entry: abort(404) subreddit = entry.get("subreddit", "") filename = entry.get("filename", "") file_path = (Path("results") / subreddit / filename).resolve() results_root = Path("results").resolve() # Prevent path traversal: ensure resolved file is inside results/ try: file_path.relative_to(results_root) except ValueError: abort(404) if not file_path.is_file(): abort(404) as_attachment = request.args.get("download", "0").lower() in {"1", "true", "yes"} safe_name = filename.replace("\n", " ").replace("\r", " ").strip() or f"{video_id}.mp4" return send_file(file_path, as_attachment=as_attachment, download_name=safe_name) # Delete one or more videos by ID @app.route("/videos/delete", methods=["POST"]) def video_delete(): data = request.get_json(silent=True) or {} ids = data.get("ids", []) if not ids or not isinstance(ids, list): return jsonify({"error": "No IDs provided"}), 400 deleted = gui.delete_videos(ids) return jsonify({"deleted": deleted}) # Make voices samples in voices folder accessible @app.route("/voices/") def voices(name): return send_from_directory("GUI/voices", name, as_attachment=True) # --- Pipeline state (shared across thread + HTTP) --- pipeline_lock = threading.Lock() pipeline_state: dict = { "running": False, "stage": "", "error": None, "result": None, # {"title": ..., "file": ..., "url": ...} "log": [], # Last N status messages "scraper_events": [], # Structured scraper events for visualization } def _event_to_summary(event_type, data): """Convert a structured scraper event to a human-readable log line.""" data = data or {} summaries = { "browser_launch": lambda d: "Launching browser...", "login": lambda d: d.get("message", "Login event"), "feed_scroll": lambda d: f"Scrolled: {d.get('new_posts', 0)} new, {d.get('total_posts', 0)} total", "post_discovered": lambda d: f"Post by {d.get('username', '?')}: {d.get('body', '')[:45]}", "search_query": lambda d: f"Search '{d.get('query', '?')}': {d.get('posts_found', 0)} posts", "filter_results": lambda d: f"Filtered {d.get('before', 0)} -> {d.get('after', 0)} candidates", "visiting_post": lambda d: f"Trying post {d.get('post_id', '')[:8]}...", "replies_found": lambda d: f"Got {d.get('count', 0)} replies (need {d.get('min_required', '?')})", "post_selected": lambda d: f"Selected: {d.get('title', '')[:55]}", "general": lambda d: d.get("message", ""), } fn = summaries.get(event_type) return fn(data) if fn else None def _run_pipeline(search_queries=None): """Run the video creation pipeline in a background thread.""" import toml from utils import console as uconsole from utils import settings with pipeline_lock: pipeline_state["running"] = True pipeline_state["stage"] = "configuring" pipeline_state["error"] = None pipeline_state["result"] = None pipeline_state["log"] = [] pipeline_state["scraper_events"] = [] try: # Load config and merge template defaults for non-interactive GUI runs. settings.config = settings.apply_template_defaults(toml.load("config.toml")) # Apply search_queries override if provided from UI if search_queries: settings.config.setdefault("threads", {}).setdefault("thread", {})["search_queries"] = search_queries # Set up progress callback with structured event support def on_progress(stage=None, event=None, data=None): with pipeline_lock: if stage: pipeline_state["stage"] = stage pipeline_state["log"].append(stage) if len(pipeline_state["log"]) > 20: pipeline_state["log"] = pipeline_state["log"][-20:] if event: entry = {"type": event, "data": data or {}, "ts": time.time()} pipeline_state["scraper_events"].append(entry) if len(pipeline_state["scraper_events"]) > 100: pipeline_state["scraper_events"] = pipeline_state["scraper_events"][-100:] summary = _event_to_summary(event, data) if summary: pipeline_state["log"].append(summary) if len(pipeline_state["log"]) > 20: pipeline_state["log"] = pipeline_state["log"][-20:] uconsole.set_progress_callback(on_progress) # Reload pipeline modules so code edits take effect without restart import importlib import video_creation.final_video import video_creation.background import video_creation.voices import TTS.engine_wrapper import platforms.threads.screenshot import main importlib.reload(video_creation.final_video) importlib.reload(video_creation.background) importlib.reload(TTS.engine_wrapper) importlib.reload(video_creation.voices) importlib.reload(platforms.threads.screenshot) importlib.reload(main) from main import main as run_pipeline run_pipeline() with pipeline_lock: pipeline_state["stage"] = "done" pipeline_state["result"] = {"message": "Video created successfully! Check the home page."} except Exception as e: with pipeline_lock: pipeline_state["stage"] = "error" pipeline_state["error"] = str(e)[:500].encode("ascii", errors="replace").decode("ascii") finally: with pipeline_lock: pipeline_state["running"] = False uconsole.set_progress_callback(None) @app.route("/create", methods=["GET", "POST"]) def create(): if request.method == "POST": if pipeline_state["running"]: return jsonify({"status": "already_running"}) data = request.get_json(silent=True) or {} search_queries = data.get("search_queries") or None thread = threading.Thread( target=_run_pipeline, kwargs={"search_queries": search_queries}, daemon=True, ) thread.start() return jsonify({"status": "started"}) # Load current config default for pre-filling the keywords input cfg = tomlkit.loads(Path("config.toml").read_text()) default_queries = cfg.get("threads", {}).get("thread", {}).get("search_queries", "") return render_template("create.html", state=pipeline_state, default_search_queries=default_queries) @app.route("/create/status") def create_status(): with pipeline_lock: state_copy = dict(pipeline_state) return jsonify(state_copy) # Run browser and start the app if __name__ == "__main__": if OPEN_BROWSER: webbrowser.open(BROWSER_URL, new=2) print("Website opened in new tab. Refresh if it didn't load.") app.run(host=HOST, port=PORT)