[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [FujiwaraChoki]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG]\"\nlabels: ''\nassignees: FujiwaraChoki\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. Linux, Windows]\n - Browser [e.g. chrome, edge]\n - Python Version [e.g. 3.9]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".gitignore",
    "content": "__pycache__\n.env\ntemp/*\nsounds/*\noutput/*\nimages/*\n*.zip\n*.srt\n*.mp4\n*.mp3\n.history\nsubtitles/*\n/venv\n.venv\nclient_secret.json\nmain.py-oauth2.json\n.DS_Store\nBackend/output*\nSongs/\nvenv/"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS Guide for MoneyPrinter\n\nThis file is the operating manual for coding agents working in this repository.\nFollow it before making changes.\n\n## 1) Repository Layout\n\n- `Backend/`: Flask API, DB-backed job queue, and video generation pipeline.\n- `Frontend/`: static HTML/JS client served by `python -m http.server`.\n- `docs/`: source-of-truth setup and runtime docs.\n- `fonts/`, `Songs/`, `subtitles/`, `temp/`: runtime assets/output folders.\n- Root output artifact: `output.mp4`.\n\n## 2) Source of Truth and Existing Rules\n\n- No `.cursor/rules/` directory found.\n- No `.cursorrules` file found.\n- No `.github/copilot-instructions.md` file found.\n- If any of the above appear later, treat them as higher-priority constraints and update this file.\n\n## 3) Environment and Setup Commands\n\n- Python version: `>=3.11` (from `pyproject.toml`).\n- Dependency manager used in docs: `uv`.\n- Create local env file: `cp .env.example .env`.\n- Install dependencies: `uv sync`.\n- Run backend: `uv run python Backend/main.py`.\n- Run worker (new terminal): `uv run python Backend/worker.py`.\n- Run frontend (new terminal): `python3 -m http.server 3000 --directory Frontend`.\n- Docker workflow: `docker compose up --build`.\n\n## 4) Build, Lint, and Test Commands\n\nThis project has a baseline `pytest` setup for backend repository tests.\nUse the commands below as the expected agent workflow.\n\n### 4.1 Build / Runtime Verification\n\n- Backend syntax check: `uv run python -m compileall Backend`.\n- Frontend syntax sanity (lightweight): open `Frontend/index.html` in browser and run generation flow.\n- API smoke check after backend start: `curl http://localhost:8080/api/models`.\n- Queue smoke check: `curl -X POST http://localhost:8080/api/generate -H \"Content-Type: application/json\" -d '{\"videoSubject\":\"test\",\"voice\":\"en_us_001\",\"paragraphNumber\":1,\"customPrompt\":\"\"}'`.\n- Full local run: backend + worker + frontend servers, then generate a short sample video.\n\n### 4.2 Lint / Formatting (Recommended)\n\n- There is no enforced formatter in-repo today.\n- Follow existing style and keep diffs minimal.\n- If linting is requested, prefer adding tooling in a separate PR.\n- Suggested ad-hoc checks when available locally:\n  - `uv run python -m py_compile Backend/*.py`\n  - `uv run python -m compileall Backend`\n\n### 4.3 Test Commands (Current and Future)\n\n- Run all tests: `uv run pytest`\n- Run one file: `uv run pytest tests/test_file.py`\n- Run a single test: `uv run pytest tests/test_file.py::test_name`\n- Run a single class test: `uv run pytest tests/test_file.py::TestClass::test_name`\n- Current suite location: `tests/`.\n\n## 5) High-Confidence Conventions from Existing Code\n\nThese conventions are inferred from current source and should guide new changes.\n\n### 5.1 Python Imports\n\n- Prefer standard library imports first, then third-party, then local modules.\n- Use one import per line for readability in long modules.\n- Avoid wildcard imports in new code (`from module import *`), even if legacy files use them.\n- Prefer explicit local imports, e.g. `from utils import ENV_FILE, TEMP_DIR`.\n\n### 5.2 Formatting and Structure\n\n- Use 4-space indentation in Python.\n- Keep line length readable; split long calls across multiple lines.\n- Favor small helper functions for distinct pipeline stages.\n- Keep side-effectful startup logic near application boot (`load_dotenv`, env checks).\n\n### 5.3 Typing and Signatures\n\n- Add type hints to all new/modified function signatures.\n- Reuse `Optional`, `List`, `Tuple`, `dict` typing already used in backend.\n- Prefer explicit return types (`-> str`, `-> None`, `-> Tuple[...]`).\n- Use `Path` for filesystem paths where practical.\n\n### 5.4 Naming Conventions\n\n- Python functions/variables: `snake_case`.\n- Constants/env keys: `UPPER_SNAKE_CASE`.\n- JS variables/functions in frontend: `camelCase`.\n- Keep API route names simple and verb-oriented (`/api/generate`, `/api/cancel`).\n\n### 5.5 Error Handling and Logging\n\n- Fail fast on missing critical env vars (current code exits early in startup checks).\n- Catch exceptions at boundary layers (HTTP handlers, external API calls, file IO).\n- Return user-safe JSON error messages from Flask endpoints.\n- Log actionable context with existing logger/log-stream helpers.\n- Do not swallow exceptions silently; at minimum emit error logs.\n\n### 5.6 Filesystem and Path Safety\n\n- Prefer `pathlib.Path` operations.\n- Ensure directories exist before writing (`mkdir(parents=True, exist_ok=True)`).\n- Sanitize uploaded filenames (`os.path.basename`) before save.\n- Avoid hardcoded OS-specific paths; rely on env vars and `Path.resolve()`.\n\n### 5.7 Backend API Patterns\n\n- Keep endpoint payloads consistent with `{\"status\": \"success|error\", ...}`.\n- Use appropriate HTTP status codes for conflict/client errors (e.g., `409`, `400`).\n- Long-running work should run in worker process from DB queue, not on request thread.\n- Preserve cancellation semantics using per-job cancellation and persisted job events.\n\n### 5.8 Frontend Patterns\n\n- Use centralized API helper (`apiRequest`) for backend calls.\n- Validate required fields before firing requests.\n- Keep user feedback explicit via toasts and status area toggles.\n- Preserve localStorage key patterns (`<fieldId>Value`).\n\n## 6) Change Scope Rules for Agents\n\n- Make minimal, targeted edits.\n- Do not rename files/modules unless required by task.\n- Do not introduce new frameworks/toolchains without request.\n- Keep backward compatibility for existing API payload shape when possible.\n- Update docs in `docs/` when setup, env vars, or runtime behavior changes.\n\n## 7) Validation Checklist Before Finishing\n\n- Ran relevant command(s) from section 4.\n- Confirmed backend still starts (`uv run python Backend/main.py`).\n- Confirmed worker still starts (`uv run python Backend/worker.py`).\n- Confirmed frontend still loads (`python3 -m http.server 3000 --directory Frontend`).\n- Verified changed endpoints still return JSON and preserve `status` field.\n- Checked no secrets were added to tracked files.\n\n## 8) Notes for Future Tooling PRs\n\n- Keep tests standardized on `pytest` and document exact paths/selectors here.\n- If adding linting, prefer `ruff` for lint + format and commit config files.\n- If adding type checks, document command and strictness level (`mypy` or equivalent).\n- Keep this file updated whenever workflow commands change.\n\n## 9) Agent Workflow Expectations\n\n- Prefer minimal diffs and preserve current behavior unless the task requires changes.\n- Keep API responses machine-parseable and consistent for frontend consumers.\n- Avoid checking in generated media/output artifacts unless explicitly requested.\n- Before returning work, include what was validated and what was not validated.\n- When adding commands or tooling, update this file and `docs/` together.\n"
  },
  {
    "path": "Backend/db.py",
    "content": "import os\n\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import DeclarativeBase, sessionmaker\n\nfrom dotenv import load_dotenv\nfrom utils import ENV_FILE\n\n\nload_dotenv(ENV_FILE)\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\ndef _database_url() -> str:\n    database_url = os.getenv(\"DATABASE_URL\")\n    if database_url:\n        return database_url\n    return \"sqlite:///moneyprinter.db\"\n\n\nDATABASE_URL = _database_url()\n\nengine = create_engine(\n    DATABASE_URL,\n    pool_pre_ping=True,\n    connect_args={\"check_same_thread\": False}\n    if DATABASE_URL.startswith(\"sqlite\")\n    else {},\n)\n\nSessionLocal = sessionmaker(\n    bind=engine, autoflush=False, autocommit=False, expire_on_commit=False\n)\n\n\ndef init_db() -> None:\n    from models import Artifact, GenerationEvent, GenerationJob, Project, Script  # noqa: F401\n\n    Base.metadata.create_all(bind=engine)\n"
  },
  {
    "path": "Backend/gpt.py",
    "content": "import re\nimport os\nimport json\nfrom ollama import Client, ResponseError\n\nfrom dotenv import load_dotenv\nfrom logstream import log\nfrom typing import Tuple, List, Optional\nfrom utils import ENV_FILE\n\n# Load environment variables\nload_dotenv(ENV_FILE)\n\n# Set environment variables\nOLLAMA_BASE_URL = os.getenv(\"OLLAMA_BASE_URL\", \"http://localhost:11434\").rstrip(\"/\")\nOLLAMA_MODEL = os.getenv(\"OLLAMA_MODEL\", \"llama3.1:8b\")\nOLLAMA_TIMEOUT = float(os.getenv(\"OLLAMA_TIMEOUT\", \"180\"))\n\n\ndef _ollama_client() -> Client:\n    return Client(host=OLLAMA_BASE_URL, timeout=OLLAMA_TIMEOUT)\n\n\ndef _extract_model_name(model_obj) -> str:\n    if hasattr(model_obj, \"model\") and getattr(model_obj, \"model\"):\n        return str(getattr(model_obj, \"model\")).strip()\n    if hasattr(model_obj, \"name\") and getattr(model_obj, \"name\"):\n        return str(getattr(model_obj, \"name\")).strip()\n    if isinstance(model_obj, dict):\n        return str(model_obj.get(\"model\") or model_obj.get(\"name\") or \"\").strip()\n    return \"\"\n\n\ndef list_ollama_models() -> Tuple[List[str], str]:\n    \"\"\"\n    Returns available Ollama model names and configured default model.\n\n    Returns:\n        Tuple[List[str], str]: (available model names, default model)\n    \"\"\"\n    try:\n        response = _ollama_client().list()\n    except Exception as err:\n        raise RuntimeError(f\"Failed to fetch Ollama models: {err}\") from err\n\n    models = []\n    if hasattr(response, \"models\") and getattr(response, \"models\") is not None:\n        models = list(getattr(response, \"models\"))\n    elif isinstance(response, dict):\n        models = response.get(\"models\") or []\n\n    model_names = [_extract_model_name(model) for model in models]\n    model_names = [name for name in model_names if name]\n\n    unique_names = list(dict.fromkeys(model_names))\n\n    if OLLAMA_MODEL and OLLAMA_MODEL in unique_names:\n        default_model = OLLAMA_MODEL\n    elif unique_names:\n        default_model = unique_names[0]\n    else:\n        default_model = OLLAMA_MODEL if OLLAMA_MODEL else \"\"\n\n    return unique_names, default_model\n\n\ndef generate_response(prompt: str, ai_model: str) -> str:\n    \"\"\"\n    Generate a script for a video, depending on the subject of the video.\n\n    Args:\n        video_subject (str): The subject of the video.\n        ai_model (str): The AI model to use for generation.\n\n\n    Returns:\n\n        str: The response from the AI model.\n\n    \"\"\"\n\n    model_name = (ai_model or \"\").strip() or OLLAMA_MODEL\n\n    try:\n        client = _ollama_client()\n        try:\n            response = client.chat(\n                model=model_name,\n                messages=[{\"role\": \"user\", \"content\": prompt}],\n                stream=False,\n            )\n        except ResponseError as err:\n            if err.status_code == 404:\n                try:\n                    response = client.generate(\n                        model=model_name, prompt=prompt, stream=False\n                    )\n                except ResponseError as fallback_err:\n                    if (\n                        fallback_err.status_code == 404\n                        and \"not found\" in str(fallback_err).lower()\n                    ):\n                        available_models, _ = list_ollama_models()\n                        available = (\n                            \", \".join(available_models) if available_models else \"none\"\n                        )\n                        raise RuntimeError(\n                            f\"Ollama model '{model_name}' is not installed. Available models: {available}. \"\n                            f\"Install it with: ollama pull {model_name}\"\n                        ) from fallback_err\n                    raise\n            else:\n                raise\n    except RuntimeError:\n        raise\n    except Exception as err:\n        raise RuntimeError(f\"Failed to connect to Ollama: {err}\") from err\n\n    content = \"\"\n    if hasattr(response, \"message\") and getattr(response, \"message\") is not None:\n        message = getattr(response, \"message\")\n        if hasattr(message, \"content\") and getattr(message, \"content\"):\n            content = str(getattr(message, \"content\")).strip()\n        elif isinstance(message, dict):\n            content = str(message.get(\"content\") or \"\").strip()\n\n    if not content:\n        if hasattr(response, \"response\") and getattr(response, \"response\"):\n            content = str(getattr(response, \"response\")).strip()\n        elif isinstance(response, dict):\n            content = (\n                str(response.get(\"message\", {}).get(\"content\") or \"\")\n                or str(response.get(\"response\") or \"\")\n            ).strip()\n\n    if not content:\n        raise RuntimeError(\"Ollama returned an empty response.\")\n\n    return content\n\n\ndef generate_script(\n    video_subject: str,\n    paragraph_number: int,\n    ai_model: str,\n    voice: str,\n    customPrompt: str,\n) -> Optional[str]:\n    \"\"\"\n    Generate a script for a video, depending on the subject of the video, the number of paragraphs, and the AI model.\n\n\n\n    Args:\n\n        video_subject (str): The subject of the video.\n\n        paragraph_number (int): The number of paragraphs to generate.\n\n        ai_model (str): The AI model to use for generation.\n\n\n\n    Returns:\n\n        str: The script for the video.\n\n    \"\"\"\n\n    # Build prompt\n\n    if customPrompt:\n        prompt = customPrompt\n    else:\n        prompt = \"\"\"\n            Generate a script for a video, depending on the subject of the video.\n\n            The script is to be returned as a string with the specified number of paragraphs.\n\n            Here is an example of a string:\n            \"This is an example string.\"\n\n            Do not under any circumstance reference this prompt in your response.\n\n            Get straight to the point, don't start with unnecessary things like, \"welcome to this video\".\n\n            Obviously, the script should be related to the subject of the video.\n\n            YOU MUST NOT INCLUDE ANY TYPE OF MARKDOWN OR FORMATTING IN THE SCRIPT, NEVER USE A TITLE.\n            YOU MUST WRITE THE SCRIPT IN THE LANGUAGE SPECIFIED IN [LANGUAGE].\n            ONLY RETURN THE RAW CONTENT OF THE SCRIPT. DO NOT INCLUDE \"VOICEOVER\", \"NARRATOR\" OR SIMILAR INDICATORS OF WHAT SHOULD BE SPOKEN AT THE BEGINNING OF EACH PARAGRAPH OR LINE. YOU MUST NOT MENTION THE PROMPT, OR ANYTHING ABOUT THE SCRIPT ITSELF. ALSO, NEVER TALK ABOUT THE AMOUNT OF PARAGRAPHS OR LINES. JUST WRITE THE SCRIPT.\n\n        \"\"\"\n\n    prompt += f\"\"\"\n    \n    Subject: {video_subject}\n    Number of paragraphs: {paragraph_number}\n    Language: {voice}\n\n    \"\"\"\n\n    # Generate script\n    response = generate_response(prompt, ai_model)\n\n    log(response, \"info\")\n\n    # Return the generated script\n    if response:\n        # Clean the script\n        # Remove asterisks, hashes\n        response = response.replace(\"*\", \"\")\n        response = response.replace(\"#\", \"\")\n\n        # Remove markdown syntax\n        response = re.sub(r\"\\[.*\\]\", \"\", response)\n        response = re.sub(r\"\\(.*\\)\", \"\", response)\n\n        # Split the script into paragraphs\n        paragraphs = response.split(\"\\n\\n\")\n\n        # Select the specified number of paragraphs\n        selected_paragraphs = paragraphs[:paragraph_number]\n\n        # Join the selected paragraphs into a single string\n        final_script = \"\\n\\n\".join(selected_paragraphs)\n\n        # Print to console the number of paragraphs used\n        log(f\"Number of paragraphs used: {len(selected_paragraphs)}\", \"success\")\n\n        return final_script\n    else:\n        log(\"[-] GPT returned an empty response.\", \"error\")\n        return None\n\n\ndef get_search_terms(\n    video_subject: str, amount: int, script: str, ai_model: str\n) -> List[str]:\n    \"\"\"\n    Generate a JSON-Array of search terms for stock videos,\n    depending on the subject of a video.\n\n    Args:\n        video_subject (str): The subject of the video.\n        amount (int): The amount of search terms to generate.\n        script (str): The script of the video.\n        ai_model (str): The AI model to use for generation.\n\n    Returns:\n        List[str]: The search terms for the video subject.\n    \"\"\"\n\n    # Build prompt\n    prompt = f\"\"\"\n    Generate {amount} search terms for stock videos,\n    depending on the subject of a video.\n    Subject: {video_subject}\n\n    The search terms are to be returned as\n    a JSON-Array of strings.\n\n    Each search term should consist of 1-3 words,\n    always add the main subject of the video.\n    \n    YOU MUST ONLY RETURN THE JSON-ARRAY OF STRINGS.\n    YOU MUST NOT RETURN ANYTHING ELSE. \n    YOU MUST NOT RETURN THE SCRIPT.\n    \n    The search terms must be related to the subject of the video.\n    Here is an example of a JSON-Array of strings:\n    [\"search term 1\", \"search term 2\", \"search term 3\"]\n\n    For context, here is the full text:\n    {script}\n    \"\"\"\n\n    # Generate search terms\n    response = generate_response(prompt, ai_model)\n    log(response, \"info\")\n\n    # Parse response into a list of search terms\n    search_terms = []\n\n    try:\n        search_terms = json.loads(response)\n        if not isinstance(search_terms, list) or not all(\n            isinstance(term, str) for term in search_terms\n        ):\n            raise ValueError(\"Response is not a list of strings.\")\n\n    except (json.JSONDecodeError, ValueError):\n        log(\"[*] GPT returned an unformatted response. Attempting to clean...\", \"warning\")\n\n        # Attempt to extract JSON array first\n        match = re.search(r\"\\[[\\s\\S]*\\]\", response)\n        if match:\n            try:\n                search_terms = json.loads(match.group())\n            except json.JSONDecodeError:\n                search_terms = []\n\n        # Last-resort fallback: collect quoted strings\n        if not search_terms:\n            search_terms = re.findall(r'\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"', response)\n            search_terms = [term.strip() for term in search_terms if term.strip()]\n\n    # Let user know\n    log(f\"\\nGenerated {len(search_terms)} search terms: {', '.join(search_terms)}\", \"info\")\n\n    # Return search terms\n    return search_terms\n\n\ndef generate_metadata(\n    video_subject: str, script: str, ai_model: str\n) -> Tuple[str, str, List[str]]:\n    \"\"\"\n    Generate metadata for a YouTube video, including the title, description, and keywords.\n\n    Args:\n        video_subject (str): The subject of the video.\n        script (str): The script of the video.\n        ai_model (str): The AI model to use for generation.\n\n    Returns:\n        Tuple[str, str, List[str]]: The title, description, and keywords for the video.\n    \"\"\"\n\n    # Build prompt for title\n    title_prompt = f\"\"\"  \n    Generate a catchy and SEO-friendly title for a YouTube shorts video about {video_subject}.  \n    \"\"\"\n\n    # Generate title\n    title = generate_response(title_prompt, ai_model).strip()\n\n    # Build prompt for description\n    description_prompt = f\"\"\"  \n    Write a brief and engaging description for a YouTube shorts video about {video_subject}.  \n    The video is based on the following script:  \n    {script}  \n    \"\"\"\n\n    # Generate description\n    description = generate_response(description_prompt, ai_model).strip()\n\n    # Generate keywords\n    keywords = get_search_terms(video_subject, 6, script, ai_model)\n\n    return title, description, keywords\n"
  },
  {
    "path": "Backend/logstream.py",
    "content": "import json\nimport queue\nimport re\nimport time\n\n\nclass LogStream:\n    \"\"\"Thread-safe log queue that doubles as an SSE generator.\"\"\"\n\n    def __init__(self, maxsize: int = 500):\n        self._queue: queue.Queue = queue.Queue(maxsize=maxsize)\n\n    def clear(self) -> None:\n        \"\"\"Drain all pending items from the queue.\"\"\"\n        while True:\n            try:\n                self._queue.get_nowait()\n            except queue.Empty:\n                break\n\n    def push(self, message: str, level: str = \"info\") -> None:\n        \"\"\"Add a log entry. Drops the oldest entry if the queue is full.\"\"\"\n        entry = {\n            \"type\": \"log\",\n            \"message\": message,\n            \"level\": level,\n            \"timestamp\": time.time(),\n        }\n        try:\n            self._queue.put_nowait(entry)\n        except queue.Full:\n            try:\n                self._queue.get_nowait()\n            except queue.Empty:\n                pass\n            try:\n                self._queue.put_nowait(entry)\n            except queue.Full:\n                pass\n\n    def push_event(self, event_type: str, data: dict | None = None) -> None:\n        \"\"\"Send a control event (complete, error, cancelled).\"\"\"\n        entry = {\n            \"type\": event_type,\n            \"timestamp\": time.time(),\n            **(data or {}),\n        }\n        try:\n            self._queue.put_nowait(entry)\n        except queue.Full:\n            try:\n                self._queue.get_nowait()\n            except queue.Empty:\n                pass\n            try:\n                self._queue.put_nowait(entry)\n            except queue.Full:\n                pass\n\n    def stream(self, timeout: float = 30.0):\n        \"\"\"Generator yielding SSE-formatted lines. Terminates on control events.\"\"\"\n        while True:\n            try:\n                entry = self._queue.get(timeout=timeout)\n                yield f\"data: {json.dumps(entry)}\\n\\n\"\n                if entry.get(\"type\") in (\"complete\", \"error\", \"cancelled\"):\n                    return\n            except queue.Empty:\n                # Send keepalive comment to prevent connection timeout\n                yield \": keepalive\\n\\n\"\n\n\n# Strip ANSI escape codes from terminal-colored strings\n_ANSI_RE = re.compile(r\"\\x1b\\[[0-9;]*m\")\n\n# Singleton shared by all modules\nlog_stream = LogStream()\n\n# Color-to-level mapping for termcolor colors\n_COLOR_LEVEL = {\n    \"green\": \"success\",\n    \"red\": \"error\",\n    \"yellow\": \"warning\",\n    \"blue\": \"info\",\n    \"cyan\": \"info\",\n    \"magenta\": \"info\",\n}\n\n\ndef log(message: str, level: str = \"info\") -> None:\n    \"\"\"Drop-in replacement for ``print(colored(msg, color))``.\n\n    Prints to the terminal **and** pushes an ANSI-stripped copy to the SSE queue.\n    \"\"\"\n    print(message)\n    clean = _ANSI_RE.sub(\"\", str(message))\n    log_stream.push(clean, level)\n"
  },
  {
    "path": "Backend/main.py",
    "content": "import os\n\nfrom dotenv import load_dotenv\nfrom flask import Flask, jsonify, request\nfrom flask_cors import CORS\nfrom sqlalchemy import and_, case, select\n\nfrom db import SessionLocal, init_db\nfrom gpt import list_ollama_models\nfrom logstream import log\nfrom repository import create_job, get_job, list_job_events, request_cancel\nfrom utils import ENV_FILE, SONGS_DIR, check_env_vars, clean_dir\n\n\nload_dotenv(ENV_FILE)\ncheck_env_vars()\ninit_db()\n\napp = Flask(__name__)\nCORS(app)\n\nHOST = \"0.0.0.0\"\nPORT = 8080\n\n\n@app.route(\"/api/models\", methods=[\"GET\"])\ndef models():\n    try:\n        available_models, default_model = list_ollama_models()\n        return jsonify(\n            {\n                \"status\": \"success\",\n                \"models\": available_models,\n                \"default\": default_model,\n            }\n        )\n    except Exception as err:\n        log(f\"[-] Error fetching Ollama models: {str(err)}\", \"error\")\n        return jsonify(\n            {\n                \"status\": \"error\",\n                \"message\": \"Could not fetch Ollama models. Is Ollama running?\",\n                \"models\": [os.getenv(\"OLLAMA_MODEL\", \"llama3.1:8b\")],\n                \"default\": os.getenv(\"OLLAMA_MODEL\", \"llama3.1:8b\"),\n            }\n        )\n\n\n@app.route(\"/api/generate\", methods=[\"POST\"])\ndef generate():\n    data = request.get_json() or {}\n    if not data.get(\"videoSubject\"):\n        return jsonify({\"status\": \"error\", \"message\": \"videoSubject is required.\"}), 400\n\n    with SessionLocal() as session:\n        job = create_job(session, payload=data)\n\n    return jsonify(\n        {\n            \"status\": \"success\",\n            \"message\": \"Video generation queued.\",\n            \"jobId\": job.id,\n        }\n    )\n\n\n@app.route(\"/api/jobs/<job_id>\", methods=[\"GET\"])\ndef get_job_status(job_id: str):\n    with SessionLocal() as session:\n        job = get_job(session, job_id)\n        if not job:\n            return jsonify({\"status\": \"error\", \"message\": \"Job not found.\"}), 404\n\n        return jsonify(\n            {\n                \"status\": \"success\",\n                \"job\": {\n                    \"id\": job.id,\n                    \"state\": job.status,\n                    \"cancelRequested\": job.cancel_requested,\n                    \"resultPath\": job.result_path,\n                    \"errorMessage\": job.error_message,\n                    \"createdAt\": job.created_at.isoformat() if job.created_at else None,\n                    \"startedAt\": job.started_at.isoformat() if job.started_at else None,\n                    \"completedAt\": job.completed_at.isoformat()\n                    if job.completed_at\n                    else None,\n                },\n            }\n        )\n\n\n@app.route(\"/api/jobs/<job_id>/events\", methods=[\"GET\"])\ndef get_events(job_id: str):\n    after_id = request.args.get(\"after\", default=0, type=int)\n\n    with SessionLocal() as session:\n        job = get_job(session, job_id)\n        if not job:\n            return jsonify({\"status\": \"error\", \"message\": \"Job not found.\"}), 404\n\n        events = list_job_events(session, job_id, after_id=after_id)\n        return jsonify(\n            {\n                \"status\": \"success\",\n                \"events\": [\n                    {\n                        \"id\": event.id,\n                        \"type\": event.event_type,\n                        \"level\": event.level,\n                        \"message\": event.message,\n                        \"payload\": event.payload,\n                        \"timestamp\": event.created_at.timestamp()\n                        if event.created_at\n                        else None,\n                    }\n                    for event in events\n                ],\n            }\n        )\n\n\n@app.route(\"/api/jobs/<job_id>/cancel\", methods=[\"POST\"])\ndef cancel_job(job_id: str):\n    with SessionLocal() as session:\n        cancelled = request_cancel(session, job_id)\n        if not cancelled:\n            return jsonify({\"status\": \"error\", \"message\": \"Job not found.\"}), 404\n\n    return jsonify({\"status\": \"success\", \"message\": \"Cancellation requested.\"})\n\n\n@app.route(\"/api/upload-songs\", methods=[\"POST\"])\ndef upload_songs():\n    try:\n        files = request.files.getlist(\"songs\")\n        if not files:\n            return jsonify({\"status\": \"error\", \"message\": \"No files uploaded.\"}), 400\n\n        clean_dir(str(SONGS_DIR))\n        saved = 0\n        for file_item in files:\n            if file_item.filename and file_item.filename.lower().endswith(\".mp3\"):\n                safe_name = os.path.basename(file_item.filename)\n                file_item.save(str(SONGS_DIR / safe_name))\n                saved += 1\n\n        if saved == 0:\n            return jsonify({\"status\": \"error\", \"message\": \"No MP3 files found.\"}), 400\n\n        log(f\"[+] Uploaded {saved} song(s) to {SONGS_DIR}\", \"success\")\n        return jsonify({\"status\": \"success\", \"message\": f\"Uploaded {saved} song(s).\"})\n    except Exception as err:\n        log(f\"[-] Error uploading songs: {str(err)}\", \"error\")\n        return jsonify({\"status\": \"error\", \"message\": str(err)}), 500\n\n\n@app.route(\"/api/cancel\", methods=[\"POST\"])\ndef cancel_latest_running_job():\n    with SessionLocal() as session:\n        from models import GenerationJob\n\n        stmt = (\n            select(GenerationJob)\n            .where(and_(GenerationJob.status.in_([\"queued\", \"running\"])))\n            .order_by(\n                case((GenerationJob.status == \"running\", 0), else_=1),\n                GenerationJob.created_at.desc(),\n            )\n            .limit(1)\n        )\n        latest_job = session.scalars(stmt).first()\n        if not latest_job:\n            return jsonify({\"status\": \"error\", \"message\": \"No active job found.\"}), 404\n\n        request_cancel(session, latest_job.id)\n\n    return jsonify(\n        {\n            \"status\": \"success\",\n            \"message\": \"Cancellation requested.\",\n            \"jobId\": latest_job.id,\n        }\n    )\n\n\nif __name__ == \"__main__\":\n    app.run(debug=True, host=HOST, port=PORT, threaded=True)\n"
  },
  {
    "path": "Backend/models.py",
    "content": "from datetime import datetime\nfrom typing import Optional\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom db import Base\n\n\nclass Project(Base):\n    __tablename__ = \"projects\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)\n    created_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True), server_default=func.now(), nullable=False\n    )\n\n\nclass GenerationJob(Base):\n    __tablename__ = \"generation_jobs\"\n\n    id: Mapped[str] = mapped_column(String(36), primary_key=True)\n    project_id: Mapped[Optional[int]] = mapped_column(\n        Integer,\n        ForeignKey(\"projects.id\", ondelete=\"SET NULL\"),\n        nullable=True,\n        index=True,\n    )\n    status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)\n    payload: Mapped[dict] = mapped_column(JSON, nullable=False)\n    cancel_requested: Mapped[bool] = mapped_column(\n        Boolean, nullable=False, default=False\n    )\n    attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)\n    max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)\n    result_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)\n    error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)\n    created_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True), server_default=func.now(), nullable=False, index=True\n    )\n    started_at: Mapped[Optional[datetime]] = mapped_column(\n        DateTime(timezone=True), nullable=True\n    )\n    completed_at: Mapped[Optional[datetime]] = mapped_column(\n        DateTime(timezone=True), nullable=True\n    )\n    updated_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True),\n        server_default=func.now(),\n        onupdate=func.now(),\n        nullable=False,\n    )\n\n    project: Mapped[Optional[Project]] = relationship(\"Project\")\n    events: Mapped[list[\"GenerationEvent\"]] = relationship(\n        \"GenerationEvent\", back_populates=\"job\", cascade=\"all, delete-orphan\"\n    )\n\n\nclass GenerationEvent(Base):\n    __tablename__ = \"generation_events\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    job_id: Mapped[str] = mapped_column(\n        String(36),\n        ForeignKey(\"generation_jobs.id\", ondelete=\"CASCADE\"),\n        nullable=False,\n        index=True,\n    )\n    event_type: Mapped[str] = mapped_column(String(20), nullable=False, default=\"log\")\n    level: Mapped[str] = mapped_column(String(20), nullable=False, default=\"info\")\n    message: Mapped[str] = mapped_column(Text, nullable=False)\n    payload: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)\n    created_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True), server_default=func.now(), nullable=False, index=True\n    )\n\n    job: Mapped[GenerationJob] = relationship(\"GenerationJob\", back_populates=\"events\")\n\n\nclass Script(Base):\n    __tablename__ = \"scripts\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    job_id: Mapped[str] = mapped_column(\n        String(36),\n        ForeignKey(\"generation_jobs.id\", ondelete=\"CASCADE\"),\n        nullable=False,\n        index=True,\n    )\n    model_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)\n    content: Mapped[str] = mapped_column(Text, nullable=False)\n    created_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True), server_default=func.now(), nullable=False\n    )\n\n\nclass Artifact(Base):\n    __tablename__ = \"artifacts\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    job_id: Mapped[str] = mapped_column(\n        String(36),\n        ForeignKey(\"generation_jobs.id\", ondelete=\"CASCADE\"),\n        nullable=False,\n        index=True,\n    )\n    artifact_type: Mapped[str] = mapped_column(String(64), nullable=False)\n    path: Mapped[str] = mapped_column(String(512), nullable=False)\n    metadata_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)\n    created_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True), server_default=func.now(), nullable=False\n    )\n"
  },
  {
    "path": "Backend/pipeline.py",
    "content": "import os\nimport shutil\nimport subprocess\n\nfrom apiclient.errors import HttpError\nfrom moviepy import (\n    AudioFileClip,\n    CompositeAudioClip,\n    VideoFileClip,\n    afx,\n    concatenate_audioclips,\n)\nfrom uuid import uuid4\n\nfrom gpt import generate_metadata, generate_script, get_search_terms\nfrom logstream import log\nfrom search import search_for_stock_videos\nfrom tiktokvoice import tts\nfrom utils import (\n    BASE_DIR,\n    PROJECT_ROOT,\n    SONGS_DIR,\n    SUBTITLES_DIR,\n    TEMP_DIR,\n    choose_random_song,\n)\nfrom video import combine_videos, generate_subtitles, generate_video, save_video\nfrom youtube import upload_video\n\n\nclass PipelineCancelled(Exception):\n    pass\n\n\ndef run_generation_pipeline(\n    data: dict,\n    is_cancelled,\n    on_log,\n    amount_of_stock_videos: int = 5,\n) -> str:\n    def emit(message: str, level: str = \"info\") -> None:\n        log(message, level)\n        if on_log:\n            on_log(message, level)\n\n    def guard_cancelled() -> None:\n        if is_cancelled and is_cancelled():\n            raise PipelineCancelled(\"Video generation was cancelled.\")\n\n    paragraph_number = int(data.get(\"paragraphNumber\", 1))\n    ai_model = data.get(\"aiModel\")\n    n_threads = data.get(\"threads\")\n    subtitles_position = data.get(\"subtitlesPosition\")\n    text_color = data.get(\"color\")\n    use_music = data.get(\"useMusic\", False)\n    automate_youtube_upload = data.get(\"automateYoutubeUpload\", False)\n\n    emit(\"[Video to be generated]\", \"info\")\n    emit(\"   Subject: \" + data[\"videoSubject\"], \"info\")\n    emit(\"   AI Model: \" + str(ai_model), \"info\")\n    emit(\"   Custom Prompt: \" + data[\"customPrompt\"], \"info\")\n\n    guard_cancelled()\n\n    voice = data.get(\"voice\", \"\")\n    voice_prefix = voice[:2]\n\n    if not voice:\n        emit('[!] No voice was selected. Defaulting to \"en_us_001\"', \"warning\")\n        voice = \"en_us_001\"\n        voice_prefix = voice[:2]\n\n    script = generate_script(\n        data[\"videoSubject\"],\n        paragraph_number,\n        ai_model,\n        voice,\n        data[\"customPrompt\"],\n    )\n\n    if not script:\n        raise RuntimeError(\n            \"Could not generate a script. Try a different model or prompt.\"\n        )\n\n    search_terms = get_search_terms(\n        data[\"videoSubject\"], amount_of_stock_videos, script, ai_model\n    )\n\n    video_urls = []\n    it = 15\n    min_dur = 10\n\n    for search_term in search_terms:\n        guard_cancelled()\n        found_urls = search_for_stock_videos(\n            search_term, os.getenv(\"PEXELS_API_KEY\"), it, min_dur\n        )\n        for url in found_urls:\n            if url not in video_urls:\n                video_urls.append(url)\n                break\n\n    if not video_urls:\n        raise RuntimeError(\"No videos found to download.\")\n\n    video_paths = []\n    emit(f\"[+] Downloading {len(video_urls)} videos...\", \"info\")\n\n    for video_url in video_urls:\n        guard_cancelled()\n        try:\n            saved_video_path = save_video(video_url)\n            video_paths.append(saved_video_path)\n        except Exception:\n            emit(f\"[-] Could not download video: {video_url}\", \"error\")\n\n    emit(\"[+] Videos downloaded!\", \"success\")\n    emit(\"[+] Script generated!\", \"success\")\n\n    guard_cancelled()\n\n    sentences = script.split(\". \")\n    sentences = list(filter(lambda x: x != \"\", sentences))\n    paths = []\n\n    for sentence in sentences:\n        guard_cancelled()\n        current_tts_path = str(TEMP_DIR / f\"{uuid4()}.mp3\")\n        tts(sentence, voice, filename=current_tts_path)\n        audio_clip = AudioFileClip(current_tts_path)\n        paths.append(audio_clip)\n\n    final_audio = concatenate_audioclips(paths)\n    tts_path = str(TEMP_DIR / f\"{uuid4()}.mp3\")\n    try:\n        final_audio.write_audiofile(tts_path)\n    finally:\n        final_audio.close()\n        for audio_clip in paths:\n            audio_clip.close()\n\n    try:\n        subtitles_path = generate_subtitles(\n            audio_path=tts_path,\n            sentences=sentences,\n            audio_clips=paths,\n            voice=voice_prefix,\n        )\n    except Exception as err:\n        emit(f\"[-] Error generating subtitles: {err}\", \"error\")\n        subtitles_path = None\n\n    if not subtitles_path:\n        raise RuntimeError(\n            \"Could not generate subtitles. Check AssemblyAI key or local subtitle settings.\"\n        )\n\n    temp_audio = AudioFileClip(tts_path)\n    try:\n        combined_video_path = combine_videos(\n            video_paths, temp_audio.duration, 5, n_threads or 2\n        )\n    finally:\n        temp_audio.close()\n\n    try:\n        final_video_path = generate_video(\n            combined_video_path,\n            tts_path,\n            subtitles_path,\n            n_threads or 2,\n            subtitles_position,\n            text_color or \"#FFFF00\",\n        )\n    except Exception as err:\n        raise RuntimeError(\n            f\"Could not render final video. Check subtitle/font/ImageMagick setup. ({err})\"\n        ) from err\n\n    title, description, keywords = generate_metadata(\n        data[\"videoSubject\"], script, ai_model\n    )\n\n    emit(\"[-] Metadata for YouTube upload:\", \"info\")\n    emit(\"   Title:\", \"info\")\n    emit(f\"   {title}\", \"info\")\n    emit(\"   Description:\", \"info\")\n    emit(f\"   {description}\", \"info\")\n    emit(\"   Keywords:\", \"info\")\n    emit(f\"  {', '.join(keywords)}\", \"info\")\n\n    if automate_youtube_upload:\n        client_secrets_file = str((BASE_DIR / \"client_secret.json\").resolve())\n        skip_yt_upload = False\n        if not os.path.exists(client_secrets_file):\n            skip_yt_upload = True\n            emit(\n                \"[-] Client secrets file missing. YouTube upload will be skipped.\",\n                \"warning\",\n            )\n            emit(\n                \"[-] Please download the client_secret.json from Google Cloud Platform and store this inside the /Backend directory.\",\n                \"error\",\n            )\n\n        if not skip_yt_upload:\n            video_category_id = \"28\"\n            privacy_status = \"private\"\n            video_metadata = {\n                \"video_path\": str((TEMP_DIR / final_video_path).resolve()),\n                \"title\": title,\n                \"description\": description,\n                \"category\": video_category_id,\n                \"keywords\": \",\".join(keywords),\n                \"privacyStatus\": privacy_status,\n            }\n\n            try:\n                video_response = upload_video(\n                    video_path=video_metadata[\"video_path\"],\n                    title=video_metadata[\"title\"],\n                    description=video_metadata[\"description\"],\n                    category=video_metadata[\"category\"],\n                    keywords=video_metadata[\"keywords\"],\n                    privacy_status=video_metadata[\"privacyStatus\"],\n                )\n                emit(f\"Uploaded video ID: {video_response.get('id')}\", \"success\")\n            except HttpError as err:\n                emit(\n                    f\"An HTTP error {err.resp.status} occurred:\\n{err.content}\", \"error\"\n                )\n\n    final_output_path = str(PROJECT_ROOT / final_video_path)\n    rendered_video_path = str(TEMP_DIR / final_video_path)\n    render_threads = n_threads or (os.cpu_count() or 2)\n\n    guard_cancelled()\n\n    if use_music:\n        song_path = choose_random_song()\n\n        if not song_path:\n            emit(\n                \"[-] Could not find songs in Songs/. Continuing without background music.\",\n                \"warning\",\n            )\n            use_music = False\n\n        if use_music:\n            video_clip = VideoFileClip(rendered_video_path)\n            song_clip = None\n            mixed_audio = None\n            mixed_audio_path = str(TEMP_DIR / f\"{uuid4()}_mixed_audio.m4a\")\n            try:\n                original_duration = video_clip.duration\n                original_audio = video_clip.audio\n                song_clip = AudioFileClip(song_path).with_fps(44100)\n                song_clip = song_clip.with_effects(\n                    [afx.AudioLoop(duration=original_duration)]\n                )\n                song_clip = song_clip.with_volume_scaled(0.1).with_fps(44100)\n\n                mixed_audio = CompositeAudioClip(\n                    [original_audio, song_clip]\n                ).with_duration(original_duration)\n                mixed_audio.write_audiofile(\n                    mixed_audio_path,\n                    fps=44100,\n                    codec=\"aac\",\n                    bitrate=\"192k\",\n                )\n            finally:\n                video_clip.close()\n                if mixed_audio is not None:\n                    mixed_audio.close()\n                if song_clip is not None:\n                    song_clip.close()\n\n            try:\n                subprocess.run(\n                    [\n                        \"ffmpeg\",\n                        \"-y\",\n                        \"-i\",\n                        rendered_video_path,\n                        \"-i\",\n                        mixed_audio_path,\n                        \"-map\",\n                        \"0:v:0\",\n                        \"-map\",\n                        \"1:a:0\",\n                        \"-c:v\",\n                        \"copy\",\n                        \"-c:a\",\n                        \"aac\",\n                        \"-b:a\",\n                        \"192k\",\n                        \"-shortest\",\n                        final_output_path,\n                    ],\n                    check=True,\n                    capture_output=True,\n                    text=True,\n                )\n            except Exception:\n                emit(\n                    \"[!] ffmpeg remux failed. Falling back to MoviePy render for music mix.\",\n                    \"warning\",\n                )\n                video_clip = VideoFileClip(rendered_video_path)\n                song_clip = None\n                try:\n                    original_duration = video_clip.duration\n                    original_audio = video_clip.audio\n                    song_clip = AudioFileClip(song_path).with_fps(44100)\n                    song_clip = song_clip.with_effects(\n                        [afx.AudioLoop(duration=original_duration)]\n                    )\n                    song_clip = song_clip.with_volume_scaled(0.1).with_fps(44100)\n                    comp_audio = CompositeAudioClip(\n                        [original_audio, song_clip]\n                    ).with_duration(original_duration)\n                    video_clip = (\n                        video_clip.with_audio(comp_audio)\n                        .with_fps(30)\n                        .with_duration(original_duration)\n                    )\n                    video_clip.write_videofile(\n                        final_output_path,\n                        threads=render_threads,\n                        fps=30,\n                        codec=\"libx264\",\n                        audio_codec=\"aac\",\n                        preset=\"medium\",\n                    )\n                finally:\n                    video_clip.close()\n                    if song_clip is not None:\n                        song_clip.close()\n            finally:\n                if os.path.exists(mixed_audio_path):\n                    os.remove(mixed_audio_path)\n\n    if not use_music:\n        shutil.copy2(rendered_video_path, final_output_path)\n\n    emit(f\"[+] Video generated: {final_video_path}!\", \"success\")\n\n    if os.name == \"nt\":\n        subprocess.run(\n            [\"taskkill\", \"/f\", \"/im\", \"ffmpeg.exe\"],\n            check=False,\n            capture_output=True,\n            text=True,\n        )\n    elif shutil.which(\"pkill\"):\n        subprocess.run(\n            [\"pkill\", \"-f\", \"ffmpeg\"],\n            check=False,\n            capture_output=True,\n            text=True,\n        )\n\n    return final_video_path\n"
  },
  {
    "path": "Backend/repository.py",
    "content": "from datetime import datetime, timezone\nfrom typing import Optional\nfrom uuid import uuid4\n\nfrom sqlalchemy import and_, select, text\nfrom sqlalchemy.orm import Session\n\nfrom models import GenerationEvent, GenerationJob\n\n\ndef utcnow() -> datetime:\n    return datetime.now(timezone.utc)\n\n\ndef create_job(session: Session, payload: dict, max_attempts: int = 1) -> GenerationJob:\n    job = GenerationJob(\n        id=str(uuid4()),\n        status=\"queued\",\n        payload=payload,\n        max_attempts=max_attempts,\n        cancel_requested=False,\n    )\n    session.add(job)\n    session.flush()\n    append_event(session, job.id, \"queued\", \"info\", \"Job queued.\")\n    session.commit()\n    session.refresh(job)\n    return job\n\n\ndef append_event(\n    session: Session,\n    job_id: str,\n    event_type: str,\n    level: str,\n    message: str,\n    payload: Optional[dict] = None,\n) -> GenerationEvent:\n    event = GenerationEvent(\n        job_id=job_id,\n        event_type=event_type,\n        level=level,\n        message=message,\n        payload=payload,\n    )\n    session.add(event)\n    session.flush()\n    return event\n\n\ndef get_job(session: Session, job_id: str) -> Optional[GenerationJob]:\n    return session.get(GenerationJob, job_id)\n\n\ndef list_job_events(\n    session: Session, job_id: str, after_id: int = 0, limit: int = 200\n) -> list[GenerationEvent]:\n    stmt = (\n        select(GenerationEvent)\n        .where(\n            and_(\n                GenerationEvent.job_id == job_id,\n                GenerationEvent.id > after_id,\n            )\n        )\n        .order_by(GenerationEvent.id.asc())\n        .limit(limit)\n    )\n    return list(session.scalars(stmt).all())\n\n\ndef request_cancel(session: Session, job_id: str) -> bool:\n    job = get_job(session, job_id)\n    if not job:\n        return False\n\n    if job.status in (\"completed\", \"failed\", \"cancelled\"):\n        return True\n\n    job.cancel_requested = True\n    job.updated_at = utcnow()\n    append_event(\n        session, job.id, \"cancel_requested\", \"warning\", \"Cancellation requested.\"\n    )\n\n    if job.status == \"queued\":\n        job.status = \"cancelled\"\n        job.completed_at = utcnow()\n        append_event(\n            session, job.id, \"cancelled\", \"warning\", \"Job cancelled before execution.\"\n        )\n\n    session.commit()\n    return True\n\n\ndef claim_next_queued_job(session: Session) -> Optional[GenerationJob]:\n    dialect = session.bind.dialect.name if session.bind else \"\"\n\n    if dialect == \"postgresql\":\n        row = session.execute(\n            text(\n                \"\"\"\n                SELECT id\n                FROM generation_jobs\n                WHERE status = 'queued' AND cancel_requested = false\n                ORDER BY created_at ASC\n                FOR UPDATE SKIP LOCKED\n                LIMIT 1\n                \"\"\"\n            )\n        ).first()\n        if not row:\n            return None\n        job = get_job(session, row[0])\n    else:\n        stmt = (\n            select(GenerationJob)\n            .where(\n                and_(\n                    GenerationJob.status == \"queued\",\n                    GenerationJob.cancel_requested.is_(False),\n                )\n            )\n            .order_by(GenerationJob.created_at.asc())\n            .limit(1)\n        )\n        job = session.scalars(stmt).first()\n\n    if not job:\n        return None\n\n    job.status = \"running\"\n    job.attempt_count = (job.attempt_count or 0) + 1\n    job.started_at = utcnow()\n    job.updated_at = utcnow()\n    append_event(session, job.id, \"running\", \"info\", \"Job started.\")\n    session.commit()\n    session.refresh(job)\n    return job\n\n\ndef mark_completed(session: Session, job_id: str, result_path: str) -> None:\n    job = get_job(session, job_id)\n    if not job:\n        return\n    job.status = \"completed\"\n    job.result_path = result_path\n    job.error_message = None\n    job.completed_at = utcnow()\n    job.updated_at = utcnow()\n    append_event(\n        session,\n        job.id,\n        \"complete\",\n        \"success\",\n        \"Video generated successfully.\",\n        {\"path\": result_path},\n    )\n    session.commit()\n\n\ndef mark_cancelled(\n    session: Session, job_id: str, reason: str = \"Job cancelled.\"\n) -> None:\n    job = get_job(session, job_id)\n    if not job:\n        return\n    job.status = \"cancelled\"\n    job.completed_at = utcnow()\n    job.updated_at = utcnow()\n    append_event(session, job.id, \"cancelled\", \"warning\", reason)\n    session.commit()\n\n\ndef mark_failed(session: Session, job_id: str, error_message: str) -> None:\n    job = get_job(session, job_id)\n    if not job:\n        return\n    job.status = \"failed\"\n    job.error_message = error_message\n    job.completed_at = utcnow()\n    job.updated_at = utcnow()\n    append_event(session, job.id, \"error\", \"error\", error_message)\n    session.commit()\n"
  },
  {
    "path": "Backend/search.py",
    "content": "import requests\n\nfrom typing import List\nfrom logstream import log\n\ndef search_for_stock_videos(query: str, api_key: str, it: int, min_dur: int) -> List[str]:\n    \"\"\"\n    Searches for stock videos based on a query.\n\n    Args:\n        query (str): The query to search for.\n        api_key (str): The API key to use.\n\n    Returns:\n        List[str]: A list of stock videos.\n    \"\"\"\n    \n    # Build headers\n    headers = {\n        \"Authorization\": api_key\n    }\n\n    # Build URL\n    qurl = f\"https://api.pexels.com/videos/search?query={query}&per_page={it}\"\n\n    # Send the request\n    r = requests.get(qurl, headers=headers)\n\n    # Parse the response\n    response = r.json()\n\n    # Parse each video\n    raw_urls = []\n    video_url = []\n    video_res = 0\n    try:\n        # loop through each video in the result\n        for i in range(it):\n            #check if video has desired minimum duration\n            if response[\"videos\"][i][\"duration\"] < min_dur:\n                continue\n            raw_urls = response[\"videos\"][i][\"video_files\"]\n            temp_video_url = \"\"\n            \n            # loop through each url to determine the best quality\n            for video in raw_urls:\n                # Check if video has a valid download link\n                if \".com/video-files\" in video[\"link\"]:\n                    # Only save the URL with the largest resolution\n                    if (video[\"width\"]*video[\"height\"]) > video_res:\n                        temp_video_url = video[\"link\"]\n                        video_res = video[\"width\"]*video[\"height\"]\n                        \n            # add the url to the return list if it's not empty\n            if temp_video_url != \"\":\n                video_url.append(temp_video_url)\n                \n    except Exception as e:\n        log(\"[-] No Videos found.\", \"error\")\n        log(str(e), \"error\")\n\n    # Let user know\n    log(f\"\\t=> \\\"{query}\\\" found {len(video_url)} Videos\", \"info\")\n\n    # Return the video url\n    return video_url\n"
  },
  {
    "path": "Backend/tiktokvoice.py",
    "content": "# author: GiorDior aka Giorgio\n# date: 12.06.2023\n# topic: TikTok-Voice-TTS\n# version: 1.0\n# credits: https://github.com/oscie57/tiktok-voice\n\n# --- MODIFIED VERSION --- #\n\nimport base64\nimport requests\nimport threading\n\nfrom typing import List\nfrom logstream import log\nfrom playsound import playsound\n\n\nVOICES = [\n    # DISNEY VOICES\n    \"en_us_ghostface\",  # Ghost Face\n    \"en_us_chewbacca\",  # Chewbacca\n    \"en_us_c3po\",  # C3PO\n    \"en_us_stitch\",  # Stitch\n    \"en_us_stormtrooper\",  # Stormtrooper\n    \"en_us_rocket\",  # Rocket\n    # ENGLISH VOICES\n    \"en_au_001\",  # English AU - Female\n    \"en_au_002\",  # English AU - Male\n    \"en_uk_001\",  # English UK - Male 1\n    \"en_uk_003\",  # English UK - Male 2\n    \"en_us_001\",  # English US - Female (Int. 1)\n    \"en_us_002\",  # English US - Female (Int. 2)\n    \"en_us_006\",  # English US - Male 1\n    \"en_us_007\",  # English US - Male 2\n    \"en_us_009\",  # English US - Male 3\n    \"en_us_010\",  # English US - Male 4\n    # EUROPE VOICES\n    \"fr_001\",  # French - Male 1\n    \"fr_002\",  # French - Male 2\n    \"de_001\",  # German - Female\n    \"de_002\",  # German - Male\n    \"es_002\",  # Spanish - Male\n    # AMERICA VOICES\n    \"es_mx_002\",  # Spanish MX - Male\n    \"br_001\",  # Portuguese BR - Female 1\n    \"br_003\",  # Portuguese BR - Female 2\n    \"br_004\",  # Portuguese BR - Female 3\n    \"br_005\",  # Portuguese BR - Male\n    # ASIA VOICES\n    \"id_001\",  # Indonesian - Female\n    \"jp_001\",  # Japanese - Female 1\n    \"jp_003\",  # Japanese - Female 2\n    \"jp_005\",  # Japanese - Female 3\n    \"jp_006\",  # Japanese - Male\n    \"kr_002\",  # Korean - Male 1\n    \"kr_003\",  # Korean - Female\n    \"kr_004\",  # Korean - Male 2\n    # SINGING VOICES\n    \"en_female_f08_salut_damour\",  # Alto\n    \"en_male_m03_lobby\",  # Tenor\n    \"en_female_f08_warmy_breeze\",  # Warmy Breeze\n    \"en_male_m03_sunshine_soon\",  # Sunshine Soon\n    # OTHER\n    \"en_male_narration\",  # narrator\n    \"en_male_funny\",  # wacky\n    \"en_female_emotional\",  # peaceful\n]\n\nENDPOINTS = [\n    \"https://tiktok-tts.weilnet.workers.dev/api/generation\",\n    \"https://tiktoktts.com/api/tiktok-tts\",\n]\ncurrent_endpoint = 0\n# in one conversion, the text can have a maximum length of 300 characters\nTEXT_BYTE_LIMIT = 300\n\n\n# create a list by splitting a string, every element has n chars\ndef split_string(string: str, chunk_size: int) -> List[str]:\n    words = string.split()\n    result = []\n    current_chunk = \"\"\n    for word in words:\n        if (\n            len(current_chunk) + len(word) + 1 <= chunk_size\n        ):  # Check if adding the word exceeds the chunk size\n            current_chunk += f\" {word}\"\n        else:\n            if current_chunk:  # Append the current chunk if not empty\n                result.append(current_chunk.strip())\n            current_chunk = word\n    if current_chunk:  # Append the last chunk if not empty\n        result.append(current_chunk.strip())\n    return result\n\n\n# checking if the website that provides the service is available\ndef get_api_response() -> requests.Response:\n    url = f'{ENDPOINTS[current_endpoint].split(\"/a\")[0]}'\n    response = requests.get(url)\n    return response\n\n\n# saving the audio file\ndef save_audio_file(base64_data: str, filename: str = \"output.mp3\") -> None:\n    audio_bytes = base64.b64decode(base64_data)\n    with open(filename, \"wb\") as file:\n        file.write(audio_bytes)\n\n\n# send POST request to get the audio data\ndef generate_audio(text: str, voice: str) -> bytes:\n    url = f\"{ENDPOINTS[current_endpoint]}\"\n    headers = {\"Content-Type\": \"application/json\"}\n    data = {\"text\": text, \"voice\": voice}\n    response = requests.post(url, headers=headers, json=data)\n    return response.content\n\n\n# creates an text to speech audio file\ndef tts(\n    text: str,\n    voice: str = \"none\",\n    filename: str = \"output.mp3\",\n    play_sound: bool = False,\n) -> None:\n    # checking if the website is available\n    global current_endpoint\n\n    if get_api_response().status_code == 200:\n        log(\"[+] TikTok TTS Service available!\", \"success\")\n    else:\n        current_endpoint = (current_endpoint + 1) % 2\n        if get_api_response().status_code == 200:\n            log(\"[+] TTS Service available!\", \"success\")\n        else:\n            log(\"[-] TTS Service not available and probably temporarily rate limited, try again later...\", \"error\")\n            return\n\n    # checking if arguments are valid\n    if voice == \"none\":\n        log(\"[-] Please specify a voice\", \"error\")\n        return\n\n    if voice not in VOICES:\n        log(\"[-] Voice not available\", \"error\")\n        return\n\n    if not text:\n        log(\"[-] Please specify a text\", \"error\")\n        return\n\n    # creating the audio file\n    try:\n        if len(text) < TEXT_BYTE_LIMIT:\n            audio = generate_audio((text), voice)\n            if current_endpoint == 0:\n                audio_base64_data = str(audio).split('\"')[5]\n            else:\n                audio_base64_data = str(audio).split('\"')[3].split(\",\")[1]\n\n            if audio_base64_data == \"error\":\n                log(\"[-] This voice is unavailable right now\", \"error\")\n                return\n\n        else:\n            # Split longer text into smaller parts\n            text_parts = split_string(text, 299)\n            audio_base64_data = [None] * len(text_parts)\n\n            # Define a thread function to generate audio for each text part\n            def generate_audio_thread(text_part, index):\n                audio = generate_audio(text_part, voice)\n                if current_endpoint == 0:\n                    base64_data = str(audio).split('\"')[5]\n                else:\n                    base64_data = str(audio).split('\"')[3].split(\",\")[1]\n\n                if audio_base64_data == \"error\":\n                    log(\"[-] This voice is unavailable right now\", \"error\")\n                    return \"error\"\n\n                audio_base64_data[index] = base64_data\n\n            threads = []\n            for index, text_part in enumerate(text_parts):\n                # Create and start a new thread for each text part\n                thread = threading.Thread(\n                    target=generate_audio_thread, args=(text_part, index)\n                )\n                thread.start()\n                threads.append(thread)\n\n            # Wait for all threads to complete\n            for thread in threads:\n                thread.join()\n\n            # Concatenate the base64 data in the correct order\n            audio_base64_data = \"\".join(audio_base64_data)\n\n        save_audio_file(audio_base64_data, filename)\n        log(f\"[+] Audio file saved successfully as '{filename}'\", \"success\")\n        if play_sound:\n            playsound(filename)\n\n    except Exception as e:\n        log(f\"[-] An error occurred during TTS: {e}\", \"error\")\n"
  },
  {
    "path": "Backend/utils.py",
    "content": "import os\nimport sys\nimport random\nimport logging\nimport shutil\n\nfrom pathlib import Path\nfrom typing import Optional\nfrom termcolor import colored\n\n\nBASE_DIR = Path(__file__).resolve().parent\nPROJECT_ROOT = BASE_DIR.parent\nTEMP_DIR = PROJECT_ROOT / \"temp\"\nSUBTITLES_DIR = PROJECT_ROOT / \"subtitles\"\nSONGS_DIR = PROJECT_ROOT / \"Songs\"\nFONTS_DIR = PROJECT_ROOT / \"fonts\"\nENV_FILE = PROJECT_ROOT / \".env\"\n\n# Configure logging\nlogging.basicConfig(level=logging.INFO)\nlogger = logging.getLogger(__name__)\n\n\ndef clean_dir(path: str) -> None:\n    \"\"\"\n    Removes every file in a directory.\n\n    Args:\n        path (str): Path to directory.\n\n    Returns:\n        None\n    \"\"\"\n    try:\n        directory = Path(path).expanduser().resolve()\n        directory.mkdir(parents=True, exist_ok=True)\n        logger.info(f\"Ensured directory exists: {directory}\")\n\n        for entry in directory.iterdir():\n            if entry.is_dir():\n                shutil.rmtree(entry)\n                logger.info(f\"Removed directory: {entry}\")\n            else:\n                entry.unlink(missing_ok=True)\n                logger.info(f\"Removed file: {entry}\")\n\n        logger.info(colored(f\"Cleaned {directory} directory\", \"green\"))\n    except Exception as e:\n        logger.error(f\"Error occurred while cleaning directory {path}: {str(e)}\")\n\n\ndef choose_random_song() -> Optional[str]:\n    \"\"\"\n    Chooses a random MP3 from the Songs/ directory.\n\n    Returns:\n        str: The path to the chosen song, or None if no MP3 files found.\n    \"\"\"\n    try:\n        if not SONGS_DIR.exists():\n            return None\n\n        songs = [\n            song\n            for song in SONGS_DIR.iterdir()\n            if song.is_file() and song.suffix.lower() == \".mp3\"\n        ]\n\n        if not songs:\n            return None\n\n        song = random.choice(songs)\n        logger.info(colored(f\"Chose song: {song}\", \"green\"))\n        return str(song)\n    except Exception as e:\n        logger.error(\n            colored(f\"Error occurred while choosing random song: {str(e)}\", \"red\")\n        )\n\n\ndef resolve_imagemagick_binary() -> Optional[str]:\n    \"\"\"\n    Resolves an ImageMagick executable path across Linux, macOS, and Windows.\n\n    Returns:\n        Optional[str]: Absolute executable path if found.\n    \"\"\"\n    configured_binary = os.getenv(\"IMAGEMAGICK_BINARY\", \"\").strip().strip('\"')\n    if configured_binary:\n        expanded = Path(configured_binary).expanduser()\n        if expanded.exists():\n            return str(expanded.resolve())\n        logger.warning(\n            colored(\"Configured IMAGEMAGICK_BINARY was not found on disk.\", \"yellow\")\n        )\n\n    candidate_names = [\n        \"magick\",\n        \"magick.exe\",\n        \"convert\",\n        \"convert.exe\",\n    ]\n\n    for candidate in candidate_names:\n        found = shutil.which(candidate)\n        if found:\n            return found\n\n    return None\n\n\ndef check_env_vars() -> None:\n    \"\"\"\n    Checks if the necessary environment variables are set.\n\n    Returns:\n        None\n\n    Raises:\n        SystemExit: If any required environment variables are missing.\n    \"\"\"\n    try:\n        required_vars = [\"PEXELS_API_KEY\", \"TIKTOK_SESSION_ID\"]\n        missing_vars = []\n        for var in required_vars:\n            value = os.getenv(var)\n            if value is None or len(value) == 0:\n                missing_vars.append(var)\n\n        if missing_vars:\n            missing_vars_str = \", \".join(missing_vars)\n            logger.error(\n                colored(\n                    f\"The following environment variables are missing: {missing_vars_str}\",\n                    \"red\",\n                )\n            )\n            logger.error(\n                colored(\n                    \"Please consult 'docs/configuration.md' for setup instructions.\",\n                    \"yellow\",\n                )\n            )\n            sys.exit(1)  # Aborts the program\n\n        imagemagick_binary = resolve_imagemagick_binary()\n        if not imagemagick_binary:\n            logger.error(\n                colored(\n                    \"IMAGEMAGICK_BINARY is not set and no ImageMagick executable was detected in PATH.\",\n                    \"red\",\n                )\n            )\n            logger.error(\n                colored(\n                    \"Set IMAGEMAGICK_BINARY in .env or install ImageMagick and add it to PATH.\",\n                    \"yellow\",\n                )\n            )\n            sys.exit(1)\n\n        os.environ[\"IMAGEMAGICK_BINARY\"] = imagemagick_binary\n    except Exception as e:\n        logger.error(f\"Error occurred while checking environment variables: {str(e)}\")\n        sys.exit(1)  # Aborts the program if an unexpected error occurs\n"
  },
  {
    "path": "Backend/video.py",
    "content": "import os\nimport uuid\n\nimport requests\nimport srt_equalizer\nimport assemblyai as aai\n\nfrom typing import List\nfrom pathlib import Path\nfrom moviepy import (\n    AudioFileClip,\n    CompositeVideoClip,\n    TextClip,\n    VideoFileClip,\n    concatenate_videoclips,\n)\nfrom dotenv import load_dotenv\nfrom logstream import log\nfrom moviepy.video.tools.subtitles import SubtitlesClip\nfrom utils import ENV_FILE, TEMP_DIR, SUBTITLES_DIR, FONTS_DIR\n\nload_dotenv(ENV_FILE)\n\nASSEMBLY_AI_API_KEY = os.getenv(\"ASSEMBLY_AI_API_KEY\")\nFRAME_EPSILON = 1 / 120\n\n\ndef save_video(video_url: str, directory: str = str(TEMP_DIR)) -> str:\n    \"\"\"\n    Saves a video from a given URL and returns the path to the video.\n\n    Args:\n        video_url (str): The URL of the video to save.\n        directory (str): The path of the temporary directory to save the video to\n\n    Returns:\n        str: The path to the saved video.\n    \"\"\"\n    destination = Path(directory).expanduser().resolve()\n    destination.mkdir(parents=True, exist_ok=True)\n    video_id = uuid.uuid4()\n    video_path = destination / f\"{video_id}.mp4\"\n    with open(video_path, \"wb\") as f:\n        f.write(requests.get(video_url).content)\n\n    return str(video_path)\n\n\ndef __generate_subtitles_assemblyai(audio_path: str, voice: str) -> str:\n    \"\"\"\n    Generates subtitles from a given audio file and returns the path to the subtitles.\n\n    Args:\n        audio_path (str): The path to the audio file to generate subtitles from.\n\n    Returns:\n        str: The generated subtitles\n    \"\"\"\n\n    language_mapping = {\n        \"br\": \"pt\",\n        \"id\": \"en\",  # AssemblyAI doesn't have Indonesian\n        \"jp\": \"ja\",\n        \"kr\": \"ko\",\n    }\n\n    if voice in language_mapping:\n        lang_code = language_mapping[voice]\n    else:\n        lang_code = voice\n\n    aai.settings.api_key = ASSEMBLY_AI_API_KEY\n    config = aai.TranscriptionConfig(language_code=lang_code)\n    transcriber = aai.Transcriber(config=config)\n    transcript = transcriber.transcribe(audio_path)\n    subtitles = transcript.export_subtitles_srt()\n\n    return subtitles\n\n\ndef __generate_subtitles_locally(\n    sentences: List[str], audio_clips: List[AudioFileClip]\n) -> str:\n    \"\"\"\n    Generates subtitles from a given audio file and returns the path to the subtitles.\n\n    Args:\n        sentences (List[str]): all the sentences said out loud in the audio clips\n        audio_clips (List[AudioFileClip]): all the individual audio clips which will make up the final audio track\n    Returns:\n        str: The generated subtitles\n    \"\"\"\n\n    def convert_to_srt_time_format(total_seconds: float) -> str:\n        # Convert total seconds to the SRT time format: HH:MM:SS,mmm\n        milliseconds_total = int(round(total_seconds * 1000))\n        hours, remainder = divmod(milliseconds_total, 3_600_000)\n        minutes, remainder = divmod(remainder, 60_000)\n        seconds, milliseconds = divmod(remainder, 1000)\n        return f\"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}\"\n\n    start_time = 0\n    subtitles = []\n\n    for i, (sentence, audio_clip) in enumerate(zip(sentences, audio_clips), start=1):\n        duration = audio_clip.duration\n        end_time = start_time + duration\n\n        # Format: subtitle index, start time --> end time, sentence\n        subtitle_entry = f\"{i}\\n{convert_to_srt_time_format(start_time)} --> {convert_to_srt_time_format(end_time)}\\n{sentence}\\n\"\n        subtitles.append(subtitle_entry)\n\n        start_time += duration  # Update start time for the next subtitle\n\n    return \"\\n\".join(subtitles)\n\n\ndef generate_subtitles(\n    audio_path: str, sentences: List[str], audio_clips: List[AudioFileClip], voice: str\n) -> str:\n    \"\"\"\n    Generates subtitles from a given audio file and returns the path to the subtitles.\n\n    Args:\n        audio_path (str): The path to the audio file to generate subtitles from.\n        sentences (List[str]): all the sentences said out loud in the audio clips\n        audio_clips (List[AudioFileClip]): all the individual audio clips which will make up the final audio track\n\n    Returns:\n        str: The path to the generated subtitles.\n    \"\"\"\n\n    def equalize_subtitles(srt_path: str, max_chars: int = 10) -> None:\n        # Equalize subtitles\n        srt_equalizer.equalize_srt_file(srt_path, srt_path, max_chars)\n\n    # Save subtitles\n    SUBTITLES_DIR.mkdir(parents=True, exist_ok=True)\n    subtitles_path = SUBTITLES_DIR / f\"{uuid.uuid4()}.srt\"\n\n    if ASSEMBLY_AI_API_KEY is not None and ASSEMBLY_AI_API_KEY != \"\":\n        log(\"[+] Creating subtitles using AssemblyAI\", \"info\")\n        subtitles = __generate_subtitles_assemblyai(audio_path, voice)\n    else:\n        log(\"[+] Creating subtitles locally\", \"info\")\n        subtitles = __generate_subtitles_locally(sentences, audio_clips)\n        # print(colored(\"[-] Local subtitle generation has been disabled for the time being.\", \"red\"))\n        # print(colored(\"[-] Exiting.\", \"red\"))\n        # sys.exit(1)\n\n    with open(subtitles_path, \"w\", encoding=\"utf-8\") as file:\n        file.write(subtitles)\n\n    # Equalize subtitles\n    equalize_subtitles(str(subtitles_path))\n\n    log(\"[+] Subtitles generated.\", \"success\")\n\n    return str(subtitles_path)\n\n\ndef combine_videos(\n    video_paths: List[str], max_duration: int, max_clip_duration: int, threads: int\n) -> str:\n    \"\"\"\n    Combines a list of videos into one video and returns the path to the combined video.\n\n    Args:\n        video_paths (List): A list of paths to the videos to combine.\n        max_duration (int): The maximum duration of the combined video.\n        max_clip_duration (int): The maximum duration of each clip.\n        threads (int): The number of threads to use for the video processing.\n\n    Returns:\n        str: The path to the combined video.\n    \"\"\"\n    video_id = uuid.uuid4()\n    TEMP_DIR.mkdir(parents=True, exist_ok=True)\n    combined_video_path = TEMP_DIR / f\"{video_id}.mp4\"\n\n    if not video_paths:\n        raise ValueError(\"No source videos were provided for concatenation.\")\n\n    max_duration = float(max_duration)\n    max_clip_duration = float(max_clip_duration)\n\n    # Required duration of each clip\n    req_dur = max_duration / len(video_paths)\n\n    log(\"[+] Combining videos...\", \"info\")\n    log(f\"[+] Each clip will be maximum {req_dur} seconds long.\", \"info\")\n\n    clips = []\n    tot_dur = 0\n    # Add downloaded clips over and over until the duration of the audio (max_duration) has been reached\n    while tot_dur < (max_duration - FRAME_EPSILON):\n        progressed = False\n        for video_path in video_paths:\n            remaining = max_duration - tot_dur\n            if remaining <= FRAME_EPSILON:\n                break\n\n            clip = VideoFileClip(video_path)\n            clip = clip.without_audio()\n            max_safe_source_duration = clip.duration - FRAME_EPSILON\n            if max_safe_source_duration <= 0:\n                clip.close()\n                continue\n\n            target_duration = min(req_dur, max_clip_duration, remaining)\n            target_duration = min(target_duration, max_safe_source_duration)\n\n            if target_duration <= 0:\n                clip.close()\n                continue\n\n            if target_duration < clip.duration:\n                clip = clip.subclipped(0, target_duration)\n            clip = clip.with_fps(30)\n\n            # Not all videos are same size,\n            # so we need to resize them\n            if round((clip.w / clip.h), 4) < 0.5625:\n                clip = clip.cropped(\n                    width=clip.w,\n                    height=round(clip.w / 0.5625),\n                    x_center=clip.w / 2,\n                    y_center=clip.h / 2,\n                )\n            else:\n                clip = clip.cropped(\n                    width=round(0.5625 * clip.h),\n                    height=clip.h,\n                    x_center=clip.w / 2,\n                    y_center=clip.h / 2,\n                )\n            clip = clip.resized(new_size=(1080, 1920))\n\n            clips.append(clip)\n            tot_dur += clip.duration\n            progressed = True\n\n        if not progressed:\n            raise RuntimeError(\"Could not reach target duration from source videos.\")\n\n    if not clips:\n        raise RuntimeError(\"No valid clips were produced for concatenation.\")\n\n    final_clip = concatenate_videoclips(clips, method=\"compose\")\n    final_clip = final_clip.with_fps(30).with_duration(max_duration)\n    try:\n        final_clip.write_videofile(\n            str(combined_video_path),\n            threads=threads,\n            fps=30,\n            codec=\"libx264\",\n            preset=\"medium\",\n            audio=False,\n        )\n    finally:\n        final_clip.close()\n        for clip in clips:\n            clip.close()\n\n    return str(combined_video_path)\n\n\ndef generate_video(\n    combined_video_path: str,\n    tts_path: str,\n    subtitles_path: str,\n    threads: int,\n    subtitles_position: str,\n    text_color: str,\n) -> str:\n    \"\"\"\n    This function creates the final video, with subtitles and audio.\n\n    Args:\n        combined_video_path (str): The path to the combined video.\n        tts_path (str): The path to the text-to-speech audio.\n        subtitles_path (str): The path to the subtitles.\n        threads (int): The number of threads to use for the video processing.\n        subtitles_position (str): The position of the subtitles.\n\n    Returns:\n        str: The path to the final video.\n    \"\"\"\n    # Make a generator that returns a TextClip when called with consecutive\n    font_path = str((FONTS_DIR / \"bold_font.ttf\").resolve())\n    generator = lambda txt: TextClip(\n        font=font_path,\n        text=txt,\n        font_size=100,\n        color=text_color,\n        stroke_color=\"black\",\n        stroke_width=5,\n    )\n\n    # Split the subtitles position into horizontal and vertical\n    horizontal_subtitles_position, vertical_subtitles_position = (\n        subtitles_position.split(\",\")\n    )\n\n    # Burn the subtitles into the video\n    subtitles = SubtitlesClip(subtitles_path, make_textclip=generator)\n    subtitle_vertical_position = vertical_subtitles_position\n    if vertical_subtitles_position == \"top\":\n        subtitle_vertical_position = 80\n\n    base_video = VideoFileClip(str(combined_video_path))\n    audio = AudioFileClip(tts_path)\n    target_duration = min(base_video.duration, audio.duration)\n\n    result = CompositeVideoClip(\n        [\n            base_video.subclipped(0, target_duration),\n            subtitles.with_position(\n                (horizontal_subtitles_position, subtitle_vertical_position)\n            ).with_duration(target_duration),\n        ]\n    )\n\n    # Clamp audio/video to exactly the same duration to avoid end-frame overreads.\n    result = result.with_audio(audio.subclipped(0, target_duration)).with_duration(\n        target_duration\n    )\n\n    output_path = TEMP_DIR / \"output.mp4\"\n    try:\n        result.write_videofile(\n            str(output_path),\n            threads=threads or 2,\n            fps=30,\n            codec=\"libx264\",\n            audio_codec=\"aac\",\n            preset=\"medium\",\n        )\n    finally:\n        result.close()\n        subtitles.close()\n        audio.close()\n        base_video.close()\n\n    return \"output.mp4\"\n"
  },
  {
    "path": "Backend/worker.py",
    "content": "import time\n\nfrom dotenv import load_dotenv\n\nfrom db import SessionLocal, init_db\nfrom pipeline import PipelineCancelled, run_generation_pipeline\nfrom repository import (\n    append_event,\n    claim_next_queued_job,\n    get_job,\n    mark_cancelled,\n    mark_completed,\n    mark_failed,\n)\nfrom utils import ENV_FILE, SUBTITLES_DIR, TEMP_DIR, check_env_vars, clean_dir\n\n\nPOLL_SECONDS = 1.0\n\n\ndef _job_cancelled(job_id: str) -> bool:\n    with SessionLocal() as session:\n        job = get_job(session, job_id)\n        if not job:\n            return True\n        return bool(job.cancel_requested or job.status == \"cancelled\")\n\n\ndef _log_event(job_id: str, message: str, level: str) -> None:\n    with SessionLocal() as session:\n        append_event(session, job_id, \"log\", level, str(message))\n        session.commit()\n\n\ndef process_next_job() -> bool:\n    with SessionLocal() as session:\n        job = claim_next_queued_job(session)\n\n    if not job:\n        return False\n\n    job_id = job.id\n\n    clean_dir(str(TEMP_DIR))\n    clean_dir(str(SUBTITLES_DIR))\n\n    try:\n        result_path = run_generation_pipeline(\n            data=job.payload,\n            is_cancelled=lambda: _job_cancelled(job_id),\n            on_log=lambda message, level: _log_event(job_id, message, level),\n        )\n        with SessionLocal() as session:\n            mark_completed(session, job_id, result_path)\n    except PipelineCancelled as err:\n        with SessionLocal() as session:\n            mark_cancelled(session, job_id, str(err))\n    except Exception as err:\n        with SessionLocal() as session:\n            mark_failed(session, job_id, str(err))\n\n    return True\n\n\ndef main() -> None:\n    load_dotenv(ENV_FILE)\n    check_env_vars()\n    init_db()\n\n    while True:\n        processed = process_next_job()\n        if not processed:\n            time.sleep(POLL_SECONDS)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "Backend/youtube.py",
    "content": "import os\nimport sys\nimport time\nimport random\nimport httplib2\nfrom pathlib import Path\n\nfrom logstream import log\nfrom oauth2client.file import Storage\nfrom apiclient.discovery import build\nfrom apiclient.errors import HttpError\nfrom apiclient.http import MediaFileUpload\nfrom oauth2client.tools import argparser, run_flow\nfrom oauth2client.client import flow_from_clientsecrets\n\n# Explicitly tell the underlying HTTP transport library not to retry, since\n# we are handling retry logic ourselves.\nhttplib2.RETRIES = 1\n\n# Maximum number of times to retry before giving up.\nMAX_RETRIES = 10\n\n# Always retry when these exceptions are raised.\nRETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError, httplib2.ServerNotFoundError)\n\n# Always retry when an apiclient.errors.HttpError with one of these status\n# codes is raised.\nRETRIABLE_STATUS_CODES = [500, 502, 503, 504]\n\n# The CLIENT_SECRETS_FILE variable specifies the name of a file that contains\n# the OAuth 2.0 information for this application, including its client_id and\n# client_secret.\nBASE_DIR = Path(__file__).resolve().parent\nCLIENT_SECRETS_FILE = str((BASE_DIR / \"client_secret.json\").resolve())\n\n# This OAuth 2.0 access scope allows an application to upload files to the\n# authenticated user's YouTube channel, but doesn't allow other types of access.\n# YOUTUBE_UPLOAD_SCOPE = \"https://www.googleapis.com/auth/youtube.upload\"\nSCOPES = [\n    \"https://www.googleapis.com/auth/youtube.upload\",\n    \"https://www.googleapis.com/auth/youtube\",\n    \"https://www.googleapis.com/auth/youtubepartner\",\n]\nYOUTUBE_API_SERVICE_NAME = \"youtube\"\nYOUTUBE_API_VERSION = \"v3\"\n\n# This variable defines a message to display if the CLIENT_SECRETS_FILE is\n# missing.\nMISSING_CLIENT_SECRETS_MESSAGE = f\"\"\"\nWARNING: Please configure OAuth 2.0\n\nTo make this sample run you will need to populate the client_secrets.json file\nfound at:\n  \n{os.path.abspath(os.path.join(os.path.dirname(__file__), CLIENT_SECRETS_FILE))}\n\nwith information from the API Console\nhttps://console.cloud.google.com/\n\nFor more information about the client_secrets.json file format, please visit:\nhttps://developers.google.com/api-client-library/python/guide/aaa_client_secrets\n\"\"\"\n\nVALID_PRIVACY_STATUSES = (\"public\", \"private\", \"unlisted\")\n\n\ndef get_authenticated_service():\n    \"\"\"\n    This method retrieves the YouTube service.\n\n    Returns:\n        any: The authenticated YouTube service.\n    \"\"\"\n    flow = flow_from_clientsecrets(\n        CLIENT_SECRETS_FILE, scope=SCOPES, message=MISSING_CLIENT_SECRETS_MESSAGE\n    )\n\n    oauth_store = BASE_DIR / f\"{Path(sys.argv[0]).name}-oauth2.json\"\n    storage = Storage(str(oauth_store))\n    credentials = storage.get()\n\n    if credentials is None or credentials.invalid:\n        flags = argparser.parse_args()\n        credentials = run_flow(flow, storage, flags)\n\n    return build(\n        YOUTUBE_API_SERVICE_NAME,\n        YOUTUBE_API_VERSION,\n        http=credentials.authorize(httplib2.Http()),\n    )\n\n\ndef initialize_upload(youtube: any, options: dict):\n    \"\"\"\n    This method uploads a video to YouTube.\n\n    Args:\n        youtube (any): The authenticated YouTube service.\n        options (dict): The options to upload the video with.\n\n    Returns:\n        response: The response from the upload process.\n    \"\"\"\n\n    tags = None\n    if options[\"keywords\"]:\n        tags = options[\"keywords\"].split(\",\")\n\n    body = {\n        \"snippet\": {\n            \"title\": options[\"title\"],\n            \"description\": options[\"description\"],\n            \"tags\": tags,\n            \"categoryId\": options[\"category\"],\n        },\n        \"status\": {\n            \"privacyStatus\": options[\"privacyStatus\"],\n            \"madeForKids\": False,  # Video is not made for kids\n            \"selfDeclaredMadeForKids\": False,  # You declare that the video is not made for kids\n        },\n    }\n\n    # Call the API's videos.insert method to create and upload the video.\n    insert_request = youtube.videos().insert(\n        part=\",\".join(body.keys()),\n        body=body,\n        media_body=MediaFileUpload(options[\"file\"], chunksize=-1, resumable=True),\n    )\n\n    return resumable_upload(insert_request)\n\n\ndef resumable_upload(insert_request: MediaFileUpload):\n    \"\"\"\n    This method implements an exponential backoff strategy to resume a\n    failed upload.\n\n    Args:\n        insert_request (MediaFileUpload): The request to insert the video.\n\n    Returns:\n        response: The response from the upload process.\n    \"\"\"\n    response = None\n    error = None\n    retry = 0\n    while response is None:\n        try:\n            log(\" => Uploading file...\", \"info\")\n            status, response = insert_request.next_chunk()\n            if \"id\" in response:\n                log(f\"Video id '{response['id']}' was successfully uploaded.\", \"success\")\n                return response\n        except HttpError as e:\n            if e.resp.status in RETRIABLE_STATUS_CODES:\n                error = f\"A retriable HTTP error {e.resp.status} occurred:\\n{e.content}\"\n            else:\n                raise\n        except RETRIABLE_EXCEPTIONS as e:\n            error = f\"A retriable error occurred: {e}\"\n\n        if error is not None:\n            log(error, \"error\")\n            retry += 1\n            if retry > MAX_RETRIES:\n                raise Exception(\"No longer attempting to retry.\")\n\n            max_sleep = 2**retry\n            sleep_seconds = random.random() * max_sleep\n            log(f\" => Sleeping {sleep_seconds} seconds and then retrying...\", \"info\")\n            time.sleep(sleep_seconds)\n\n\ndef upload_video(video_path, title, description, category, keywords, privacy_status):\n    try:\n        # Get the authenticated YouTube service\n        youtube = get_authenticated_service()\n\n        # Retrieve and print the channel ID for the authenticated user\n        channels_response = youtube.channels().list(mine=True, part=\"id\").execute()\n        for channel in channels_response[\"items\"]:\n            log(f\" => Channel ID: {channel['id']}\", \"info\")\n\n        # Initialize the upload process\n        video_response = initialize_upload(\n            youtube,\n            {\n                \"file\": video_path,  # The path to the video file\n                \"title\": title,\n                \"description\": description,\n                \"category\": category,\n                \"keywords\": keywords,\n                \"privacyStatus\": privacy_status,\n            },\n        )\n        return video_response  # Return the response from the upload process\n    except HttpError as e:\n        log(f\"[-] An HTTP error {e.resp.status} occurred:\\n{e.content}\", \"error\")\n        if e.resp.status in [401, 403]:\n            # Here you could refresh the credentials and retry the upload\n            youtube = (\n                get_authenticated_service()\n            )  # This will prompt for re-authentication if necessary\n            video_response = initialize_upload(\n                youtube,\n                {\n                    \"file\": video_path,\n                    \"title\": title,\n                    \"description\": description,\n                    \"category\": category,\n                    \"keywords\": keywords,\n                    \"privacyStatus\": privacy_status,\n                },\n            )\n            return video_response\n        else:\n            raise e\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nMoneyPrinter automates YouTube Shorts creation from text topics. It uses Ollama for script generation, TikTok TTS for voiceover, Pexels for stock footage, and moviepy/ImageMagick for video composition. Output: a 9:16 vertical video (`output.mp4`).\n\n## Commands\n\n### Setup\n```bash\ncp .env.example .env        # then fill in API keys\nuv sync                     # install dependencies\nollama serve                # start Ollama (separate terminal)\nollama pull llama3.1:8b     # pull default model\n```\n\n### Run (local)\n```bash\nuv run python Backend/main.py                              # API on :8080\nuv run python Backend/worker.py                            # queue worker\npython3 -m http.server 3000 --directory Frontend           # frontend on :3000\n```\n\n### Run (Docker)\n```bash\ndocker compose up --build   # frontend :8001, backend :8080, postgres :5432\n```\n\n### Verify\n```bash\nuv run python -m compileall Backend          # syntax check\ncurl http://localhost:8080/api/models         # API smoke test\n```\n\n### Tests\nNo test suite exists yet. If added, use pytest:\n```bash\nuv run pytest -q                                           # all tests\nuv run pytest tests/test_file.py::test_name -q             # single test\n```\n\n## Architecture\n\n### Video Generation Pipeline (end-to-end flow)\n\n```\nUser input (Frontend) → POST /api/generate → generation_jobs (Postgres queue)\n  → worker.py claims queued job\n  → gpt.py: generate_script() via Ollama\n  → gpt.py: get_search_terms() → JSON keywords\n  → search.py: Pexels API → download stock clips to temp/\n  → tiktokvoice.py: TTS per sentence → MP3 chunks (threaded)\n  → video.py: generate_subtitles() → .srt (AssemblyAI or local timestamps)\n  → video.py: combine_videos() → concatenate/crop to 9:16\n  → video.py: generate_video() → burn subtitles via ImageMagick, merge audio\n  → (optional) mix background music from Songs/ at 10% volume\n  → (optional) youtube.py: OAuth2 upload\n  → output.mp4\n```\n\n### Frontend ↔ Backend Communication\n- **REST**: JSON payloads to Flask endpoints (`/api/generate`, `/api/jobs/:id`, `/api/jobs/:id/events`, `/api/jobs/:id/cancel`, `/api/models`, `/api/upload-songs`)\n- **Polling**: frontend polls job status and persisted generation events.\n\n### Key Backend Modules\n| File | Responsibility |\n|------|---------------|\n| `main.py` | Flask app and queue/job endpoints |\n| `worker.py` | Job consumer that executes generation pipeline |\n| `db.py`/`models.py`/`repository.py` | DB engine, schema, queue/event persistence |\n| `gpt.py` | Ollama client: script generation, search terms, YouTube metadata |\n| `video.py` | Video processing: combine clips, burn subtitles, merge audio |\n| `search.py` | Pexels stock video search and download |\n| `tiktokvoice.py` | TikTok TTS API (60+ voices, 300-char chunking, threaded) |\n| `youtube.py` | YouTube upload via Google API with OAuth2 |\n| `utils.py` | Path constants, env validation, ImageMagick detection |\n| `pipeline.py` | Reusable generation pipeline used by worker |\n\n### Frontend\n- `index.html`: UI with inline CSS, form fields, live log viewer\n- `app.js`: API client (`apiRequest()`), job polling UI, localStorage persistence\n\n### Runtime Directories\n- `temp/`: intermediate video/audio files (cleared each generation)\n- `subtitles/`: generated .srt files (cleared each generation)\n- `Songs/`: user-uploaded background music MP3s\n- `fonts/`: subtitle font (`bold_font.ttf`)\n\n## Required Environment Variables\n\n- `TIKTOK_SESSION_ID` — TikTok cookie for TTS\n- `PEXELS_API_KEY` — stock video API\n- `IMAGEMAGICK_BINARY` — leave empty to auto-detect from PATH\n\nOptional: `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, `ASSEMBLY_AI_API_KEY`, `DATABASE_URL`\n\n## Conventions\n\n- **Python**: 4-space indent, `snake_case`, type hints on all new/modified signatures, `pathlib.Path` for filesystem ops\n- **JS**: `camelCase`, centralized API calls via `apiRequest()`\n- **API responses**: `{\"status\": \"success|error\", ...}` with proper HTTP codes\n- **Long-running work**: database-backed queue and separate worker process\n- **Concurrency**: multiple jobs can be queued; worker processes them safely via DB locking\n- Update `docs/` when setup, env vars, or runtime behavior changes\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.11-slim-buster\n\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/\n\nRUN apt-get update && apt-get install --no-install-recommends -y \\\n    build-essential autoconf pkg-config wget ghostscript curl libpng-dev\n\nRUN wget https://github.com/ImageMagick/ImageMagick/archive/refs/tags/7.1.0-31.tar.gz && \\\n    tar xzf 7.1.0-31.tar.gz && \\\n    rm 7.1.0-31.tar.gz && \\\n    apt-get clean && \\\n    apt-get autoremove\n\nRUN sh ./ImageMagick-7.1.0-31/configure --prefix=/usr/local --with-bzlib=yes --with-fontconfig=yes --with-freetype=yes --with-gslib=yes --with-gvc=yes --with-jpeg=yes --with-jp2=yes --with-png=yes --with-tiff=yes --with-xml=yes --with-gs-font-dir=yes && \\\n    make -j && make install && ldconfig /usr/local/lib/\n\nWORKDIR /app\n\nCOPY pyproject.toml .\n\nRUN uv pip install --system -r pyproject.toml"
  },
  {
    "path": "Frontend/app.js",
    "content": "// ===== DOM REFS =====\nconst videoSubject = document.getElementById(\"videoSubject\");\nconst aiModel = document.getElementById(\"aiModel\");\nconst voice = document.getElementById(\"voice\");\nconst songFiles = document.getElementById(\"songFiles\");\nconst paragraphNumber = document.getElementById(\"paragraphNumber\");\nconst youtubeToggle = document.getElementById(\"youtubeUploadToggle\");\nconst useMusicToggle = document.getElementById(\"useMusicToggle\");\nconst customPrompt = document.getElementById(\"customPrompt\");\nconst generateButton = document.getElementById(\"generateButton\");\nconst cancelButton = document.getElementById(\"cancelButton\");\nconst advancedOptionsToggle = document.getElementById(\"advancedOptionsToggle\");\nconst advancedPanel = document.getElementById(\"advancedOptions\");\nconst statusArea = document.getElementById(\"statusArea\");\nconst colorDot = document.getElementById(\"colorDot\");\nconst subtitlesColor = document.getElementById(\"subtitlesColor\");\nconst logViewer = document.getElementById(\"logViewer\");\nconst logViewerBody = document.getElementById(\"logViewerBody\");\nconst logClearBtn = document.getElementById(\"logClearBtn\");\nconst backendHost = window.location.hostname || \"localhost\";\nconst backendProtocol = window.location.protocol || \"http:\";\nconst API_BASE_URL = `${backendProtocol}//${backendHost}:8080`;\nconst API_FALLBACK_URL = `http://${backendHost}:8080`;\n\nlet activeJobId = null;\nlet pollHandle = null;\nlet lastEventId = 0;\n\n// ===== API HELPERS =====\nasync function apiRequest(path, options = {}) {\n  const endpoint = path.startsWith(\"/\") ? path : `/${path}`;\n\n  async function request(baseUrl) {\n    const response = await fetch(`${baseUrl}${endpoint}`, options);\n    const data = await response.json();\n    if (!response.ok) {\n      throw new Error(data.message || `Request failed with status ${response.status}`);\n    }\n    return data;\n  }\n\n  try {\n    return await request(API_BASE_URL);\n  } catch (firstError) {\n    if (API_BASE_URL !== API_FALLBACK_URL) {\n      return request(API_FALLBACK_URL);\n    }\n    throw firstError;\n  }\n}\n\nfunction setModelOptions(models, preferredModel) {\n  aiModel.innerHTML = \"\";\n\n  models.forEach((modelName) => {\n    const option = document.createElement(\"option\");\n    option.value = modelName;\n    option.textContent = modelName;\n    aiModel.appendChild(option);\n  });\n\n  if (preferredModel && models.includes(preferredModel)) {\n    aiModel.value = preferredModel;\n  } else if (models.length > 0) {\n    aiModel.value = models[0];\n  }\n}\n\nasync function loadOllamaModels(reuseEnabled) {\n  const fallbackModel = localStorage.getItem(\"aiModelValue\") || \"llama3.1:8b\";\n\n  try {\n    const data = await apiRequest(\"/api/models\", {\n      method: \"GET\",\n      headers: {\n        Accept: \"application/json\",\n      },\n    });\n\n    const models = Array.isArray(data.models)\n      ? data.models.filter((item) => typeof item === \"string\" && item.trim())\n      : [];\n    const defaultModel =\n      typeof data.default === \"string\" && data.default.trim()\n        ? data.default.trim()\n        : fallbackModel;\n    const preferredModel =\n      reuseEnabled && localStorage.getItem(\"aiModelValue\")\n        ? localStorage.getItem(\"aiModelValue\")\n        : defaultModel;\n\n    if (data.status && data.status !== \"success\" && data.message) {\n      showToast(data.message, \"error\");\n    }\n\n    if (models.length === 0) {\n      setModelOptions([defaultModel], preferredModel);\n      showToast(\"No Ollama models found. Pull one with: ollama pull llama3.1:8b\", \"error\");\n      return;\n    }\n\n    setModelOptions(models, preferredModel);\n  } catch {\n    setModelOptions([fallbackModel], fallbackModel);\n    showToast(\"Could not load Ollama models. Is backend/Ollama running?\", \"error\");\n  }\n}\n\n// ===== TOAST NOTIFICATIONS =====\nfunction showToast(message, type = \"info\") {\n  const container = document.getElementById(\"toastContainer\");\n  const toast = document.createElement(\"div\");\n  toast.className = `toast toast-${type}`;\n  toast.innerHTML = `\n    <span class=\"toast-dot\"></span>\n    <span class=\"toast-msg\">${message}</span>\n    <button class=\"toast-close\" aria-label=\"Close\">&times;</button>\n  `;\n  toast.querySelector(\".toast-close\").addEventListener(\"click\", () => {\n    dismissToast(toast);\n  });\n  container.appendChild(toast);\n\n  requestAnimationFrame(() => {\n    requestAnimationFrame(() => toast.classList.add(\"show\"));\n  });\n\n  setTimeout(() => dismissToast(toast), 5000);\n}\n\nfunction dismissToast(toast) {\n  toast.classList.remove(\"show\");\n  toast.addEventListener(\"transitionend\", () => toast.remove(), { once: true });\n}\n\n// ===== COLOR DOT =====\nfunction updateColorDot() {\n  if (colorDot && subtitlesColor) {\n    colorDot.style.backgroundColor = subtitlesColor.value;\n  }\n}\nupdateColorDot();\nsubtitlesColor.addEventListener(\"change\", updateColorDot);\n\n// ===== ADVANCED OPTIONS TOGGLE =====\nadvancedOptionsToggle.addEventListener(\"click\", () => {\n  advancedOptionsToggle.classList.toggle(\"open\");\n  advancedPanel.classList.toggle(\"open\");\n});\n\n// ===== LOG STREAM (SSE) =====\nfunction formatTimestamp(ts) {\n  const d = new Date((ts || Date.now() / 1000) * 1000);\n  return d.toLocaleTimeString(\"en-GB\", { hour12: false });\n}\n\nfunction appendLogEntry(entry) {\n  const row = document.createElement(\"div\");\n  row.className = \"log-entry\";\n\n  const time = document.createElement(\"span\");\n  time.className = \"log-time\";\n  time.textContent = formatTimestamp(entry.timestamp);\n\n  const msg = document.createElement(\"span\");\n  msg.className = `log-msg log-${entry.level || \"info\"}`;\n  msg.textContent = entry.message;\n\n  row.appendChild(time);\n  row.appendChild(msg);\n  logViewerBody.appendChild(row);\n\n  // Auto-scroll to bottom\n  logViewerBody.scrollTop = logViewerBody.scrollHeight;\n}\n\nasync function pollJob() {\n  if (!activeJobId) return;\n\n  try {\n    const eventsResult = await apiRequest(`/api/jobs/${activeJobId}/events?after=${lastEventId}`, {\n      method: \"GET\",\n      headers: {\n        Accept: \"application/json\",\n      },\n    });\n\n    const events = Array.isArray(eventsResult.events) ? eventsResult.events : [];\n    events.forEach((event) => {\n      appendLogEntry({\n        timestamp: event.timestamp,\n        message: event.message,\n        level: event.level || \"info\",\n      });\n      lastEventId = Math.max(lastEventId, event.id || 0);\n    });\n\n    const jobResult = await apiRequest(`/api/jobs/${activeJobId}`, {\n      method: \"GET\",\n      headers: {\n        Accept: \"application/json\",\n      },\n    });\n\n    const state = jobResult?.job?.state;\n    if (state === \"completed\") {\n      showToast(\"Video generated successfully.\", \"success\");\n      stopJobPolling();\n      setGeneratingState(false);\n    } else if (state === \"failed\") {\n      showToast(jobResult?.job?.errorMessage || \"Generation failed.\", \"error\");\n      stopJobPolling();\n      setGeneratingState(false);\n    } else if (state === \"cancelled\") {\n      showToast(\"Generation cancelled.\", \"warning\");\n      stopJobPolling();\n      setGeneratingState(false);\n    }\n  } catch {\n    // Ignore transient polling failures.\n  }\n}\n\nfunction startJobPolling(jobId) {\n  stopJobPolling();\n  activeJobId = jobId;\n  lastEventId = 0;\n  logViewer.classList.add(\"active\");\n  pollHandle = setInterval(pollJob, 1200);\n  pollJob();\n}\n\nfunction stopJobPolling() {\n  if (pollHandle) {\n    clearInterval(pollHandle);\n    pollHandle = null;\n  }\n}\n\n// ===== GENERATE / CANCEL =====\nfunction setGeneratingState(active) {\n  if (active) {\n    generateButton.classList.add(\"hidden\");\n    cancelButton.classList.remove(\"hidden\");\n    statusArea.classList.add(\"active\");\n  } else {\n    stopJobPolling();\n    activeJobId = null;\n    generateButton.classList.remove(\"hidden\");\n    cancelButton.classList.add(\"hidden\");\n    statusArea.classList.remove(\"active\");\n    generateButton.disabled = false;\n    logViewer.classList.remove(\"active\");\n  }\n}\n\nfunction cancelGeneration() {\n  const targetPath = activeJobId ? `/api/jobs/${activeJobId}/cancel` : \"/api/cancel\";\n\n  apiRequest(targetPath, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Accept: \"application/json\",\n    },\n  })\n    .then((data) => showToast(data.message, \"success\"))\n    .catch(() => showToast(\"Failed to cancel. Is the server running?\", \"error\"));\n}\n\nasync function uploadSongs() {\n  const files = songFiles.files;\n  if (!files || files.length === 0) return true;\n\n  const mp3s = Array.from(files).filter((f) => f.name.toLowerCase().endsWith(\".mp3\"));\n  if (mp3s.length === 0) {\n    showToast(\"No MP3 files found in the selected folder.\", \"error\");\n    return false;\n  }\n\n  const formData = new FormData();\n  mp3s.forEach((file) => formData.append(\"songs\", file));\n\n  try {\n    await apiRequest(\"/api/upload-songs\", {\n      method: \"POST\",\n      body: formData,\n    });\n    return true;\n  } catch {\n    showToast(\"Failed to upload songs.\", \"error\");\n    return false;\n  }\n}\n\nasync function generateVideo() {\n  const subject = videoSubject.value.trim();\n  if (!subject) {\n    showToast(\"Please enter a video subject.\", \"error\");\n    videoSubject.focus();\n    return;\n  }\n\n  generateButton.disabled = true;\n  setGeneratingState(true);\n\n  // Clear previous log entries\n  logViewerBody.innerHTML = \"\";\n\n  // Upload songs first if a folder was selected\n  if (useMusicToggle.checked && songFiles.files.length > 0) {\n    const uploaded = await uploadSongs();\n    if (!uploaded) {\n      setGeneratingState(false);\n      return;\n    }\n  }\n\n  const data = {\n    videoSubject: subject,\n    aiModel: aiModel.value || \"llama3.1:8b\",\n    voice: voice.value,\n    paragraphNumber: paragraphNumber.value,\n    automateYoutubeUpload: youtubeToggle.checked,\n    useMusic: useMusicToggle.checked,\n    threads: document.getElementById(\"threads\").value,\n    subtitlesPosition: document.getElementById(\"subtitlesPosition\").value,\n    customPrompt: customPrompt.value,\n    color: subtitlesColor.value,\n  };\n\n  try {\n    const result = await apiRequest(\"/api/generate\", {\n      method: \"POST\",\n      body: JSON.stringify(data),\n      headers: {\n        \"Content-Type\": \"application/json\",\n        Accept: \"application/json\",\n      },\n    });\n\n    if (result.status === \"success\") {\n      if (!result.jobId) {\n        showToast(\"Generation queued, but no job ID was returned.\", \"error\");\n        setGeneratingState(false);\n        return;\n      }\n      startJobPolling(result.jobId);\n    } else {\n      showToast(result.message, \"error\");\n      setGeneratingState(false);\n    }\n  } catch {\n    showToast(\"Connection error. Is the backend server running?\", \"error\");\n    setGeneratingState(false);\n  }\n}\n\ngenerateButton.addEventListener(\"click\", generateVideo);\ncancelButton.addEventListener(\"click\", cancelGeneration);\n\nvideoSubject.addEventListener(\"keydown\", (event) => {\n  if (event.key === \"Enter\" && !event.shiftKey) {\n    event.preventDefault();\n    generateVideo();\n  }\n});\n\n// ===== LOG CLEAR BUTTON =====\nlogClearBtn.addEventListener(\"click\", () => {\n  logViewerBody.innerHTML = \"\";\n});\n\n// ===== LOCAL STORAGE PERSISTENCE =====\nconst toggleIds = [\n  \"youtubeUploadToggle\",\n  \"useMusicToggle\",\n  \"reuseChoicesToggle\",\n];\nconst fieldIds = [\n  \"voice\",\n  \"paragraphNumber\",\n  \"videoSubject\",\n  \"customPrompt\",\n  \"threads\",\n  \"subtitlesPosition\",\n  \"subtitlesColor\",\n];\n\ndocument.addEventListener(\"DOMContentLoaded\", async () => {\n  const reuseEnabled =\n    localStorage.getItem(\"reuseChoicesToggleValue\") === \"true\";\n\n  await loadOllamaModels(reuseEnabled);\n\n  aiModel.addEventListener(\"change\", (e) => {\n    localStorage.setItem(\"aiModelValue\", e.target.value);\n  });\n\n  // Restore toggles\n  toggleIds.forEach((id) => {\n    const el = document.getElementById(id);\n    if (!el) return;\n    const stored = localStorage.getItem(`${id}Value`);\n    if (stored !== null && reuseEnabled) {\n      el.checked = stored === \"true\";\n    }\n    el.addEventListener(\"change\", (e) => {\n      localStorage.setItem(`${id}Value`, e.target.checked);\n    });\n  });\n\n  // Restore fields\n  fieldIds.forEach((id) => {\n    const el = document.getElementById(id);\n    if (!el) return;\n    const stored = localStorage.getItem(`${id}Value`);\n    if (stored && reuseEnabled) {\n      el.value = stored;\n    }\n    el.addEventListener(\"change\", (e) => {\n      localStorage.setItem(`${id}Value`, e.target.value);\n    });\n  });\n\n  // Update color dot after restoring values\n  updateColorDot();\n});\n"
  },
  {
    "path": "Frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>MoneyPrinter</title>\n    <link\n      rel=\"icon\"\n      href=\"data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💸</text></svg>\"\n    />\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link\n      href=\"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700;9..144,800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap\"\n      rel=\"stylesheet\"\n    />\n    <style>\n      *,\n      *::before,\n      *::after {\n        margin: 0;\n        padding: 0;\n        box-sizing: border-box;\n      }\n\n      :root {\n        --bg: #f6f5f0;\n        --card: #ffffff;\n        --input-bg: #f2f1ec;\n        --input-bg-focus: #ffffff;\n        --border: #e5e3dc;\n        --border-focus: #16a34a;\n        --text: #1c1917;\n        --text-2: #57534e;\n        --text-3: #a8a29e;\n        --accent: #16a34a;\n        --accent-hover: #15803d;\n        --accent-light: rgba(22, 163, 74, 0.07);\n        --accent-ring: rgba(22, 163, 74, 0.12);\n        --danger: #dc2626;\n        --danger-light: rgba(220, 38, 38, 0.06);\n        --radius: 10px;\n        --radius-lg: 20px;\n        --shadow-sm: 0 1px 2px rgba(28, 25, 23, 0.03),\n          0 2px 8px rgba(28, 25, 23, 0.04);\n        --shadow: 0 1px 3px rgba(28, 25, 23, 0.03),\n          0 6px 24px rgba(28, 25, 23, 0.06);\n        --shadow-lg: 0 2px 4px rgba(28, 25, 23, 0.02),\n          0 12px 40px rgba(28, 25, 23, 0.08);\n        --font-display: \"Fraunces\", serif;\n        --font-body: \"Plus Jakarta Sans\", sans-serif;\n      }\n\n      html {\n        font-size: 16px;\n        -webkit-font-smoothing: antialiased;\n        -moz-osx-font-smoothing: grayscale;\n      }\n\n      body {\n        font-family: var(--font-body);\n        background: var(--bg);\n        color: var(--text);\n        min-height: 100vh;\n        line-height: 1.55;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n      }\n\n      ::-webkit-scrollbar {\n        width: 8px;\n      }\n      ::-webkit-scrollbar-track {\n        background: var(--bg);\n      }\n      ::-webkit-scrollbar-thumb {\n        background: var(--border);\n        border-radius: 4px;\n      }\n      ::-webkit-scrollbar-thumb:hover {\n        background: #d6d3cd;\n      }\n\n      /* ===== LAYOUT ===== */\n      .container {\n        max-width: 660px;\n        margin: 0 auto;\n        padding: 72px 24px 48px;\n      }\n\n      /* ===== HEADER ===== */\n      .header {\n        text-align: center;\n        margin-bottom: 36px;\n        animation: fadeUp 0.5s ease both;\n      }\n\n      .title {\n        font-family: var(--font-display);\n        font-weight: 800;\n        font-size: clamp(2.2rem, 5.5vw, 3rem);\n        color: var(--text);\n        letter-spacing: -0.02em;\n        line-height: 1.15;\n      }\n\n      .title-emoji {\n        display: inline-block;\n        margin-left: 2px;\n        font-style: normal;\n      }\n\n      .subtitle {\n        font-size: 0.95rem;\n        font-weight: 400;\n        color: var(--text-2);\n        margin-top: 8px;\n      }\n\n      /* ===== CARD ===== */\n      .card {\n        background: var(--card);\n        border: 1px solid var(--border);\n        border-radius: var(--radius-lg);\n        padding: 32px;\n        box-shadow: var(--shadow);\n        animation: fadeUp 0.5s ease 0.08s both;\n      }\n\n      /* ===== FORM ELEMENTS ===== */\n      .form-group {\n        display: flex;\n        flex-direction: column;\n        gap: 5px;\n      }\n\n      .label {\n        font-size: 0.8rem;\n        font-weight: 600;\n        color: var(--text-2);\n      }\n\n      .label-hint {\n        font-weight: 400;\n        color: var(--text-3);\n      }\n\n      .form-input,\n      .form-textarea,\n      .form-select {\n        width: 100%;\n        font-family: var(--font-body);\n        font-size: 0.9rem;\n        font-weight: 400;\n        color: var(--text);\n        background: var(--input-bg);\n        border: 1.5px solid transparent;\n        border-radius: var(--radius);\n        padding: 10px 13px;\n        outline: none;\n        transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;\n      }\n\n      .form-input:hover,\n      .form-textarea:hover,\n      .form-select:hover {\n        border-color: var(--border);\n      }\n\n      .form-input:focus,\n      .form-textarea:focus,\n      .form-select:focus {\n        background: var(--input-bg-focus);\n        border-color: var(--accent);\n        box-shadow: 0 0 0 3px var(--accent-ring);\n      }\n\n      .form-textarea {\n        resize: vertical;\n        min-height: 44px;\n        line-height: 1.55;\n      }\n\n      .form-select {\n        appearance: none;\n        -webkit-appearance: none;\n        cursor: pointer;\n        background-image: url(\"data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%2378716c' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n        background-repeat: no-repeat;\n        background-position: right 12px center;\n        padding-right: 36px;\n      }\n\n      .form-select optgroup {\n        font-weight: 600;\n        color: var(--text-2);\n      }\n\n      .form-input::placeholder,\n      .form-textarea::placeholder {\n        color: var(--text-3);\n      }\n\n      .form-grid {\n        display: grid;\n        grid-template-columns: 1fr 1fr;\n        gap: 16px;\n      }\n\n      /* ===== COLOR DOT ===== */\n      .color-field {\n        position: relative;\n      }\n\n      .color-dot {\n        position: absolute;\n        top: 50%;\n        left: 13px;\n        transform: translateY(-50%);\n        width: 14px;\n        height: 14px;\n        border-radius: 50%;\n        border: 2px solid rgba(0, 0, 0, 0.06);\n        pointer-events: none;\n        z-index: 1;\n      }\n\n      .color-field .form-select {\n        padding-left: 36px;\n      }\n\n      /* ===== DIVIDER ===== */\n      .divider {\n        height: 1px;\n        background: var(--border);\n        margin: 24px 0;\n      }\n\n      /* ===== ADVANCED TOGGLE ===== */\n      .advanced-toggle {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        width: 100%;\n        padding: 0;\n        background: none;\n        border: none;\n        cursor: pointer;\n        font-family: var(--font-body);\n        font-size: 0.85rem;\n        font-weight: 600;\n        color: var(--text-2);\n        transition: color 0.2s;\n      }\n\n      .advanced-toggle:hover {\n        color: var(--text);\n      }\n\n      .advanced-toggle.open {\n        color: var(--accent);\n      }\n\n      .chevron {\n        width: 14px;\n        height: 14px;\n        transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);\n        flex-shrink: 0;\n      }\n\n      .advanced-toggle.open .chevron {\n        transform: rotate(180deg);\n      }\n\n      /* ===== ADVANCED PANEL ===== */\n      .advanced-panel {\n        display: grid;\n        grid-template-rows: 0fr;\n        transition: grid-template-rows 0.4s cubic-bezier(0.22, 1, 0.36, 1);\n      }\n\n      .advanced-panel.open {\n        grid-template-rows: 1fr;\n      }\n\n      .advanced-inner {\n        overflow: hidden;\n      }\n\n      .advanced-content {\n        display: flex;\n        flex-direction: column;\n        gap: 18px;\n        padding-top: 20px;\n      }\n\n      /* ===== TOGGLE SWITCHES ===== */\n      .toggles-row {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 14px 24px;\n        padding-top: 2px;\n      }\n\n      .toggle {\n        display: flex;\n        align-items: center;\n        gap: 9px;\n        cursor: pointer;\n        user-select: none;\n      }\n\n      .toggle input {\n        position: absolute;\n        opacity: 0;\n        width: 0;\n        height: 0;\n        pointer-events: none;\n      }\n\n      .toggle-track {\n        position: relative;\n        width: 36px;\n        height: 20px;\n        background: #d6d3cd;\n        border-radius: 10px;\n        flex-shrink: 0;\n        transition: background 0.25s;\n      }\n\n      .toggle-thumb {\n        position: absolute;\n        top: 2px;\n        left: 2px;\n        width: 16px;\n        height: 16px;\n        background: #fff;\n        border-radius: 50%;\n        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);\n        transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);\n      }\n\n      .toggle input:checked + .toggle-track {\n        background: var(--accent);\n      }\n\n      .toggle input:checked + .toggle-track .toggle-thumb {\n        transform: translateX(16px);\n      }\n\n      .toggle-text {\n        font-size: 0.84rem;\n        font-weight: 500;\n        color: var(--text-2);\n        transition: color 0.15s;\n      }\n\n      .toggle:hover .toggle-text {\n        color: var(--text);\n      }\n\n      /* ===== STATUS AREA ===== */\n      .status-area {\n        display: none;\n        flex-direction: column;\n        gap: 10px;\n        padding: 22px 18px 16px;\n        background: var(--accent-light);\n        border: 1px solid rgba(22, 163, 74, 0.12);\n        border-radius: var(--radius);\n        margin-top: 12px;\n      }\n\n      .status-area.active {\n        display: flex;\n      }\n\n      .progress-track {\n        width: 100%;\n        height: 3px;\n        background: rgba(22, 163, 74, 0.12);\n        border-radius: 2px;\n        overflow: hidden;\n      }\n\n      .progress-fill {\n        height: 100%;\n        width: 30%;\n        background: var(--accent);\n        border-radius: 2px;\n        animation: indeterminate 1.6s ease-in-out infinite;\n      }\n\n      .status-text {\n        font-size: 0.8rem;\n        font-weight: 500;\n        color: var(--accent);\n      }\n\n      /* ===== LOG VIEWER ===== */\n      .log-viewer {\n        display: none;\n        flex-direction: column;\n        margin-top: 10px;\n        border-radius: 8px;\n        overflow: hidden;\n        border: 1px solid rgba(28, 25, 23, 0.08);\n      }\n\n      .log-viewer.active {\n        display: flex;\n      }\n\n      .log-viewer-header {\n        display: flex;\n        align-items: center;\n        justify-content: space-between;\n        padding: 8px 12px;\n        background: #292524;\n        border-bottom: 1px solid #3f3a36;\n      }\n\n      .log-viewer-title {\n        font-size: 0.7rem;\n        font-weight: 600;\n        color: #a8a29e;\n        text-transform: uppercase;\n        letter-spacing: 0.05em;\n      }\n\n      .log-viewer-clear {\n        background: none;\n        border: none;\n        color: #78716c;\n        font-size: 1rem;\n        cursor: pointer;\n        padding: 0 4px;\n        line-height: 1;\n        transition: color 0.15s;\n      }\n\n      .log-viewer-clear:hover {\n        color: #d6d3cd;\n      }\n\n      .log-viewer-body {\n        background: #1c1917;\n        max-height: 240px;\n        overflow-y: auto;\n        padding: 10px 12px;\n        font-family: \"SF Mono\", \"Cascadia Code\", \"Fira Code\", \"Consolas\",\n          monospace;\n        font-size: 0.75rem;\n        line-height: 1.6;\n      }\n\n      .log-viewer-body::-webkit-scrollbar {\n        width: 6px;\n      }\n\n      .log-viewer-body::-webkit-scrollbar-track {\n        background: #1c1917;\n      }\n\n      .log-viewer-body::-webkit-scrollbar-thumb {\n        background: #3f3a36;\n        border-radius: 3px;\n      }\n\n      .log-viewer-body::-webkit-scrollbar-thumb:hover {\n        background: #57534e;\n      }\n\n      .log-entry {\n        display: flex;\n        gap: 8px;\n        padding: 1px 0;\n      }\n\n      .log-time {\n        color: #57534e;\n        flex-shrink: 0;\n      }\n\n      .log-msg {\n        color: #d6d3cd;\n        word-break: break-word;\n        white-space: pre-wrap;\n      }\n\n      .log-msg.log-success {\n        color: #4ade80;\n      }\n\n      .log-msg.log-error {\n        color: #f87171;\n      }\n\n      .log-msg.log-warning {\n        color: #facc15;\n      }\n\n      .log-msg.log-info {\n        color: #a8a29e;\n      }\n\n      /* ===== BUTTONS ===== */\n      .actions {\n        margin-top: 24px;\n      }\n\n      .btn {\n        width: 100%;\n        padding: 13px 24px;\n        font-family: var(--font-body);\n        font-size: 0.92rem;\n        font-weight: 700;\n        border: none;\n        border-radius: var(--radius);\n        cursor: pointer;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        gap: 8px;\n        transition: background 0.2s, box-shadow 0.2s, transform 0.1s;\n      }\n\n      .btn:active {\n        transform: scale(0.985);\n      }\n\n      .btn-generate {\n        background: var(--accent);\n        color: #fff;\n      }\n\n      .btn-generate:hover {\n        background: var(--accent-hover);\n        box-shadow: 0 4px 14px rgba(22, 163, 74, 0.2);\n      }\n\n      .btn-generate .btn-icon {\n        width: 17px;\n        height: 17px;\n      }\n\n      .btn-cancel {\n        background: var(--danger-light);\n        color: var(--danger);\n        border: 1px solid rgba(220, 38, 38, 0.12);\n        margin-top: 8px;\n      }\n\n      .btn-cancel:hover {\n        background: rgba(220, 38, 38, 0.1);\n      }\n\n      .hidden {\n        display: none !important;\n      }\n\n      /* ===== FOOTER ===== */\n      .footer {\n        text-align: center;\n        padding: 36px 0 20px;\n        animation: fadeUp 0.5s ease 0.16s both;\n      }\n\n      .footer p {\n        font-size: 0.8rem;\n        color: var(--text-3);\n      }\n\n      .footer a {\n        color: var(--text-2);\n        text-decoration: none;\n        font-weight: 500;\n        transition: color 0.15s;\n      }\n\n      .footer a:hover {\n        color: var(--accent);\n      }\n\n      /* ===== TOASTS ===== */\n      .toast-container {\n        position: fixed;\n        top: 20px;\n        right: 20px;\n        z-index: 99999;\n        display: flex;\n        flex-direction: column;\n        gap: 8px;\n        pointer-events: none;\n      }\n\n      .toast {\n        pointer-events: auto;\n        display: flex;\n        align-items: center;\n        gap: 10px;\n        padding: 13px 16px;\n        background: var(--card);\n        border: 1px solid var(--border);\n        border-radius: var(--radius);\n        font-size: 0.85rem;\n        font-weight: 500;\n        color: var(--text);\n        max-width: 380px;\n        box-shadow: var(--shadow-lg);\n        transform: translateX(120%);\n        opacity: 0;\n        transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1),\n          opacity 0.3s ease;\n      }\n\n      .toast.show {\n        transform: translateX(0);\n        opacity: 1;\n      }\n\n      .toast-success {\n        border-left: 3px solid var(--accent);\n      }\n\n      .toast-error {\n        border-left: 3px solid var(--danger);\n      }\n\n      .toast-dot {\n        width: 7px;\n        height: 7px;\n        border-radius: 50%;\n        flex-shrink: 0;\n        background: var(--text-3);\n      }\n\n      .toast-success .toast-dot {\n        background: var(--accent);\n      }\n\n      .toast-error .toast-dot {\n        background: var(--danger);\n      }\n\n      .toast-msg {\n        flex: 1;\n        line-height: 1.4;\n      }\n\n      .toast-close {\n        background: none;\n        border: none;\n        color: var(--text-3);\n        cursor: pointer;\n        padding: 2px;\n        font-size: 1.1rem;\n        line-height: 1;\n        transition: color 0.15s;\n        flex-shrink: 0;\n      }\n\n      .toast-close:hover {\n        color: var(--text);\n      }\n\n      /* ===== ANIMATIONS ===== */\n      @keyframes fadeUp {\n        from {\n          opacity: 0;\n          transform: translateY(14px);\n        }\n        to {\n          opacity: 1;\n          transform: translateY(0);\n        }\n      }\n\n      @keyframes indeterminate {\n        0% {\n          transform: translateX(-100%);\n        }\n        100% {\n          transform: translateX(430%);\n        }\n      }\n\n      /* ===== RESPONSIVE ===== */\n      @media (max-width: 600px) {\n        .container {\n          padding: 40px 16px 32px;\n        }\n\n        .card {\n          padding: 22px 18px;\n          border-radius: 16px;\n        }\n\n        .form-grid {\n          grid-template-columns: 1fr;\n        }\n\n        .toggles-row {\n          flex-direction: column;\n          gap: 12px;\n        }\n\n        .toast-container {\n          left: 12px;\n          right: 12px;\n        }\n\n        .toast {\n          max-width: 100%;\n        }\n      }\n    </style>\n  </head>\n\n  <body>\n    <main class=\"container\">\n      <header class=\"header\">\n        <h1 class=\"title\">\n          MoneyPrinter<span class=\"title-emoji\"> 💸</span>\n        </h1>\n        <p class=\"subtitle\">Automate YouTube Shorts with AI</p>\n      </header>\n\n      <div class=\"card\">\n        <!-- Subject -->\n        <div class=\"form-group\">\n          <label for=\"videoSubject\" class=\"label\">Video Subject</label>\n          <textarea\n            id=\"videoSubject\"\n            name=\"videoSubject\"\n            class=\"form-textarea\"\n            rows=\"3\"\n            placeholder=\"What should the video be about?\"\n          ></textarea>\n        </div>\n\n        <div class=\"divider\"></div>\n\n        <!-- Advanced Options Toggle -->\n        <button\n          type=\"button\"\n          id=\"advancedOptionsToggle\"\n          class=\"advanced-toggle\"\n        >\n          <svg\n            class=\"chevron\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n          >\n            <path d=\"M4 6l4 4 4-4\" />\n          </svg>\n          <span>Advanced Options</span>\n        </button>\n\n        <!-- Advanced Panel -->\n        <div id=\"advancedOptions\" class=\"advanced-panel\">\n          <div class=\"advanced-inner\">\n            <div class=\"advanced-content\">\n              <!-- AI Model + Voice -->\n              <div class=\"form-grid\">\n                <div class=\"form-group\">\n                  <label for=\"aiModel\" class=\"label\">Ollama Model</label>\n                  <select id=\"aiModel\" name=\"aiModel\" class=\"form-select\">\n                    <option value=\"\">Loading Ollama models...</option>\n                  </select>\n                </div>\n\n                <div class=\"form-group\">\n                  <label for=\"voice\" class=\"label\">Voice</label>\n                  <select id=\"voice\" name=\"voice\" class=\"form-select\">\n                    <optgroup label=\"Character\">\n                      <option value=\"en_us_ghostface\">Ghost Face</option>\n                      <option value=\"en_us_chewbacca\">Chewbacca</option>\n                      <option value=\"en_us_c3po\">C3PO</option>\n                      <option value=\"en_us_stitch\">Stitch</option>\n                      <option value=\"en_us_stormtrooper\">Stormtrooper</option>\n                      <option value=\"en_us_rocket\">Rocket</option>\n                    </optgroup>\n                    <optgroup label=\"English (AU)\">\n                      <option value=\"en_au_001\">Female</option>\n                      <option value=\"en_au_002\">Male</option>\n                    </optgroup>\n                    <optgroup label=\"English (UK)\">\n                      <option value=\"en_uk_001\">Male 1</option>\n                      <option value=\"en_uk_003\">Male 2</option>\n                    </optgroup>\n                    <optgroup label=\"English (US)\">\n                      <option value=\"en_us_001\">Female 1</option>\n                      <option value=\"en_us_002\">Female 2</option>\n                      <option value=\"en_us_006\">Male 1</option>\n                      <option value=\"en_us_007\">Male 2</option>\n                      <option value=\"en_us_009\">Male 3</option>\n                      <option value=\"en_us_010\">Male 4</option>\n                    </optgroup>\n                    <optgroup label=\"French\">\n                      <option value=\"fr_001\">Male 1</option>\n                      <option value=\"fr_002\">Male 2</option>\n                    </optgroup>\n                    <optgroup label=\"German\">\n                      <option value=\"de_001\">Female</option>\n                      <option value=\"de_002\">Male</option>\n                    </optgroup>\n                    <optgroup label=\"Spanish\">\n                      <option value=\"es_002\">Male</option>\n                      <option value=\"es_mx_002\">Mexican Male</option>\n                    </optgroup>\n                    <optgroup label=\"Portuguese (BR)\">\n                      <option value=\"br_001\">Female 1</option>\n                      <option value=\"br_003\">Female 2</option>\n                      <option value=\"br_004\">Female 3</option>\n                      <option value=\"br_005\">Male</option>\n                    </optgroup>\n                    <optgroup label=\"Indonesian\">\n                      <option value=\"id_001\">Female</option>\n                    </optgroup>\n                    <optgroup label=\"Japanese\">\n                      <option value=\"jp_001\">Female 1</option>\n                      <option value=\"jp_003\">Female 2</option>\n                      <option value=\"jp_005\">Female 3</option>\n                      <option value=\"jp_006\">Male</option>\n                    </optgroup>\n                    <optgroup label=\"Korean\">\n                      <option value=\"kr_002\">Male 1</option>\n                      <option value=\"kr_003\">Female</option>\n                      <option value=\"kr_004\">Male 2</option>\n                    </optgroup>\n                    <optgroup label=\"Singing\">\n                      <option value=\"en_female_f08_salut_damour\">Alto</option>\n                      <option value=\"en_male_m03_lobby\">Tenor</option>\n                      <option value=\"en_female_f08_warmy_breeze\">\n                        Warmy Breeze\n                      </option>\n                      <option value=\"en_male_m03_sunshine_soon\">\n                        Sunshine Soon\n                      </option>\n                    </optgroup>\n                    <optgroup label=\"Effects\">\n                      <option value=\"en_male_narration\">Narrator</option>\n                      <option value=\"en_male_funny\">Wacky</option>\n                      <option value=\"en_female_emotional\">Peaceful</option>\n                    </optgroup>\n                  </select>\n                </div>\n              </div>\n\n              <!-- Subtitles Position + Color -->\n              <div class=\"form-grid\">\n                <div class=\"form-group\">\n                  <label for=\"subtitlesPosition\" class=\"label\"\n                    >Subtitles Position</label\n                  >\n                  <select\n                    id=\"subtitlesPosition\"\n                    name=\"subtitlesPosition\"\n                    class=\"form-select\"\n                  >\n                    <option value=\"center,top\">Center &middot; Top</option>\n                    <option value=\"center,bottom\">\n                      Center &middot; Bottom\n                    </option>\n                    <option value=\"center,center\">\n                      Center &middot; Center\n                    </option>\n                    <option value=\"left,center\">Left &middot; Center</option>\n                    <option value=\"left,bottom\">Left &middot; Bottom</option>\n                    <option value=\"right,center\">Right &middot; Center</option>\n                    <option value=\"right,bottom\">Right &middot; Bottom</option>\n                  </select>\n                </div>\n\n                <div class=\"form-group\">\n                  <label for=\"subtitlesColor\" class=\"label\"\n                    >Subtitle Color</label\n                  >\n                  <div class=\"color-field\">\n                    <span class=\"color-dot\" id=\"colorDot\"></span>\n                    <select\n                      id=\"subtitlesColor\"\n                      name=\"subtitlesColor\"\n                      class=\"form-select\"\n                    >\n                      <option value=\"#FFFF00\">Yellow</option>\n                      <option value=\"#f4a261\">Orange</option>\n                      <option value=\"#e63946\">Red</option>\n                      <option value=\"#1d3557\">Blue</option>\n                      <option value=\"#fff\">White</option>\n                      <option value=\"#03071e\">Black</option>\n                    </select>\n                  </div>\n                </div>\n              </div>\n\n              <!-- Threads + Paragraphs -->\n              <div class=\"form-grid\">\n                <div class=\"form-group\">\n                  <label for=\"threads\" class=\"label\">Threads</label>\n                  <input\n                    type=\"number\"\n                    id=\"threads\"\n                    name=\"threads\"\n                    class=\"form-input\"\n                    value=\"2\"\n                    min=\"1\"\n                    max=\"100\"\n                  />\n                </div>\n\n                <div class=\"form-group\">\n                  <label for=\"paragraphNumber\" class=\"label\">Paragraphs</label>\n                  <input\n                    type=\"number\"\n                    id=\"paragraphNumber\"\n                    name=\"paragraphNumber\"\n                    class=\"form-input\"\n                    value=\"1\"\n                    min=\"1\"\n                    max=\"100\"\n                  />\n                </div>\n              </div>\n\n              <!-- Songs Folder -->\n              <div class=\"form-group\">\n                <label for=\"songFiles\" class=\"label\"\n                  >Songs Folder\n                  <span class=\"label-hint\"\n                    >&mdash; select a folder with MP3 files</span\n                  ></label\n                >\n                <input\n                  type=\"file\"\n                  id=\"songFiles\"\n                  name=\"songFiles\"\n                  class=\"form-input\"\n                  accept=\".mp3\"\n                  webkitdirectory\n                />\n              </div>\n\n              <!-- Custom Prompt -->\n              <div class=\"form-group\">\n                <label for=\"customPrompt\" class=\"label\">Custom Prompt</label>\n                <textarea\n                  id=\"customPrompt\"\n                  name=\"customPrompt\"\n                  class=\"form-textarea\"\n                  rows=\"3\"\n                  placeholder=\"Override the default AI prompt\"\n                ></textarea>\n              </div>\n\n              <!-- Toggles -->\n              <div class=\"toggles-row\">\n                <label class=\"toggle\">\n                  <input\n                    type=\"checkbox\"\n                    id=\"youtubeUploadToggle\"\n                    name=\"youtubeUploadToggle\"\n                  />\n                  <span class=\"toggle-track\">\n                    <span class=\"toggle-thumb\"></span>\n                  </span>\n                  <span class=\"toggle-text\">Upload to YouTube</span>\n                </label>\n\n                <label class=\"toggle\">\n                  <input\n                    type=\"checkbox\"\n                    id=\"useMusicToggle\"\n                    name=\"useMusicToggle\"\n                  />\n                  <span class=\"toggle-track\">\n                    <span class=\"toggle-thumb\"></span>\n                  </span>\n                  <span class=\"toggle-text\">Use Music</span>\n                </label>\n\n                <label class=\"toggle\">\n                  <input\n                    type=\"checkbox\"\n                    id=\"reuseChoicesToggle\"\n                    name=\"reuseChoicesToggle\"\n                  />\n                  <span class=\"toggle-track\">\n                    <span class=\"toggle-thumb\"></span>\n                  </span>\n                  <span class=\"toggle-text\">Reuse Choices</span>\n                </label>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Status Area -->\n        <div class=\"status-area\" id=\"statusArea\">\n          <div class=\"progress-track\">\n            <div class=\"progress-fill\"></div>\n          </div>\n          <span class=\"status-text\">Generating your video&hellip;</span>\n          <div class=\"log-viewer\" id=\"logViewer\">\n            <div class=\"log-viewer-header\">\n              <span class=\"log-viewer-title\">Live Output</span>\n              <button type=\"button\" class=\"log-viewer-clear\" id=\"logClearBtn\">&times;</button>\n            </div>\n            <div class=\"log-viewer-body\" id=\"logViewerBody\"></div>\n          </div>\n        </div>\n\n        <!-- Actions -->\n        <div class=\"actions\">\n          <button type=\"button\" id=\"generateButton\" class=\"btn btn-generate\">\n            <svg class=\"btn-icon\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n              <path\n                d=\"M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z\"\n              />\n            </svg>\n            Generate\n          </button>\n          <button\n            type=\"button\"\n            id=\"cancelButton\"\n            class=\"btn btn-cancel hidden\"\n          >\n            Cancel\n          </button>\n        </div>\n      </div>\n\n      <footer class=\"footer\">\n        <p>\n          Made with &hearts; by\n          <a href=\"https://github.com/FujiwaraChoki\" target=\"_blank\"\n            >Fuji Codes</a\n          >\n        </p>\n      </footer>\n    </main>\n\n    <div id=\"toastContainer\" class=\"toast-container\"></div>\n    <script src=\"app.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 FujiwaraChoki\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# MoneyPrinter 💸\n\n> ♥︎ Sponsor: The Best AI Chat App: [shiori.ai](https://www.shiori.ai)\n---\n\n> 𝕏 Also, follow me on X: [@DevBySami](https://x.com/DevBySami).\n\nAutomate the creation of YouTube Shorts by providing a video topic.\n\nMoneyPrinter is Ollama-first: script generation and metadata are fully powered by local Ollama models.\n\nMoneyPrinter now uses a DB-backed generation queue (API + worker + Postgres in Docker) for reliable, restart-safe processing.\n\n<a href=\"https://trendshift.io/repositories/7545\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/7545\" alt=\"FujiwaraChoki%2FMoneyPrinter | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n> **Important** Please make sure you look through existing/closed issues before opening your own. If it's just a question, please join our [discord](https://dsc.gg/fuji-community) and ask there.\n\n> **🎥** Watch the video on [YouTube](https://youtu.be/mkZsaDA2JnA?si=pNne3MnluRVkWQbE).\n\n## Documentation\n\nDocs are centralized in [`docs/`](docs/README.md):\n\n- [Interactive Setup Script](setup.sh)\n- [Quickstart](docs/quickstart.md)\n- [Configuration](docs/configuration.md)\n- [Architecture](docs/architecture.md)\n- [Docker](docs/docker.md)\n- [Testing](docs/testing.md)\n- [Troubleshooting](docs/troubleshooting.md)\n\n## FAQ 🤔\n\n### Which AI provider does MoneyPrinter use?\n\nMoneyPrinter is fully Ollama-based. Start Ollama, pull a model, and select the model in the UI.\n\n```bash\nollama serve\nollama pull llama3.1:8b\n```\n\n### How do I get the TikTok session ID?\n\nYou can obtain your TikTok session ID by logging into TikTok in your browser and copying the value of the `sessionid` cookie.\n\n### My ImageMagick binary is not being detected\n\nMoneyPrinter auto-detects ImageMagick from your `PATH` on Linux, macOS, and Windows. If auto-detection fails, set the executable path manually in `.env`, for example:\n\n```env\nIMAGEMAGICK_BINARY=\"C:\\\\Program Files\\\\ImageMagick-7.1.0-Q16\\\\magick.exe\"\n```\n\nDon't forget to use double backslashes (`\\\\`) in the path, instead of one.\n\n### I can't install `playsound`: Wheel failed to build\n\nIf you're having trouble installing `playsound`, you can try installing it using the following command:\n\n```bash\nuv pip install -U wheel\nuv pip install -U playsound\n```\n\nIf you were not able to find your solution, check [Troubleshooting](docs/troubleshooting.md), ask in Discord, or create an issue.\n\n## Donate 🎁\n\nIf you like and enjoy `MoneyPrinter`, and would like to donate, you can do that by clicking on the button on the right hand side of the repository. ❤️\nYou will have your name (and/or logo) added to this repository as a supporter as a sign of appreciation.\n\n## Contributing 🤝\n\nPull Requests will not be accepted for the time-being.\n\n## Star History 🌟\n\n[![Star History Chart](https://api.star-history.com/svg?repos=FujiwaraChoki/MoneyPrinter&type=Date)](https://star-history.com/#FujiwaraChoki/MoneyPrinter&Date)\n\n## License 📝\n\nSee [`LICENSE`](LICENSE) file for more information.\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"3\"\nservices:\n  postgres:\n    image: postgres:16-alpine\n    container_name: \"postgres\"\n    ports:\n      - \"5432:5432\"\n    environment:\n      - POSTGRES_DB=${POSTGRES_DB:-moneyprinter}\n      - POSTGRES_USER=${POSTGRES_USER:-moneyprinter}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-moneyprinter}\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U ${POSTGRES_USER:-moneyprinter} -d ${POSTGRES_DB:-moneyprinter}\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n    restart: always\n\n  frontend:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    container_name: \"frontend\"\n    ports:\n      - \"8001:8001\"\n    command: [\"python3\", \"-m\", \"http.server\", \"8001\", \"--directory\", \"frontend\"]\n    volumes:\n      - ./Frontend:/app/frontend\n    restart: always\n  backend:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    container_name: \"backend\"\n    ports:\n      - \"8080:8080\"\n    command: [\"python3\", \"backend/main.py\"]\n    volumes:\n      - ./files:/temp\n      - ./Backend:/app/backend\n      - ./fonts:/app/fonts\n    environment:\n      - ASSEMBLY_AI_API_KEY=${ASSEMBLY_AI_API_KEY}\n      - TIKTOK_SESSION_ID=${TIKTOK_SESSION_ID}\n      - IMAGEMAGICK_BINARY=/usr/local/bin/magick\n      - PEXELS_API_KEY=${PEXELS_API_KEY}\n      - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}\n      - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.1:8b}\n      - DATABASE_URL=${DATABASE_URL:-postgresql+psycopg://moneyprinter:moneyprinter@postgres:5432/moneyprinter}\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n    depends_on:\n      - frontend\n      - postgres\n    restart: always\n\n  worker:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    container_name: \"worker\"\n    command: [\"python3\", \"backend/worker.py\"]\n    volumes:\n      - ./files:/temp\n      - ./Backend:/app/backend\n      - ./fonts:/app/fonts\n    environment:\n      - ASSEMBLY_AI_API_KEY=${ASSEMBLY_AI_API_KEY}\n      - TIKTOK_SESSION_ID=${TIKTOK_SESSION_ID}\n      - IMAGEMAGICK_BINARY=/usr/local/bin/magick\n      - PEXELS_API_KEY=${PEXELS_API_KEY}\n      - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}\n      - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.1:8b}\n      - DATABASE_URL=${DATABASE_URL:-postgresql+psycopg://moneyprinter:moneyprinter@postgres:5432/moneyprinter}\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n    depends_on:\n      - postgres\n      - backend\n      - frontend\n    restart: always\n\nvolumes:\n  files:\n  postgres_data:\n"
  },
  {
    "path": "docs/README.md",
    "content": "# MoneyPrinter Docs\n\nThis folder is the single source of truth for setup, configuration, and troubleshooting.\n\n## Start Here\n\n- [Interactive Setup Script](../setup.sh)\n- [Quickstart](quickstart.md)\n- [Configuration](configuration.md)\n- [Architecture](architecture.md)\n- [Docker](docker.md)\n- [Testing](testing.md)\n- [Troubleshooting](troubleshooting.md)\n\n## Recommended Reading Order\n\n1. Quickstart\n2. Configuration\n3. Docker (if you use containers)\n4. Testing\n5. Troubleshooting (when something breaks)\n"
  },
  {
    "path": "docs/architecture.md",
    "content": "# Architecture\n\nMoneyPrinter now uses a database-backed queue architecture designed for reliability, restart safety, and future scaling.\n\n## Overview\n\n- `Frontend` submits generation requests and polls job status/events.\n- `API (Flask)` validates input and enqueues jobs in Postgres.\n- `Worker` claims queued jobs and runs the generation pipeline.\n- `Postgres` is the source of truth for job state, progress events, and artifacts.\n\n```mermaid\nflowchart LR\n    U[User] --> F[Frontend\\nindex.html + app.js]\n    F -->|POST /api/generate| A[API\\nBackend/main.py]\n    F -->|GET /api/jobs/:id| A\n    F -->|GET /api/jobs/:id/events| A\n    F -->|POST /api/jobs/:id/cancel| A\n\n    A -->|insert job| DB[(Postgres)]\n    A -->|read status/events| DB\n\n    W[Worker\\nBackend/worker.py] -->|claim queued job| DB\n    W -->|write logs/events| DB\n    W -->|update final state| DB\n    W --> P[Pipeline\\nBackend/pipeline.py]\n    P --> FS[(temp/subtitles/output files)]\n```\n\n## Runtime Services (Docker)\n\n```mermaid\nflowchart TB\n    subgraph Compose\n      FE[frontend]\n      API[backend]\n      WK[worker]\n      PG[(postgres)]\n    end\n\n    FE --> API\n    API --> PG\n    WK --> PG\n    WK --> API\n```\n\n## Generation Lifecycle\n\n```mermaid\nstateDiagram-v2\n    [*] --> queued\n    queued --> running: worker claims job\n    queued --> cancelled: cancel before claim\n    running --> completed: success\n    running --> failed: unrecoverable error\n    running --> cancelled: cancellation requested\n    completed --> [*]\n    failed --> [*]\n    cancelled --> [*]\n```\n\n## API + Worker Sequence\n\n```mermaid\nsequenceDiagram\n    participant UI as Frontend\n    participant API as Flask API\n    participant DB as Postgres\n    participant WK as Worker\n    participant PL as Pipeline\n\n    UI->>API: POST /api/generate\n    API->>DB: INSERT generation_jobs(status=queued)\n    API-->>UI: { status: success, jobId }\n\n    loop Polling\n      UI->>API: GET /api/jobs/:id\n      API->>DB: SELECT job\n      API-->>UI: job state\n      UI->>API: GET /api/jobs/:id/events?after=n\n      API->>DB: SELECT events > n\n      API-->>UI: event list\n    end\n\n    WK->>DB: claim queued job\n    WK->>DB: UPDATE status=running + INSERT event\n    WK->>PL: run generation pipeline\n    PL-->>WK: result path OR error\n    WK->>DB: UPDATE status + INSERT terminal event\n\n    UI->>API: POST /api/jobs/:id/cancel\n    API->>DB: set cancel_requested=true\n    WK->>DB: observes cancel and marks cancelled\n```\n\n## Data Model (Current Core)\n\n```mermaid\nerDiagram\n    projects ||--o{ generation_jobs : contains\n    generation_jobs ||--o{ generation_events : has\n    generation_jobs ||--o{ scripts : produces\n    generation_jobs ||--o{ artifacts : produces\n\n    projects {\n      int id PK\n      string name\n      datetime created_at\n    }\n\n    generation_jobs {\n      string id PK\n      int project_id FK\n      string status\n      json payload\n      boolean cancel_requested\n      int attempt_count\n      int max_attempts\n      string result_path\n      text error_message\n      datetime created_at\n      datetime started_at\n      datetime completed_at\n      datetime updated_at\n    }\n\n    generation_events {\n      int id PK\n      string job_id FK\n      string event_type\n      string level\n      text message\n      json payload\n      datetime created_at\n    }\n\n    scripts {\n      int id PK\n      string job_id FK\n      string model_name\n      text content\n      datetime created_at\n    }\n\n    artifacts {\n      int id PK\n      string job_id FK\n      string artifact_type\n      string path\n      json metadata_json\n      datetime created_at\n    }\n```\n\n## Current Guarantees\n\n- API is fast and non-blocking for generation requests.\n- Job state and logs survive API/worker restarts.\n- Cancellation is job-scoped (`cancel_requested`) and checked during processing.\n- Frontend can recover progress after refresh by polling persisted events.\n\n## Planned Next Hardening\n\n- Add migration tool (Alembic) for schema versioning.\n- Add retries/backoff with `next_retry_at` and dead-letter semantics.\n- Add artifact metadata population and checksum tracking.\n- Add worker concurrency controls and queue metrics endpoints.\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "# Configuration\n\nMoneyPrinter reads configuration from `.env` (project root).\n\nUse `.env.example` as your template.\n\n## Required\n\n| Variable | Description |\n|---|---|\n| `TIKTOK_SESSION_ID` | TikTok session cookie (`sessionid`) used for TTS voice endpoint calls. |\n| `PEXELS_API_KEY` | API key used to fetch stock video clips. |\n\n## Optional\n\n| Variable | Description | Default |\n|---|---|---|\n| `IMAGEMAGICK_BINARY` | Absolute path to ImageMagick executable. If empty, auto-detected from `PATH`. | auto-detect |\n| `OLLAMA_BASE_URL` | Ollama server base URL used for model listing and chat generation. | `http://localhost:11434` |\n| `OLLAMA_MODEL` | Fallback model if frontend does not send a model value. | `llama3.1:8b` |\n| `ASSEMBLY_AI_API_KEY` | If set, subtitles are generated with AssemblyAI; otherwise local subtitle generation is used. | empty |\n| `POSTGRES_DB` | Database name for Docker Postgres service. | `moneyprinter` |\n| `POSTGRES_USER` | Database user for Docker Postgres service. | `moneyprinter` |\n| `POSTGRES_PASSWORD` | Database password for Docker Postgres service. | `moneyprinter` |\n| `DATABASE_URL` | SQLAlchemy DSN used by API and worker (`postgresql+psycopg://...` or `sqlite:///...`). | `sqlite:///moneyprinter.db` |\n\n## Notes\n\n- Ollama models shown in the frontend are fetched from backend endpoint `/api/models`, which queries `OLLAMA_BASE_URL/api/tags`.\n- Pull models before use, for example:\n\n```bash\nollama pull llama3.1:8b\n```\n\n- If ImageMagick is not discovered automatically, set `IMAGEMAGICK_BINARY` explicitly.\n- New architecture uses a database-backed job queue. In Docker, use Postgres via `DATABASE_URL`.\n"
  },
  {
    "path": "docs/docker.md",
    "content": "# Docker\n\nRun MoneyPrinter frontend, API, worker, and Postgres with Docker Compose.\n\n## 1) Prepare environment\n\n```bash\ncp .env.example .env\n```\n\nSet required keys in `.env`:\n\n- `TIKTOK_SESSION_ID`\n- `PEXELS_API_KEY`\n\nDatabase defaults (already in `.env.example`):\n\n- `POSTGRES_DB=moneyprinter`\n- `POSTGRES_USER=moneyprinter`\n- `POSTGRES_PASSWORD=moneyprinter`\n- `DATABASE_URL=postgresql+psycopg://moneyprinter:moneyprinter@postgres:5432/moneyprinter`\n\n## 2) Ollama connectivity\n\nBy default, Docker backend expects Ollama on host machine:\n\n- `OLLAMA_BASE_URL=http://host.docker.internal:11434`\n\nLinux support is included via compose `extra_hosts` host-gateway mapping.\n\nIf Ollama runs in another container or machine, set `OLLAMA_BASE_URL` accordingly.\n\n## 3) Start services\n\n```bash\ndocker compose up --build\n```\n\n## 4) Access apps\n\n- Frontend: `http://localhost:8001`\n- Backend API: `http://localhost:8080`\n- Postgres: `localhost:5432`\n\n## 5) Verify model listing\n\n```bash\ncurl http://localhost:8080/api/models\n```\n\nYou should receive a JSON payload with `models` and `default`.\n\n## 6) Queue a generation job\n\n```bash\ncurl -X POST http://localhost:8080/api/generate \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"videoSubject\":\"AI business ideas\",\"aiModel\":\"llama3.1:8b\",\"voice\":\"en_us_001\",\"paragraphNumber\":1,\"customPrompt\":\"\"}'\n```\n\nResponse includes `jobId`. Query status and events:\n\n```bash\ncurl http://localhost:8080/api/jobs/<jobId>\ncurl \"http://localhost:8080/api/jobs/<jobId>/events?after=0\"\n```\n"
  },
  {
    "path": "docs/quickstart.md",
    "content": "# Quickstart\n\nRun MoneyPrinter locally with an Ollama model.\n\n## 1) Clone repository\n\n```bash\ngit clone https://github.com/FujiwaraChoki/MoneyPrinter.git\ncd MoneyPrinter\n```\n\n## 2) Quick setup (recommended)\n\nRun the interactive setup script:\n\n```bash\n./setup.sh\n```\n\nThis script checks dependencies, sets up `.env`, installs Python packages with `uv`, and can optionally pull an Ollama model.\n\n## 3) Manual setup\n\nUse this path if you prefer to run each step yourself.\n\n### Prerequisites\n\n- Python 3.11+\n- [uv](https://docs.astral.sh/uv/getting-started/installation/)\n- FFmpeg\n- ImageMagick\n- Ollama\n\n### Install and create env file\n\n```bash\nuv sync\ncp .env.example .env\n```\n\nWindows PowerShell for `.env` copy:\n\n```powershell\nCopy-Item .env.example .env\n```\n\n## 4) Configure environment\n\nSet required values in `.env`:\n\n- `TIKTOK_SESSION_ID`\n- `PEXELS_API_KEY`\n\nSee [Configuration](configuration.md) for all variables.\n\n## Optional: Run tests\n\n```bash\nuv sync --group dev\nuv run pytest\n```\n\n## 5) Start Ollama and pull a model\n\n```bash\nollama serve\nollama pull llama3.1:8b\n```\n\nIf Ollama runs on another machine/port, set `OLLAMA_BASE_URL` in `.env`.\n\n## 6) Run backend\n\n```bash\nuv run python Backend/main.py\n```\n\n## 7) Run worker\n\nIn a new terminal:\n\n```bash\nuv run python Backend/worker.py\n```\n\n## 8) Run frontend\n\nIn a new terminal:\n\n```bash\ncd Frontend\npython3 -m http.server 3000\n```\n\nOpen `http://localhost:3000`.\n\n## 9) Generate video\n\n1. Enter a video subject.\n2. Expand advanced options.\n3. Choose an Ollama model from the dropdown (loaded dynamically from Ollama).\n4. Click Generate.\n\nOutput file: `output.mp4` at project root.\n"
  },
  {
    "path": "docs/testing.md",
    "content": "# Testing\n\nMoneyPrinter uses `pytest` for backend tests.\n\n## Install test dependencies\n\nInstall dev dependencies (includes `pytest`):\n\n```bash\nuv sync --group dev\n```\n\n## Run tests\n\nRun all tests:\n\n```bash\nuv run pytest\n```\n\nRun one test file:\n\n```bash\nuv run pytest tests/test_repository.py\n```\n\nRun one test:\n\n```bash\nuv run pytest tests/test_repository.py::test_create_job_persists_payload_and_queued_event\n```\n\n## Current test scope\n\n- `tests/test_api_jobs.py`: API queue, job status/events, and cancellation endpoints.\n- `tests/test_api_misc.py`: API model listing fallback and song upload endpoint behavior.\n- `tests/test_repository.py`: queue/repository behavior for create, claim, cancel, and completion events.\n- `tests/test_worker.py`: worker loop behavior for success, cancellation, failure, and empty queue.\n- `tests/test_utils.py`: filesystem cleanup, song selection, and ImageMagick binary resolution.\n- `tests/conftest.py`: isolated SQLite session fixture per test.\n"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "# Troubleshooting\n\n## No Ollama models in dropdown\n\n- Ensure Ollama is running: `ollama serve`\n- Ensure at least one model exists: `ollama list`\n- Pull a model if needed: `ollama pull llama3.1:8b`\n- Verify backend can reach Ollama base URL in `.env` (`OLLAMA_BASE_URL`)\n\n## Frontend cannot connect to backend\n\n- Confirm backend is running on port `8080`\n- Confirm frontend is opened from local server (for example `python3 -m http.server`)\n- Check browser console/network for `/api/generate` or `/api/models` failures\n\n## ImageMagick not detected\n\n- Install ImageMagick and ensure executable is on `PATH`\n- Or set explicit path in `.env`, for example:\n\n```env\nIMAGEMAGICK_BINARY=\"/usr/local/bin/magick\"\n```\n\nWindows example:\n\n```env\nIMAGEMAGICK_BINARY=\"C:\\\\Program Files\\\\ImageMagick-7.1.1-Q16-HDRI\\\\magick.exe\"\n```\n\n## No stock videos found\n\n- Verify `PEXELS_API_KEY` is valid\n- Try a broader video subject\n- Retry generation; stock results vary by query\n\n## Subtitles fail\n\n- If using AssemblyAI, verify `ASSEMBLY_AI_API_KEY`\n- If not using AssemblyAI, local subtitle generation should still work\n\n## YouTube upload skipped\n\n- Place `client_secret.json` inside `Backend/`\n- Enable required YouTube scopes and OAuth consent in Google Cloud\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"moneyprinter\"\nversion = \"1.0.0\"\ndescription = \"Automate the creation of YouTube Shorts by providing a video topic.\"\nreadme = \"README.md\"\nrequires-python = \">=3.11\"\ndependencies = [\n    \"requests==2.31.0\",\n    \"ollama==0.5.1\",\n    \"moviepy==2.2.1\",\n    \"termcolor==2.4.0\",\n    \"flask==3.0.0\",\n    \"curl-cffi\",\n    \"flask-cors==4.0.0\",\n    \"playsound==1.2.2\",\n    \"pillow==9.5.0\",\n    \"python-dotenv==1.0.0\",\n    \"srt-equalizer==0.1.8\",\n    \"platformdirs==4.1.0\",\n    \"undetected-chromedriver\",\n    \"assemblyai\",\n    \"brotli\",\n    \"google-api-python-client\",\n    \"oauth2client\",\n    \"sqlalchemy==2.0.36\",\n    \"psycopg[binary]==3.2.3\",\n]\n\n[dependency-groups]\ndev = [\n    \"pytest==8.4.1\",\n]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\naddopts = \"-q\"\n"
  },
  {
    "path": "setup.sh",
    "content": "#!/usr/bin/env bash\n\nset -u\n\nSCRIPT_DIR=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROJECT_ROOT=\"$SCRIPT_DIR\"\n\nif ! cd \"$PROJECT_ROOT\"; then\n  printf 'Failed to enter project directory: %s\\n' \"$PROJECT_ROOT\"\n  exit 1\nfi\n\nif [ -t 1 ] && command -v tput >/dev/null 2>&1; then\n  COLOR_COUNT=\"$(tput colors 2>/dev/null || printf '0')\"\nelse\n  COLOR_COUNT=\"0\"\nfi\n\nif [ \"$COLOR_COUNT\" -ge 8 ]; then\n  BOLD=\"$(tput bold)\"\n  RESET=\"$(tput sgr0)\"\n  RED=\"$(tput setaf 1)\"\n  GREEN=\"$(tput setaf 2)\"\n  YELLOW=\"$(tput setaf 3)\"\n  BLUE=\"$(tput setaf 4)\"\n  MAGENTA=\"$(tput setaf 5)\"\n  CYAN=\"$(tput setaf 6)\"\nelse\n  BOLD=''\n  RESET=''\n  RED=''\n  GREEN=''\n  YELLOW=''\n  BLUE=''\n  MAGENTA=''\n  CYAN=''\nfi\n\nprint_banner() {\n  printf '\\n'\n  printf '%s%sMoneyPrinter Interactive Setup%s\\n' \"$BOLD\" \"$MAGENTA\" \"$RESET\"\n  printf '%s--------------------------------%s\\n' \"$MAGENTA\" \"$RESET\"\n  printf '%sThis script helps you prepare your local environment.%s\\n\\n' \"$CYAN\" \"$RESET\"\n}\n\ninfo() {\n  printf '%s[INFO]%s %s\\n' \"$BLUE\" \"$RESET\" \"$1\"\n}\n\nok() {\n  printf '%s[OK]%s   %s\\n' \"$GREEN\" \"$RESET\" \"$1\"\n}\n\nwarn() {\n  printf '%s[WARN]%s %s\\n' \"$YELLOW\" \"$RESET\" \"$1\"\n}\n\nerror() {\n  printf '%s[ERR]%s  %s\\n' \"$RED\" \"$RESET\" \"$1\"\n}\n\ncommand_exists() {\n  command -v \"$1\" >/dev/null 2>&1\n}\n\nask_yes_no() {\n  prompt=\"$1\"\n  default=\"$2\"\n\n  while true; do\n    if [ \"$default\" = \"y\" ]; then\n      printf '%s [Y/n]: ' \"$prompt\"\n    else\n      printf '%s [y/N]: ' \"$prompt\"\n    fi\n\n    read -r reply\n    case \"$reply\" in\n      [Yy]|[Yy][Ee][Ss])\n        return 0\n        ;;\n      [Nn]|[Nn][Oo])\n        return 1\n        ;;\n      '')\n        if [ \"$default\" = \"y\" ]; then\n          return 0\n        fi\n        return 1\n        ;;\n      *)\n        warn 'Please answer y or n.'\n        ;;\n    esac\n  done\n}\n\ncheck_python_version() {\n  if ! command_exists python3; then\n    error 'python3 not found (required: 3.11+).'\n    return 1\n  fi\n\n  PYTHON_VERSION=\"$(python3 -c 'import sys; print(\".\".join(map(str, sys.version_info[:3])))' 2>/dev/null || printf '0.0.0')\"\n  MAJOR=\"$(printf '%s' \"$PYTHON_VERSION\" | cut -d. -f1)\"\n  MINOR=\"$(printf '%s' \"$PYTHON_VERSION\" | cut -d. -f2)\"\n\n  if [ \"$MAJOR\" -gt 3 ] || { [ \"$MAJOR\" -eq 3 ] && [ \"$MINOR\" -ge 11 ]; }; then\n    ok \"python3 found ($PYTHON_VERSION)\"\n    return 0\n  fi\n\n  error \"python3 version is $PYTHON_VERSION (need 3.11+)\"\n  return 1\n}\n\ncheck_prereqs() {\n  info 'Checking prerequisites...'\n\n  missing_critical=0\n\n  if ! check_python_version; then\n    missing_critical=1\n  fi\n\n  if command_exists uv; then\n    ok 'uv found'\n  else\n    error 'uv not found (install: https://docs.astral.sh/uv/getting-started/installation/)'\n    missing_critical=1\n  fi\n\n  if command_exists ffmpeg; then\n    ok 'ffmpeg found'\n  else\n    warn 'ffmpeg not found (required for video generation).'\n  fi\n\n  if command_exists magick || command_exists convert; then\n    ok 'ImageMagick found'\n  else\n    warn 'ImageMagick not found (some text rendering features may fail).'\n  fi\n\n  if command_exists ollama; then\n    ok 'ollama found'\n  else\n    warn 'ollama not found (required for script generation).'\n  fi\n\n  if [ \"$missing_critical\" -eq 1 ]; then\n    error 'Missing critical dependencies. Please install them, then rerun setup.'\n    return 1\n  fi\n\n  return 0\n}\n\nconfigure_local_database_url() {\n  if [ ! -f .env ]; then\n    return 0\n  fi\n\n  db_result=\"$(python3 - <<'PY'\nfrom pathlib import Path\n\nenv_path = Path('.env')\ntext = env_path.read_text(encoding='utf-8')\nhas_trailing_newline = text.endswith('\\n')\nlines = text.splitlines()\ntarget = 'DATABASE_URL=\"sqlite:///moneyprinter.db\"'\n\nfor index, line in enumerate(lines):\n    if not line.startswith('DATABASE_URL='):\n        continue\n\n    value = line.split('=', 1)[1].strip().strip('\"').strip(\"'\")\n    if value == '' or value.startswith('postgresql+psycopg://'):\n        lines[index] = target\n        env_path.write_text(\n            '\\n'.join(lines) + ('\\n' if has_trailing_newline else ''),\n            encoding='utf-8',\n        )\n        print('updated')\n    else:\n        print('kept')\n    break\nelse:\n    lines.append(target)\n    env_path.write_text(\n        '\\n'.join(lines) + ('\\n' if has_trailing_newline or lines else ''),\n        encoding='utf-8',\n    )\n    print('added')\nPY\n)\"\n\n  case \"$db_result\" in\n    updated)\n      info 'Set DATABASE_URL to local SQLite default in .env'\n      ;;\n    added)\n      info 'Added DATABASE_URL local SQLite default to .env'\n      ;;\n    *)\n      info 'Keeping existing DATABASE_URL in .env'\n      ;;\n  esac\n}\n\nsetup_env_file() {\n  if [ ! -f .env.example ]; then\n    warn '.env.example is missing; skipping env setup.'\n    return 0\n  fi\n\n  if [ -f .env ]; then\n    if ask_yes_no '.env already exists. Overwrite it from .env.example?' 'n'; then\n      cp .env.example .env\n      ok '.env overwritten from .env.example'\n    else\n      info 'Keeping existing .env'\n    fi\n  else\n    cp .env.example .env\n    ok 'Created .env from .env.example'\n  fi\n\n  configure_local_database_url\n\n  if ask_yes_no 'Open .env now to edit required keys?' 'y'; then\n    if [ -n \"${EDITOR:-}\" ] && command_exists \"$EDITOR\"; then\n      \"$EDITOR\" .env\n    elif command_exists nano; then\n      nano .env\n    elif command_exists vi; then\n      vi .env\n    else\n      warn \"No terminal editor detected. Please edit $PROJECT_ROOT/.env manually.\"\n    fi\n  fi\n}\n\ninstall_dependencies() {\n  if ask_yes_no 'Install Python dependencies with uv sync?' 'y'; then\n    info 'Running uv sync...'\n    if uv sync; then\n      ok 'Dependencies installed'\n    else\n      error 'uv sync failed'\n      return 1\n    fi\n  else\n    warn 'Skipped dependency installation.'\n  fi\n\n  return 0\n}\n\ncheck_ollama_models() {\n  if ! command_exists ollama; then\n    return 0\n  fi\n\n  if ! ask_yes_no 'Check local Ollama models now?' 'y'; then\n    return 0\n  fi\n\n  info 'Querying Ollama model list...'\n  if ollama list; then\n    ok 'Ollama is reachable.'\n  else\n    warn 'Could not query Ollama. If needed, run: ollama serve'\n    return 0\n  fi\n\n  if ask_yes_no 'Pull default model llama3.1:8b now?' 'n'; then\n    printf 'Model name [llama3.1:8b]: '\n    read -r model_name\n    model_name=\"${model_name:-llama3.1:8b}\"\n\n    info \"Pulling model $model_name ...\"\n    if ollama pull \"$model_name\"; then\n      ok \"Model $model_name is ready\"\n    else\n      warn \"Failed to pull model $model_name\"\n    fi\n  fi\n}\n\nprint_next_steps() {\n  printf '\\n%sNext steps%s\\n' \"$BOLD\" \"$RESET\"\n  printf '%s1.%s Start backend: %suv run python Backend/main.py%s\\n' \"$CYAN\" \"$RESET\" \"$BOLD\" \"$RESET\"\n  printf '%s2.%s Start worker (new terminal): %suv run python Backend/worker.py%s\\n' \"$CYAN\" \"$RESET\" \"$BOLD\" \"$RESET\"\n  printf '%s3.%s Start frontend (new terminal): %spython3 -m http.server 3000 --directory Frontend%s\\n' \"$CYAN\" \"$RESET\" \"$BOLD\" \"$RESET\"\n  printf '%s4.%s Open: %shttp://localhost:3000%s\\n\\n' \"$CYAN\" \"$RESET\" \"$BOLD\" \"$RESET\"\n}\n\nmain() {\n  print_banner\n\n  if ! check_prereqs; then\n    exit 1\n  fi\n\n  setup_env_file\n\n  if ! install_dependencies; then\n    exit 1\n  fi\n\n  check_ollama_models\n  print_next_steps\n  ok 'Setup complete. Happy building!'\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import os\nimport sys\nfrom pathlib import Path\n\nimport pytest\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import sessionmaker\n\n\nPROJECT_ROOT = Path(__file__).resolve().parents[1]\nBACKEND_DIR = PROJECT_ROOT / \"Backend\"\nif str(BACKEND_DIR) not in sys.path:\n    sys.path.insert(0, str(BACKEND_DIR))\n\nos.environ[\"DATABASE_URL\"] = \"sqlite:///:memory:\"\n\nfrom db import Base  # noqa: E402\nimport models  # noqa: F401,E402\n\n\n@pytest.fixture\ndef session_factory(tmp_path: Path):\n    database_file = tmp_path / \"test.db\"\n    engine = create_engine(\n        f\"sqlite:///{database_file}\",\n        connect_args={\"check_same_thread\": False},\n    )\n    session_factory = sessionmaker(\n        bind=engine,\n        autoflush=False,\n        autocommit=False,\n        expire_on_commit=False,\n    )\n    Base.metadata.create_all(bind=engine)\n\n    yield session_factory\n\n    Base.metadata.drop_all(bind=engine)\n    engine.dispose()\n\n\n@pytest.fixture\ndef session(session_factory):\n    with session_factory() as db_session:\n        yield db_session\n"
  },
  {
    "path": "tests/test_api_jobs.py",
    "content": "import os\n\nimport pytest\n\nfrom repository import append_event, create_job, get_job, list_job_events\n\n\nos.environ.setdefault(\"PEXELS_API_KEY\", \"test-key\")\nos.environ.setdefault(\"TIKTOK_SESSION_ID\", \"test-session\")\nos.environ.setdefault(\"IMAGEMAGICK_BINARY\", \"/bin/echo\")\nos.environ.setdefault(\"DATABASE_URL\", \"sqlite:///moneyprinter_api_bootstrap.db\")\n\nimport main\n\n\n@pytest.fixture\ndef client(monkeypatch, session_factory):\n    monkeypatch.setattr(main, \"SessionLocal\", session_factory)\n    return main.app.test_client()\n\n\ndef test_generate_requires_video_subject(client):\n    response = client.post(\"/api/generate\", json={})\n\n    assert response.status_code == 400\n    payload = response.get_json()\n    assert payload[\"status\"] == \"error\"\n    assert payload[\"message\"] == \"videoSubject is required.\"\n\n\ndef test_generate_creates_job_and_job_status_is_fetchable(client):\n    response = client.post(\n        \"/api/generate\",\n        json={\n            \"videoSubject\": \"api queue test\",\n            \"paragraphNumber\": 1,\n            \"customPrompt\": \"\",\n        },\n    )\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"success\"\n    assert payload[\"message\"] == \"Video generation queued.\"\n\n    job_id = payload[\"jobId\"]\n    job_response = client.get(f\"/api/jobs/{job_id}\")\n    job_payload = job_response.get_json()\n\n    assert job_response.status_code == 200\n    assert job_payload[\"status\"] == \"success\"\n    assert job_payload[\"job\"][\"id\"] == job_id\n    assert job_payload[\"job\"][\"state\"] == \"queued\"\n\n\ndef test_get_events_respects_after_query_parameter(client, session_factory):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"events\"})\n        first_event = list_job_events(session, job.id)[0]\n        append_event(session, job.id, \"log\", \"info\", \"step 2\")\n        append_event(session, job.id, \"log\", \"info\", \"step 3\")\n        session.commit()\n\n    response = client.get(f\"/api/jobs/{job.id}/events?after={first_event.id}\")\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"success\"\n    assert len(payload[\"events\"]) == 2\n    assert payload[\"events\"][0][\"message\"] == \"step 2\"\n    assert payload[\"events\"][1][\"message\"] == \"step 3\"\n\n\ndef test_cancel_job_endpoint_cancels_existing_job(client, session_factory):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"cancel endpoint\"})\n\n    response = client.post(f\"/api/jobs/{job.id}/cancel\")\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"success\"\n    assert payload[\"message\"] == \"Cancellation requested.\"\n\n    with session_factory() as session:\n        updated_job = get_job(session, job.id)\n        assert updated_job is not None\n        assert updated_job.status == \"cancelled\"\n        assert updated_job.cancel_requested is True\n\n\ndef test_cancel_job_endpoint_returns_404_for_unknown_job(client):\n    response = client.post(\"/api/jobs/missing-id/cancel\")\n\n    assert response.status_code == 404\n    payload = response.get_json()\n    assert payload[\"status\"] == \"error\"\n    assert payload[\"message\"] == \"Job not found.\"\n\n\ndef test_cancel_latest_running_job_returns_404_when_no_active_job(client):\n    response = client.post(\"/api/cancel\")\n\n    assert response.status_code == 404\n    payload = response.get_json()\n    assert payload[\"status\"] == \"error\"\n    assert payload[\"message\"] == \"No active job found.\"\n\n\ndef test_cancel_latest_running_job_cancels_active_job(client, session_factory):\n    with session_factory() as session:\n        older_job = create_job(session, payload={\"videoSubject\": \"older\"})\n        newer_job = create_job(session, payload={\"videoSubject\": \"newer\"})\n\n    response = client.post(\"/api/cancel\")\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"success\"\n    assert payload[\"message\"] == \"Cancellation requested.\"\n    assert payload[\"jobId\"] in {older_job.id, newer_job.id}\n\n    with session_factory() as session:\n        older = get_job(session, older_job.id)\n        newer = get_job(session, newer_job.id)\n        assert older is not None\n        assert newer is not None\n        cancelled_count = int(older.cancel_requested) + int(newer.cancel_requested)\n        assert cancelled_count == 1\n"
  },
  {
    "path": "tests/test_api_misc.py",
    "content": "import io\nimport os\n\nimport pytest\n\n\nos.environ.setdefault(\"PEXELS_API_KEY\", \"test-key\")\nos.environ.setdefault(\"TIKTOK_SESSION_ID\", \"test-session\")\nos.environ.setdefault(\"IMAGEMAGICK_BINARY\", \"/bin/echo\")\nos.environ.setdefault(\"DATABASE_URL\", \"sqlite:///moneyprinter_api_misc_bootstrap.db\")\n\nimport main\n\n\n@pytest.fixture\ndef client():\n    return main.app.test_client()\n\n\ndef test_models_endpoint_success_response(client, monkeypatch):\n    def fake_list_models():\n        return [\"llama3.1:8b\", \"qwen3:8b\"], \"qwen3:8b\"\n\n    monkeypatch.setattr(main, \"list_ollama_models\", fake_list_models)\n\n    response = client.get(\"/api/models\")\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"success\"\n    assert payload[\"models\"] == [\"llama3.1:8b\", \"qwen3:8b\"]\n    assert payload[\"default\"] == \"qwen3:8b\"\n\n\ndef test_models_endpoint_fallback_on_error(client, monkeypatch):\n    monkeypatch.setenv(\"OLLAMA_MODEL\", \"custom:model\")\n\n    def fake_list_models():\n        raise RuntimeError(\"ollama unavailable\")\n\n    monkeypatch.setattr(main, \"list_ollama_models\", fake_list_models)\n\n    response = client.get(\"/api/models\")\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"error\"\n    assert payload[\"message\"] == \"Could not fetch Ollama models. Is Ollama running?\"\n    assert payload[\"models\"] == [\"custom:model\"]\n    assert payload[\"default\"] == \"custom:model\"\n\n\ndef test_upload_songs_requires_files(client):\n    response = client.post(\n        \"/api/upload-songs\", data={}, content_type=\"multipart/form-data\"\n    )\n\n    assert response.status_code == 400\n    payload = response.get_json()\n    assert payload[\"status\"] == \"error\"\n    assert payload[\"message\"] == \"No files uploaded.\"\n\n\ndef test_upload_songs_rejects_non_mp3_files(client, monkeypatch, tmp_path):\n    songs_dir = tmp_path / \"Songs\"\n    songs_dir.mkdir()\n\n    monkeypatch.setattr(main, \"SONGS_DIR\", songs_dir)\n    monkeypatch.setattr(main, \"clean_dir\", lambda path: None)\n\n    data = {\n        \"songs\": (io.BytesIO(b\"not-mp3\"), \"track.wav\"),\n    }\n    response = client.post(\n        \"/api/upload-songs\",\n        data=data,\n        content_type=\"multipart/form-data\",\n    )\n\n    assert response.status_code == 400\n    payload = response.get_json()\n    assert payload[\"status\"] == \"error\"\n    assert payload[\"message\"] == \"No MP3 files found.\"\n    assert list(songs_dir.iterdir()) == []\n\n\ndef test_upload_songs_saves_mp3_and_sanitizes_filename(client, monkeypatch, tmp_path):\n    songs_dir = tmp_path / \"Songs\"\n    songs_dir.mkdir()\n    stale_file = songs_dir / \"stale.mp3\"\n    stale_file.write_bytes(b\"old\")\n\n    def fake_clean_dir(path: str):\n        assert path == str(songs_dir)\n        for item in songs_dir.iterdir():\n            if item.is_file():\n                item.unlink()\n\n    monkeypatch.setattr(main, \"SONGS_DIR\", songs_dir)\n    monkeypatch.setattr(main, \"clean_dir\", fake_clean_dir)\n\n    data = {\n        \"songs\": [\n            (io.BytesIO(b\"song-a\"), \"../danger.mp3\"),\n            (io.BytesIO(b\"song-b\"), \"safe.mp3\"),\n            (io.BytesIO(b\"ignore\"), \"note.txt\"),\n        ]\n    }\n    response = client.post(\n        \"/api/upload-songs\",\n        data=data,\n        content_type=\"multipart/form-data\",\n    )\n\n    assert response.status_code == 200\n    payload = response.get_json()\n    assert payload[\"status\"] == \"success\"\n    assert payload[\"message\"] == \"Uploaded 2 song(s).\"\n\n    saved_names = sorted(path.name for path in songs_dir.iterdir())\n    assert saved_names == [\"danger.mp3\", \"safe.mp3\"]\n"
  },
  {
    "path": "tests/test_repository.py",
    "content": "from repository import (\n    claim_next_queued_job,\n    create_job,\n    list_job_events,\n    mark_failed,\n    mark_completed,\n    mark_cancelled,\n    request_cancel,\n)\n\n\ndef test_create_job_persists_payload_and_queued_event(session):\n    payload = {\"videoSubject\": \"money basics\", \"paragraphNumber\": 1}\n\n    job = create_job(session, payload=payload)\n\n    assert job.id\n    assert job.status == \"queued\"\n    assert job.payload == payload\n\n    events = list_job_events(session, job.id)\n    assert len(events) == 1\n    assert events[0].event_type == \"queued\"\n    assert events[0].message == \"Job queued.\"\n\n\ndef test_request_cancel_cancels_queued_job_and_tracks_events(session):\n    job = create_job(session, payload={\"videoSubject\": \"cancel me\"})\n\n    cancelled = request_cancel(session, job.id)\n\n    assert cancelled is True\n\n    events = list_job_events(session, job.id)\n    event_types = [event.event_type for event in events]\n    assert \"cancel_requested\" in event_types\n    assert \"cancelled\" in event_types\n\n\ndef test_claim_next_queued_job_marks_running_and_skips_cancelled(session):\n    first_job = create_job(session, payload={\"videoSubject\": \"first\"})\n    second_job = create_job(session, payload={\"videoSubject\": \"second\"})\n    request_cancel(session, first_job.id)\n\n    claimed_job = claim_next_queued_job(session)\n\n    assert claimed_job is not None\n    assert claimed_job.id == second_job.id\n    assert claimed_job.status == \"running\"\n    assert claimed_job.attempt_count == 1\n\n\ndef test_mark_completed_updates_status_and_emits_complete_event(session):\n    job = create_job(session, payload={\"videoSubject\": \"done\"})\n    running_job = claim_next_queued_job(session)\n    assert running_job is not None\n\n    mark_completed(session, job.id, result_path=\"/tmp/output.mp4\")\n\n    events = list_job_events(session, job.id)\n    complete_events = [event for event in events if event.event_type == \"complete\"]\n    assert len(complete_events) == 1\n    assert complete_events[0].payload == {\"path\": \"/tmp/output.mp4\"}\n\n\ndef test_mark_failed_updates_error_message_and_event(session):\n    job = create_job(session, payload={\"videoSubject\": \"bad run\"})\n\n    mark_failed(session, job.id, error_message=\"render crash\")\n\n    events = list_job_events(session, job.id)\n    assert events[-1].event_type == \"error\"\n    assert events[-1].message == \"render crash\"\n\n\ndef test_mark_cancelled_sets_status_and_writes_cancelled_event(session):\n    job = create_job(session, payload={\"videoSubject\": \"stop\"})\n\n    mark_cancelled(session, job.id, reason=\"cancelled in worker\")\n\n    events = list_job_events(session, job.id)\n    assert events[-1].event_type == \"cancelled\"\n    assert events[-1].message == \"cancelled in worker\"\n"
  },
  {
    "path": "tests/test_utils.py",
    "content": "from pathlib import Path\n\nimport utils\n\n\ndef test_clean_dir_removes_existing_files_and_directories(tmp_path: Path):\n    target_dir = tmp_path / \"cleanup\"\n    nested_dir = target_dir / \"nested\"\n    nested_dir.mkdir(parents=True)\n    (target_dir / \"root.txt\").write_text(\"root\")\n    (nested_dir / \"nested.txt\").write_text(\"nested\")\n\n    utils.clean_dir(str(target_dir))\n\n    assert target_dir.exists()\n    assert list(target_dir.iterdir()) == []\n\n\ndef test_choose_random_song_returns_none_if_songs_dir_missing(\n    monkeypatch, tmp_path: Path\n):\n    songs_dir = tmp_path / \"Songs\"\n    monkeypatch.setattr(utils, \"SONGS_DIR\", songs_dir)\n\n    assert utils.choose_random_song() is None\n\n\ndef test_choose_random_song_returns_selected_mp3(monkeypatch, tmp_path: Path):\n    songs_dir = tmp_path / \"Songs\"\n    songs_dir.mkdir()\n    first_song = songs_dir / \"a.mp3\"\n    second_song = songs_dir / \"b.mp3\"\n    ignored_file = songs_dir / \"notes.txt\"\n    first_song.write_text(\"a\")\n    second_song.write_text(\"b\")\n    ignored_file.write_text(\"ignore\")\n\n    monkeypatch.setattr(utils, \"SONGS_DIR\", songs_dir)\n    monkeypatch.setattr(utils.random, \"choice\", lambda songs: songs[0])\n\n    selected = utils.choose_random_song()\n\n    assert selected == str(first_song)\n\n\ndef test_resolve_imagemagick_binary_prefers_configured_existing_path(\n    monkeypatch, tmp_path: Path\n):\n    fake_binary = tmp_path / \"magick\"\n    fake_binary.write_text(\"binary\")\n    monkeypatch.setenv(\"IMAGEMAGICK_BINARY\", str(fake_binary))\n\n    resolved = utils.resolve_imagemagick_binary()\n\n    assert resolved == str(fake_binary.resolve())\n\n\ndef test_resolve_imagemagick_binary_falls_back_to_path_lookup(monkeypatch):\n    monkeypatch.setenv(\"IMAGEMAGICK_BINARY\", \"\")\n\n    def fake_which(candidate: str):\n        if candidate == \"magick\":\n            return \"/usr/local/bin/magick\"\n        return None\n\n    monkeypatch.setattr(utils.shutil, \"which\", fake_which)\n\n    resolved = utils.resolve_imagemagick_binary()\n\n    assert resolved == \"/usr/local/bin/magick\"\n"
  },
  {
    "path": "tests/test_worker.py",
    "content": "from repository import create_job, get_job, list_job_events\nimport worker\n\n\ndef _disable_cleanup(monkeypatch):\n    monkeypatch.setattr(worker, \"clean_dir\", lambda _: None)\n\n\ndef test_process_next_job_returns_false_when_queue_is_empty(\n    monkeypatch, session_factory\n):\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n    _disable_cleanup(monkeypatch)\n\n    assert worker.process_next_job() is False\n\n\ndef test_process_next_job_marks_completed_on_pipeline_success(\n    monkeypatch, session_factory\n):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"worker success\"})\n\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n    _disable_cleanup(monkeypatch)\n\n    def fake_pipeline(data, is_cancelled, on_log):\n        assert data[\"videoSubject\"] == \"worker success\"\n        assert is_cancelled() is False\n        on_log(\"pipeline started\", \"info\")\n        return \"rendered.mp4\"\n\n    monkeypatch.setattr(worker, \"run_generation_pipeline\", fake_pipeline)\n\n    assert worker.process_next_job() is True\n\n    with session_factory() as session:\n        updated_job = get_job(session, job.id)\n        assert updated_job is not None\n        assert updated_job.status == \"completed\"\n        assert updated_job.result_path == \"rendered.mp4\"\n\n        event_types = [event.event_type for event in list_job_events(session, job.id)]\n        assert \"running\" in event_types\n        assert \"log\" in event_types\n        assert \"complete\" in event_types\n\n\ndef test_process_next_job_marks_cancelled_on_pipeline_cancelled(\n    monkeypatch, session_factory\n):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"worker cancelled\"})\n\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n    _disable_cleanup(monkeypatch)\n\n    def fake_pipeline(*_args, **_kwargs):\n        raise worker.PipelineCancelled(\"cancelled by user\")\n\n    monkeypatch.setattr(worker, \"run_generation_pipeline\", fake_pipeline)\n\n    assert worker.process_next_job() is True\n\n    with session_factory() as session:\n        updated_job = get_job(session, job.id)\n        assert updated_job is not None\n        assert updated_job.status == \"cancelled\"\n\n        events = list_job_events(session, job.id)\n        assert events[-1].event_type == \"cancelled\"\n        assert events[-1].message == \"cancelled by user\"\n\n\ndef test_process_next_job_marks_failed_on_pipeline_error(monkeypatch, session_factory):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"worker failure\"})\n\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n    _disable_cleanup(monkeypatch)\n\n    def fake_pipeline(*_args, **_kwargs):\n        raise RuntimeError(\"pipeline exploded\")\n\n    monkeypatch.setattr(worker, \"run_generation_pipeline\", fake_pipeline)\n\n    assert worker.process_next_job() is True\n\n    with session_factory() as session:\n        updated_job = get_job(session, job.id)\n        assert updated_job is not None\n        assert updated_job.status == \"failed\"\n        assert updated_job.error_message == \"pipeline exploded\"\n\n        events = list_job_events(session, job.id)\n        assert events[-1].event_type == \"error\"\n        assert events[-1].message == \"pipeline exploded\"\n\n\ndef test_job_cancelled_helper_returns_true_for_missing_job(\n    monkeypatch, session_factory\n):\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n\n    assert worker._job_cancelled(\"missing-job-id\") is True\n\n\ndef test_job_cancelled_helper_reflects_cancel_flag(monkeypatch, session_factory):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"cancel-check\"})\n\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n\n    assert worker._job_cancelled(job.id) is False\n\n    with session_factory() as session:\n        job_to_update = get_job(session, job.id)\n        assert job_to_update is not None\n        job_to_update.cancel_requested = True\n        session.commit()\n\n    assert worker._job_cancelled(job.id) is True\n\n\ndef test_log_event_helper_persists_log_event(monkeypatch, session_factory):\n    with session_factory() as session:\n        job = create_job(session, payload={\"videoSubject\": \"log-check\"})\n\n    monkeypatch.setattr(worker, \"SessionLocal\", session_factory)\n\n    worker._log_event(job.id, \"hello event\", \"warning\")\n\n    with session_factory() as session:\n        events = list_job_events(session, job.id)\n        assert events[-1].event_type == \"log\"\n        assert events[-1].level == \"warning\"\n        assert events[-1].message == \"hello event\"\n"
  }
]